From 833ba8b28ee28b2ae1e8f7babf2f8abfa78f7566 Mon Sep 17 00:00:00 2001 From: Michael Lohrer Date: Wed, 27 Dec 2023 12:22:37 -0500 Subject: [PATCH 1/5] Ignore E121 for hanging indents in autopep8 --- .vscode/settings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index f65266bcf..efd388d81 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,6 +23,7 @@ }, "autopep8.args": [ "--indent-size=2", + "--ignore=E121", // E121=hanging indent, no way to set to 2. "--max-line-length=120" ], "pylint.args": ["--generated-members", "signal.Signals,GPIO.*"], From fa7049a11a870bd48d324453bd117c80dd5644fc Mon Sep 17 00:00:00 2001 From: Michael Lohrer Date: Wed, 27 Dec 2023 14:43:10 -0500 Subject: [PATCH 2/5] Update VS Code and pylint with 120 max line length --- .pylintrc | 5 +++-- .vscode/settings.json | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.pylintrc b/.pylintrc index 6ec85e387..576daf4cc 100644 --- a/.pylintrc +++ b/.pylintrc @@ -41,10 +41,11 @@ good-names=i, # Number of spaces of indent required inside a hanging or continued line. indent-after-paren=2 -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 tab). indent-string=' ' +max-line-length=120 + [EXCEPTIONS] # Exceptions that will emit a warning when being caught. Defaults to diff --git a/.vscode/settings.json b/.vscode/settings.json index efd388d81..742e4ef7d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,7 +19,8 @@ }, "[python]": { "editor.formatOnSave": true, - "editor.defaultFormatter": "ms-python.autopep8" + "editor.defaultFormatter": "ms-python.autopep8", + "editor.rulers": [120] }, "autopep8.args": [ "--indent-size=2", From f8967cb746812c2f6dc7b898c347e5293e86b4a7 Mon Sep 17 00:00:00 2001 From: Michael Lohrer Date: Wed, 27 Dec 2023 14:42:30 -0500 Subject: [PATCH 3/5] Auto-format amplipi .py files --- amplipi/app.py | 171 +++++++--- amplipi/asgi.py | 2 + amplipi/auth.py | 34 +- amplipi/ctrl.py | 105 ++++--- amplipi/defaults.py | 150 +++++---- amplipi/eeprom.py | 28 +- amplipi/extras.py | 10 +- amplipi/formatter.py | 2 + amplipi/hw.py | 78 +++-- amplipi/models.py | 729 +++++++++++++++++++++++-------------------- amplipi/mpris.py | 10 +- amplipi/rt.py | 203 ++++++------ amplipi/streams.py | 204 +++++++----- amplipi/utils.py | 56 +++- 14 files changed, 1024 insertions(+), 758 deletions(-) diff --git a/amplipi/app.py b/amplipi/app.py index 7f62453a0..fe1229754 100755 --- a/amplipi/app.py +++ b/amplipi/app.py @@ -24,8 +24,6 @@ import argparse -DEBUG_API = False - import os # type handling, fastapi leverages type checking for performance and easy docs @@ -33,7 +31,7 @@ from enum import Enum from types import SimpleNamespace -import urllib.request # For custom album art size +import urllib.request # For custom album art size from functools import lru_cache import asyncio import json @@ -42,11 +40,11 @@ from subprocess import Popen from time import sleep -from PIL import Image # For custom album art size +from PIL import Image # For custom album art size # web framework from fastapi import FastAPI, Request, Response, HTTPException, Depends, Path -from fastapi.openapi.utils import get_openapi # docs +from fastapi.openapi.utils import get_openapi # docs from fastapi.staticfiles import StaticFiles from fastapi.routing import APIRoute, APIRouter from fastapi.templating import Jinja2Templates @@ -63,7 +61,7 @@ import amplipi.utils as utils import amplipi.models as models import amplipi.defaults as defaults -from amplipi.ctrl import Api, ApiResponse, ApiCode # we don't import ctrl here to avoid naming ambiguity with a ctrl variable +from amplipi.ctrl import Api, ApiResponse, ApiCode # we don't import ctrl here to avoid naming ambiguity with a ctrl variable from amplipi.auth import CookieOrParamAPIKey, router as auth_router, NotAuthenticatedException, not_authenticated_exception_handler # start in the web directory @@ -72,14 +70,16 @@ GENERATED_DIR = os.path.abspath('web/generated') WEB_DIR = os.path.abspath('web/dist') -app = FastAPI(openapi_url=None, redoc_url=None,) # we host docs using rapidoc instead via a custom endpoint, so the default endpoints need to be disabled +# we host docs using rapidoc instead via a custom endpoint, so the default endpoints need to be disabled +app = FastAPI(openapi_url=None, redoc_url=None,) app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") # This will get generated as a tmpfs on AmpliPi, # but won't exist if testing on another machine. os.makedirs(GENERATED_DIR, exist_ok=True) -app.mount("/generated", StaticFiles(directory=GENERATED_DIR), name="generated") # TODO: make this register as a dynamic folder??? +# TODO: make this register as a dynamic folder??? +app.mount("/generated", StaticFiles(directory=GENERATED_DIR), name="generated") app.add_exception_handler(NotAuthenticatedException, not_authenticated_exception_handler) @@ -98,15 +98,19 @@ def add_api_route(self, path: str, endpoint: Callable[..., Any], **kwargs: Any) return super().add_api_route(path, endpoint, **kwargs) # Helper functions + + def unused_groups(ctrl: Api, src: int) -> Dict[int, str]: """ Get groups that are not connected to src """ groups = ctrl.status.groups - return {g.id : g.name for g in groups if g.source_id != src and g.id is not None} + return {g.id: g.name for g in groups if g.source_id != src and g.id is not None} + def unused_zones(ctrl: Api, src: int) -> Dict[int, str]: """ Get zones that are not conencted to src """ zones = ctrl.status.zones - return {z.id : z.name for z in zones if z.source_id != src and z.id is not None and not z.disabled} + return {z.id: z.name for z in zones if z.source_id != src and z.id is not None and not z.disabled} + def ungrouped_zones(ctrl: Api, src: int) -> List[models.Zone]: """ Get zones that are connected to src, but don't belong to a full group """ @@ -123,42 +127,50 @@ def ungrouped_zones(ctrl: Api, src: int) -> List[models.Zone]: ungrouped_zones_ = source_zones.difference(grouped_zones) return [zones[z] for z in ungrouped_zones_ if z is not None and not zones[z].disabled] -# add a default controller (this is overriden below in create_app) +# add a default controller (this is overridden below in create_app) # TODO: this get_ctrl Singleton needs to be removed and the API converted to be instantiated by a class with ctrl state -@lru_cache(1) # Api controller should only be instantiated once (we clear the cache with get_ctr.cache_clear() after settings object is configured) + + +@lru_cache(1) # Api controller should only be instantiated once (we clear the cache with get_ctr.cache_clear() after settings object is configured) def get_ctrl() -> Api: """ Get the controller Makes a single instance of the controller to avoid duplicates (Singleton pattern) """ return Api(models.AppSettings()) + class params(SimpleNamespace): """ Describe standard path ID's for each api type """ # pylint: disable=too-few-public-methods # pylint: disable=invalid-name - SourceID = Path(..., ge=0, le=models.MAX_SOURCES-1, description="Source ID") + SourceID = Path(..., ge=0, le=models.MAX_SOURCES - 1, description="Source ID") ZoneID = Path(..., ge=0, le=35, description="Zone ID") GroupID = Path(..., ge=0, description="Stream ID") StreamID = Path(..., ge=0, description="Stream ID") StreamCommand = Path(..., description="Stream Command") PresetID = Path(..., ge=0, description="Preset ID") - StationID = Path(..., ge=0, title="Pandora Station ID", description="Number found on the end of a pandora url while playing the station, ie 4610303469018478727 in https://www.pandora.com/station/play/4610303469018478727") + StationID = Path(..., ge=0, title="Pandora Station ID", + description="Number found on the end of a pandora url while playing the station, ie 4610303469018478727 in https://www.pandora.com/station/play/4610303469018478727") ImageHeight = Path(..., ge=1, le=500, description="Image Height in pixels") + api = SimplifyingRouter(dependencies=[Depends(CookieOrParamAPIKey)]) + @api.get('/api', tags=['status']) @api.get('/api/', tags=['status']) def get_status(ctrl: Api = Depends(get_ctrl)) -> models.Status: """ Get the system status and configuration """ return ctrl.get_state() + @api.post('/api/load', tags=['status']) def load_config(config: models.Status, ctrl: Api = Depends(get_ctrl)) -> models.Status: """ Load a new configuration (and return the configuration loaded). This will overwrite the current configuration so it is advised to save the previous config from. """ ctrl.reinit(settings=ctrl._settings, change_notifier=notify_on_change, config=config) return ctrl.get_state() + @api.post('/api/factory_reset', tags=['status']) def load_factory_config(ctrl: Api = Depends(get_ctrl)) -> models.Status: """ Load the "factory" configuration (and return the configuration loaded). @@ -168,16 +180,19 @@ def load_factory_config(ctrl: Api = Depends(get_ctrl)) -> models.Status: default_config = defaults.default_config(is_streamer=ctrl.is_streamer, lms_mode=ctrl.lms_mode) return load_config(models.Status(**default_config), ctrl) + @api.post('/api/reset', tags=['status']) def reset(ctrl: Api = Depends(get_ctrl)) -> models.Status: """ Reload the current configuration, resetting the firmware in the process. """ ctrl.reinit(settings=ctrl._settings, change_notifier=notify_on_change) return ctrl.get_state() -@api.post('/api/reboot', tags=['status'], + +@api.post( + '/api/reboot', tags=['status'], response_class=Response, - responses = { - 200: {} + responses={ + 200: {} } ) def reboot(): @@ -189,10 +204,12 @@ def reboot(): # start the restart, and return immediately (hopefully before the restart process begins) Popen('sleep 1 && sudo systemctl reboot', shell=True) -@api.post('/api/shutdown', tags=['status'], + +@api.post( + '/api/shutdown', tags=['status'], response_class=Response, - responses = { - 200: {} + responses={ + 200: {} } ) def shutdown(): @@ -205,14 +222,16 @@ def shutdown(): # start the shutdown process and returning immediately (hopeully before the shutdown process begins) Popen('sleep 1 && sudo systemctl poweroff', shell=True) -@api.post('/api/lms_mode', response_class=Response, - responses = { + +@api.post( + '/api/lms_mode', response_class=Response, + responses={ 200: {} } ) def lms_mode(ctrl: Api = Depends(get_ctrl)): """ Toggles Logitech Media Server mode on or off. """ - new_config : models.Status + new_config: models.Status if ctrl.lms_mode: print("turning LMS mode off...") try: @@ -230,18 +249,24 @@ def lms_mode(ctrl: Api = Depends(get_ctrl)): new_config = models.Status(**defaults.default_config(is_streamer=ctrl.is_streamer, lms_mode=True)) load_config(new_config, ctrl) + subscribers: Dict[int, 'Queue[models.Status]'] = {} + + def notify_on_change(status: models.Status) -> None: """ Notify subscribers that something has changed """ for msg_que in subscribers.values(): msg_que.put(status) # @api.get('/api/subscribe') # TODO: uncomment this to add SSE Support and properly document it + + async def subscribe(req: Request): """ Subscribe to SSE events """ msg_que: Queue = Queue(3) next_sub = max(subscribers.keys(), default=0) + 1 subscribers[next_sub] = msg_que + async def stream(): try: while True: @@ -259,6 +284,7 @@ async def stream(): raise exc return EventSourceResponse(stream()) + def code_response(ctrl: Api, resp: Union[ApiResponse, models.BaseModel]): """ Convert amplipi.ctrl.Api responses to json/http responses """ if isinstance(resp, ApiResponse): @@ -271,10 +297,13 @@ def code_response(ctrl: Api, resp: Union[ApiResponse, models.BaseModel]): return resp # sources + + @api.get('/api/sources', tags=['source']) def get_sources(ctrl: Api = Depends(get_ctrl)) -> Dict[str, List[models.Source]]: """ Get all sources """ - return {'sources' : ctrl.get_state().sources} + return {'sources': ctrl.get_state().sources} + @api.get('/api/sources/{sid}', tags=['source']) def get_source(ctrl: Api = Depends(get_ctrl), sid: int = params.SourceID) -> models.Source: @@ -283,6 +312,7 @@ def get_source(ctrl: Api = Depends(get_ctrl), sid: int = params.SourceID) -> mod sources = ctrl.get_state().sources return sources[sid] + @api.patch('/api/sources/{sid}', tags=['source']) def set_source(update: models.SourceUpdate, ctrl: Api = Depends(get_ctrl), sid: int = params.SourceID) -> models.Status: """ Update a source's configuration (source=**sid**) """ @@ -293,21 +323,23 @@ def set_source(update: models.SourceUpdate, ctrl: Api = Depends(get_ctrl), sid: print(f'correcting deprecated use of RCA inputs from {update} to {valid_update}') return code_response(ctrl, ctrl.set_source(sid, update)) -@api.get('/api/sources/{sid}/image/{height}', tags=['source'], + +@api.get( + '/api/sources/{sid}/image/{height}', tags=['source'], # Manually specify a possible response # see https://github.com/tiangolo/fastapi/issues/3258 response_class=Response, - responses = { - 200: { - "content": {"image/jpg": {}} - } + responses={ + 200: { + "content": {"image/jpg": {}} + } }, ) async def get_image(ctrl: Api = Depends(get_ctrl), sid: int = params.SourceID, height: int = params.ImageHeight): """ Get a square jpeg image representing the current media playing on source @sid This was added to support low power touch panels """ - width = height # square image + width = height # square image source_info = ctrl.status.sources[sid].info if source_info is None or source_info.img_url is None: uri = 'static/imgs/disconnected.png' @@ -344,11 +376,13 @@ async def get_image(ctrl: Api = Depends(get_ctrl), sid: int = params.SourceID, h # zones + @api.get('/api/zones', tags=['zone']) def get_zones(ctrl: Api = Depends(get_ctrl)) -> Dict[str, List[models.Zone]]: """ Get all zones """ return {'zones': ctrl.get_state().zones} + @api.get('/api/zones/{zid}', tags=['zone']) def get_zone(ctrl: Api = Depends(get_ctrl), zid: int = params.ZoneID) -> models.Zone: """ Get Zone with id=**zid** """ @@ -357,11 +391,13 @@ def get_zone(ctrl: Api = Depends(get_ctrl), zid: int = params.ZoneID) -> models. return zones[zid] raise HTTPException(404, f'zone {zid} not found') + @api.patch('/api/zones/{zid}', tags=['zone']) def set_zone(zone: models.ZoneUpdate, ctrl: Api = Depends(get_ctrl), zid: int = params.ZoneID) -> models.Status: """ Update a zone's configuration (zone=**zid**) """ return code_response(ctrl, ctrl.set_zone(zid, zone)) + @api.patch('/api/zones', tags=['zone']) def set_zones(multi_update: models.MultiZoneUpdate, ctrl: Api = Depends(get_ctrl)) -> models.Status: """ Update a bunch of zones (and groups) with the same configuration changes """ @@ -369,16 +405,19 @@ def set_zones(multi_update: models.MultiZoneUpdate, ctrl: Api = Depends(get_ctrl # groups + @api.post('/api/group', tags=['group']) def create_group(group: models.Group, ctrl: Api = Depends(get_ctrl)) -> models.Group: """ Create a new grouping of zones """ # TODO: add named example group return code_response(ctrl, ctrl.create_group(group)) + @api.get('/api/groups', tags=['group']) def get_groups(ctrl: Api = Depends(get_ctrl)) -> Dict[str, List[models.Group]]: """ Get all groups """ - return {'groups' : ctrl.get_state().groups} + return {'groups': ctrl.get_state().groups} + @api.get('/api/groups/{gid}', tags=['group']) def get_group(ctrl: Api = Depends(get_ctrl), gid: int = params.GroupID) -> models.Group: @@ -388,11 +427,13 @@ def get_group(ctrl: Api = Depends(get_ctrl), gid: int = params.GroupID) -> model return grp raise HTTPException(404, f'group {gid} not found') + @api.patch('/api/groups/{gid}', tags=['group']) def set_group(group: models.GroupUpdate, ctrl: Api = Depends(get_ctrl), gid: int = params.GroupID) -> models.Status: """ Update a groups's configuration (group=**gid**) """ return code_response(ctrl, ctrl.set_group(gid, group)) + @api.delete('/api/groups/{gid}', tags=['group']) def delete_group(ctrl: Api = Depends(get_ctrl), gid: int = params.GroupID) -> models.Status: """ Delete a group (group=**gid**) """ @@ -400,6 +441,7 @@ def delete_group(ctrl: Api = Depends(get_ctrl), gid: int = params.GroupID) -> mo # streams + @api.post('/api/stream', tags=['stream']) def create_stream(stream: models.Stream, ctrl: Api = Depends(get_ctrl)) -> models.Stream: """ Create a new audio stream @@ -407,10 +449,12 @@ def create_stream(stream: models.Stream, ctrl: Api = Depends(get_ctrl)) -> model """ return code_response(ctrl, ctrl.create_stream(stream)) + @api.get('/api/streams', tags=['stream']) def get_streams(ctrl: Api = Depends(get_ctrl)) -> Dict[str, List[models.Stream]]: """ Get all streams """ - return {'streams' : ctrl.get_state().streams} + return {'streams': ctrl.get_state().streams} + @api.get('/api/streams/{sid}', tags=['stream']) def get_stream(ctrl: Api = Depends(get_ctrl), sid: int = params.StreamID) -> models.Stream: @@ -420,11 +464,13 @@ def get_stream(ctrl: Api = Depends(get_ctrl), sid: int = params.StreamID) -> mod return stream raise HTTPException(404, f'stream {sid} not found') + @api.patch('/api/streams/{sid}', tags=['stream']) def set_stream(update: models.StreamUpdate, ctrl: Api = Depends(get_ctrl), sid: int = params.StreamID) -> models.Status: """ Update a stream's configuration (stream=**sid**) """ return code_response(ctrl, ctrl.set_stream(sid, update)) + @api.delete('/api/streams/{sid}', tags=['stream']) def delete_stream(ctrl: Api = Depends(get_ctrl), sid: int = params.StreamID) -> models.Status: """ Delete a stream """ @@ -432,11 +478,13 @@ def delete_stream(ctrl: Api = Depends(get_ctrl), sid: int = params.StreamID) -> # The following is a specific endpoint to api/stream/{} and needs to be placed before the catch all exec_command + @api.post('/api/streams/{sid}/station={station}', tags=['stream']) def change_station(ctrl: Api = Depends(get_ctrl), sid: int = params.StreamID, station: int = params.StationID) -> models.Status: """ Change station on a pandora stream (stream=**sid**) """ return code_response(ctrl, ctrl.exec_stream_command(sid, cmd=f'station={station}')) + @api.post('/api/streams/{sid}/{cmd}', tags=['stream']) def exec_command(cmd: models.StreamCommand, ctrl: Api = Depends(get_ctrl), sid: int = params.StreamID) -> models.Status: """ Executes a comamnd on a stream (stream=**sid**). @@ -455,15 +503,18 @@ def exec_command(cmd: models.StreamCommand, ctrl: Api = Depends(get_ctrl), sid: # presets + @api.post('/api/preset', tags=['preset']) def create_preset(preset: models.Preset, ctrl: Api = Depends(get_ctrl)) -> models.Preset: """ Create a new preset configuration """ return code_response(ctrl, ctrl.create_preset(preset)) + @api.get('/api/presets', tags=['preset']) def get_presets(ctrl: Api = Depends(get_ctrl)) -> Dict[str, List[models.Preset]]: """ Get all presets """ - return {'presets' : ctrl.get_state().presets} + return {'presets': ctrl.get_state().presets} + @api.get('/api/presets/{pid}', tags=['preset']) def get_preset(ctrl: Api = Depends(get_ctrl), pid: int = params.PresetID) -> models.Preset: @@ -473,16 +524,19 @@ def get_preset(ctrl: Api = Depends(get_ctrl), pid: int = params.PresetID) -> mod return preset raise HTTPException(404, f'preset {pid} not found') + @api.patch('/api/presets/{pid}', tags=['preset']) def set_preset(update: models.PresetUpdate, ctrl: Api = Depends(get_ctrl), pid: int = params.PresetID) -> models.Status: """ Update a preset's configuration (preset=**pid**) """ return code_response(ctrl, ctrl.set_preset(pid, update)) + @api.delete('/api/presets/{pid}', tags=['preset']) def delete_preset(ctrl: Api = Depends(get_ctrl), pid: int = params.PresetID) -> models.Status: """ Delete a preset """ return code_response(ctrl, ctrl.delete_preset(pid)) + @api.post('/api/presets/{pid}/load', tags=['preset']) def load_preset(ctrl: Api = Depends(get_ctrl), pid: int = params.PresetID) -> models.Status: """ Load a preset configuration """ @@ -490,6 +544,7 @@ def load_preset(ctrl: Api = Depends(get_ctrl), pid: int = params.PresetID) -> mo # PA + @api.post('/api/announce', tags=['announce']) def announce(announcement: models.Announcement, ctrl: Api = Depends(get_ctrl)) -> models.Status: """ Make an announcement """ @@ -497,11 +552,13 @@ def announce(announcement: models.Announcement, ctrl: Api = Depends(get_ctrl)) - # Info + @api.get('/api/info', tags=['status']) def get_info(ctrl: Api = Depends(get_ctrl)) -> models.Info: """ Get additional information """ return code_response(ctrl, ctrl.get_info()) + @app.get('/debug') def debug() -> models.DebugResponse: """ Returns debug status and configuration. """ @@ -510,18 +567,20 @@ def debug() -> models.DebugResponse: return models.DebugResponse() try: with open(debug_file) as f: - return models.DebugResponse(**json.load(f)) + return models.DebugResponse(**json.load(f)) except Exception as e: print("couldn't load debug file: {e}") return models.DebugResponse() # include all routes above + app.include_router(api) app.include_router(auth_router) # API Documentation + def get_body_model(route: APIRoute) -> Optional[Dict[str, Any]]: """ Get json model for the body of an api request """ try: @@ -532,6 +591,7 @@ def get_body_model(route: APIRoute) -> Optional[Dict[str, Any]]: except: return None + def get_response_model(route: APIRoute) -> Optional[Dict[str, Any]]: """ Get json model for the response of an api request """ try: @@ -542,11 +602,12 @@ def get_response_model(route: APIRoute) -> Optional[Dict[str, Any]]: except: return None + def add_creation_examples(openapi_schema, route: APIRoute) -> None: """ Add creation examples for a given route (for modifying request types) """ req_model = get_body_model(route) if req_model and ('examples' in req_model or 'creation_examples' in req_model): - if 'creation_examples' in req_model: # prefer creation examples for POST request, this allows us to have different examples for get response and creation requests + if 'creation_examples' in req_model: # prefer creation examples for POST request, this allows us to have different examples for get response and creation requests examples = req_model['creation_examples'] else: examples = req_model['examples'] @@ -554,7 +615,8 @@ def add_creation_examples(openapi_schema, route: APIRoute) -> None: # Only POST, PATCH, and PUT methods have a request body if method in {"POST", "PATCH", "PUT"}: openapi_schema['paths'][route.path][method.lower()]['requestBody'][ - 'content']['application/json']['examples'] = examples + 'content']['application/json']['examples'] = examples + def add_response_examples(openapi_schema, route: APIRoute) -> None: """ Add response examples for a given route """ @@ -565,12 +627,13 @@ def add_response_examples(openapi_schema, route: APIRoute) -> None: openapi_schema['paths'][route.path][method.lower()]['responses']['200'][ 'content']['application/json']['examples'] = examples if route.path in ['/api/zones', '/api/groups', '/api/sources', '/api/streams', '/api/presets']: - if 'get' in openapi_schema['paths'][route.path]: + if 'get' in openapi_schema['paths'][route.path]: piece = route.path.replace('/api/', '') example_status = list(models.Status.Config.schema_extra['examples'].values())[0]['value'] openapi_schema['paths'][route.path]['get']['responses']['200'][ 'content']['application/json']['example'] = {piece: example_status[piece]} + def get_live_examples(tags: List[Union[str, Enum]]) -> Dict[str, Dict[str, Any]]: """ Create a list of examples using the live configuration """ live_examples = {} @@ -583,6 +646,7 @@ def get_live_examples(tags: List[Union[str, Enum]]) -> Dict[str, Dict[str, Any]] live_examples[i.name] = {'value': i.id, 'summary': i.name} return live_examples + def get_xid_param(route): """ Check if path has an Xid parameter """ id_param = None @@ -592,6 +656,7 @@ def get_xid_param(route): break return id_param + def add_example_params(openapi_schema, route: APIRoute) -> None: """ Manually add relevant example parameters based on the current configuration (for paths that require parameters) """ for method in route.methods: @@ -606,6 +671,7 @@ def add_example_params(openapi_schema, route: APIRoute) -> None: if param['name'] == xid_param: param['examples'] = live_examples + def generate_openapi_spec(add_test_docs=True): """ Generate the openapi spec using documentation embedded in the models and routes """ if app.openapi_schema: @@ -649,12 +715,12 @@ def generate_openapi_spec(add_test_docs=True): openapi_schema['info']['contact'] = { 'email': 'info@micro-nova.com', - 'name': 'Micronova', - 'url': 'http://micro-nova.com', + 'name': 'Micronova', + 'url': 'http://micro-nova.com', } openapi_schema['info']['license'] = { 'name': 'GPL', - 'url': 'https://github.com/micro-nova/AmpliPi/blob/main/COPYING', + 'url': 'https://github.com/micro-nova/AmpliPi/blob/main/COPYING', } # Manually add examples present in pydancticModel.schema_extra into openAPI schema @@ -672,6 +738,7 @@ def generate_openapi_spec(add_test_docs=True): return openapi_schema + YAML_DESCRIPTION = """| # The links in the description below are tested to work with redoc and may not be portable This is the AmpliPi home audio system's control server. @@ -742,6 +809,7 @@ def generate_openapi_spec(add_test_docs=True): This API is documented using the OpenAPI specification """ + @lru_cache(2) def create_yaml_doc(add_test_docs=True) -> str: """ Create the openapi yaml schema intended for display by rapidaoc @@ -757,6 +825,8 @@ def create_yaml_doc(add_test_docs=True) -> str: # additional yaml version of openapi.json # this is much more human readable + + @app.get('/openapi.yaml', include_in_schema=False) def read_openapi_yaml() -> Response: """ Read the openapi yaml file @@ -764,6 +834,7 @@ def read_openapi_yaml() -> Response: This much more human readable than the json version """ return Response(create_yaml_doc(), media_type='text/yaml') + @app.get('/openapi.json', include_in_schema=False) def read_openapi_json(): """ Read the openapi json file @@ -771,19 +842,23 @@ def read_openapi_json(): This is slightly easier to process by our test framework """ return app.openapi() -app.openapi = generate_openapi_spec # type: ignore + +app.openapi = generate_openapi_spec # type: ignore # Documentation + @app.get('/doc', include_in_schema=False) def doc(): """ Get the API documentation """ # TODO: add hosted python docs as well return FileResponse(f'{TEMPLATE_DIR}/rest-api-doc.html') + # Website app.mount('/', StaticFiles(directory=WEB_DIR, html=True), name='web') + def create_app(mock_ctrl=None, mock_streams=None, config_file=None, delay_saves=None, settings: models.AppSettings = models.AppSettings()) -> FastAPI: """ Create the AmpliPi web app with a specific configuration """ # specify old parameters @@ -800,6 +875,7 @@ def create_app(mock_ctrl=None, mock_streams=None, config_file=None, delay_saves= # Shutdown + @app.on_event('shutdown') def on_shutdown(): print('Shutting down AmpliPi') @@ -811,6 +887,7 @@ def on_shutdown(): # MDNS + def get_ip_info(iface: str = 'eth0') -> Tuple[Optional[str], Optional[str]]: """ Get the IP address of interface @iface """ try: @@ -819,6 +896,7 @@ def get_ip_info(iface: str = 'eth0') -> Tuple[Optional[str], Optional[str]]: except: return None, None + def advertise_service(port, que: Queue): """ Advertise the AmpliPi api via zeroconf, can be verified with 'avahi-browse -ar' Expected to be run as a seperate process, eg: @@ -834,7 +912,7 @@ def advertise_service(port, que: Queue): url = f'http://{hostname}' # search for the best interface to advertise on - ifaces = ['eth0', 'wlan0'] # default pi interfaces + ifaces = ['eth0', 'wlan0'] # default pi interfaces try: for iface in ni.interfaces(): if iface.startswith('w') or iface.startswith('e'): @@ -845,13 +923,13 @@ def advertise_service(port, que: Queue): for iface in ifaces: ip_addr, mac_addr = get_ip_info(iface) if ip_addr and mac_addr: - break # take the first good interface found + break # take the first good interface found if not ip_addr: print(f'AmpliPi zeroconf - unable to register service on one of {ifaces}, \ they all are either not available or have no IP address.') print(f'AmpliPi zeroconf - is this running on AmpliPi?') - ip_addr = '0.0.0.0' # Any hosted ip on this device + ip_addr = '0.0.0.0' # Any hosted ip on this device if port != 80: url += f':{port}' info_deprecated = ServiceInfo( @@ -871,7 +949,7 @@ def advertise_service(port, que: Queue): 'web_app': url, 'documentation': f'{url}/doc' }, - server=f'{hostname}.', # Trailing '.' is required by the SRV_record specification + server=f'{hostname}.', # Trailing '.' is required by the SRV_record specification ) info = ServiceInfo( @@ -892,7 +970,7 @@ def advertise_service(port, que: Queue): 'web_app': url, 'documentation': f'{url}/doc' }, - server=f'{hostname}.', # Trailing '.' is required by the SRV_record specification + server=f'{hostname}.', # Trailing '.' is required by the SRV_record specification ) print(f'AmpliPi zeroconf - registering service: {info}') @@ -911,6 +989,7 @@ def advertise_service(port, que: Queue): zeroconf.unregister_service(info) zeroconf.close() + if __name__ == '__main__': """ Create the openapi yaml file describing the AmpliPi API """ parser = argparse.ArgumentParser(description="Create AmpliPi's openapi spec in YAML") diff --git a/amplipi/asgi.py b/amplipi/asgi.py index bb8c41268..272f57a3f 100755 --- a/amplipi/asgi.py +++ b/amplipi/asgi.py @@ -40,11 +40,13 @@ zc_reg = Process(target=amplipi.app.advertise_service, args=(PORT, zc_que)) zc_reg.start() + @application.on_event('shutdown') def on_shutdown(): zc_que.put('done') zc_reg.join() + if __name__ == '__main__': """ Debug the webserver """ uvicorn.run(application, host='0.0.0.0', port=PORT) diff --git a/amplipi/auth.py b/amplipi/auth.py index 2da3ec4c5..f213077d3 100644 --- a/amplipi/auth.py +++ b/amplipi/auth.py @@ -48,15 +48,17 @@ USERS_FILE = os.path.join(USER_CONFIG_DIR, 'users.json') + class UserData(BaseModel): type: Literal["user", "api"] access_key: Union[str, None] access_key_updated: Union[datetime, None] password_hash: Union[str, None] + def _get_users() -> dict: """ Returns the users file contents """ - users : Dict[str, UserData] = {} + users: Dict[str, UserData] = {} # Load fields from users file (if it exists), falling back to no users. # TODO: We should guard around edge cases more. If a user is able to trick any # component into messing with this file, authentication gets removed. however, we @@ -83,6 +85,7 @@ def _get_users() -> dict: raise e return users + def _set_users(users_update: Dict[str, UserData]) -> None: """ Sets the user file. This takes a partial or full representation of the user data and .update()'s it; to note, this means it will not delete @@ -93,6 +96,7 @@ def _set_users(users_update: Dict[str, UserData]) -> None: with open(USERS_FILE, encoding='utf-8', mode='w') as users_file: json.dump(users, users_file) + def _get_password_hash(user: str) -> str: """ Get a user password hash. This does not handle KeyError exceptions; this should explicitly be handled by the caller. @@ -100,14 +104,17 @@ def _get_password_hash(user: str) -> str: users = _get_users() return users[user]['password_hash'] + def _verify_password(plain_password: str, hashed_password: str) -> bool: """ Verify a plaintext password using constant-time hashing. """ return pwd_context.verify(hashed_password, plain_password) + def _hash_password(password: str) -> str: """ Given a plaintext password, return a hashed password. """ return pwd_context.hash(password) + def _get_access_key(user: str) -> str: """ Get a username's access key. This does not handle KeyError exceptions; this should explicitly be handled by the caller @@ -115,6 +122,7 @@ def _get_access_key(user: str) -> str: users = _get_users() return users[user]["access_key"] + def create_access_key(user: str) -> str: """ Creates an access key. Also creates the user if it does not already exist. """ users = _get_users() @@ -128,6 +136,7 @@ def create_access_key(user: str) -> str: _set_users(users) return access_key + def set_password_hash(user: str, password: str) -> None: """ Sets a password for a given user. (Re/)sets the session/access key for a user. If the user does not exist, it is created. @@ -140,20 +149,23 @@ def set_password_hash(user: str, password: str) -> None: _set_users(users) create_access_key(user) + def unset_password_hash(user) -> None: """ Removes a password for a given user. """ users = _get_users() try: del users[user]["password_hash"] - except KeyError: # user doesn't exist, or has no "password_hash" + except KeyError: # user doesn't exist, or has no "password_hash" pass _set_users(users) + def user_exists(username: str) -> bool: """ Utility function for determining if a user exists """ users = _get_users() return username in users.keys() + def _user_password_set(username: str) -> bool: """ Utility function for determining if a user has a password set. """ users = _get_users() @@ -172,6 +184,7 @@ def _user_password_set(username: str) -> bool: return True + def user_access_key_set(username: str) -> bool: """ Utility function for determing if a user has a session key set """ users = _get_users() @@ -186,12 +199,14 @@ def user_access_key_set(username: str) -> bool: return True + def get_access_key(username: str) -> str: """ Given a username, return its access key. """ assert user_access_key_set(username) users = _get_users() return users[username]["access_key"] + def _authenticate_user_with_password(username: str, password: str) -> bool: """ Given a username and a plaintext password, authenticate the user. """ if not _user_password_set(username): @@ -202,6 +217,7 @@ def _authenticate_user_with_password(username: str, password: str) -> bool: print(f"exception in _verify_password(): {e}") return False + def _check_access_key(key: APIKey) -> Union[bool, str]: """ Check a user's access key using constant-time comparison. """ for username in _get_users().keys(): @@ -210,6 +226,7 @@ def _check_access_key(key: APIKey) -> Union[bool, str]: return username return False + def no_user_passwords_set() -> bool: """ Determines if there are no user passwords set. """ for user in _get_users(): @@ -217,6 +234,7 @@ def no_user_passwords_set() -> bool: return False return True + def _next_url(request: Request) -> str: """ Gets the next URL after a login, given a Request. """ if "next_url" in request.query_params.keys() and request.query_params['next_url']: @@ -227,10 +245,13 @@ def _next_url(request: Request) -> str: # The following class & function need to be added to the `app` using `.add_exception_handler`. # See: https://github.com/tiangolo/fastapi/issues/1667 + + class NotAuthenticatedException(Exception): """ An exception used for handling unauthenticated requests """ pass + async def not_authenticated_exception_handler(request: Request, exc: NotAuthenticatedException) -> TemplateResponse: """ Render the login page; used as an exception handler. Code that lands here will appear to come from the original API endpoint; thus we set `next_url` to the current @@ -238,19 +259,22 @@ async def not_authenticated_exception_handler(request: Request, exc: NotAuthenti """ return templates.TemplateResponse("login.html", {"request": request, "next_url": _next_url(request)}, status_code=401) + def cookie_auth(session: APIKey = Depends(APIKeyCookie(name="amplipi-session", auto_error=False))) -> Union[bool, str]: """ Attempt cookie authentication, using the key stored in `amplipi-session`. """ if not session: return False return _check_access_key(session) -def query_param_auth(api_key : APIKey = Depends(APIKeyQuery(name="api-key", auto_error=False))) -> Union[bool, str]: + +def query_param_auth(api_key: APIKey = Depends(APIKeyQuery(name="api-key", auto_error=False))) -> Union[bool, str]: """ Attempt query parameter authentication, using the key provided with the parameter `api-key` """ if not api_key: return False return _check_access_key(api_key) -async def CookieOrParamAPIKey(cookie_result = Depends(cookie_auth), query_param = Depends(query_param_auth), no_passwords = Depends(no_user_passwords_set)) -> bool: + +async def CookieOrParamAPIKey(cookie_result=Depends(cookie_auth), query_param=Depends(query_param_auth), no_passwords=Depends(no_user_passwords_set)) -> bool: """ Authentication scheme. Any one of cookie auth, query param auth, or having no user passwords set will pass this authentication. """ @@ -258,11 +282,13 @@ async def CookieOrParamAPIKey(cookie_result = Depends(cookie_auth), query_param raise NotAuthenticatedException return True + @router.get("/login", response_class=Response) def login_page(request: Request) -> TemplateResponse: """ Render the login page. """ return templates.TemplateResponse("login.html", {"request": request, "next_url": _next_url(request)}) + @router.post("/login", response_class=Response) def login(request: Request, next_url: str = "/", form_data: OAuth2PasswordRequestForm = Depends()): """ Handle a POST to the login page. """ diff --git a/amplipi/ctrl.py b/amplipi/ctrl.py index 3bae034e1..52963ea58 100644 --- a/amplipi/ctrl.py +++ b/amplipi/ctrl.py @@ -25,7 +25,7 @@ from enum import Enum from copy import deepcopy -import os # files +import os # files from pathlib import Path import time @@ -41,7 +41,8 @@ from amplipi import defaults -_DEBUG_API = False # print out a graphical state of the api after each call +_DEBUG_API = False # print out a graphical state of the api after each call + @wrapt.decorator def save_on_success(wrapped, instance: 'Api', args, kwargs): @@ -52,13 +53,16 @@ def save_on_success(wrapped, instance: 'Api', args, kwargs): instance.mark_changes() return result + class ApiCode(Enum): """ Ctrl Api Response code """ OK = 1 ERROR = 2 + class ApiResponse: """ Ctrl Api Response object """ + def __init__(self, code: ApiCode, msg: str = ''): self.code = code self.msg = msg @@ -81,13 +85,14 @@ def ok(): OK = ApiCode.OK ERROR = ApiCode.ERROR + class Api: """ Amplipi Controller API""" # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-public-methods # TODO: move these variables to the init, they should not be class variables. - _initialized = False # we need to know when we initialized first + _initialized = False # we need to know when we initialized first _mock_hw: bool _mock_streams: bool _save_timer: Optional[threading.Timer] = None @@ -102,10 +107,10 @@ class Api: streams: Dict[int, amplipi.streams.AnyStream] lms_mode: bool - # TODO: migrate to init setting instance vars to a disconnected state (API requests will throw Api.DisconnectedException() in this state # with this reinit will be called connect and will attempt to load the configuration and connect to an AmpliPi (mocked or real) # returning a boolean on whether or not it was successful + def __init__(self, settings: models.AppSettings = models.AppSettings(), change_notifier: Optional[Callable[[models.Status], None]] = None): self.reinit(settings, change_notifier) self._initialized = True @@ -143,20 +148,22 @@ def reinit(self, settings: models.AppSettings = models.AppSettings(), change_not # Create firmware interface if needed. If one already exists delete then re-init. if self._initialized: # we need to make sure to mute every zone before resetting the fw - zones_update = models.MultiZoneUpdate(zones=[z.id for z in self.status.zones], update=models.ZoneUpdate(mute=True)) + zones_update = models.MultiZoneUpdate(zones=[z.id for z in self.status.zones], + update=models.ZoneUpdate(mute=True)) self.set_zones(zones_update, force_update=True, internal=True) try: - del self._rt # remove the low level hardware connection + del self._rt # remove the low level hardware connection except AttributeError: pass - self._rt = rt.Mock() if settings.mock_ctrl or self.is_streamer else rt.Rpi() # reset the fw + self._rt = rt.Mock() if settings.mock_ctrl or self.is_streamer else rt.Rpi() # reset the fw # test open the config file, this will throw an exception if there are issues writing to the file - with open(settings.config_file, 'a', encoding='utf-8'): # use append more to make sure we have read and write permissions, but won't overrite the file + # use append more to make sure we have read and write permissions, but won't overwrite the file + with open(settings.config_file, 'a', encoding='utf-8'): pass self.config_file = settings.config_file self.backup_config_file = settings.config_file + '.bak' - self.config_file_valid = True # initially we assume the config file is valid + self.config_file_valid = True # initially we assume the config file is valid errors = [] if config: self.status = config @@ -173,7 +180,7 @@ def reinit(self, settings: models.AppSettings = models.AppSettings(), change_not break errors.append(f'config file "{cfg_path}" does not exist') except Exception as exc: - self.config_file_valid = False # mark the config file as invalid so we don't try to back it up + self.config_file_valid = False # mark the config file as invalid so we don't try to back it up errors.append(f'error loading config file: {exc}') # make a config flag to recognize this unit's subtype @@ -218,7 +225,7 @@ def reinit(self, settings: models.AppSettings = models.AppSettings(), change_not for major, minor, ghash, dirty in self._rt.read_versions(): fw_info = models.FirmwareInfo(version=f'{major}.{minor}', git_hash=f'{ghash:x}', git_dirty=dirty) self.status.info.fw.append(fw_info) - self._update_sys_info() # TODO: does sys info need to be updated at init time? + self._update_sys_info() # TODO: does sys info need to be updated at init time? # detect missing zones if self._mock_hw and not self.is_streamer: @@ -245,7 +252,7 @@ def reinit(self, settings: models.AppSettings = models.AppSettings(), change_not _, mute_all_pst = utils.find(self.status.presets, defaults.MUTE_ALL_ID) if mute_all_pst and mute_all_pst.name == 'Mute All': if mute_all_pst.state and mute_all_pst.state.zones: - muted_zones = { z.id for z in mute_all_pst.state.zones } + muted_zones = {z.id for z in mute_all_pst.state.zones} for z in self.status.zones: if z.id not in muted_zones: mute_all_pst.state.zones.append(models.ZoneUpdateWithId(id=z.id, mute=True)) @@ -283,11 +290,11 @@ def reinit(self, settings: models.AppSettings = models.AppSettings(), change_not # particular source; the client+server connection bootstrapping takes a while, which is a less than ideal # user experience. if self.lms_mode and stream.type == 'lms': - self.streams[stream.id].activate() # type: ignore + self.streams[stream.id].activate() # type: ignore except Exception as exc: print(f"Failed to create '{stream.name}' stream: {exc}") failed_streams.append(stream.id) - self._sync_stream_info() # need to update the status with the new streams + self._sync_stream_info() # need to update the status with the new streams # add/remove dynamic bluetooth stream bt_streams = [sid for sid, stream in self.streams.items() if isinstance(stream, amplipi.streams.Bluetooth)] @@ -421,7 +428,7 @@ def _is_digital(self, sinput: str) -> bool: producing a small amount of white noise. """ try: - sid = int(sinput.replace('stream=','')) + sid = int(sinput.replace('stream=', '')) return sid not in defaults.RCAs except: return True @@ -439,7 +446,8 @@ def get_inputs(self, src: models.Source) -> Dict[Optional[str], str]: """ inputs: Dict[Optional[str], str] = {'': ''} for sid, stream in self.streams.items(): - connectable = stream.requires_src() in [None, src.id] # TODO: remove this filter when sources can dynamically change output + # TODO: remove this filter when sources can dynamically change output + connectable = stream.requires_src() in [None, src.id] connected = src.get_stream() if sid == connected: assert connectable, print(f'Source {src} has invalid input: stream={connected}') @@ -465,7 +473,7 @@ def _check_latest_release(self) -> str: pass return release - def _update_sys_info(self, throttled = True) -> None: + def _update_sys_info(self, throttled=True) -> None: """Update current system information""" if self.status.info is None: raise Exception("No info generated, system in a bad state") @@ -580,7 +588,7 @@ def set_source(self, sid: int, update: models.SourceUpdate, force_update: bool = old_stream.disconnect() # start new stream last_input = src.input - src.input = input_ # reconfigure the input so get_stream knows which stream to get + src.input = input_ # reconfigure the input so get_stream knows which stream to get stream = self.get_stream(src) if stream: stolen_from: Optional[models.Source] = None @@ -596,8 +604,8 @@ def set_source(self, sid: int, update: models.SourceUpdate, force_update: bool = # potentially deactivate the old stream to save resources # NOTE: old_stream and new stream could be the same if force_update is True if old_stream and old_stream != stream and old_stream.is_activated(): - if not old_stream.is_persistent(): # type: ignore - old_stream.deactivate() # type: ignore + if not old_stream.is_persistent(): # type: ignore + old_stream.deactivate() # type: ignore except Exception as iexc: print(f"Failed to update {sid}'s input to {stream.name}: {iexc}") stream.disconnect() @@ -614,20 +622,20 @@ def set_source(self, sid: int, update: models.SourceUpdate, force_update: bool = stolen_from.input = input_ # now that we recovered, show that this failed raise iexc - elif src.input and 'stream=' in src.input: # invalid stream id? + elif src.input and 'stream=' in src.input: # invalid stream id? # TODO: should this stream id validation happen in the Source model? src.input = last_input raise Exception(f'StreamID specified by "{src.input}" not found') elif old_stream and old_stream.is_activated(): - if not old_stream.is_persistent(): # type: ignore - old_stream.deactivate() # type: ignore + if not old_stream.is_persistent(): # type: ignore + old_stream.deactivate() # type: ignore if not self.is_streamer: rt_needs_update = self._is_digital(input_) != self._is_digital(last_input) if rt_needs_update or force_update: src_cfg = self._get_source_config() if not self._rt.update_sources(src_cfg): raise Exception('failed to set source') - self._update_src_info(src) # synchronize the source's info + self._update_src_info(src) # synchronize the source's info if not internal: self.mark_changes() else: @@ -675,7 +683,7 @@ def set_zone(self, zid, update: models.ZoneUpdate, force_update: bool = False, i # update the zone's associated source zones = self.status.zones - if update_source_id or force_update : + if update_source_id or force_update: # the preamp fw needs nearby zones source-ids since each source id register contains the source ids of 3 zones zone_sources = [utils.clamp(zone.source_id, 0, 3) for zone in zones] # update with the pending change @@ -762,7 +770,7 @@ def set_zones(self, multi_update: models.MultiZoneUpdate, force_update: bool = F all_zids = utils.zones_from_all(self.status, multi_update.zones, multi_update.groups) # update each of the zones for zid in all_zids: - zupdate = multi_update.update.copy() # we potentially need to make changes to the underlying update + zupdate = multi_update.update.copy() # we potentially need to make changes to the underlying update if zupdate.name: # ensure all zones don't get named the same zupdate.name = f'{zupdate.name} {zid+1}' @@ -785,13 +793,13 @@ def _update_groups(self) -> None: sources = {z.source_id for z in zones} vols = [z.vol_f for z in zones] vols.sort() - group.mute = False not in mutes # group is only considered muted if all zones are muted + group.mute = False not in mutes # group is only considered muted if all zones are muted if len(sources) == 1: - group.source_id = sources.pop() # TODO: how should we handle different sources in the group? - else: # multiple sources + group.source_id = sources.pop() # TODO: how should we handle different sources in the group? + else: # multiple sources group.source_id = None if vols: - group.vol_f = (vols[0] + vols[-1]) / 2 # group volume is the midpoint between the highest and lowest source + group.vol_f = (vols[0] + vols[-1]) / 2 # group volume is the midpoint between the highest and lowest source else: group.vol_f = models.MIN_VOL_F group.vol_delta = utils.vol_float_to_db(group.vol_f) @@ -861,7 +869,6 @@ def create_group(self, group: models.Group) -> models.Group: # get the new groug's id group.id = self._new_group_id() - # add the new group self.status.groups.append(group) @@ -897,7 +904,8 @@ def create_stream(self, data: models.Stream, internal=False) -> models.Stream: """ Create a new stream """ try: if not internal and data.type == 'rca': - raise Exception(f'Unable to create protected RCA stream, the RCA streams for each RCA input {defaults.RCAs} already exist') + raise Exception( + f'Unable to create protected RCA stream, the RCA streams for each RCA input {defaults.RCAs} already exist') # Make a new stream and add it to streams stream = amplipi.streams.build_stream(data, mock=self._mock_streams) sid = self._new_stream_id() @@ -947,15 +955,15 @@ def delete_stream(self, sid: int, internal=False) -> ApiResponse: del self.streams[sid] i, _ = utils.find(self.status.streams, sid) if i is not None: - del self.status.streams[i] # delete the cached stream state just in case + del self.status.streams[i] # delete the cached stream state just in case self._sync_stream_info() if not internal: self.mark_changes() return ApiResponse.ok() - except KeyError : + except KeyError: msg = f'delete stream failed: {sid} does not exist' except Exception as exc: - msg = f'delete stream failed: {exc}' + msg = f'delete stream failed: {exc}' if internal: raise Exception(msg) return ApiResponse.error(msg) @@ -1007,7 +1015,7 @@ def get_stations(self, sid, stream_index=None) -> Union[ApiResponse, Dict[str, s except Exception: # TODO: throw useful exceptions to next level pass - #print(utils.error('Failed to get station list - it may not exist: {}'.format(e))) + # print(utils.error('Failed to get station list - it may not exist: {}'.format(e))) # TODO: Change these prints to returns in final state return {} @@ -1022,7 +1030,7 @@ def create_preset(self, preset: models.Preset, internal=False) -> Union[ApiRespo # TODO: validate preset pid = self._new_preset_id() preset.id = pid - preset.last_used = None # indicates this preset has never been used + preset.last_used = None # indicates this preset has never been used self.status.presets.append(preset) if not internal: self.mark_changes() @@ -1054,7 +1062,7 @@ def delete_preset(self, pid: int) -> ApiResponse: try: idx, _ = utils.find(self.status.presets, pid) if idx is not None: - del self.status.presets[idx] # delete the cached preset state just in case + del self.status.presets[idx] # delete the cached preset state just in case return ApiResponse.ok() return ApiResponse.error('delete preset failed: {} does not exist'.format(pid)) except KeyError: @@ -1097,7 +1105,7 @@ def _load_preset_state(self, preset_state: models.PresetState) -> None: if src.id is not None: self.set_source(src.id, src.as_update(), internal=True) else: - pass # TODO: support some id-less source concept that allows dynamic source allocation + pass # TODO: support some id-less source concept that allows dynamic source allocation # execute changes group by group in increasing order for group in preset_state.groups or []: @@ -1163,7 +1171,7 @@ def load_preset(self, pid: int, internal=False) -> ApiResponse: last_config = models.Preset( id=defaults.LAST_PRESET_ID, name='Restore last config', - last_used=None, # this need to be in javascript time format + last_used=None, # this need to be in javascript time format state=models.PresetState( sources=deepcopy(status.sources), zones=deepcopy(status.zones), @@ -1196,12 +1204,14 @@ def load_preset(self, pid: int, internal=False) -> ApiResponse: def announce(self, announcement: models.Announcement) -> ApiResponse: """ Create and play an announcement """ # create a temporary announcement stream using fileplayer - resp0 = self.create_stream(models.Stream(type='fileplayer', name='Announcement', url=announcement.media), internal=True) + resp0 = self.create_stream(models.Stream(type='fileplayer', name='Announcement', + url=announcement.media), internal=True) if isinstance(resp0, ApiResponse): return resp0 stream = resp0 # create a temporary preset with all zones connected to the announcement stream and load it - pa_src = models.SourceUpdateWithId(id=announcement.source_id, input=f'stream={stream.id}') # for now we just use the last source + # for now we just use the last source + pa_src = models.SourceUpdateWithId(id=announcement.source_id, input=f'stream={stream.id}') if announcement.zones is None and announcement.groups is None: zones_to_use = {z.id for z in self.status.zones if z.id is not None and not z.disabled} else: @@ -1209,10 +1219,13 @@ def announce(self, announcement: models.Announcement) -> ApiResponse: zones_to_use = utils.enabled_zones(self.status, unique_zones) # Set the volume of the announcement, forcing db only if it is specified if announcement.vol is not None: - pa_zones = [models.ZoneUpdateWithId(id=zid, source_id=pa_src.id, mute=False, vol=announcement.vol) for zid in zones_to_use] + pa_zones = [models.ZoneUpdateWithId(id=zid, source_id=pa_src.id, mute=False, + vol=announcement.vol) for zid in zones_to_use] else: - pa_zones = [models.ZoneUpdateWithId(id=zid, source_id=pa_src.id, mute=False, vol_f=announcement.vol_f) for zid in zones_to_use] - resp1 = self.create_preset(models.Preset(name='PA - announcement', state=models.PresetState(sources=[pa_src], zones=pa_zones))) + pa_zones = [models.ZoneUpdateWithId(id=zid, source_id=pa_src.id, mute=False, + vol_f=announcement.vol_f) for zid in zones_to_use] + resp1 = self.create_preset(models.Preset(name='PA - announcement', + state=models.PresetState(sources=[pa_src], zones=pa_zones))) if isinstance(resp1, ApiResponse): return resp1 pa_preset = resp1 @@ -1232,7 +1245,7 @@ def announce(self, announcement: models.Announcement) -> ApiResponse: if stream_inst.state in ['stopped', 'disconnected']: break resp4 = self.load_preset(defaults.LAST_PRESET_ID, internal=True) - resp5 = self.delete_stream(stream.id, internal=True) # remember to delete the temporary stream + resp5 = self.delete_stream(stream.id, internal=True) # remember to delete the temporary stream if resp5.code != ApiCode.OK: return resp5 self.mark_changes() diff --git a/amplipi/defaults.py b/amplipi/defaults.py index 30ec6a343..2c02f082d 100644 --- a/amplipi/defaults.py +++ b/amplipi/defaults.py @@ -15,8 +15,8 @@ USER_CONFIG_DIR = os.path.join(os.path.expanduser('~'), '.config', 'amplipi') -DEFAULT_CONFIG = { # This is the system state response that will come back from the amplipi box - "sources": [ # this is an array of source objects, each has an id, name, type specifying whether source comes from a local (like RCA) or streaming input like pandora +DEFAULT_CONFIG = { # This is the system state response that will come back from the amplipi box + "sources": [ # this is an array of source objects, each has an id, name, type specifying whether source comes from a local (like RCA) or streaming input like pandora {"id": 0, "name": "Input 1", "input": ""}, {"id": 1, "name": "Input 2", "input": ""}, {"id": 2, "name": "Input 3", "input": ""}, @@ -29,60 +29,67 @@ {"id": RCAs[1], "name": "Input 2", "type": "rca", "index": 1, "disabled": False}, {"id": RCAs[2], "name": "Input 3", "type": "rca", "index": 2, "disabled": False}, {"id": RCAs[3], "name": "Input 4", "type": "rca", "index": 3, "disabled": False}, - {"id": 1000, "name": "Groove Salad", "type": "internetradio", "url": "http://ice6.somafm.com/groovesalad-32-aac", "logo": "https://somafm.com/img3/groovesalad-400.jpg", "disabled": False}, - ], - "zones": [ # this is an array of zones, array length depends on # of boxes connected - {"id": 0, "name": "Zone 1", "source_id": 0, "mute": True, "disabled": False, "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, - {"id": 1, "name": "Zone 2", "source_id": 0, "mute": True, "disabled": False, "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, - {"id": 2, "name": "Zone 3", "source_id": 0, "mute": True, "disabled": False, "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, - {"id": 3, "name": "Zone 4", "source_id": 0, "mute": True, "disabled": False, "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, - {"id": 4, "name": "Zone 5", "source_id": 0, "mute": True, "disabled": False, "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, - {"id": 5, "name": "Zone 6", "source_id": 0, "mute": True, "disabled": False, "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + {"id": 1000, "name": "Groove Salad", "type": "internetradio", "url": "http://ice6.somafm.com/groovesalad-32-aac", + "logo": "https://somafm.com/img3/groovesalad-400.jpg", "disabled": False}, + ], + "zones": [ # this is an array of zones, array length depends on # of boxes connected + {"id": 0, "name": "Zone 1", "source_id": 0, "mute": True, "disabled": False, + "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + {"id": 1, "name": "Zone 2", "source_id": 0, "mute": True, "disabled": False, + "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + {"id": 2, "name": "Zone 3", "source_id": 0, "mute": True, "disabled": False, + "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + {"id": 3, "name": "Zone 4", "source_id": 0, "mute": True, "disabled": False, + "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + {"id": 4, "name": "Zone 5", "source_id": 0, "mute": True, "disabled": False, + "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + {"id": 5, "name": "Zone 6", "source_id": 0, "mute": True, "disabled": False, + "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, ], "groups": [ ], - "presets" : [ - {"id": MUTE_ALL_ID, - # NOTE: additional zones are added automatically to this preset - "name": "Mute All", - "state" : { - "zones" : [ - {"id": 0, "mute": True}, - {"id": 1, "mute": True}, - {"id": 2, "mute": True}, - {"id": 3, "mute": True}, - {"id": 4, "mute": True}, - {"id": 5, "mute": True}, - ] - } - }, - ] + "presets": [{ + # NOTE: additional zones are added automatically to this preset + "id": MUTE_ALL_ID, + "name": "Mute All", + "state": { + "zones": [ + {"id": 0, "mute": True}, + {"id": 1, "mute": True}, + {"id": 2, "mute": True}, + {"id": 3, "mute": True}, + {"id": 4, "mute": True}, + {"id": 5, "mute": True}, + ] + } + }] } -STREAMER_CONFIG = { # This is the system state response that will come back from the amplipi box - "sources": [ # this is an array of source objects, each has an id, name, type specifying whether source comes from a local (like RCA) or streaming input like pandora +STREAMER_CONFIG = { # This is the system state response that will come back from the amplipi box + "sources": [ # this is an array of source objects, each has an id, name, type specifying whether source comes from a local (like RCA) or streaming input like pandora {"id": 0, "name": "Output 1", "input": ""}, {"id": 1, "name": "Output 2", "input": ""}, {"id": 2, "name": "Output 3", "input": ""}, {"id": 3, "name": "Output 4", "input": ""}, ], "streams": [ - {"id": 1000, "name": "Groove Salad", "type": "internetradio", "url": "http://ice6.somafm.com/groovesalad-32-aac", "logo": "https://somafm.com/img3/groovesalad-400.jpg", "disabled": False}, + {"id": 1000, "name": "Groove Salad", "type": "internetradio", "url": "http://ice6.somafm.com/groovesalad-32-aac", + "logo": "https://somafm.com/img3/groovesalad-400.jpg", "disabled": False}, ], - "zones": [ # this is an array of zones, array length depends on # of boxes connected + "zones": [ # this is an array of zones, array length depends on # of boxes connected ], "groups": [ ], - "presets" : [ + "presets": [ ] } -DEFAULT_LMS_CONFIG = { # This is the system state response that will come back from the amplipi box - "sources": [ # this is an array of source objects, each has an id, name, type specifying whether source comes from a local (like RCA) or streaming input like pandora - {"id": 1, "name": "Input 1", "input": f"stream={LMS_DEFAULTS[0]}"}, - {"id": 2, "name": "Input 2", "input": f"stream={LMS_DEFAULTS[1]}"}, - {"id": 3, "name": "Input 3", "input": f"stream={LMS_DEFAULTS[2]}"}, - {"id": 4, "name": "Input 4", "input": f"stream={LMS_DEFAULTS[3]}"}, +DEFAULT_LMS_CONFIG = { # This is the system state response that will come back from the amplipi box + "sources": [ # this is an array of source objects, each has an id, name, type specifying whether source comes from a local (like RCA) or streaming input like pandora + {"id": 1, "name": "Input 1", "input": f"stream={LMS_DEFAULTS[0]}"}, + {"id": 2, "name": "Input 2", "input": f"stream={LMS_DEFAULTS[1]}"}, + {"id": 3, "name": "Input 3", "input": f"stream={LMS_DEFAULTS[2]}"}, + {"id": 4, "name": "Input 4", "input": f"stream={LMS_DEFAULTS[3]}"}, ], # NOTE: streams and groups seem like they should be stored as dictionaries with integer keys # this does not make sense because JSON only allows string based keys @@ -96,40 +103,44 @@ {"id": LMS_DEFAULTS[2], "name": "Music 3", "type": "lms", "server": "localhost"}, {"id": LMS_DEFAULTS[3], "name": "Music 4", "type": "lms", "server": "localhost"}, ], - "zones": [ # this is an array of zones, array length depends on # of boxes connected - {"id": 0, "name": "Zone 1", "source_id": 0, "mute": True, "disabled": False, "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, - {"id": 1, "name": "Zone 2", "source_id": 0, "mute": True, "disabled": False, "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, - {"id": 2, "name": "Zone 3", "source_id": 0, "mute": True, "disabled": False, "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, - {"id": 3, "name": "Zone 4", "source_id": 0, "mute": True, "disabled": False, "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, - {"id": 4, "name": "Zone 5", "source_id": 0, "mute": True, "disabled": False, "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, - {"id": 5, "name": "Zone 6", "source_id": 0, "mute": True, "disabled": False, "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "zones": [ # this is an array of zones, array length depends on # of boxes connected + {"id": 0, "name": "Zone 1", "source_id": 0, "mute": True, "disabled": False, + "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + {"id": 1, "name": "Zone 2", "source_id": 0, "mute": True, "disabled": False, + "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + {"id": 2, "name": "Zone 3", "source_id": 0, "mute": True, "disabled": False, + "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + {"id": 3, "name": "Zone 4", "source_id": 0, "mute": True, "disabled": False, + "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + {"id": 4, "name": "Zone 5", "source_id": 0, "mute": True, "disabled": False, + "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + {"id": 5, "name": "Zone 6", "source_id": 0, "mute": True, "disabled": False, + "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, ], "groups": [ ], - "presets" : [ - {"id": MUTE_ALL_ID, - # NOTE: additional zones are added automatically to this preset - "name": "Mute All", - "state" : { - "zones" : [ - {"id": 0, "mute": True}, - {"id": 1, "mute": True}, - {"id": 2, "mute": True}, - {"id": 3, "mute": True}, - {"id": 4, "mute": True}, - {"id": 5, "mute": True}, - ] - } - }, - ] + "presets": [{ + # NOTE: additional zones are added automatically to this preset"id": MUTE_ALL_ID, + "name": "Mute All", + "state": { + "zones": [ + {"id": 0, "mute": True}, + {"id": 1, "mute": True}, + {"id": 2, "mute": True}, + {"id": 3, "mute": True}, + {"id": 4, "mute": True}, + {"id": 5, "mute": True}, + ] + } + }] } -STREAMER_LMS_CONFIG = { # This is the system state response that will come back from the amplipi box - "sources": [ # this is an array of source objects, each has an id, name, type specifying whether source comes from a local (like RCA) or streaming input like pandora - {"id": 1, "name": "Output 1", "input": f"stream={LMS_DEFAULTS[0]}"}, - {"id": 2, "name": "Output 2", "input": f"stream={LMS_DEFAULTS[1]}"}, - {"id": 3, "name": "Output 3", "input": f"stream={LMS_DEFAULTS[2]}"}, - {"id": 4, "name": "Output 4", "input": f"stream={LMS_DEFAULTS[3]}"}, +STREAMER_LMS_CONFIG = { # This is the system state response that will come back from the amplipi box + "sources": [ # this is an array of source objects, each has an id, name, type specifying whether source comes from a local (like RCA) or streaming input like pandora + {"id": 1, "name": "Output 1", "input": f"stream={LMS_DEFAULTS[0]}"}, + {"id": 2, "name": "Output 2", "input": f"stream={LMS_DEFAULTS[1]}"}, + {"id": 3, "name": "Output 3", "input": f"stream={LMS_DEFAULTS[2]}"}, + {"id": 4, "name": "Output 4", "input": f"stream={LMS_DEFAULTS[3]}"}, ], "streams": [ {"id": LMS_DEFAULTS[0], "name": "Music 1", "type": "lms", "server": "localhost"}, @@ -137,14 +148,15 @@ {"id": LMS_DEFAULTS[2], "name": "Music 3", "type": "lms", "server": "localhost"}, {"id": LMS_DEFAULTS[3], "name": "Music 4", "type": "lms", "server": "localhost"}, ], - "zones": [ # this is an array of zones, array length depends on # of boxes connected + "zones": [ # this is an array of zones, array length depends on # of boxes connected ], "groups": [ ], - "presets" : [ + "presets": [ ] } + def default_config(is_streamer: bool, lms_mode: bool) -> dict: """ Given a little bit of system state, return the correct default configuration for a given appliance. diff --git a/amplipi/eeprom.py b/amplipi/eeprom.py index 6b311c277..c71cddb36 100644 --- a/amplipi/eeprom.py +++ b/amplipi/eeprom.py @@ -13,7 +13,7 @@ pass WP_PIN = 34 -WRITE_CHECK_ADDRESS = (int)((2048/8)-1) #last byte of 2kbit EEPROM, 8bit per address +WRITE_CHECK_ADDRESS = (int)((2048 / 8) - 1) # last byte of 2kbit EEPROM, 8bit per address FORMAT_ADDR = 0x00 SERIAL_ADDR = 0x01 UNIT_TYPE_ADDR = 0x05 @@ -23,12 +23,14 @@ SUPPORTED_FORMATS = [0x00] FORMAT = 0x00 + class UnitType(Enum): """Unit type""" PI = 0 PRO = 1 STREAMER = 2 + class BoardType(Enum): """Matches the I2C address. The address is stored in the lowest 7 bits of the byte. * For EEPROMs connected directly to the Pi's I2C bus, the MSB is 0. @@ -37,7 +39,8 @@ class BoardType(Enum): The MC24C02's base I2C address is 0x50, with the 3 LSBs controlled by pins E[2:0]. """ STREAMER_SUPPORT = 0x50 - PREAMP = 0xD0 + PREAMP = 0xD0 + @dataclass class BoardInfo: @@ -57,17 +60,22 @@ def __str__(self): class EEPROMWriteError(Exception): """Error writing to EEPROM.""" + class EEPROMReadError(Exception): """Error reading from EEPROM.""" + class EEPROMWriteProtectError(Exception): """Error setting EEPROM to write mode.""" + class UnsupportedFormatError(Exception): """Unknown EEPROM format.""" + class EEPROM: """Class for reading from and writing to EEPROM.""" + def __init__(self, bus: int, board: BoardType) -> None: self._i2c = smbus.SMBus(bus) self._address = board.value @@ -102,12 +110,12 @@ def _enable_write(self) -> None: try: val = self._i2c.read_i2c_block_data(self._address, WRITE_CHECK_ADDRESS, 1)[0] - self._i2c.write_i2c_block_data(self._address, WRITE_CHECK_ADDRESS, [val+1]) + self._i2c.write_i2c_block_data(self._address, WRITE_CHECK_ADDRESS, [val + 1]) sleep(0.1) check = self._i2c.read_i2c_block_data(self._address, WRITE_CHECK_ADDRESS, 1)[0] self._i2c.write_i2c_block_data(self._address, WRITE_CHECK_ADDRESS, [val]) sleep(0.1) - if check != val+1: + if check != val + 1: raise EEPROMWriteProtectError("Write enable failed") except OSError as exception: @@ -130,7 +138,7 @@ def _read(self, address: int, length: int) -> List[int]: def _write_number(self, bytes_len: int, addr: int, value: int) -> None: """Write number to EEPROM, big-endian.""" - write_out = [0]*bytes_len + write_out = [0] * bytes_len for i in range(0, bytes_len): write_out[i] = value & 0xFF value >>= 8 @@ -184,7 +192,7 @@ def write_board_rev(self, rev: Tuple[int, str]) -> None: if self.get_format() not in SUPPORTED_FORMATS: raise UnsupportedFormatError("Unsupported format") self._write_number(1, BOARD_REV_ADDR, rev[0]) - self._write_letter(BOARD_REV_ADDR+1, rev[1].upper()) + self._write_letter(BOARD_REV_ADDR + 1, rev[1].upper()) def get_format(self) -> int: """Check EEPROM format.""" @@ -219,14 +227,14 @@ def get_board_rev(self) -> Tuple[int, str]: raise UnsupportedFormatError("Unsupported format") return (self._read_number(1, BOARD_REV_ADDR), - self._read_letter(BOARD_REV_ADDR+1)) + self._read_letter(BOARD_REV_ADDR + 1)) def get_board_info(self) -> BoardInfo: """Get board info from EEPROM.""" return BoardInfo(self.get_serial(), - self.get_unit_type(), - self.get_board_type(), - self.get_board_rev()) + self.get_unit_type(), + self.get_board_type(), + self.get_board_rev()) def write_board_info(self, board_info: BoardInfo) -> None: """Write board info to EEPROM. diff --git a/amplipi/extras.py b/amplipi/extras.py index 5103a90fe..ae96723da 100644 --- a/amplipi/extras.py +++ b/amplipi/extras.py @@ -22,18 +22,20 @@ from amplipi import models from amplipi import utils + def vol_string(vol, min_vol=models.MIN_VOL_F, max_vol=models.MAX_VOL_F): """ Make a visual representation of a volume """ vol_range = max_vol - min_vol + 1 vol_str_len = 20 vol_scale = vol_range / vol_str_len - vol_level = int((vol - min_vol) / vol_scale) + vol_level = int((vol - min_vol) / vol_scale) assert 0 <= vol_level < vol_str_len vol_str = ['-'] * vol_str_len - vol_str[vol_level] = '|' # place the volume slider bar at its current spot - return ''.join(vol_str) # turn that char array into a string + vol_str[vol_level] = '|' # place the volume slider bar at its current spot + return ''.join(vol_str) # turn that char array into a string + -def visualize_api(status : models.Status): +def visualize_api(status: models.Status): """Creates a command line visualization of the system state, mostly the volume levels of each zone and group Returns: diff --git a/amplipi/formatter.py b/amplipi/formatter.py index 266ff5ab6..2a3b2c398 100644 --- a/amplipi/formatter.py +++ b/amplipi/formatter.py @@ -21,11 +21,13 @@ import argparse + class AmpliPiHelpFormatter(argparse.HelpFormatter): """ Custom help formatter that shows default values and doesn't show duplicate metavars. """ # https://stackoverflow.com/a/23941599/8055271 + def _format_action_invocation(self, action): if not action.option_strings: metavar, = self._metavar_formatter(action, action.dest)(1) diff --git a/amplipi/hw.py b/amplipi/hw.py index d60d62553..70ee1fc9a 100755 --- a/amplipi/hw.py +++ b/amplipi/hw.py @@ -70,31 +70,31 @@ class Preamp: class Reg(Enum): """ Preamp register addresses """ - SRC_AD = 0x00 - ZONE123_SRC = 0x01 - ZONE456_SRC = 0x02 - MUTE = 0x03 - STANDBY = 0x04 - VOL_ZONE1 = 0x05 - VOL_ZONE2 = 0x06 - VOL_ZONE3 = 0x07 - VOL_ZONE4 = 0x08 - VOL_ZONE5 = 0x09 - VOL_ZONE6 = 0x0A - POWER_STATUS = 0x0B - FAN_CTRL = 0x0C - LED_CTRL = 0x0D - LED_VAL = 0x0E - EXPANSION = 0x0F - HV1_VOLTAGE = 0x10 - AMP_TEMP1 = 0x11 - HV1_TEMP = 0x12 - AMP_TEMP2 = 0x13 - VERSION_MAJOR = 0xFA - VERSION_MINOR = 0xFB - GIT_HASH_27_20 = 0xFC - GIT_HASH_19_12 = 0xFD - GIT_HASH_11_04 = 0xFE + SRC_AD = 0x00 + ZONE123_SRC = 0x01 + ZONE456_SRC = 0x02 + MUTE = 0x03 + STANDBY = 0x04 + VOL_ZONE1 = 0x05 + VOL_ZONE2 = 0x06 + VOL_ZONE3 = 0x07 + VOL_ZONE4 = 0x08 + VOL_ZONE5 = 0x09 + VOL_ZONE6 = 0x0A + POWER_STATUS = 0x0B + FAN_CTRL = 0x0C + LED_CTRL = 0x0D + LED_VAL = 0x0E + EXPANSION = 0x0F + HV1_VOLTAGE = 0x10 + AMP_TEMP1 = 0x11 + HV1_TEMP = 0x12 + AMP_TEMP2 = 0x13 + VERSION_MAJOR = 0xFA + VERSION_MINOR = 0xFB + GIT_HASH_27_20 = 0xFC + GIT_HASH_19_12 = 0xFD + GIT_HASH_11_04 = 0xFE GIT_HASH_STATUS = 0xFF def __init__(self, unit: int, bus: SMBus): @@ -114,8 +114,8 @@ def available(self) -> bool: """ try: self.bus.write_byte_data(self.addr, self.Reg.VERSION_MAJOR.value, 0) - except: #OSError as err: - #print(err) + except: # OSError as err: + # print(err) return False return True @@ -192,14 +192,14 @@ class Preamps: MAX_UNITS = 6 """ The maximum number of AmpliPi units, including the master """ - BAUD_RATES = ( 1200, 1800, 2400, 4800, 9600, 19200, - 38400, 57600, 115200, 128000, 230400, 256000, + BAUD_RATES = (1200, 1800, 2400, 4800, 9600, 19200, + 38400, 57600, 115200, 128000, 230400, 256000, 460800, 500000, 576000, 921600, 1000000) """ Valid UART baud rates """ class Pin(Enum): """ Pi GPIO pins to control the master unit's preamp """ - NRST = 4 + NRST = 4 BOOT0 = 5 preamps: List[Preamp] @@ -209,7 +209,7 @@ def __init__(self, reset: bool = False): self.preamps = [] if reset: print('Resetting all preamps...') - self.reset(unit = 0, bootloader = False) + self.reset(unit=0, bootloader=False) else: self.enumerate() @@ -326,7 +326,7 @@ def program(self, filepath: str, unit: int = 0, baud: int = 115200) -> bool: baud = 9600 if unit > 0 else baud # Reset the unit to be programmed into bootloader mode - self.reset(unit = unit, bootloader = True) + self.reset(unit=unit, bootloader=True) # Set UART passthrough on any previous units for p in range(unit): @@ -337,7 +337,7 @@ def program(self, filepath: str, unit: int = 0, baud: int = 115200) -> bool: # Before attempting programming, verify the unit even exists. try: subprocess.run([f'stm32flash -b {baud} {PI_SERIAL_PORT}'], shell=True, - check=True, stdout=subprocess.DEVNULL) + check=True, stdout=subprocess.DEVNULL) except subprocess.CalledProcessError: # Failed to handshake with the bootloader. Assume unit not present. print(f"Couldn't communicate with {self.unit_num_to_name(unit)}'s bootloader.") @@ -352,7 +352,7 @@ def program(self, filepath: str, unit: int = 0, baud: int = 115200) -> bool: prog_success = False try: subprocess.run([f'stm32flash -vb {baud} -w {filepath} {PI_SERIAL_PORT}'], - shell=True, check=True) + shell=True, check=True) prog_success = True except subprocess.CalledProcessError: # TODO: Error handling @@ -431,13 +431,9 @@ def program_all(self, filepath: str, num_units: Optional[int] = None, baud: int return False -#class PeakDetect: - #""" """ - - if __name__ == '__main__': parser = argparse.ArgumentParser(description="Interface to AmpliPi's Preamp Board firmware", - formatter_class=formatter.AmpliPiHelpFormatter) + formatter_class=formatter.AmpliPiHelpFormatter) parser.add_argument('-r', '--reset', action='store_true', default=False, help='reset the preamp(s) before communicating over I2C') parser.add_argument('--flash', metavar='FW.bin', @@ -448,14 +444,14 @@ def program_all(self, filepath: str, num_units: Optional[int] = None, baud: int help='print preamp firmware version(s)') parser.add_argument('-l', '--log', metavar='LEVEL', default='WARNING', help='set logging level as DEBUG, INFO, WARNING, ERROR, or CRITICAL') - parser.add_argument('-n', '--num-units', metavar='N', type=int, choices=range(1,7), + parser.add_argument('-n', '--num-units', metavar='N', type=int, choices=range(1, 7), help='set the number of preamps instead of auto-detecting') args = parser.parse_args() preamps = Preamps(args.reset) if args.flash is not None: - if not preamps.program_all(filepath = args.flash, num_units = args.num_units, baud = args.baud): + if not preamps.program_all(filepath=args.flash, num_units=args.num_units, baud=args.baud): sys.exit(2) if len(preamps) == 0: diff --git a/amplipi/models.py b/amplipi/models.py index bc5d83244..c8bf519c1 100644 --- a/amplipi/models.py +++ b/amplipi/models.py @@ -52,20 +52,24 @@ SOURCE_DISCONNECTED = -1 """ Indicate no source connection, simulated in SW by muting zone for now """ + def pcnt2Vol(pcnt: float) -> int: """ Convert a percent to volume in dB """ assert MIN_VOL_F <= pcnt <= MAX_VOL_F return round(pcnt * (MAX_VOL_DB - MIN_VOL_DB) + MIN_VOL_DB) + class fields(SimpleNamespace): """ AmpliPi's field types """ ID = Field(description='Unique identifier') Name = Field(description='Friendly name') - SourceId = Field(ge=SOURCE_DISCONNECTED, le=MAX_SOURCES-1, description='id of the connected source, or -1 for no connection') + SourceId = Field(ge=SOURCE_DISCONNECTED, le=MAX_SOURCES - 1, + description='id of the connected source, or -1 for no connection') ZoneId = Field(ge=0, le=35) Mute = Field(description='Set to true if output is muted') Volume = Field(ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Output volume in dB') - VolumeF = Field(ge=MIN_VOL_F, le=MAX_VOL_F, description='Output volume as a floating-point scalar from 0.0 to 1.0 representing MIN_VOL_DB to MAX_VOL_DB') + VolumeF = Field(ge=MIN_VOL_F, le=MAX_VOL_F, + description='Output volume as a floating-point scalar from 0.0 to 1.0 representing MIN_VOL_DB to MAX_VOL_DB') VolumeMin = Field(ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Min output volume in dB') VolumeMax = Field(ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Max output volume in dB') GroupMute = Field(description='Set to true if output is all zones muted') @@ -80,25 +84,30 @@ class fields(SimpleNamespace): * Nothing ('') behind the scenes this is muxed to a digital output """) + class fields_w_default(SimpleNamespace): """ AmpliPi's field types that need a default value These are needed because there is ambiguity where an optional field has a default value """ # TODO: less duplication - SourceId = Field(default=0, ge=SOURCE_DISCONNECTED, le=MAX_SOURCES-1, description='id of the connected source, or -1 for no connection') + SourceId = Field(default=0, ge=SOURCE_DISCONNECTED, le=MAX_SOURCES - 1, + description='id of the connected source, or -1 for no connection') Mute = Field(default=True, description='Set to true if output is muted') Volume = Field(default=MIN_VOL_DB, ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Output volume in dB') - VolumeF = Field(default=MIN_VOL_F, ge=MIN_VOL_F, le=MAX_VOL_F, description='Output volume as a floating-point scalar from 0.0 to 1.0 representing MIN_VOL_DB to MAX_VOL_DB') + VolumeF = Field(default=MIN_VOL_F, ge=MIN_VOL_F, le=MAX_VOL_F, + description='Output volume as a floating-point scalar from 0.0 to 1.0 representing MIN_VOL_DB to MAX_VOL_DB') VolumeMin = Field(default=MIN_VOL_DB, ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Min output volume in dB') VolumeMax = Field(default=MAX_VOL_DB, ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Max output volume in dB') GroupMute = Field(default=True, description='Set to true if output is all zones muted') GroupVolume = Field(default=MIN_VOL_F, ge=MIN_VOL_F, le=MAX_VOL_F, description='Average output volume') - GroupVolumeF = Field(default=MIN_VOL_F, ge=MIN_VOL_F, le=MAX_VOL_F, description='Average output volume as a floating-point number') + GroupVolumeF = Field(default=MIN_VOL_F, ge=MIN_VOL_F, le=MAX_VOL_F, + description='Average output volume as a floating-point number') Disabled = Field(default=False, description='Set to true if not connected to a speaker') + class Base(BaseModel): """ Base class for AmpliPi Models id: Per type unique id generated on instance creation @@ -109,26 +118,30 @@ class Base(BaseModel): id: Optional[int] = fields.ID name: str = fields.Name + class BaseUpdate(BaseModel): """ Base class for updates to AmpliPi models name: Associated name, updated if necessary """ name: Optional[str] = fields.Name + class SourceInfo(BaseModel): name: str - state: str # paused, playing, stopped, unknown, loading ??? + state: str # paused, playing, stopped, unknown, loading ??? artist: Optional[str] track: Optional[str] album: Optional[str] - station: Optional[str] # name of radio station + station: Optional[str] # name of radio station img_url: Optional[str] supported_cmds: List[str] = [] + class Source(Base): """ An audio source """ input: str = fields.AudioInput - info: Optional[SourceInfo] = Field(description='Additional info about the current audio playing from the stream (generated during playback)') + info: Optional[SourceInfo] = Field( + description='Additional info about the current audio playing from the stream (generated during playback)') def get_stream(self) -> Optional[int]: """ Get a source's connected stream if any """ @@ -151,7 +164,7 @@ class Config: 'examples': { 'stream connected': { 'value': { - 'id' : 1, + 'id': 1, 'name': '1', 'input': 'stream=1004', 'info': { @@ -166,7 +179,7 @@ class Config: }, 'nothing connected': { 'value': { - 'id' : 2, + 'id': 2, 'name': '2', 'input': '', 'info': { @@ -177,7 +190,7 @@ class Config: }, 'rca connected': { 'value': { - 'id' : 3, + 'id': 3, 'name': '3', 'input': 'stream=999', 'info': { @@ -189,6 +202,7 @@ class Config: } } + class SourceUpdate(BaseUpdate): """ Partial reconfiguration of an audio Source """ input: Optional[str] = fields.AudioInput @@ -208,9 +222,10 @@ class Config: } } + class SourceUpdateWithId(SourceUpdate): """ Partial reconfiguration of a specific audio Source """ - id : int = Field(ge=0,le=MAX_SOURCES-1) + id: int = Field(ge=0, le=MAX_SOURCES - 1) def as_update(self) -> SourceUpdate: """ Convert to SourceUpdate """ @@ -218,6 +233,7 @@ def as_update(self) -> SourceUpdate: update.pop('id') return SourceUpdate.parse_obj(update) + class Zone(Base): """ Audio output to a stereo pair of speakers, typically belonging to a room """ source_id: int = fields_w_default.SourceId @@ -237,11 +253,11 @@ def as_update(self) -> 'ZoneUpdate': class Config: schema_extra = { 'examples': { - 'Living Room' : { + 'Living Room': { 'value': { 'name': 'Living Room', 'source_id': 1, - 'mute' : False, + 'mute': False, 'vol': pcnt2Vol(0.69), 'vol_f': 0.69, 'vol_min': MIN_VOL_DB, @@ -249,11 +265,11 @@ class Config: 'disabled': False, } }, - 'Dining Room' : { + 'Dining Room': { 'value': { 'name': 'Dining Room', 'source_id': 2, - 'mute' : True, + 'mute': True, 'vol': pcnt2Vol(0.19), 'vol_f': 0.19, 'vol_min': int(0.1 * (MAX_VOL_DB + MIN_VOL_DB)), @@ -264,6 +280,7 @@ class Config: } } + class ZoneUpdate(BaseUpdate): """ Reconfiguration of a Zone """ source_id: Optional[int] = fields.SourceId @@ -311,6 +328,7 @@ class Config: }, } + class ZoneUpdateWithId(ZoneUpdate): """ Reconfiguration of a specific Zone """ id: int = fields.ZoneId @@ -321,6 +339,7 @@ def as_update(self) -> ZoneUpdate: update.pop('id') return ZoneUpdate.parse_obj(update) + class MultiZoneUpdate(BaseModel): """ Reconfiguration of multiple zones specified by zone_ids and group_ids """ @@ -333,25 +352,26 @@ class Config: 'examples': { 'Connect all zones to source 1': { 'value': { - 'zones': [0,1,2,3,4,5], - 'update': { 'source_id': 0 } + 'zones': [0, 1, 2, 3, 4, 5], + 'update': {'source_id': 0} } }, 'Change the relative volume on all zones': { 'value': { - 'zones': [0,1,2,3,4,5], - 'update': { 'vol_f': 0.5, "mute": False } + 'zones': [0, 1, 2, 3, 4, 5], + 'update': {'vol_f': 0.5, "mute": False} } }, }, } + class Group(Base): """ A group of zones that can share the same audio input and be controlled as a group ie. Upstairs. Volume, mute, and source_id fields are aggregates of the member zones.""" source_id: Optional[int] = fields.SourceId - zones: List[int] = fields.Zones # should be a set, but JSON doesn't have native sets + zones: List[int] = fields.Zones # should be a set, but JSON doesn't have native sets mute: Optional[bool] = fields.GroupMute vol_delta: Optional[int] = fields.GroupVolume vol_f: Optional[float] = fields.GroupVolumeF @@ -374,7 +394,7 @@ class Config: 'Downstairs Group': { 'value': { 'name': 'Downstairs', - 'zones': [6,7,8,9] + 'zones': [6, 7, 8, 9] } } }, @@ -392,7 +412,7 @@ class Config: 'value': { 'id': 102, 'name': 'Downstairs', - 'zones': [6,7,8,9], + 'zones': [6, 7, 8, 9], 'vol_delta': pcnt2Vol(0.63), 'vol_f': 0.63, } @@ -400,6 +420,7 @@ class Config: }, } + class GroupUpdate(BaseUpdate): """ Reconfiguration of a Group """ source_id: Optional[int] = fields.SourceId @@ -414,7 +435,7 @@ class Config: 'Rezone group': { 'value': { 'name': 'Upstairs', - 'zones': [3,4,5] + 'zones': [3, 4, 5] } }, 'Change name': { @@ -445,6 +466,7 @@ class Config: }, } + class GroupUpdateWithId(GroupUpdate): """ Reconfiguration of a specific Group """ id: int @@ -455,6 +477,7 @@ def as_update(self) -> GroupUpdate: update.pop('id') return GroupUpdate.parse_obj(update) + class Stream(Base): """ Digital stream such as Pandora, AirPlay or Spotify """ type: str = Field(description="""stream type @@ -482,7 +505,8 @@ class Stream(Base): token: Optional[str] = Field(description='Plexamp token for server.json') server: Optional[str] = Field(description='Server url') index: Optional[int] = Field(description='RCA index') - disabled: Optional[bool] = Field(description="Soft disable use of this stream. It won't be shown as a selectable option") + disabled: Optional[bool] = Field( + description="Soft disable use of this stream. It won't be shown as a selectable option") ap2: Optional[bool] = Field(description='Is Airplay stream AirPlay2?') # add examples for each type of stream @@ -509,7 +533,7 @@ class Config: 'value': { 'name': 'Replace this text with a name you like!', 'type': 'dlna' - } + } }, 'Add Groove Salad Internet Radio Station': { 'value': { @@ -550,7 +574,7 @@ class Config: 'ap2': True } }, - "Play single file or announcement" : { + "Play single file or announcement": { 'value': { 'name': 'Play NASA Announcement', 'type': 'fileplayer', @@ -631,11 +655,13 @@ class Config: } } + @lru_cache(1) def optional_stream_fields() -> Set: """ Extra fields that can be preset in a stream """ model = Stream(id=0, name='', type='fake').dict() - return { k for k, v in model.items() if v is None } + return {k for k, v in model.items() if v is None} + class StreamUpdate(BaseUpdate): """ Reconfiguration of a Stream """ @@ -648,7 +674,8 @@ class StreamUpdate(BaseUpdate): freq: Optional[str] server: Optional[str] ap2: Optional[bool] = Field(description='Is Airplay stream AirPlay2?') - disabled: Optional[bool] = Field(description="Soft disable use of this stream. It won't be shown as a selectable option") + disabled: Optional[bool] = Field( + description="Soft disable use of this stream. It won't be shown as a selectable option") class Config: schema_extra = { @@ -662,8 +689,8 @@ class Config: 'Change name': { 'value': { 'name': 'Matt and Kim Radio' - } - }, + } + }, 'Change pandora radio station': { 'value': { 'station': '0982034049300' @@ -690,17 +717,20 @@ class StreamCommand(str, Enum): ACTIVATE = 'activate' DEACTIVATE = 'deactivate' + class PresetState(BaseModel): """ A set of partial configuration changes to make to sources, zones, and groups """ sources: Optional[List[SourceUpdateWithId]] zones: Optional[List[ZoneUpdateWithId]] groups: Optional[List[GroupUpdateWithId]] + class Command(BaseModel): """ A command to execute on a stream """ stream_id: int = Field(description="Stream to execute the command on") cmd: str = Field(description="Command to execute") + class Preset(Base): """ A partial controller configuration the can be loaded on demand. In addition to most of the configuration found in Status, this can contain commands as well that configure the state of different streaming services. @@ -709,7 +739,6 @@ class Preset(Base): commands: Optional[List[Command]] last_used: Union[int, None] = None - class Config: schema_extra = { 'creation_examples': { @@ -749,6 +778,7 @@ class Config: } } + class PresetUpdate(BaseUpdate): """ Changes to a current preset @@ -777,14 +807,16 @@ class Config: } } + class Announcement(BaseModel): """ A PA-like Announcement IF no zones or groups are specified, all available zones are used """ - media : str = Field(description="URL to media to play as the announcement") - vol: Optional[int] = Field(default=None, ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Output volume in dB, overrides vol_f') + media: str = Field(description="URL to media to play as the announcement") + vol: Optional[int] = Field(default=None, ge=MIN_VOL_DB, le=MAX_VOL_DB, + description='Output volume in dB, overrides vol_f') vol_f: float = Field(default=0.5, ge=MIN_VOL_F, le=MAX_VOL_F, description="Output Volume (float)") - source_id: int = Field(default=3, ge=0, le=MAX_SOURCES-1, description='Source to announce with') + source_id: int = Field(default=3, ge=0, le=MAX_SOURCES - 1, description='Source to announce with') zones: Optional[List[int]] = fields.Zones groups: Optional[List[int]] = fields.Groups @@ -799,24 +831,28 @@ class Config: } } + class FirmwareInfo(BaseModel): """ Firmware Info for an AmpliPi controller or expansion unit's preamp board """ version: str = Field(default='unknown', description="preamp firmware version") git_hash: str = Field(default='unknown', description="short git hash of firmware") git_dirty: bool = Field(default=False, description="True if local changes were made. Used for development.") + class Info(BaseModel): """ AmpliPi System information """ version: str = Field(description="software version") config_file: str = Field(default='unknown', description='config file location') - mock_ctrl: bool = Field(default=False, description='Is the controller being mocked? Indicates AmpliPi hardware is not connected') + mock_ctrl: bool = Field( + default=False, description='Is the controller being mocked? Indicates AmpliPi hardware is not connected') mock_streams: bool = Field(default=False, description='Are streams being faked? Used for testing.') is_streamer: bool = Field(default=False, description='Are we a streamer unit?') online: bool = Field(default=False, description='can the system connect to the internet?') latest_release: str = Field(default='unknown', description='Latest software release available from GitHub') access_key: str = Field(default='', description='session token/API key used for authentication') lms_mode: bool = Field(default=False, description='Are we running in LMS mode?') - fw: List[FirmwareInfo] = Field(default=[], description='firmware information for each connected controller or expansion unit') + fw: List[FirmwareInfo] = Field( + default=[], description='firmware information for each connected controller or expansion unit') class Config: schema_extra = { @@ -844,12 +880,13 @@ class Config: } } + class Status(BaseModel): """ Full Controller Configuration and Status """ sources: List[Source] = [Source(id=i, name=str(i)) for i in range(MAX_SOURCES)] zones: List[Zone] = [Zone(id=i, name=f'Zone {i + 1}') for i in range(6)] groups: List[Group] = [] - streams: List[Stream] = [Stream(id=996+i, name=f'Input {i + 1}', type='rca', index=i) for i in range(MAX_SOURCES)] + streams: List[Stream] = [Stream(id=996 + i, name=f'Input {i + 1}', type='rca', index=i) for i in range(MAX_SOURCES)] presets: List[Preset] = [] info: Optional[Info] @@ -858,326 +895,327 @@ class Config: 'examples': { "Status of Jason's AmpliPi": { 'value': { - 'groups': [ { 'id': 100, - 'mute': True, - 'name': 'Upstairs', - 'vol_delta': -39, - 'vol_f': 0.51, - 'zones': [0, 1, 2, 3, 4, 5, 6, 7, 11, 16]}, - { 'id': 102, - 'mute': True, - 'name': 'Outside', - 'source_id': 1, - 'vol_delta': -41, - 'vol_f': 0.4909090909090909, - 'zones': [9, 10]}, - { 'id': 103, - 'mute': True, - 'name': 'Offices', - 'vol_delta': -54, - 'vol_f': 0.33, - 'zones': [0, 7]}, - { 'id': 104, - 'mute': True, - 'name': 'Downstairs', - 'source_id': 1, - 'vol_delta': -57, - 'vol_f': 0.28500000000000003, - 'zones': [12, 13, 14, 15, 17]}, - { 'id': 105, - 'mute': True, - 'name': 'Main Unit', - 'vol_delta': -39, - 'vol_f': 0.51, - 'zones': [0, 1, 2, 3, 4, 5]}, - { 'id': 106, - 'mute': True, - 'name': 'Expander 1 (HV)', - 'vol_delta': -39, - 'vol_f': 0.515, - 'zones': [6, 7, 8, 9, 10, 11]}, - { 'id': 107, - 'mute': True, - 'name': 'Expander 2', - 'vol_delta': -58, - 'vol_f': 0.275, - 'zones': [12, 13, 14, 15, 16, 17]} - ], - 'info': { 'config_file': 'house.json', - 'fw': [ { 'git_dirty': False, - 'git_hash': 'de0f8eb', - 'version': '1.6'}, - { 'git_dirty': False, - 'git_hash': 'de0f8eb', - 'version': '1.6'}, - { 'git_dirty': False, - 'git_hash': 'de0f8eb', - 'version': '1.6'}], - 'latest_release': '0.1.9', - 'mock_ctrl': False, - 'mock_streams': False, - 'is_streamer': False, - 'lms_mode': False, - 'online': True, - 'version': '0.1.9'}, - 'presets': [ { 'id': 10000, - 'last_used': 1658242203, - 'name': 'Mute All', - 'state': { 'zones': [ {'id': 0, 'mute': True}, - {'id': 1, 'mute': True}, - {'id': 2, 'mute': True}, - {'id': 3, 'mute': True}, - {'id': 4, 'mute': True}, - {'id': 5, 'mute': True}, - {'id': 6, 'mute': True}, - {'id': 7, 'mute': True}, - {'id': 8, 'mute': True}, - {'id': 9, 'mute': True}, - {'id': 10, 'mute': True}, - {'id': 11, 'mute': True}, - {'id': 12, 'mute': True}, - {'id': 13, 'mute': True}, - {'id': 14, 'mute': True}, - {'id': 15, 'mute': True}, - {'id': 16, 'mute': True}, - {'id': 17, 'mute': True}]}} - ], - 'sources': [ { 'id': 0, - 'info': { 'img_url': 'static/imgs/disconnected.png', - 'name': 'None', - 'state': 'stopped', - 'supported_cmds': []}, - 'input': '', - 'name': 'TV'}, - { 'id': 1, - 'info': { 'img_url': 'static/imgs/disconnected.png', - 'name': 'None', - 'state': 'stopped', - 'supported_cmds': []}, - 'input': '', - 'name': 'Record Player'}, - { 'id': 2, - 'info': { 'album': 'Charleston Butterfly', - 'artist': 'Parov Stelar', - 'img_url': 'http://mediaserver-cont-sv5-2-v4v6.pandora.com/images/00/4c/b7/12/d64a4ffe82251fcc9c44555c/1080W_1080H.jpg', - 'name': 'Blackmill Radio - pandora', - 'state': 'playing', - 'station': 'Blackmill Radio', - 'supported_cmds': [ 'play', 'pause', 'stop', 'next', - 'love', 'ban', 'shelve'], - 'track': 'Chambermaid Swing'}, - 'input': 'stream=1006', - 'name': 'Input 3'}, - { 'id': 3, - 'info': { 'album': 'Joshua Bell, Romance of the Violin', - 'artist': 'Fryderyk Chopin', - 'img_url': 'http://cont.p-cdn.us/images/e9/cc/f2/8e/890e4e5e9940c98ba864aaee/1080W_1080H.jpg', - 'name': 'Classical - pandora', - 'state': 'playing', - 'station': 'Antonio Vivaldi Radio', - 'supported_cmds': [ 'play', 'pause', 'stop', 'next', - 'love', 'ban', 'shelve'], - 'track': 'Nocturne For Piano In C Sharp Minor, Kk ' - 'Anh.ia/6'}, - 'input': 'stream=1005', - 'name': 'Input 4'}], - 'streams': [ { 'id': 1000, - 'logo': 'https://somafm.com/img3/groovesalad-400.jpg', - 'name': 'Groove Salad', - 'type': 'internetradio', - 'url': 'http://ice6.somafm.com/groovesalad-32-aac'}, - {'id': 1001, 'name': "Jason's House", 'type': 'airplay'}, - {'id': 1002, 'name': 'Jasons House', 'type': 'spotify'}, - {'id': 1003, 'name': "Jason's House", 'type': 'dlna'}, - { 'id': 1004, - 'name': 'Matt and Kim Radio', - 'password': '', - 'station': '135242131387190035', - 'type': 'pandora', - 'user': 'example@micro-nova.com'}, - { 'id': 1005, - 'name': 'Classical', - 'password': '', - 'station': '134892486689565953', - 'type': 'pandora', - 'user': 'example@micro-nova.com'}, - { 'id': 1006, - 'name': 'Blackmill Radio', - 'password': '', - 'station': '91717963601693449', - 'type': 'pandora', - 'user': 'example@micro-nova.com'}, - { 'id': 1007, - 'logo': 'http://www.beatlesradio.com/content/images/thumbs/0000587.gif', - 'name': 'Beatles Radio', - 'type': 'internetradio', - 'url': 'http://www.beatlesradio.com:8000/stream/1/'}], - 'zones': [ { 'disabled': False, - 'id': 0, - 'mute': True, - 'name': 'Software Office', - 'source_id': 2, - 'vol': -56, - 'vol_f': 0.28, - 'vol_max': -20, - 'vol_min': -70}, - { 'disabled': False, - 'id': 1, - 'mute': True, - 'name': 'Upstairs Living Room', - 'source_id': 3, - 'vol': -49, - 'vol_f': 0.42, - 'vol_max': -20, - 'vol_min': -70}, - { 'disabled': False, - 'id': 2, + 'groups': [{'id': 100, 'mute': True, - 'name': 'Upstairs Dining', - 'source_id': 0, - 'vol': -66, - 'vol_f': 0.08, - 'vol_max': -20, - 'vol_min': -70}, - { 'disabled': False, - 'id': 3, + 'name': 'Upstairs', + 'vol_delta': -39, + 'vol_f': 0.51, + 'zones': [0, 1, 2, 3, 4, 5, 6, 7, 11, 16]}, + {'id': 102, 'mute': True, - 'name': 'Upstairs Laundry', - 'source_id': 0, - 'vol': -66, - 'vol_f': 0.08, - 'vol_max': -20, - 'vol_min': -70}, - { 'disabled': False, - 'id': 4, - 'mute': True, - 'name': 'Upstairs Kitchen High', - 'source_id': 0, - 'vol': -45, - 'vol_f': 0.5, - 'vol_max': -20, - 'vol_min': -70}, - { 'disabled': False, - 'id': 5, - 'mute': True, - 'name': 'Upstairs Master Bath', - 'source_id': 0, - 'vol': -23, - 'vol_f': 0.94, - 'vol_max': -20, - 'vol_min': -70}, - { 'disabled': False, - 'id': 6, - 'mute': True, - 'name': 'Upstairs Kitchen Low HV', - 'source_id': 0, - 'vol': -66, - 'vol_f': 0.1, - 'vol_max': -30, - 'vol_min': -70}, - { 'disabled': False, - 'id': 7, - 'mute': True, - 'name': 'Hardware Office HV', - 'source_id': 0, - 'vol': -51, - 'vol_f': 0.38, - 'vol_max': -20, - 'vol_min': -70}, - { 'disabled': False, - 'id': 8, - 'mute': True, - 'name': 'Basement Workshop HV', - 'source_id': 0, - 'vol': -13, - 'vol_f': 0.95, - 'vol_max': -10, - 'vol_min': -70}, - { 'disabled': False, - 'id': 9, - 'mute': True, - 'name': 'Screened Room HV', + 'name': 'Outside', 'source_id': 1, - 'vol': -43, + 'vol_delta': -41, 'vol_f': 0.4909090909090909, - 'vol_max': -15, - 'vol_min': -70}, - { 'disabled': False, - 'id': 10, + 'zones': [9, 10]}, + {'id': 103, 'mute': True, - 'name': 'Upstairs Deck Living HV', - 'source_id': 1, - 'vol': -43, - 'vol_f': 0.4909090909090909, - 'vol_max': -15, - 'vol_min': -70}, - { 'disabled': False, - 'id': 11, + 'name': 'Offices', + 'vol_delta': -54, + 'vol_f': 0.33, + 'zones': [0, 7]}, + {'id': 104, 'mute': True, - 'name': 'Upstairs Main Bedroom HV', - 'source_id': 0, - 'vol': -66, - 'vol_f': 0.08, - 'vol_max': -20, - 'vol_min': -70}, - { 'disabled': False, - 'id': 12, - 'mute': True, - 'name': 'Downstairs Living Room', - 'source_id': 1, - 'vol': -48, - 'vol_f': 0.44, - 'vol_max': -20, - 'vol_min': -70}, - { 'disabled': False, - 'id': 13, - 'mute': True, - 'name': 'Downstairs Dining', - 'source_id': 1, - 'vol': -48, - 'vol_f': 0.44, - 'vol_max': -20, - 'vol_min': -70}, - { 'disabled': False, - 'id': 14, - 'mute': True, - 'name': 'Downstairs Bath', + 'name': 'Downstairs', 'source_id': 1, - 'vol': -64, - 'vol_f': 0.12, - 'vol_max': -20, - 'vol_min': -70}, - { 'disabled': False, - 'id': 15, + 'vol_delta': -57, + 'vol_f': 0.28500000000000003, + 'zones': [12, 13, 14, 15, 17]}, + {'id': 105, 'mute': True, - 'name': 'Downstairs Laundry', - 'source_id': 1, - 'vol': -48, - 'vol_f': 0.44, - 'vol_max': -20, - 'vol_min': -70}, - { 'disabled': False, - 'id': 16, + 'name': 'Main Unit', + 'vol_delta': -39, + 'vol_f': 0.51, + 'zones': [0, 1, 2, 3, 4, 5]}, + {'id': 106, 'mute': True, - 'name': 'Upstairs Main Bath', - 'source_id': 0, - 'vol': -66, - 'vol_f': 0.1, - 'vol_max': -30, - 'vol_min': -70}, - { 'disabled': False, - 'id': 17, + 'name': 'Expander 1 (HV)', + 'vol_delta': -39, + 'vol_f': 0.515, + 'zones': [6, 7, 8, 9, 10, 11]}, + {'id': 107, 'mute': True, - 'name': 'Downstairs Bedroom', - 'source_id': 1, - 'vol': -52, - 'vol_f': 0.45, - 'vol_max': -30, - 'vol_min': -70}]} + 'name': 'Expander 2', + 'vol_delta': -58, + 'vol_f': 0.275, + 'zones': [12, 13, 14, 15, 16, 17]} + ], + 'info': {'config_file': 'house.json', + 'fw': [{'git_dirty': False, + 'git_hash': 'de0f8eb', + 'version': '1.6'}, + {'git_dirty': False, + 'git_hash': 'de0f8eb', + 'version': '1.6'}, + {'git_dirty': False, + 'git_hash': 'de0f8eb', + 'version': '1.6'}], + 'latest_release': '0.1.9', + 'mock_ctrl': False, + 'mock_streams': False, + 'is_streamer': False, + 'lms_mode': False, + 'online': True, + 'version': '0.1.9'}, + 'presets': [{'id': 10000, + 'last_used': 1658242203, + 'name': 'Mute All', + 'state': {'zones': [{'id': 0, 'mute': True}, + {'id': 1, 'mute': True}, + {'id': 2, 'mute': True}, + {'id': 3, 'mute': True}, + {'id': 4, 'mute': True}, + {'id': 5, 'mute': True}, + {'id': 6, 'mute': True}, + {'id': 7, 'mute': True}, + {'id': 8, 'mute': True}, + {'id': 9, 'mute': True}, + {'id': 10, 'mute': True}, + {'id': 11, 'mute': True}, + {'id': 12, 'mute': True}, + {'id': 13, 'mute': True}, + {'id': 14, 'mute': True}, + {'id': 15, 'mute': True}, + {'id': 16, 'mute': True}, + {'id': 17, 'mute': True}]}} + ], + 'sources': [{'id': 0, + 'info': {'img_url': 'static/imgs/disconnected.png', + 'name': 'None', + 'state': 'stopped', + 'supported_cmds': []}, + 'input': '', + 'name': 'TV'}, + {'id': 1, + 'info': {'img_url': 'static/imgs/disconnected.png', + 'name': 'None', + 'state': 'stopped', + 'supported_cmds': []}, + 'input': '', + 'name': 'Record Player'}, + {'id': 2, + 'info': {'album': 'Charleston Butterfly', + 'artist': 'Parov Stelar', + 'img_url': 'http://mediaserver-cont-sv5-2-v4v6.pandora.com/images/00/4c/b7/12/d64a4ffe82251fcc9c44555c/1080W_1080H.jpg', + 'name': 'Blackmill Radio - pandora', + 'state': 'playing', + 'station': 'Blackmill Radio', + 'supported_cmds': ['play', 'pause', 'stop', 'next', + 'love', 'ban', 'shelve'], + 'track': 'Chambermaid Swing'}, + 'input': 'stream=1006', + 'name': 'Input 3'}, + {'id': 3, + 'info': {'album': 'Joshua Bell, Romance of the Violin', + 'artist': 'Fryderyk Chopin', + 'img_url': 'http://cont.p-cdn.us/images/e9/cc/f2/8e/890e4e5e9940c98ba864aaee/1080W_1080H.jpg', + 'name': 'Classical - pandora', + 'state': 'playing', + 'station': 'Antonio Vivaldi Radio', + 'supported_cmds': ['play', 'pause', 'stop', 'next', + 'love', 'ban', 'shelve'], + 'track': 'Nocturne For Piano In C Sharp Minor, Kk ' + 'Anh.ia/6'}, + 'input': 'stream=1005', + 'name': 'Input 4'}], + 'streams': [{'id': 1000, + 'logo': 'https://somafm.com/img3/groovesalad-400.jpg', + 'name': 'Groove Salad', + 'type': 'internetradio', + 'url': 'http://ice6.somafm.com/groovesalad-32-aac'}, + {'id': 1001, 'name': "Jason's House", 'type': 'airplay'}, + {'id': 1002, 'name': 'Jasons House', 'type': 'spotify'}, + {'id': 1003, 'name': "Jason's House", 'type': 'dlna'}, + {'id': 1004, + 'name': 'Matt and Kim Radio', + 'password': '', + 'station': '135242131387190035', + 'type': 'pandora', + 'user': 'example@micro-nova.com'}, + {'id': 1005, + 'name': 'Classical', + 'password': '', + 'station': '134892486689565953', + 'type': 'pandora', + 'user': 'example@micro-nova.com'}, + {'id': 1006, + 'name': 'Blackmill Radio', + 'password': '', + 'station': '91717963601693449', + 'type': 'pandora', + 'user': 'example@micro-nova.com'}, + {'id': 1007, + 'logo': 'http://www.beatlesradio.com/content/images/thumbs/0000587.gif', + 'name': 'Beatles Radio', + 'type': 'internetradio', + 'url': 'http://www.beatlesradio.com:8000/stream/1/'}], + 'zones': [{'disabled': False, + 'id': 0, + 'mute': True, + 'name': 'Software Office', + 'source_id': 2, + 'vol': -56, + 'vol_f': 0.28, + 'vol_max': -20, + 'vol_min': -70}, + {'disabled': False, + 'id': 1, + 'mute': True, + 'name': 'Upstairs Living Room', + 'source_id': 3, + 'vol': -49, + 'vol_f': 0.42, + 'vol_max': -20, + 'vol_min': -70}, + {'disabled': False, + 'id': 2, + 'mute': True, + 'name': 'Upstairs Dining', + 'source_id': 0, + 'vol': -66, + 'vol_f': 0.08, + 'vol_max': -20, + 'vol_min': -70}, + {'disabled': False, + 'id': 3, + 'mute': True, + 'name': 'Upstairs Laundry', + 'source_id': 0, + 'vol': -66, + 'vol_f': 0.08, + 'vol_max': -20, + 'vol_min': -70}, + {'disabled': False, + 'id': 4, + 'mute': True, + 'name': 'Upstairs Kitchen High', + 'source_id': 0, + 'vol': -45, + 'vol_f': 0.5, + 'vol_max': -20, + 'vol_min': -70}, + {'disabled': False, + 'id': 5, + 'mute': True, + 'name': 'Upstairs Master Bath', + 'source_id': 0, + 'vol': -23, + 'vol_f': 0.94, + 'vol_max': -20, + 'vol_min': -70}, + {'disabled': False, + 'id': 6, + 'mute': True, + 'name': 'Upstairs Kitchen Low HV', + 'source_id': 0, + 'vol': -66, + 'vol_f': 0.1, + 'vol_max': -30, + 'vol_min': -70}, + {'disabled': False, + 'id': 7, + 'mute': True, + 'name': 'Hardware Office HV', + 'source_id': 0, + 'vol': -51, + 'vol_f': 0.38, + 'vol_max': -20, + 'vol_min': -70}, + {'disabled': False, + 'id': 8, + 'mute': True, + 'name': 'Basement Workshop HV', + 'source_id': 0, + 'vol': -13, + 'vol_f': 0.95, + 'vol_max': -10, + 'vol_min': -70}, + {'disabled': False, + 'id': 9, + 'mute': True, + 'name': 'Screened Room HV', + 'source_id': 1, + 'vol': -43, + 'vol_f': 0.4909090909090909, + 'vol_max': -15, + 'vol_min': -70}, + {'disabled': False, + 'id': 10, + 'mute': True, + 'name': 'Upstairs Deck Living HV', + 'source_id': 1, + 'vol': -43, + 'vol_f': 0.4909090909090909, + 'vol_max': -15, + 'vol_min': -70}, + {'disabled': False, + 'id': 11, + 'mute': True, + 'name': 'Upstairs Main Bedroom HV', + 'source_id': 0, + 'vol': -66, + 'vol_f': 0.08, + 'vol_max': -20, + 'vol_min': -70}, + {'disabled': False, + 'id': 12, + 'mute': True, + 'name': 'Downstairs Living Room', + 'source_id': 1, + 'vol': -48, + 'vol_f': 0.44, + 'vol_max': -20, + 'vol_min': -70}, + {'disabled': False, + 'id': 13, + 'mute': True, + 'name': 'Downstairs Dining', + 'source_id': 1, + 'vol': -48, + 'vol_f': 0.44, + 'vol_max': -20, + 'vol_min': -70}, + {'disabled': False, + 'id': 14, + 'mute': True, + 'name': 'Downstairs Bath', + 'source_id': 1, + 'vol': -64, + 'vol_f': 0.12, + 'vol_max': -20, + 'vol_min': -70}, + {'disabled': False, + 'id': 15, + 'mute': True, + 'name': 'Downstairs Laundry', + 'source_id': 1, + 'vol': -48, + 'vol_f': 0.44, + 'vol_max': -20, + 'vol_min': -70}, + {'disabled': False, + 'id': 16, + 'mute': True, + 'name': 'Upstairs Main Bath', + 'source_id': 0, + 'vol': -66, + 'vol_f': 0.1, + 'vol_max': -30, + 'vol_min': -70}, + {'disabled': False, + 'id': 17, + 'mute': True, + 'name': 'Downstairs Bedroom', + 'source_id': 1, + 'vol': -52, + 'vol_f': 0.45, + 'vol_max': -30, + 'vol_min': -70}]} - } } } + } + class AppSettings(BaseSettings): """ Controller settings """ @@ -1186,6 +1224,7 @@ class AppSettings(BaseSettings): config_file: str = 'house.json' delay_saves: bool = True + class DebugResponse(BaseModel): """ Response from /debug, which directly reads a file from JSON or returns an empty response """ debug: Optional[bool] diff --git a/amplipi/mpris.py b/amplipi/mpris.py index 564bc851b..05e2f91be 100644 --- a/amplipi/mpris.py +++ b/amplipi/mpris.py @@ -11,12 +11,14 @@ from dasbus.client.proxy import disconnect_proxy from amplipi import utils + class CommandTypes(Enum): PLAY = auto() PAUSE = auto() NEXT = auto() PREVIOUS = auto() + @dataclass class Metadata: """A data class for storing metadata on a song.""" @@ -34,9 +36,9 @@ class MPRIS: def __init__(self, service_suffix, metadata_path, debug=False) -> None: self.mpris = SessionMessageBus().get_proxy( - service_name = f"org.mpris.MediaPlayer2.{service_suffix}", - object_path = "/org/mpris/MediaPlayer2", - interface_name = "org.mpris.MediaPlayer2.Player" + service_name=f"org.mpris.MediaPlayer2.{service_suffix}", + object_path="/org/mpris/MediaPlayer2", + interface_name="org.mpris.MediaPlayer2.Player" ) self.debug = debug @@ -53,7 +55,7 @@ def __init__(self, service_suffix, metadata_path, debug=False) -> None: m.state = "Stopped" json.dump(m.__dict__, f) except Exception as e: - print (f'Exception clearing metadata file: {e}') + print(f'Exception clearing metadata file: {e}') try: child_args = [sys.executable, diff --git a/amplipi/rt.py b/amplipi/rt.py index f53410eab..caae4c463 100644 --- a/amplipi/rt.py +++ b/amplipi/rt.py @@ -18,67 +18,70 @@ """Runtimes to communicate with the AmpliPi hardware """ -import math import io +import math import os import time -from amplipi import models # TODO: importing this takes ~0.5s, reduce from enum import Enum from typing import Dict, List, Tuple, Union, Optional +from smbus2 import SMBus +from serial import Serial +from amplipi import models # TODO: importing this takes ~0.5s, reduce + # TODO: move constants like this to their own file -DEBUG_PREAMPS = False # print out preamp state after register write +DEBUG_PREAMPS = False # print out preamp state after register write -from serial import Serial -from smbus2 import SMBus # Preamp register addresses _REG_ADDRS = { - 'SRC_AD' : 0x00, - 'ZONE123_SRC' : 0x01, - 'ZONE456_SRC' : 0x02, - 'MUTE' : 0x03, - 'STANDBY' : 0x04, - 'VOL_ZONE1' : 0x05, - 'VOL_ZONE2' : 0x06, - 'VOL_ZONE3' : 0x07, - 'VOL_ZONE4' : 0x08, - 'VOL_ZONE5' : 0x09, - 'VOL_ZONE6' : 0x0A, - 'POWER' : 0x0B, - 'FANS' : 0x0C, - 'LED_CTRL' : 0x0D, - 'LED_VAL' : 0x0E, - 'EXPANSION' : 0x0F, - 'HV1_VOLTAGE' : 0x10, - 'AMP_TEMP1' : 0x11, - 'HV1_TEMP' : 0x12, - 'AMP_TEMP2' : 0x13, - 'PI_TEMP' : 0x14, - 'FAN_DUTY' : 0x15, - 'FAN_VOLTS' : 0x16, - 'HV2_VOLTAGE' : 0x17, - 'HV2_TEMP' : 0x18, - 'VERSION_MAJOR' : 0xFA, - 'VERSION_MINOR' : 0xFB, - 'GIT_HASH_27_20' : 0xFC, - 'GIT_HASH_19_12' : 0xFD, - 'GIT_HASH_11_04' : 0xFE, - 'GIT_HASH_STATUS' : 0xFF, + 'SRC_AD': 0x00, + 'ZONE123_SRC': 0x01, + 'ZONE456_SRC': 0x02, + 'MUTE': 0x03, + 'STANDBY': 0x04, + 'VOL_ZONE1': 0x05, + 'VOL_ZONE2': 0x06, + 'VOL_ZONE3': 0x07, + 'VOL_ZONE4': 0x08, + 'VOL_ZONE5': 0x09, + 'VOL_ZONE6': 0x0A, + 'POWER': 0x0B, + 'FANS': 0x0C, + 'LED_CTRL': 0x0D, + 'LED_VAL': 0x0E, + 'EXPANSION': 0x0F, + 'HV1_VOLTAGE': 0x10, + 'AMP_TEMP1': 0x11, + 'HV1_TEMP': 0x12, + 'AMP_TEMP2': 0x13, + 'PI_TEMP': 0x14, + 'FAN_DUTY': 0x15, + 'FAN_VOLTS': 0x16, + 'HV2_VOLTAGE': 0x17, + 'HV2_TEMP': 0x18, + 'VERSION_MAJOR': 0xFA, + 'VERSION_MINOR': 0xFB, + 'GIT_HASH_27_20': 0xFC, + 'GIT_HASH_19_12': 0xFD, + 'GIT_HASH_11_04': 0xFE, + 'GIT_HASH_STATUS': 0xFF, } _SRC_TYPES = { - 1 : 'Digital', - 0 : 'Analog', + 1: 'Digital', + 0: 'Analog', } _DEV_ADDRS = [0x08, 0x10, 0x18, 0x20, 0x28, 0x30] MAX_ZONES = 6 * len(_DEV_ADDRS) + class FanCtrl(Enum): MAX6644 = 0 - PWM = 1 - LINEAR = 2 - FORCED = 3 + PWM = 1 + LINEAR = 2 + FORCED = 3 + def is_amplipi(): """ Check if the current hardware is an AmpliPi @@ -121,12 +124,12 @@ class _Preamps: """ Low level discovery and communication for the AmpliPi firmware """ - preamps: Dict[int, List[int]] # Key: i2c address, Val: register values + preamps: Dict[int, List[int]] # Key: i2c address, Val: register values - def __init__(self, reset: bool = True, set_addr: bool = True, bootloader: bool = False, debug = True): + def __init__(self, reset: bool = True, set_addr: bool = True, bootloader: bool = False, debug=True): self.preamps = dict() if not is_amplipi(): - self.bus = None # TODO: Use i2c-stub + self.bus = None # TODO: Use i2c-stub print('Not running on AmpliPi hardware, mocking preamp connection') else: if reset: @@ -152,7 +155,6 @@ def __del__(self): if self.bus: self.bus.close() - def reset_preamps(self, bootloader: bool = False): """ Resets the preamp board. Any slave preamps will be reset one-by-one by the previous preamp. @@ -167,10 +169,10 @@ def reset_preamps(self, bootloader: bool = False): # Reset preamp board before establishing a communication channel GPIO.setmode(GPIO.BCM) GPIO.setup(4, GPIO.OUT) - GPIO.output(4, 0) # Low pulse on the reset line (GPIO4) + GPIO.output(4, 0) # Low pulse on the reset line (GPIO4) GPIO.setup(5, GPIO.OUT) - GPIO.output(5, boot0) # Ensure BOOT0 is set (GPIO5) - time.sleep(0.001) # Hold reset low for >20 us, but <10 ms. + GPIO.output(5, boot0) # Ensure BOOT0 is set (GPIO5) + time.sleep(0.001) # Hold reset low for >20 us, but <10 ms. GPIO.output(4, 1) # Each box theoretically takes ~6 to undergo a reset. @@ -208,18 +210,18 @@ def reset_expander(self, preamp: int, bootload: bool = False): def new_preamp(self, addr: int): """ Populate initial register values """ self.preamps[addr] = [ - 0x0F, - 0x00, - 0x00, - 0x3F, - 0x00, - 0x4F, - 0x4F, - 0x4F, - 0x4F, - 0x4F, - 0x4F, - ] + 0x0F, + 0x00, + 0x00, + 0x3F, + 0x00, + 0x4F, + 0x4F, + 0x4F, + 0x4F, + 0x4F, + 0x4F, + ] def write_byte_data(self, preamp_addr, reg, data): assert preamp_addr in _DEV_ADDRS @@ -231,7 +233,7 @@ def write_byte_data(self, preamp_addr, reg, data): if self.bus is None: self.new_preamp(preamp_addr) else: - return None # Preamp is not connected, do nothing + return None # Preamp is not connected, do nothing if DEBUG_PREAMPS: print("writing to 0x{:02x} @ 0x{:02x} with 0x{:02x}".format(preamp_addr, reg, data)) @@ -239,7 +241,7 @@ def write_byte_data(self, preamp_addr, reg, data): # TODO: need to handle volume modifying mute state in mock if self.bus is not None: try: - time.sleep(0.001) # space out sequential calls to avoid bus errors + time.sleep(0.001) # space out sequential calls to avoid bus errors self.bus.write_byte_data(preamp_addr, reg, data) except Exception: time.sleep(0.001) @@ -278,19 +280,20 @@ def read_version(self, preamp: int = 1): """ assert 1 <= preamp <= 6 if self.bus is not None: - major = self.bus.read_byte_data(preamp*8, _REG_ADDRS['VERSION_MAJOR']) - minor = self.bus.read_byte_data(preamp*8, _REG_ADDRS['VERSION_MINOR']) - git_hash = self.bus.read_byte_data(preamp*8, _REG_ADDRS['GIT_HASH_27_20']) << 20 - git_hash |= (self.bus.read_byte_data(preamp*8, _REG_ADDRS['GIT_HASH_19_12']) << 12) - git_hash |= (self.bus.read_byte_data(preamp*8, _REG_ADDRS['GIT_HASH_11_04']) << 4) - git_hash4_stat = self.bus.read_byte_data(preamp*8, _REG_ADDRS['GIT_HASH_STATUS']) + major = self.bus.read_byte_data(preamp * 8, _REG_ADDRS['VERSION_MAJOR']) + minor = self.bus.read_byte_data(preamp * 8, _REG_ADDRS['VERSION_MINOR']) + git_hash = self.bus.read_byte_data(preamp * 8, _REG_ADDRS['GIT_HASH_27_20']) << 20 + git_hash |= (self.bus.read_byte_data(preamp * 8, _REG_ADDRS['GIT_HASH_19_12']) << 12) + git_hash |= (self.bus.read_byte_data(preamp * 8, _REG_ADDRS['GIT_HASH_11_04']) << 4) + git_hash4_stat = self.bus.read_byte_data(preamp * 8, _REG_ADDRS['GIT_HASH_STATUS']) git_hash |= (git_hash4_stat >> 4) dirty = (git_hash4_stat & 0x01) != 0 return major, minor, git_hash, dirty return None, None, None, None - def read_power_status(self, preamp: int = 1) -> Tuple[Optional[bool], - Optional[bool], Optional[bool], Optional[bool], Optional[float]]: + def read_power_status(self, preamp: int = 1) -> Tuple[ + Optional[bool], Optional[bool], Optional[bool], Optional[bool], Optional[float] + ]: """ Read the status of the power supplies Returns: @@ -302,18 +305,19 @@ def read_power_status(self, preamp: int = 1) -> Tuple[Optional[bool], """ assert 1 <= preamp <= 6 if self.bus is not None: - pstat = self.bus.read_byte_data(preamp*8, _REG_ADDRS['POWER']) + pstat = self.bus.read_byte_data(preamp * 8, _REG_ADDRS['POWER']) en_12v = (pstat & 0x08) != 0 pg_12v = (pstat & 0x04) != 0 en_9v = (pstat & 0x02) != 0 pg_9v = (pstat & 0x01) != 0 - fvstat = self.bus.read_byte_data(preamp*8, _REG_ADDRS['FAN_VOLTS']) + fvstat = self.bus.read_byte_data(preamp * 8, _REG_ADDRS['FAN_VOLTS']) v12 = fvstat / 2**4 return pg_9v, en_9v, pg_12v, en_12v, v12 return None, None, None, None, None - def read_fan_status(self, preamp: int = 1) -> Union[Tuple[FanCtrl, - bool, bool, bool, bool], Tuple[None, None, None, None, None]]: + def read_fan_status(self, preamp: int = 1) -> Union[ + Tuple[FanCtrl, bool, bool, bool, bool], Tuple[None, None, None, None, None] + ]: """ Read the status of the fans Returns: @@ -325,7 +329,7 @@ def read_fan_status(self, preamp: int = 1) -> Union[Tuple[FanCtrl, """ assert 1 <= preamp <= 6 if self.bus is not None: - fstat = self.bus.read_byte_data(preamp*8, _REG_ADDRS['FANS']) + fstat = self.bus.read_byte_data(preamp * 8, _REG_ADDRS['FANS']) ctrl = FanCtrl(fstat & 0x03) fans_on = (fstat & 0x04) != 0 ovr_tmp = (fstat & 0x08) != 0 @@ -342,26 +346,26 @@ def read_fan_duty(self, preamp: int = 1) -> Optional[float]: """ assert 1 <= preamp <= 6 if self.bus is not None: - duty = self.bus.read_byte_data(preamp*8, _REG_ADDRS['FAN_DUTY']) + duty = self.bus.read_byte_data(preamp * 8, _REG_ADDRS['FAN_DUTY']) return duty / (1 << 7) return None @staticmethod def _fix2temp(fval: int) -> float: """ Convert UQ7.1 + 20 degC format to degC """ - if fval == 0: # Disconnected + if fval == 0: # Disconnected temp = -math.inf - elif fval == 255: # Shorted + elif fval == 255: # Shorted temp = math.inf else: - temp = fval/2 - 20 + temp = fval / 2 - 20 return temp def hv2_present(self, preamp: int = 1) -> Optional[bool]: """ Check if a second high voltage power supply is present """ assert 1 <= preamp <= 6 if self.bus is not None: - pstat = self.bus.read_byte_data(preamp*8, _REG_ADDRS['POWER']) + pstat = self.bus.read_byte_data(preamp * 8, _REG_ADDRS['POWER']) hv2_present = (pstat & 0x80) != 0 return hv2_present return None @@ -381,14 +385,14 @@ def read_temps(self, preamp: int = 1) -> Union[ amp2: Temperature of the heatsink over zones 4-6 in degrees C """ if self.bus is not None: - temp_hv1_f = self.bus.read_byte_data(preamp*8, _REG_ADDRS['HV1_TEMP']) - temp_amp1_f = self.bus.read_byte_data(preamp*8, _REG_ADDRS['AMP_TEMP1']) - temp_amp2_f = self.bus.read_byte_data(preamp*8, _REG_ADDRS['AMP_TEMP2']) + temp_hv1_f = self.bus.read_byte_data(preamp * 8, _REG_ADDRS['HV1_TEMP']) + temp_amp1_f = self.bus.read_byte_data(preamp * 8, _REG_ADDRS['AMP_TEMP1']) + temp_amp2_f = self.bus.read_byte_data(preamp * 8, _REG_ADDRS['AMP_TEMP2']) temp_hv1 = self._fix2temp(temp_hv1_f) temp_amp1 = self._fix2temp(temp_amp1_f) temp_amp2 = self._fix2temp(temp_amp2_f) if self.hv2_present(preamp): - temp_hv2_f = self.bus.read_byte_data(preamp*8, _REG_ADDRS['HV2_TEMP']) + temp_hv2_f = self.bus.read_byte_data(preamp * 8, _REG_ADDRS['HV2_TEMP']) temp_hv2 = self._fix2temp(temp_hv2_f) return temp_hv1, temp_hv2, temp_amp1, temp_amp2 else: @@ -407,11 +411,11 @@ def read_hv(self, preamp: int = 1) -> Tuple[Optional[float], Optional[float]]: """ assert 1 <= preamp <= 6 if self.bus is not None: - hv1_f = self.bus.read_byte_data(preamp*8, _REG_ADDRS['HV1_VOLTAGE']) - hv1 = hv1_f / 4 # Convert from UQ6.2 format + hv1_f = self.bus.read_byte_data(preamp * 8, _REG_ADDRS['HV1_VOLTAGE']) + hv1 = hv1_f / 4 # Convert from UQ6.2 format if self.hv2_present(preamp): - hv2_f = self.bus.read_byte_data(preamp*8, _REG_ADDRS['HV2_VOLTAGE']) - hv2 = hv2_f / 4 # Convert from UQ6.2 format + hv2_f = self.bus.read_byte_data(preamp * 8, _REG_ADDRS['HV2_VOLTAGE']) + hv2 = hv2_f / 4 # Convert from UQ6.2 format return hv1, hv2 else: return hv1, None @@ -420,7 +424,7 @@ def read_hv(self, preamp: int = 1) -> Tuple[Optional[float], Optional[float]]: def force_fans(self, preamp: int = 1, force: bool = True): assert 1 <= preamp <= 6 if self.bus is not None: - self.bus.write_byte_data(preamp*8, _REG_ADDRS['FANS'], + self.bus.write_byte_data(preamp * 8, _REG_ADDRS['FANS'], 3 if force is True else 0) def read_leds(self, preamp: int = 1): @@ -437,7 +441,7 @@ def read_leds(self, preamp: int = 1): """ assert 1 <= preamp <= 6 if self.bus is not None: - leds = self.bus.read_byte_data(preamp*8, _REG_ADDRS['LED_VAL']) + leds = self.bus.read_byte_data(preamp * 8, _REG_ADDRS['LED_VAL']) return leds return None @@ -456,10 +460,10 @@ def led_override(self, preamp: int = 1, leds: Optional[int] = 0xFF): assert leds is None or 0 <= leds <= 255 if self.bus is not None: if leds is None: - self.bus.write_byte_data(preamp*8, _REG_ADDRS['LED_CTRL'], 0) + self.bus.write_byte_data(preamp * 8, _REG_ADDRS['LED_CTRL'], 0) else: - self.bus.write_byte_data(preamp*8, _REG_ADDRS['LED_CTRL'], 1) - self.bus.write_byte_data(preamp*8, _REG_ADDRS['LED_VAL'], leds) + self.bus.write_byte_data(preamp * 8, _REG_ADDRS['LED_CTRL'], 1) + self.bus.write_byte_data(preamp * 8, _REG_ADDRS['LED_VAL'], leds) def __str__(self): preamp_str = '' @@ -484,6 +488,7 @@ def get_zone_state_str(self, zone): muted = (regs[_REG_ADDRS['MUTE']] & (1 << zone)) > 0 return f' {src}({src_type[0]}) --> zone {zone} vol {vol}{" (muted)" if muted else ""}' + class Mock: """ Mock of an Amplipi Runtime @@ -570,7 +575,8 @@ def update_zone_vol(self, zone, vol): return True def exists(self, zone): - return True + return True + class Rpi: """ Actual Amplipi Runtime @@ -581,7 +587,6 @@ class Rpi: def __init__(self): self._bus = _Preamps() - def __del__(self): del self._bus @@ -592,7 +597,7 @@ def reset(self): def read_versions(self) -> List[Tuple[int, int, int, bool]]: """ Read the firmware information for all preamps (major, minor, hash, dirty?)""" fw_versions: List[Tuple[int, int, int, bool]] = [] - for i in range(1,6): + for i in range(1, 6): try: infos = self._bus.read_version(i) if infos[0] is None: @@ -645,9 +650,9 @@ def update_zone_sources(self, zone, sources): src = sources[preamp * 6 + z] assert type(src) == int or src == None if z < 3: - source_cfg123 = source_cfg123 | (src << (z*2)) + source_cfg123 = source_cfg123 | (src << (z * 2)) else: - source_cfg456 = source_cfg456 | (src << ((z-3)*2)) + source_cfg456 = source_cfg456 | (src << ((z - 3) * 2)) self._bus.write_byte_data(_DEV_ADDRS[preamp], _REG_ADDRS['ZONE123_SRC'], source_cfg123) self._bus.write_byte_data(_DEV_ADDRS[preamp], _REG_ADDRS['ZONE456_SRC'], source_cfg456) @@ -664,7 +669,7 @@ def update_zone_vol(self, zone, vol): Returns: True on success, False on hw failure """ - preamp = int(zone / 6) # int(x/y) does the same thing as (x // y) + preamp = int(zone / 6) # int(x/y) does the same thing as (x // y) assert zone >= 0 assert preamp < 15 assert models.MIN_VOL_DB <= vol <= models.MAX_VOL_DB diff --git a/amplipi/streams.py b/amplipi/streams.py index ea0c1b408..436e2463f 100644 --- a/amplipi/streams.py +++ b/amplipi/streams.py @@ -34,7 +34,7 @@ import json import signal import socket -import hashlib # md5 for string -> MAC generation +import hashlib # md5 for string -> MAC generation from amplipi import models from amplipi import utils @@ -43,12 +43,14 @@ # We use Popen for long running process control this error is not useful: # pylint: disable=consider-using-with + def write_config_file(filename, config): """ Write a simple config file (@filename) with key=value pairs given by @config """ with open(filename, 'wt', encoding='utf-8') as cfg_file: for key, value in config.items(): cfg_file.write(f'{key}={value}\n') + def write_sp_config_file(filename, config): """ Write a shairport config file (@filename) with a hierarchy of grouped key=value pairs given by @config """ with open(filename, 'wt', encoding='utf-8') as cfg_file: @@ -61,6 +63,7 @@ def write_sp_config_file(filename, config): cfg_file.write(f' {key} = {value}\n') cfg_file.write('};\n') + def uuid_gen(): """ Generates a UUID for use in DLNA and Plexamp streams """ uuid_proc = subprocess.run(args='uuidgen', capture_output=True, check=False) @@ -68,14 +71,16 @@ def uuid_gen(): c_check = uuid_str[0] val = uuid_str[2] - if c_check[0:16] == 'CompletedProcess': # Did uuidgen succeed? + if c_check[0:16] == 'CompletedProcess': # Did uuidgen succeed? return val[10:46] # Generic UUID in case of failure return '39ae35cc-b4c1-444d-b13a-294898d771fa' + class BaseStream: """ BaseStream class containing methods that all other streams inherit """ - def __init__(self, stype: str, name: str, only_src=None, disabled: bool=False, mock=False): + + def __init__(self, stype: str, name: str, only_src=None, disabled: bool = False, mock=False): self.name = name self.disabled = disabled self.proc: Optional[subprocess.Popen] = None @@ -159,10 +164,12 @@ def send_cmd(self, cmd: str) -> None: """ raise NotImplementedError(f'{self.name} does not support commands') + class VirtualSources: """ Virtual source allocator to mind ALSA limits""" + def __init__(self, num_sources: int): - self._sources : List[Optional[int]] = [None] * num_sources + self._sources: List[Optional[int]] = [None] * num_sources def available(self) -> bool: """ Are any sources available """ @@ -183,11 +190,14 @@ def free(self, vsrc: int): raise Exception(f'unable to free virtual source {vsrc} it was not allocated') self._sources[vsrc] = None + vsources = VirtualSources(12) + class PersistentStream(BaseStream): """ Base class for streams that are able to persist without a direct connection to an output """ - def __init__(self, stype: str, name: str, disabled: bool=False, mock=False): + + def __init__(self, stype: str, name: str, disabled: bool = False, mock=False): super().__init__(stype, name, None, disabled, mock) self.vsrc: Optional[int] = None self._cproc: Optional[subprocess.Popen] = None @@ -208,9 +218,9 @@ def activate(self): try: vsrc = vsources.alloc() self.vsrc = vsrc - self.state = "connected" # optimistically make this look like a normal stream for now + self.state = "connected" # optimistically make this look like a normal stream for now if not self.mock: - self._activate(vsrc) # might override self.state + self._activate(vsrc) # might override self.state print(f"Activating {self.name} ({'persistant' if self.is_persistent() else 'temporarily'})") except Exception as e: print(f'Failed to activate {self.name}: {e}') @@ -251,7 +261,7 @@ def reactivate(self): print(f'reactivating {self.name}') if self.is_activated(): self.deactivate() - time.sleep(0.1) # wait a bit just in case + time.sleep(0.1) # wait a bit just in case def connect(self, src: int): """ Connect an output to a given audio source """ @@ -274,7 +284,7 @@ def connect(self, src: int): self._cproc = subprocess.Popen(args=args) except Exception as exc: print(f'Failed to start alsaloop connection: {exc}') - time.sleep(0.1) # Delay a bit + time.sleep(0.1) # Delay a bit self.src = src def disconnect(self): @@ -291,8 +301,10 @@ def disconnect(self): pass self.src = None + class RCA(BaseStream): """ A built-in RCA input """ + def __init__(self, name: str, index: int, disabled: bool = False, mock: bool = False): super().__init__('rca', name, only_src=index, disabled=disabled, mock=mock) # for serialiation the stream model's field needs to map to a stream's fields @@ -329,8 +341,10 @@ def connect(self, src): def disconnect(self): self._disconnect() + class AirPlay(PersistentStream): """ An AirPlay Stream """ + def __init__(self, name: str, ap2: bool, disabled: bool = False, mock: bool = False): super().__init__('airplay', name, disabled=disabled, mock=mock) self.mpris: Optional[MPRIS] = None @@ -341,8 +355,8 @@ def __init__(self, name: str, ap2: bool, disabled: bool = False, mock: bool = Fa 'pause', 'next', 'prev' - ] - self.STATE_TIMEOUT = 300 # seconds + ] + self.STATE_TIMEOUT = 300 # seconds self._connect_time = 0.0 self._coverart_dir = '' @@ -367,7 +381,7 @@ def _activate(self, vsrc: int): # if stream is airplay2 check for other airplay2s and error if found # pgrep has it's own process that will include the process name so we sub 1 from the results if self.ap2: - if len(os.popen("pgrep -f shairport-sync-ap2").read().strip().splitlines())-1 > 0: + if len(os.popen("pgrep -f shairport-sync-ap2").read().strip().splitlines()) - 1 > 0: self.ap2_exists = True # TODO: we need a better way of showing errors to user print(f'Another Airplay 2 stream is already in use, unable to start {self.name}, mocking connection') @@ -381,21 +395,22 @@ def _activate(self, vsrc: int): config = { 'general': { 'name': self.name, - 'port': 5100 + 100 * vsrc, # Listen for service requests on this port - 'udp_port_base': 6101 + 100 * vsrc, # start allocating UDP ports from this port number when needed - 'drift': 2000, # allow this number of frames of drift away from exact synchronisation before attempting to correct it - 'resync_threshold': 0, # a synchronisation error greater than this will cause resynchronisation; 0 disables it - 'log_verbosity': 0, # "0" means no debug verbosity, "3" is most verbose. + 'port': 5100 + 100 * vsrc, # Listen for service requests on this port + 'udp_port_base': 6101 + 100 * vsrc, # start allocating UDP ports from this port number when needed + 'drift': 2000, # allow this number of frames of drift away from exact synchronisation before attempting to correct it + 'resync_threshold': 0, # a synchronisation error greater than this will cause resynchronisation; 0 disables it + 'log_verbosity': 0, # "0" means no debug verbosity, "3" is most verbose. 'mpris_service_bus': 'Session', }, - 'metadata':{ + 'metadata': { 'enabled': 'yes', 'include_cover_art': 'yes', 'cover_art_cache_directory': self._coverart_dir, }, 'alsa': { - 'output_device': utils.virtual_output_device(vsrc), # alsa output device - 'audio_backend_buffer_desired_length': 11025 # If set too small, buffer underflow occurs on low-powered machines. Too long and the response times with software mixer become annoying. + 'output_device': utils.virtual_output_device(vsrc), # alsa output device + # If set too small, buffer underflow occurs on low-powered machines. Too long and the response times with software mixer become annoying. + 'audio_backend_buffer_desired_length': 11025 }, } @@ -408,7 +423,8 @@ def _activate(self, vsrc: int): shairport_args = f"{utils.get_folder('streams')}/shairport-sync{'-ap2' if self.ap2 else ''} -c {config_file}".split(' ') print(f'shairport_args: {shairport_args}') - self.proc = subprocess.Popen(args=shairport_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + self.proc = subprocess.Popen(args=shairport_args, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) try: mpris_name = 'ShairportSync' @@ -473,17 +489,16 @@ def info(self) -> models.SourceInfo: source.artist = md.artist source.track = md.title source.album = md.album - source.supported_cmds=list(self.supported_cmds) + source.supported_cmds = list(self.supported_cmds) if md.title != '': - #if there is a title, attempt to get coverart + # if there is a title, attempt to get coverart images = os.listdir(self._coverart_dir) if len(images) > 0: source.img_url = f'generated/{self.src}/{images[0]}' else: source.track = "No metadata available" - except Exception as e: print(f"error in airplay: {e}") @@ -505,8 +520,10 @@ def send_cmd(self, cmd): except Exception as e: print(f"error in shairport: {e}") + class Spotify(PersistentStream): """ A Spotify Stream """ + def __init__(self, name: str, disabled: bool = False, mock: bool = False): super().__init__('spotify', name, disabled=disabled, mock=mock) @@ -560,9 +577,9 @@ def _activate(self, vsrc: int): try: self.proc = subprocess.Popen(args=spotify_args, cwd=f'{src_config_folder}') - time.sleep(0.1) # Delay a bit + time.sleep(0.1) # Delay a bit - self.mpris = MPRIS(f'spotifyd.instance{self.proc.pid}', f'v{vsrc}') # TODO: MPRIS should just need a path! + self.mpris = MPRIS(f'spotifyd.instance{self.proc.pid}', f'v{vsrc}') # TODO: MPRIS should just need a path! except Exception as exc: print(f'error starting spotify: {exc}') @@ -588,7 +605,7 @@ def info(self) -> models.SourceInfo: source = models.SourceInfo( name=self.full_name(), state=self.state, - img_url='static/imgs/spotify.png' # report generic spotify image in place of unspecified album art + img_url='static/imgs/spotify.png' # report generic spotify image in place of unspecified album art ) if self.mpris is None: return source @@ -600,7 +617,7 @@ def info(self) -> models.SourceInfo: source.artist = md.artist source.track = md.title source.album = md.album - source.supported_cmds=self.supported_cmds + source.supported_cmds = self.supported_cmds if md.art_url: source.img_url = md.art_url @@ -625,23 +642,25 @@ def send_cmd(self, cmd): except Exception as e: raise Exception(f"Error sending command {cmd}: {e}") from e + class Pandora(PersistentStream): """ A Pandora Stream """ + def __init__(self, name: str, user, password: str, station: str, disabled: bool = False, mock: bool = False): super().__init__('pandora', name, disabled=disabled, mock=mock) self.user = user self.password = password self.station = station - #if station is None: + # if station is None: # raise ValueError("station must be specified") # TODO: handle getting station list (it looks like you have to play a song before the station list gets updated through eventcmd) - self.ctrl = '' # control fifo location + self.ctrl = '' # control fifo location self.supported_cmds = { - 'play': {'cmd': 'P\n', 'state': 'playing'}, - 'pause': {'cmd': 'S\n', 'state': 'paused'}, - 'stop': {'cmd': 'q\n', 'state': 'stopped'}, - 'next': {'cmd': 'n\n', 'state': 'playing'}, - 'love': {'cmd': '+\n', 'state': None}, # love does not change state - 'ban': {'cmd': '-\n', 'state': 'playing'}, + 'play': {'cmd': 'P\n', 'state': 'playing'}, + 'pause': {'cmd': 'S\n', 'state': 'paused'}, + 'stop': {'cmd': 'q\n', 'state': 'stopped'}, + 'next': {'cmd': 'n\n', 'state': 'playing'}, + 'love': {'cmd': '+\n', 'state': None}, # love does not change state + 'ban': {'cmd': '-\n', 'state': 'playing'}, 'shelve': {'cmd': 't\n', 'state': 'playing'}, } @@ -651,7 +670,7 @@ def reconfig(self, **kwargs): self.disabled = kwargs['disabled'] pb_fields = ['user', 'password', 'station'] fields = list(pb_fields) + ['name'] - for k,v in kwargs.items(): + for k, v in kwargs.items(): if k in fields and self.__dict__[k] != v: self.__dict__[k] = v if k in pb_fields: @@ -677,7 +696,7 @@ def _activate(self, vsrc: int): pb_src_config_file = f'{pb_home}/.libao' # make all of the necessary dir(s) os.system(f'mkdir -p {pb_config_folder}') - os.system(f'cp {eventcmd_template} {pb_eventcmd_file}') # Copy to retain executable status + os.system(f'cp {eventcmd_template} {pb_eventcmd_file}') # Copy to retain executable status # write pianobar and libao config files write_config_file(pb_config_file, { 'user': self.user, @@ -695,10 +714,12 @@ def _activate(self, vsrc: int): # start pandora process in special home print(f'Pianobar config at {pb_config_folder}') try: - self.proc = subprocess.Popen(args='pianobar', stdin=subprocess.PIPE, stdout=open(pb_output_file, 'w', encoding='utf-8'), stderr=open(pb_error_file, 'w', encoding='utf-8'), env={'HOME' : pb_home}) - time.sleep(0.1) # Delay a bit before creating a control pipe to pianobar + self.proc = subprocess.Popen( + args='pianobar', stdin=subprocess.PIPE, stdout=open(pb_output_file, 'w', encoding='utf-8'), + stderr=open(pb_error_file, 'w', encoding='utf-8'), env={'HOME': pb_home}) + time.sleep(0.1) # Delay a bit before creating a control pipe to pianobar self.ctrl = pb_control_fifo - self.state = 'playing' # TODO: we need to pause pandora if it isn't playing anywhere + self.state = 'playing' # TODO: we need to pause pandora if it isn't playing anywhere except Exception as exc: print(f'error starting pianobar: {exc}') @@ -732,7 +753,7 @@ def info(self) -> models.SourceInfo: return source except Exception: pass - #print(error('Failed to get currentSong - it may not exist: {}'.format(e))) + # print(error('Failed to get currentSong - it may not exist: {}'.format(e))) # TODO: report the status of pianobar with station name, playing/paused, song info # ie. Playing: "Cameras by Matt and Kim" on "Matt and Kim Radio" return source @@ -758,7 +779,7 @@ def send_cmd(self, cmd): file.flush() file.write(f'{station_id}\n') file.flush() - self.state = 'playing' # TODO: add verification (station could be wrong) + self.state = 'playing' # TODO: add verification (station could be wrong) else: raise ValueError(f'station= expected, ie. station=23432423; received "{cmd}"') else: @@ -766,8 +787,10 @@ def send_cmd(self, cmd): except Exception as exc: raise RuntimeError(f'Command {cmd} failed to send: {exc}') from exc + class DLNA(BaseStream): """ A DLNA Stream """ + def __init__(self, name: str, disabled: bool = False, mock: bool = False): super().__init__('dlna', name, disabled=disabled, mock=mock) self.proc2 = None @@ -784,7 +807,7 @@ def reconfig(self, **kwargs): if self._is_running(): last_src = self.src self.disconnect() - time.sleep(0.1) # delay a bit, is this needed? + time.sleep(0.1) # delay a bit, is this needed? self.connect(last_src) def __del__(self): @@ -809,11 +832,12 @@ def connect(self, src): meta_args = [f'{utils.get_folder("streams")}/dlna_metadata.bash', f'{src_config_folder}'] dlna_args = ['gmediarender', '--gstout-audiosink', 'alsasink', - '--gstout-audiodevice', utils.real_output_device(src), '--gstout-initial-volume-db', - '0.0', '-p', f'{portnum}', '-u', f'{self.uuid}', - '-f', f'{self.name}', '--logfile', - f'{src_config_folder}/metafifo'] - self.proc = subprocess.Popen(args=meta_args, preexec_fn=os.setpgrp, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + '--gstout-audiodevice', utils.real_output_device(src), '--gstout-initial-volume-db', + '0.0', '-p', f'{portnum}', '-u', f'{self.uuid}', + '-f', f'{self.name}', '--logfile', + f'{src_config_folder}/metafifo'] + self.proc = subprocess.Popen(args=meta_args, preexec_fn=os.setpgrp, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) self.proc2 = subprocess.Popen(args=dlna_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) self._connect(src) @@ -848,8 +872,10 @@ def info(self) -> models.SourceInfo: pass return source + class InternetRadio(BaseStream): """ An Internet Radio Stream """ + def __init__(self, name: str, url: str, logo: Optional[str], disabled: bool = False, mock: bool = False): super().__init__('internet radio', name, disabled=disabled, mock=mock) self.url = url @@ -860,7 +886,7 @@ def reconfig(self, **kwargs): reconnect_needed = False ir_fields = ['url', 'logo'] fields = list(ir_fields) + ['name', 'disabled'] - for k,v in kwargs.items(): + for k, v in kwargs.items(): if k in fields and self.__dict__[k] != v: self.__dict__[k] = v if k in ir_fields: @@ -868,7 +894,7 @@ def reconfig(self, **kwargs): if reconnect_needed and self._is_running(): last_src = self.src self.disconnect() - time.sleep(0.1) # delay a bit, is this needed? + time.sleep(0.1) # delay a bit, is this needed? self.connect(last_src) def connect(self, src): @@ -890,7 +916,10 @@ def connect(self, src): # Start audio via runvlc.py song_info_path = f'{src_config_folder}/currentSong' log_file_path = f'{src_config_folder}/log' - inetradio_args = [sys.executable, f"{utils.get_folder('streams')}/runvlc.py", self.url, utils.real_output_device(src), '--song-info', song_info_path, '--log', log_file_path] + inetradio_args = [ + sys.executable, f"{utils.get_folder('streams')}/runvlc.py", self.url, utils.real_output_device(src), + '--song-info', song_info_path, '--log', log_file_path + ] print(f'running: {inetradio_args}') self.proc = subprocess.Popen(args=inetradio_args, preexec_fn=os.setpgrp) @@ -908,9 +937,9 @@ def info(self) -> models.SourceInfo: src_config_folder = f"{utils.get_folder('config')}/srcs/{self.src}" loc = f'{src_config_folder}/currentSong' source = models.SourceInfo(name=self.full_name(), - state=self.state, - img_url='static/imgs/internet_radio.png', - supported_cmds=self.supported_cmds) + state=self.state, + img_url='static/imgs/internet_radio.png', + supported_cmds=self.supported_cmds) if self.logo: source.img_url = self.logo try: @@ -946,10 +975,12 @@ def send_cmd(self, cmd): except Exception: pass + class Plexamp(BaseStream): """ A Plexamp Stream TODO: old plexamp interface was disabled, integrate support for new PlexAmp """ + def __init__(self, name: str, client_id, token, disabled: bool = False, mock: bool = False): super().__init__('plexamp', name, disabled=disabled, mock=mock) @@ -975,8 +1006,10 @@ def info(self) -> models.SourceInfo: source.track = "Not currently supported" return source + class FilePlayer(BaseStream): """ An Single one shot file player - initially intended for use as a part of the PA Announcements """ + def __init__(self, name: str, url: str, disabled: bool = False, mock: bool = False): super().__init__('file player', name, disabled=disabled, mock=mock) self.url = url @@ -994,7 +1027,7 @@ def reconfig(self, **kwargs): if reconnect_needed: last_src = self.src self.disconnect() - time.sleep(0.1) # delay a bit, is this needed? + time.sleep(0.1) # delay a bit, is this needed? self.connect(last_src) def connect(self, src): @@ -1011,7 +1044,8 @@ def connect(self, src): # Start audio via runvlc.py vlc_args = f'cvlc -A alsa --alsa-audio-device {utils.real_output_device(src)} {self.url} vlc://quit' print(f'running: {vlc_args}') - self.proc = subprocess.Popen(args=vlc_args.split(), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + self.proc = subprocess.Popen(args=vlc_args.split(), stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) self._connect(src) # make a thread that waits for a couple of secends and returns after setting info to stopped self.bkg_thread = threading.Thread(target=self.wait_on_proc) @@ -1021,10 +1055,10 @@ def connect(self, src): def wait_on_proc(self): """ Wait for the vlc process to finish """ if self.proc is not None: - self.proc.wait() # TODO: add a time here + self.proc.wait() # TODO: add a time here else: - time.sleep(0.3) # handles mock case - self.state = 'stopped' # notify that the audio is done playing + time.sleep(0.3) # handles mock case + self.state = 'stopped' # notify that the audio is done playing def disconnect(self): if self._is_running(): @@ -1038,8 +1072,10 @@ def info(self) -> models.SourceInfo: source = models.SourceInfo(name=self.full_name(), state=self.state, img_url='static/imgs/plexamp.png') return source + class FMRadio(BaseStream): """ An FMRadio Stream using RTLSDR """ + def __init__(self, name: str, freq, logo: Optional[str] = None, disabled: bool = False, mock: bool = False): super().__init__('fm radio', name, disabled=disabled, mock=mock) self.freq = freq @@ -1057,7 +1093,7 @@ def reconfig(self, **kwargs): if reconnect_needed and self._is_running(): last_src = self.src self.disconnect() - time.sleep(0.1) # delay a bit, is this needed? + time.sleep(0.1) # delay a bit, is this needed? self.connect(last_src) def connect(self, src): @@ -1073,9 +1109,13 @@ def connect(self, src): song_info_path = f'{src_config_folder}/currentSong' log_file_path = f'{src_config_folder}/log' - fmradio_args = [sys.executable, f"{utils.get_folder('streams')}/fmradio.py", self.freq, utils.real_output_device(src), '--song-info', song_info_path, '--log', log_file_path] + fmradio_args = [ + sys.executable, f"{utils.get_folder('streams')}/fmradio.py", self.freq, utils.real_output_device(src), + '--song-info', song_info_path, '--log', log_file_path + ] print(f'running: {fmradio_args}') - self.proc = subprocess.Popen(args=fmradio_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, preexec_fn=os.setpgrp) + self.proc = subprocess.Popen(args=fmradio_args, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, preexec_fn=os.setpgrp) self._connect(src) def _is_running(self): @@ -1099,7 +1139,7 @@ def info(self) -> models.SourceInfo: with open(loc, 'r', encoding='utf-8') as file: data = json.loads(file.read()) # Example JSON: "station": "Mixx96.1", "callsign": "KXXO", "prog_type": "Soft rock", "radiotext": " x96.1" - #print(json.dumps(data)) + # print(json.dumps(data)) if data['prog_type']: source.artist = data['prog_type'] else: @@ -1120,14 +1160,16 @@ def info(self) -> models.SourceInfo: return source except Exception: pass - #print('Failed to get currentSong - it may not exist: {}'.format(e)) + # print('Failed to get currentSong - it may not exist: {}'.format(e)) return source + class LMS(PersistentStream): """ An LMS Stream using squeezelite""" + def __init__(self, name: str, server: Optional[str] = None, disabled: bool = False, mock: bool = False): super().__init__('lms', name, disabled=disabled, mock=mock) - self.server : Optional[str] = server + self.server: Optional[str] = server def is_persistent(self): return True @@ -1161,26 +1203,27 @@ def _activate(self, vsrc: int): md5 = hashlib.md5() md5.update(self.name.encode('utf-8')) md5_hex = md5.hexdigest() - fake_mac = ':'.join([md5_hex[i:i+2] for i in range(0, 12, 2)]) + fake_mac = ':'.join([md5_hex[i:i + 2] for i in range(0, 12, 2)]) # Process - lms_args = [f'{utils.get_folder("streams")}/process_monitor.py', - '/usr/bin/squeezelite', - '-n', self.name, - '-m', fake_mac, - '-o', utils.virtual_output_device(vsrc), - '-f', f'{src_config_folder}/lms_log.txt', - '-i', f'{src_config_folder}/lms_remote', # specify this to avoid collisions, even if unused - ] + lms_args = [ + f'{utils.get_folder("streams")}/process_monitor.py', + '/usr/bin/squeezelite', + '-n', self.name, + '-m', fake_mac, + '-o', utils.virtual_output_device(vsrc), + '-f', f'{src_config_folder}/lms_log.txt', + '-i', f'{src_config_folder}/lms_remote', # specify this to avoid collisions, even if unused + ] if self.server: # specify the server to connect to (if unspecified squeezelite starts in discovery mode) server = self.server # some versions of amplipi have an LMS server embedded, using localhost avoids hardcoding the hostname - if 'localhost' == server: + if 'localhost' == server: # squeezelite does not support localhost and requires the actual hostname # NOTE: port 9000 is assumed server.replace('localhost', socket.gethostname()) - lms_args += [ '-s', server] + lms_args += ['-s', server] # TODO: allow port to be specified with server (embedding it in the server URL does not work) self.proc = subprocess.Popen(args=lms_args) @@ -1208,6 +1251,7 @@ def info(self) -> models.SourceInfo: ) return source + class Bluetooth(BaseStream): """ A source for Bluetooth streams, which requires an external Bluetooth USB dongle """ @@ -1231,7 +1275,7 @@ def is_hw_available(): btcmd_proc = subprocess.run('bluetoothctl show'.split(), check=True, stdout=subprocess.PIPE, timeout=0.5) return 'No default controller available' not in btcmd_proc.stdout.decode('utf-8') except Exception as e: - if 'timed out' not in str(e): # a timeout indicates bluetooth module is missing + if 'timed out' not in str(e): # a timeout indicates bluetooth module is missing print(f'Error checking for bluetooth hardware: {e}') return False @@ -1311,10 +1355,12 @@ def send_cmd(self, cmd): raise RuntimeError(f'Command {cmd} failed to send: {e}') from e traceback.print_exc() + # Simple handling of stream types before we have a type heirarchy AnyStream = Union[RCA, AirPlay, Spotify, InternetRadio, DLNA, Pandora, Plexamp, FilePlayer, FMRadio, LMS, Bluetooth] + def build_stream(stream: models.Stream, mock=False) -> AnyStream: """ Build a stream from the generic arguments given in stream, discriminated by stream.type @@ -1328,7 +1374,7 @@ def build_stream(stream: models.Stream, mock=False) -> AnyStream: return RCA(name, args['index'], disabled=disabled, mock=mock) if stream.type == 'pandora': return Pandora(name, args['user'], args['password'], station=args.get('station', None), disabled=disabled, mock=mock) - if stream.type in ['shairport', 'airplay']: # handle older configs + if stream.type in ['shairport', 'airplay']: # handle older configs return AirPlay(name, args.get('ap2', False), disabled=disabled, mock=mock) if stream.type == 'spotify': return Spotify(name, disabled=disabled, mock=mock) diff --git a/amplipi/utils.py b/amplipi/utils.py index 3c26c9124..beeedf8e3 100644 --- a/amplipi/utils.py +++ b/amplipi/utils.py @@ -31,7 +31,7 @@ from typing import Dict, Iterable, List, Optional, Set, Tuple, TypeVar, Union from fastapi import HTTPException, status, Depends -import pkg_resources # version +import pkg_resources # version from amplipi import models from amplipi.defaults import USER_CONFIG_DIR @@ -41,35 +41,44 @@ IDENTITY_FILE = os.path.join(USER_CONFIG_DIR, "identity") # Helper functions + + def encode(pydata): """ Encode a dictionary as JSON """ return json.dumps(pydata) + def decode(j): """ Decode JSON into dictionary """ return json.loads(j) + def parse_int(i, options): """ Parse an integer into one of the given options """ if int(i) in options: return int(i) raise ValueError(f'{i} is not in [{options}]') + def error(msg): """ wrap the error message specified by msg into an error """ print(f'Error: {msg}') return {'error': msg} + VT = TypeVar("VT") + def updated_val(update: Optional[VT], val: VT) -> Tuple[VT, bool]: """ get the potentially updated value, @update, defaulting to the current value, @val, if it is None """ if update is None: return val, False return update, update != val + BT_co = TypeVar("BT_co", bound='models.Base', covariant=True) + def find(items: Iterable[BT_co], item_id: Optional[int], key='id') -> Union[Tuple[int, BT_co], Tuple[None, None]]: """ Find an item by id """ if item_id is None: @@ -79,6 +88,7 @@ def find(items: Iterable[BT_co], item_id: Optional[int], key='id') -> Union[Tupl return i, item return None, None + def next_available_id(items: Iterable[BT_co], default: int = 0) -> int: """ Get a new unique id among @items """ # TODO; use `largest_item = max(items, key=lambda item: item.id, default=None)` to find max if models.Base changes id to be required @@ -93,14 +103,17 @@ def next_available_id(items: Iterable[BT_co], default: int = 0) -> int: return largest_id + 1 return default + def clamp(xval, xmin, xmax): """ Clamp and value between min and max """ return max(xmin, min(xval, xmax)) -def compact_str(list_:List): + +def compact_str(list_: List): """ stringify a compact list""" return str(list_).replace(' ', '') + def max_len(items, len_determiner=len): """ Determine the item with the max len, based on the @len_determiner's definition of length @@ -116,13 +129,16 @@ def max_len(items, len_determiner=len): largest = max(items, key=len_determiner) return len_determiner(largest) + def abbreviate_src(src_type): """ Abbreviate source's type for pretty printing """ return src_type[0].upper() if src_type else '_' + def src_zones(status: models.Status) -> Dict[int, List[int]]: """ Get a mapping from source ids to zones """ - return { src.id : [ zone.id for zone in status.zones if zone.id is not None and zone.source_id == src.id] for src in status.sources if src.id is not None} + return {src.id: [zone.id for zone in status.zones if zone.id is not None and zone.source_id == src.id] for src in status.sources if src.id is not None} + @functools.lru_cache(1) def available_outputs(): @@ -134,7 +150,7 @@ def available_outputs(): This will cache the result since alsa outputs do not change dynamically (unless you edit a config file). """ try: - outputs = [ o for o in subprocess.check_output('aplay -L'.split()).decode().split('\n') if o and o[0] != ' ' ] + outputs = [o for o in subprocess.check_output('aplay -L'.split()).decode().split('\n') if o and o[0] != ' '] except: outputs = [] if 'ch0' not in outputs: @@ -152,7 +168,8 @@ def virtual_output_device(vsid: int) -> str: dev = f'lb{vsid}c' if dev in available_outputs(): return dev - return 'default' # for now we want basic streams to play for testing + return 'default' # for now we want basic streams to play for testing + def configure_inputs(): """ The IEC598 and Aux inputs are being muted/misconfigured during system startup @@ -161,25 +178,28 @@ def configure_inputs(): if is_amplipi(): # setup usb soundcard input volumes and unmute try: - subprocess.run(shlex.split(r'amixer -D hw:cmedia8chint set Speaker 100% unmute'), check=True) # is this required?? + # is 'set Speaker 100%' required?? + subprocess.run(shlex.split(r'amixer -D hw:cmedia8chint set Speaker 100% unmute'), check=True) subprocess.run(shlex.split('amixer -D hw:cmedia8chint set Line capture cap 0dB playback mute 0%'), check=True) subprocess.run(shlex.split('amixer -D hw:cmedia8chint set "IEC958 In" cap'), check=True) except Exception as e: print(f'Failed to configure inputs: {e}') + def virtual_connection_device(vsid: int) -> Optional[str]: """ Get a virtual source's corresponding connection (capture device) string for use with alsaloop """ # NOTE: we use the other side (DEV=0) for devices 6-11 since we only have 6 loopbacks lb_id = vsid % 6 lb_dev_in = vsid // 6 - lb_dev_out = {0:1, 1:0}[lb_dev_in] # loopback output is on the opposite side + lb_dev_out = {0: 1, 1: 0}[lb_dev_in] # loopback output is on the opposite side assert vsid < 12, "only 12 virtual outputs are supported" - #dev = f'hw:CARD=Loopback_{lb_id},DEV={lb_dev_out}'.replace('_0', '') # here we use the hw side + # dev = f'hw:CARD=Loopback_{lb_id},DEV={lb_dev_out}'.replace('_0', '') # here we use the hw side dev = f'lb{vsid}p' if dev in available_outputs(): - return dev.replace('CARD=', '').replace('DEV=', '') # alsaloop doesn't like these specifiers + return dev.replace('CARD=', '').replace('DEV=', '') # alsaloop doesn't like these specifiers return None + def real_output_device(sid: int) -> str: """ Get the plug ALSA device connected directly to an output DAC """ dev = f'ch{sid}' @@ -187,6 +207,7 @@ def real_output_device(sid: int) -> str: return dev return 'default' + def zones_from_groups(status: models.Status, groups: List[int]) -> Set[int]: """ Get the set of zones from some groups """ zones = set() @@ -197,6 +218,7 @@ def zones_from_groups(status: models.Status, groups: List[int]) -> Set[int]: zones.update(match.zones) return zones + def zones_from_all(status: models.Status, zones: Optional[List[int]], groups: Optional[List[int]]) -> Set[int]: """ Find the unique set of enabled zones given some zones and some groups of zones """ # add all of the zones given @@ -205,11 +227,13 @@ def zones_from_all(status: models.Status, zones: Optional[List[int]], groups: Op z_unique.update(zones_from_groups(status, groups or [])) return z_unique + def enabled_zones(status: models.Status, zones: Set[int]) -> Set[int]: """ Get only enabled zones """ z_disabled = {z.id for z in status.zones if z.id is not None and z.disabled} return zones.difference(z_disabled) + @functools.lru_cache(maxsize=8) def get_folder(folder): """ Get a directory @@ -221,8 +245,10 @@ def get_folder(folder): print(f'Error creating dir: {folder}') return os.path.abspath(folder) + TOML_VERSION_STR = re.compile(r'version\s*=\s*"(.*)"') + @functools.lru_cache(1) def detect_version() -> str: """ Get the AmpliPi software version from the project TOML file """ @@ -246,6 +272,7 @@ def detect_version() -> str: pass return version + def is_amplipi(): """ Check if the current hardware is an AmpliPi @@ -282,9 +309,11 @@ def is_amplipi(): return amplipi + class TimeBasedCache: """ Cache the value of a timely but costly method, @updater, for @keep_for s """ - def __init__(self, updater, keep_for:float, name): + + def __init__(self, updater, keep_for: float, name): self.name = name self._updater = updater self._keep_for = keep_for @@ -301,6 +330,7 @@ def get(self, throttled=True): self._update() return self._val + def vol_float_to_db(vol: float, db_min: int = models.MIN_VOL_DB, db_max: int = models.MAX_VOL_DB) -> int: """ Convert floating-point volume to dB """ # A linear conversion works here because the volume control IC directly takes dB. @@ -313,6 +343,7 @@ def vol_float_to_db(vol: float, db_min: int = models.MIN_VOL_DB, db_max: int = m vol_db_clamped = clamp(vol_db, models.MIN_VOL_DB, models.MAX_VOL_DB) return vol_db_clamped + def vol_db_to_float(vol: int, db_min: int = models.MIN_VOL_DB, db_max: int = models.MAX_VOL_DB) -> float: """ Convert volume in a dB range to floating-point """ range_f = models.MAX_VOL_F - models.MIN_VOL_F @@ -321,13 +352,15 @@ def vol_db_to_float(vol: int, db_min: int = models.MIN_VOL_DB, db_max: int = mod vol_f_clamped = clamp(vol_f, models.MIN_VOL_F, models.MAX_VOL_F) return vol_f_clamped + def debug_enabled() -> bool: """ Returns true or false if debug is enabled """ return pathlib.Path.home().joinpath(".config/amplipi/debug.json").exists() + def get_identity() -> dict: """ Returns the identity file contents """ - identity : Dict[str, str] = { + identity: Dict[str, str] = { 'name': 'AmpliPi', 'website': 'http://www.amplipi.com', 'touch_logo': 'amplipi/display/imgs/amplipi_320x126.png' @@ -354,6 +387,7 @@ def get_identity() -> dict: raise e return identity + def set_identity(settings: Dict): identity = get_identity() identity.update(settings) From 8599afdc9f7b317baed8abd4293736dbf7b49ea8 Mon Sep 17 00:00:00 2001 From: Michael Lohrer Date: Wed, 27 Dec 2023 14:45:00 -0500 Subject: [PATCH 4/5] Auto-format display .py files --- amplipi/display/common.py | 3 + amplipi/display/display.py | 4 +- amplipi/display/einkdisplay.py | 13 ++-- amplipi/display/epd2in13_V3.py | 132 +++++++++++++++++---------------- amplipi/display/tftdisplay.py | 8 +- 5 files changed, 86 insertions(+), 74 deletions(-) diff --git a/amplipi/display/common.py b/amplipi/display/common.py index 6ef9639c8..724eab747 100644 --- a/amplipi/display/common.py +++ b/amplipi/display/common.py @@ -5,6 +5,7 @@ from enum import Enum from typing import Tuple + class Display: """Abstract External Display Used to display system information like password and IP address""" @@ -23,6 +24,7 @@ def stop(self): """Called by exit handler. Stops the run method.""" raise NotImplementedError('Display.stop') + class Color(Enum): """ Colors used in the AmpliPi front-panel display """ GREEN = '#28a745' @@ -34,6 +36,7 @@ class Color(Enum): LIGHTGRAY = '#999999' WHITE = '#FFFFFF' + class DefaultPass: """Helper class to read and verify the current pi user's password against the stored default AmpliPi password.""" diff --git a/amplipi/display/display.py b/amplipi/display/display.py index e15ec775b..1feaed607 100644 --- a/amplipi/display/display.py +++ b/amplipi/display/display.py @@ -9,6 +9,7 @@ from amplipi.display.einkdisplay import EInkDisplay from amplipi.display.tftdisplay import TFTDisplay + def main(): """Run the available external display""" if 'venv' not in sys.prefix: @@ -57,7 +58,8 @@ def main(): if not initialized: log.error("Display failed to initialize. Exiting.") - sys.exit(-1) # expose failure to make the service restart + sys.exit(-1) # expose failure to make the service restart + if __name__ == '__main__': main() diff --git a/amplipi/display/einkdisplay.py b/amplipi/display/einkdisplay.py index 9c34aecd9..e9af6bcf1 100644 --- a/amplipi/display/einkdisplay.py +++ b/amplipi/display/einkdisplay.py @@ -17,6 +17,7 @@ SysInfo = namedtuple('SysInfo', ['hostname', 'password', 'ip']) + class EInkDisplay(Display): """ Display system infomation on EInk Panel""" @@ -27,11 +28,11 @@ class EInkDisplay(Display): def __init__(self, iface: str = 'eth0', log_level: str = 'WARNING'): self.iface = iface - self.epd : Optional[epd2in13_V3.EPD] = None - self.font : Optional[ImageFont.FreeTypeFont] = None - self.pass_font : Optional[ImageFont.FreeTypeFont] = None - self.char_height : int = 0 - self.char_width : int = 0 + self.epd: Optional[epd2in13_V3.EPD] = None + self.font: Optional[ImageFont.FreeTypeFont] = None + self.pass_font: Optional[ImageFont.FreeTypeFont] = None + self.char_height: int = 0 + self.char_width: int = 0 self.width = 0 self.height = 0 self.pass_fontsize = 15 @@ -111,7 +112,7 @@ def display_refresh_base(self): """Draw the base image used for partial refresh""" self.update_display(draw_base=True) - def update_display(self, info: SysInfo=SysInfo(None, None, None), draw_base=False): + def update_display(self, info: SysInfo = SysInfo(None, None, None), draw_base=False): """Update display with new info using partial refresh""" if not self.epd: log.error('Failed to update display, display driver not initialized') diff --git a/amplipi/display/epd2in13_V3.py b/amplipi/display/epd2in13_V3.py index f7787a1ac..c3fad6ad6 100644 --- a/amplipi/display/epd2in13_V3.py +++ b/amplipi/display/epd2in13_V3.py @@ -43,9 +43,10 @@ pass # Display resolution -EPD_WIDTH = 122 +EPD_WIDTH = 122 EPD_HEIGHT = 250 + class RaspberryPi: """Raspberry Pi driver for display, modfied for AmpliPi and readability""" RST_PIN = 12 @@ -103,51 +104,52 @@ def module_exit(self): GPIO.cleanup([self.RST_PIN, self.DC_PIN, self.CS_PIN, self.BUSY_PIN]) + class EPD: """Electronic paper driver""" PARTIAL_UPDATE = [ - 0x0,0x40,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x80,0x80,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x40,0x40,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x80,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x14,0x0,0x0,0x0,0x0,0x0,0x0, - 0x1,0x0,0x0,0x0,0x0,0x0,0x0, - 0x1,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x22,0x22,0x22,0x22,0x22,0x22,0x0,0x0,0x0, - 0x22,0x17,0x41,0x00,0x32,0x36, + 0x0, 0x40, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x80, 0x80, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x40, 0x40, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x80, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x14, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x0, 0x0, 0x0, + 0x22, 0x17, 0x41, 0x00, 0x32, 0x36, ] FULL_UPDATE = [ - 0x80,0x4A,0x40,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x40,0x4A,0x80,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x80,0x4A,0x40,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x40,0x4A,0x80,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0xF,0x0,0x0,0x0,0x0,0x0,0x0, - 0xF,0x0,0x0,0xF,0x0,0x0,0x2, - 0xF,0x0,0x0,0x0,0x0,0x0,0x0, - 0x1,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x0,0x0,0x0,0x0,0x0,0x0,0x0, - 0x22,0x22,0x22,0x22,0x22,0x22,0x0,0x0,0x0, - 0x22,0x17,0x41,0x0,0x32,0x36, + 0x80, 0x4A, 0x40, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x40, 0x4A, 0x80, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x80, 0x4A, 0x40, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x40, 0x4A, 0x80, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0xF, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0xF, 0x0, 0x0, 0xF, 0x0, 0x0, 0x2, + 0xF, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x0, 0x0, 0x0, + 0x22, 0x17, 0x41, 0x0, 0x32, 0x36, ] def __init__(self, log_level='WARNING'): @@ -199,7 +201,7 @@ def send_data2(self, data): def _is_bus_busy(self): return self.driver.digital_read(self.busy_pin) == 1 - def wait_done(self, timeout_s = 5): + def wait_done(self, timeout_s=5): """Wait until the busy_pin goes LOW""" if self._is_bus_busy(): timeout_ms = timeout_s * 1000 @@ -214,16 +216,16 @@ def wait_done(self, timeout_s = 5): def enable_display(self): """Turn on display""" - self.send_command(0x22) # Display Update Control + self.send_command(0x22) # Display Update Control self.send_data(0xC7) - self.send_command(0x20) # Activate Display Update Sequence + self.send_command(0x20) # Activate Display Update Sequence self.wait_done() def enable_partial_display(self): """Turn on display, partial""" - self.send_command(0x22) # Display Update Control + self.send_command(0x22) # Display Update Control self.send_data(0x0f) # fast:0x0c, quality:0x0f, 0xcf - self.send_command(0x20) # Activate Display Update Sequence + self.send_command(0x20) # Activate Display Update Sequence self.wait_done() def set_lut(self, lut): @@ -245,12 +247,12 @@ def set_lut(self, lut): def set_window(self, x_start, y_start, x_end, y_end): """Configure the display window""" - self.send_command(0x44) # SET_RAM_X_ADDRESS_START_END_POSITION + self.send_command(0x44) # SET_RAM_X_ADDRESS_START_END_POSITION # x point must be the multiple of 8 or the last 3 bits will be ignored - self.send_data((x_start>>3) & 0xFF) - self.send_data((x_end>>3) & 0xFF) + self.send_data((x_start >> 3) & 0xFF) + self.send_data((x_end >> 3) & 0xFF) - self.send_command(0x45) # SET_RAM_Y_ADDRESS_START_END_POSITION + self.send_command(0x45) # SET_RAM_Y_ADDRESS_START_END_POSITION self.send_data(y_start & 0xFF) self.send_data((y_start >> 8) & 0xFF) self.send_data(y_end & 0xFF) @@ -258,11 +260,11 @@ def set_window(self, x_start, y_start, x_end, y_end): def set_cursor(self, x, y): """ Set cursor position in x and y""" - self.send_command(0x4E) # SET_RAM_X_ADDRESS_COUNTER + self.send_command(0x4E) # SET_RAM_X_ADDRESS_COUNTER # x point must be the multiple of 8 or the last 3 bits will be ignored self.send_data(x & 0xFF) - self.send_command(0x4F) # SET_RAM_Y_ADDRESS_COUNTER + self.send_command(0x4F) # SET_RAM_Y_ADDRESS_COUNTER self.send_data(y & 0xFF) self.send_data((y >> 8) & 0xFF) @@ -273,24 +275,24 @@ def init(self): self.reset() self.wait_done() - self.send_command(0x12) #SWRESET + self.send_command(0x12) # SWRESET self.wait_done() - self.send_command(0x01) #Driver output control + self.send_command(0x01) # Driver output control self.send_data(0xf9) self.send_data(0x00) self.send_data(0x00) - self.send_command(0x11) #data entry mode + self.send_command(0x11) # data entry mode self.send_data(0x03) - self.set_window(0, 0, self.width-1, self.height-1) + self.set_window(0, 0, self.width - 1, self.height - 1) self.set_cursor(0, 0) self.send_command(0x3c) self.send_data(0x05) - self.send_command(0x21) # Display update control + self.send_command(0x21) # Display update control self.send_data(0x00) self.send_data(0x80) @@ -313,17 +315,17 @@ def get_buffer(self, image): else: log.warning(f"Wrong image dimensions: must be {self.width}x{self.height}") # return a blank buffer - return [0x00] * (int(self.width/8) * self.height) + return [0x00] * (int(self.width / 8) * self.height) buf = bytearray(img.tobytes('raw')) return buf def display(self, image): """Send image buffer in RAM to e-Paper and displays""" - if self.width%8 == 0: - linewidth = int(self.width/8) + if self.width % 8 == 0: + linewidth = int(self.width / 8) else: - linewidth = int(self.width/8) + 1 + linewidth = int(self.width / 8) + 1 self.send_command(0x24) for j in range(0, self.height): @@ -350,7 +352,7 @@ def display_partial(self, image): self.send_data(0x00) self.send_data(0x00) - self.send_command(0x3C) #BorderWavefrom + self.send_command(0x3C) # BorderWavefrom self.send_data(0x80) self.send_command(0x22) @@ -361,7 +363,7 @@ def display_partial(self, image): self.set_window(0, 0, self.width - 1, self.height - 1) self.set_cursor(0, 0) - self.send_command(0x24) # WRITE_RAM + self.send_command(0x24) # WRITE_RAM # for j in range(0, self.height): # for i in range(0, linewidth): # self.send_data(image[i + j * linewidth]) @@ -382,10 +384,10 @@ def display_partial_base(self, base_image): def clear(self, color): """Clear screen""" - if self.width%8 == 0: - linewidth = int(self.width/8) + if self.width % 8 == 0: + linewidth = int(self.width / 8) else: - linewidth = int(self.width/8) + 1 + linewidth = int(self.width / 8) + 1 self.send_command(0x24) self.send_data2([color] * int(self.height * linewidth)) @@ -393,7 +395,7 @@ def clear(self, color): def sleep(self): """ Enter deep sleep mode """ - self.send_command(0x10) #enter deep sleep + self.send_command(0x10) # enter deep sleep self.send_data(0x01) self.driver.delay_ms(2000) diff --git a/amplipi/display/tftdisplay.py b/amplipi/display/tftdisplay.py index c897a8e41..d537dc8cc 100644 --- a/amplipi/display/tftdisplay.py +++ b/amplipi/display/tftdisplay.py @@ -115,7 +115,8 @@ def init(self) -> bool: self.spi = busio.SPI(clock=self.clk_pin, MOSI=self.mosi_pin, MISO=self.miso_pin) # Create the ILI9341 display: - self.display = ili9341.ILI9341(self.spi, cs=self.disp_cs, dc=self.disp_dc, rst=self.rst_pin, baudrate=self.spi_baud, rotation=270) + self.display = ili9341.ILI9341(self.spi, cs=self.disp_cs, dc=self.disp_dc, + rst=self.rst_pin, baudrate=self.spi_baud, rotation=270) # Set backlight brightness out of 65535 # Turn off until first image is written to work around not having RST @@ -148,7 +149,7 @@ def init(self) -> bool: self.ap_logo = Image.open(identity['touch_logo']).convert('RGB') except Exception: # default to a black image - self.ap_logo = Image.new('RGB', (320 , 126)) + self.ap_logo = Image.new('RGB', (320, 126)) # Turn on display backlight now that an image is loaded # Anything duty cycle less than 100% causes flickering @@ -485,6 +486,7 @@ def backlight(self, on: bool): else: self.led.duty_cycle = 0 + def draw_volume_bars(draw, font, small_font, zones: List[models.Zone], x=0, y=0, width=320, height=240): n = len(zones) if n == 0: # No zone info from AmpliPi server @@ -532,6 +534,7 @@ def draw_volume_bars(draw, font, small_font, zones: List[models.Zone], x=0, y=0, draw.rectangle((xb, yb, xb + wb, yv), fill=color) # TODO: For more than 18 zones, show on multiple screens. + def gradient(num, min_val=0, max_val=100): # red = round(255*(val - min_val) / (max_val - min_val)) # grn = round(255-red)#255*(max_val - val) / (max_val - min_val) @@ -551,6 +554,7 @@ def gradient(num, min_val=0, max_val=100): grn = 255 - round(scale * (num - mid)) return f'#{red:02X}{grn:02X}00' + def get_amplipi_data(base_url: Optional[str]) -> Tuple[bool, List[models.Source], List[models.Zone]]: """ Get the AmpliPi's status via the REST API Returns true/false on success/failure, as well as the sources and zones From f97a012d336503b294c480f217797775385971aa Mon Sep 17 00:00:00 2001 From: Michael Lohrer Date: Wed, 27 Dec 2023 14:45:33 -0500 Subject: [PATCH 5/5] Auto-format updater asgi.py --- amplipi/updater/asgi.py | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/amplipi/updater/asgi.py b/amplipi/updater/asgi.py index b7d7ec4a2..a62a9d409 100644 --- a/amplipi/updater/asgi.py +++ b/amplipi/updater/asgi.py @@ -56,11 +56,13 @@ sse_messages: queue.Queue = queue.Queue() + class ReleaseInfo(BaseModel): """ Software Release Information """ url: str version: str + # host all of the static files the client will look for real_path = os.path.realpath(__file__) dir_path = os.path.dirname(real_path) @@ -87,12 +89,14 @@ class ReleaseInfo(BaseModel): except Exception as e: print(f'Error loading identity file: {e}') + @router.get('/update') def get_index(): """ Get the update website """ # FileResponse knows nothing about the static mount return FileResponse(f'{dir_path}/static/index.html') + def save_upload_file(upload_file: UploadFile, destination: pathlib.Path) -> None: """ Save the update file """ try: @@ -101,6 +105,7 @@ def save_upload_file(upload_file: UploadFile, destination: pathlib.Path) -> None finally: upload_file.file.close() + @router.post("/update/upload") async def start_upload(file: UploadFile = File(...)): """ Start a upload based update """ @@ -115,6 +120,7 @@ async def start_upload(file: UploadFile = File(...)): print(e) return 500 + def download(url, file_name): """ Download a binary file from @url to @file_name """ with open(file_name, "wb") as file: @@ -124,6 +130,7 @@ def download(url, file_name): file.write(response.content) # TODO: verify file has amplipi version + @router.post("/update/download") async def download_update(info: ReleaseInfo): """ Download the update """ @@ -136,7 +143,8 @@ async def download_update(info: ReleaseInfo): print(e) return 500 -@router.get('/update/restart') # an old version accidentally used get instead of post + +@router.get('/update/restart') # an old version accidentally used get instead of post @router.post('/update/restart') def restart(): """ Restart the OS and all of the AmpliPi services including the updater. @@ -147,8 +155,10 @@ def restart(): subprocess.Popen(f'python3 {INSTALL_DIR}/scripts/configure.py --restart-updater'.split()) return 200 + TOML_VERSION_STR = re.compile(r'version\s*=\s*"(.*)"') + @router.get('/update/version') def get_version(): """ Get the AmpliPi software version from the project TOML file """ @@ -168,25 +178,36 @@ def get_version(): pass return {'version': version} + def _sse_message(t, msg): """ Report an SSE message """ msg = msg.replace('\n', '
') - sse_msg = {'data' : json.dumps({'message': msg, 'type' : t})} + sse_msg = {'data': json.dumps({'message': msg, 'type': t})} sse_messages.put(sse_msg) # Give the SSE publisher time to handle the messages, is there a way to just yield? time.sleep(0.1) + def _sse_info(msg): _sse_message('info', msg) + + def _sse_warning(msg): _sse_message('warning', msg) + + def _sse_error(msg): _sse_message('error', msg) + + def _sse_done(msg): _sse_message('success', msg) + + def _sse_failed(msg): _sse_message('failed', msg) + @router.route('/update/install/progress') async def progress(req: Request): """ SSE Progress server """ @@ -207,6 +228,7 @@ async def stream(): raise e return EventSourceResponse(stream()) + def extract_to_home(home): """ The simple, pip-less install. Extract tarball and copy into users home directory """ temp_dir = mkdtemp() @@ -222,10 +244,12 @@ def extract_to_home(home): subprocess.check_call(f'mkdir -p {home}'.split()) subprocess.check_call(f'cp -a {files_to_copy} {home}/'.split()) + def indent(p: str): """ indent paragraph p """ return ' ' + ' '.join(p.splitlines(keepends=True)) + def install_thread(): """ Basic tar.gz based installation """ @@ -240,9 +264,10 @@ def install_thread(): try: # use the configure script provided by the new install to configure the installation - time.sleep(1) # update was just copied in, add a small delay to make sure we are accessing the new files + time.sleep(1) # update was just copied in, add a small delay to make sure we are accessing the new files sys.path.insert(0, f'{INSTALL_DIR}/scripts') - import configure # we want the new configure! # pylint: disable=import-error,import-outside-toplevel + import configure # we want the new configure! # pylint: disable=import-error,import-outside-toplevel + def progress_sse(tasks): for task in tasks: _sse_info(task.name) @@ -264,6 +289,7 @@ def progress_sse(tasks): _sse_failed(f'installation failed, error configuring update: {e}') return + @router.get('/update/install') def install(): """ Start the install after update is downloaded """ @@ -271,9 +297,11 @@ def install(): t.start() return {} + class PasswordInput(BaseModel): password: str + @router.post('/password') def set_admin_password(input: PasswordInput): """ Sets the admin password and (re)sets its access key.""" @@ -294,4 +322,4 @@ def set_admin_password(input: PasswordInput): if __name__ == '__main__': uvicorn.run(app, host="0.0.0.0", port=8000) -application = app # asgi assumes application var for app +application = app # asgi assumes application var for app