From a3e2224bc028798f029d679509851bd1b86c5f5d Mon Sep 17 00:00:00 2001 From: Ryan <2199132+rsnodgrass@users.noreply.github.com> Date: Wed, 17 Jan 2024 15:04:12 -0800 Subject: [PATCH 001/162] Create python-publish.yml --- .github/workflows/python-publish.yml | 39 ++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/python-publish.yml diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..bdaab28 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,39 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} From 74b697cf5c87a8bb9ba52f979b6f1e40450e6ba8 Mon Sep 17 00:00:00 2001 From: Ryan <2199132+rsnodgrass@users.noreply.github.com> Date: Wed, 17 Jan 2024 15:05:40 -0800 Subject: [PATCH 002/162] Create codeql.yml --- .github/workflows/codeql.yml | 84 ++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..7429698 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,84 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '23 7 * * 3' + +jobs: + analyze: + name: Analyze + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners + # Consider using larger runners for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + # required for all workflows + security-events: write + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] + # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" From cf254f3d69862e3e5ea2bc26a424869c46425357 Mon Sep 17 00:00:00 2001 From: Ryan <2199132+rsnodgrass@users.noreply.github.com> Date: Wed, 17 Jan 2024 15:06:21 -0800 Subject: [PATCH 003/162] Create python-package.yml --- .github/workflows/python-package.yml | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/python-package.yml diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 0000000..3edde1c --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,40 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Python package + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11","3.12"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest From 6f5d11cece746800c18bf920dfd8f33b61147db2 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Wed, 17 Jan 2024 15:10:03 -0800 Subject: [PATCH 004/162] update --- .github/workflows/python-package.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 3edde1c..3be1b88 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -4,8 +4,8 @@ name: Python package on: - push: - branches: [ "main" ] +# push: +# branches: [ "main" ] pull_request: branches: [ "main" ] @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11","3.12"] + python-version: [ "3.11", "3.12" ] steps: - uses: actions/checkout@v3 From fed5904df6013581e0aa6b8a3bd09242dec8394a Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 22 Jan 2024 00:00:01 -0800 Subject: [PATCH 005/162] various cleanup --- example-async.py | 13 ++++----- example-sync.py | 8 +++--- pyavcontrol/client/base.py | 32 ++++++++++++---------- pyavcontrol/client/sync_client.py | 2 +- pyavcontrol/config.py | 2 +- pyavcontrol/connection/async_connection.py | 14 +++++----- pyavcontrol/connection/sync_connection.py | 21 ++++++++------ pyavcontrol/core.py | 2 +- pyavcontrol/helper.py | 18 ++++++------ pyavcontrol/library/docs.py | 8 +++--- 10 files changed, 62 insertions(+), 58 deletions(-) diff --git a/example-async.py b/example-async.py index b12b642..e58af24 100755 --- a/example-async.py +++ b/example-async.py @@ -12,6 +12,7 @@ import coloredlogs from pyavcontrol import DeviceClient, DeviceModelLibrary +from pyavcontrol.helper import construct_async_client LOG = logging.getLogger(__name__) coloredlogs.install(level='DEBUG') @@ -41,15 +42,11 @@ async def main(): try: loop = asyncio.get_event_loop() - library = DeviceModelLibrary.create(event_loop=loop) - model_def = await library.load_model(args.model) - client = DeviceClient.create( - model_def, - args.url, - connection_config_overrides={'baudrate': args.baud}, - event_loop=loop, - ) + # FIXME: connection! + + config_overrides = {'baudrate': args.baud} + client = construct_async_client(args.model, args.url, loop, connection_config=config_overrides) # help(client.power) await client.send_raw(b'!PING?') diff --git a/example-sync.py b/example-sync.py index 9730c9a..02152b6 100755 --- a/example-sync.py +++ b/example-sync.py @@ -10,6 +10,7 @@ import coloredlogs from pyavcontrol import DeviceClient, DeviceModelLibrary +from pyavcontrol.helper import construct_synchronous_client LOG = logging.getLogger(__name__) coloredlogs.install(level='DEBUG') @@ -37,10 +38,9 @@ def main(): - model_def = DeviceModelLibrary.create().load_model(args.model) - client = DeviceClient.create( - model_def, args.url, connection_config_overrides={'baudrate': args.baud} - ) + config_overrides = {'baudrate': args.baud} + client = construct_synchronous_client(args.model, args.url, + connection_config=config_overrides) client.send_raw(b'!PING?') print(client.ping.ping()) diff --git a/pyavcontrol/client/base.py b/pyavcontrol/client/base.py index fc9126f..33a706b 100644 --- a/pyavcontrol/client/base.py +++ b/pyavcontrol/client/base.py @@ -11,9 +11,8 @@ from ..core import ( camel_case, generate_docs_for_action, - get_args_for_command, missing_keys_in_dict, - substitute_fstring_vars, + substitute_fstring_vars, get_args_for_command, ) from ..library.model import DeviceModel @@ -82,6 +81,9 @@ def _inject_client_api(client: DeviceClient, model: DeviceModel): """ api = model.definition.get(CONFIG.api, {}) for group_name, group_actions in api.items(): + if getattr(type(client), group_name): + raise RuntimeError(f'Injecting "{group_name}" failed as it already exists in class') + # LOG.debug(f'Adding property for group {group_name}') group_class = _create_activity_group_class( client, model, group_name, group_actions @@ -90,7 +92,6 @@ def _inject_client_api(client: DeviceClient, model: DeviceModel): return client - def _create_action_method( client: DeviceClient, cls_name: str, @@ -106,6 +107,7 @@ def _create_action_method( a synchronous method is returned by default. Calling code knows whether they instantiated a synchronous or asynchronous client. """ + # noinspection PyShadowingNames LOG = logging.getLogger(cls_name) required_args = get_args_for_command(action_def) wait_for_response = False @@ -123,12 +125,14 @@ def _prepare_request(**kwargs): return request.encode(client.encoding()) return None + # noinspection PyUnusedLocal def _activity_call_sync(self, **kwargs) -> None: """Synchronous version of making a client call""" if request := _prepare_request(**kwargs): return client.send_raw(request) LOG.warning(f'Failed to make request for {group_name}.{action_name}') + # noinspection PyUnusedLocal async def _activity_call_async(self, **kwargs) -> None: """ Asynchronous version of making a client call is used when an event_loop @@ -152,9 +156,8 @@ class DeviceClient(ABC): to control a device. """ - def _new__(cls, *args, **kwargs): + def __new__(cls, *args, **kwargs): return super().__new__(cls, *args, **kwargs) - # return def __init__(self, model: DeviceModel, connection: DeviceConnection): super().__init__() @@ -177,15 +180,15 @@ def is_async(self): """ return False - @abstractmethod - def send_command(self, group: str, action: str, **kwargs) -> None: - """ - Call a command by the group/action and args as defined in the - device's protocol yaml. E.g. - client.send_command(group, action, arg1=one, my_arg=my_arg) - """ - raise NotImplementedError() + #@abstractmethod + #def send_command(self, group: str, action: str, **kwargs) -> None: + #""" + #Call a command by the group/action and args as defined in the + #device's protocol yaml. E.g. + #client.send_command(group, action, arg1=one, my_arg=my_arg) + #""" + #raise NotImplementedError() @abstractmethod def send_raw(self, data: bytes) -> None: @@ -242,8 +245,7 @@ def create( is returned. :param model: DeviceModel - :param url: pyserial supported url for communication (e.g. '/dev/ttyUSB0' or 'socket://remote-host:4999/') - :param connection_config_overrides: dictionary of serial port configuration overrides (e.g. baudrate) + :param connection: connection to the device :param event_loop: optionally to get an interface that can be used asynchronously, pass in an event loop :return an instance of DeviceControllerBase diff --git a/pyavcontrol/client/sync_client.py b/pyavcontrol/client/sync_client.py index 4cd59da..c9a2d41 100644 --- a/pyavcontrol/client/sync_client.py +++ b/pyavcontrol/client/sync_client.py @@ -29,7 +29,7 @@ def __init__(self, model: DeviceModel, connection: DeviceConnection): def send_raw(self, data: bytes) -> None: if LOG.isEnabledFor(logging.DEBUG): LOG.debug(f'Sending {self._connection!r}: {data}') - self._connection.sent(data) + self._connection.send(data) @synchronized def send_command(self, group: str, action: str, **kwargs) -> None: diff --git a/pyavcontrol/config.py b/pyavcontrol/config.py index e804783..b5e1cc1 100644 --- a/pyavcontrol/config.py +++ b/pyavcontrol/config.py @@ -1,4 +1,3 @@ -import typing as t from dataclasses import dataclass # FIXME: also consider pydantic @@ -15,6 +14,7 @@ class _Config: timeout = 'timeout' min_time_between_commands = 'min_time_between_commands' format = 'format' + baudrate = 'baudrate' CONFIG = _Config() diff --git a/pyavcontrol/connection/async_connection.py b/pyavcontrol/connection/async_connection.py index b626928..e436217 100644 --- a/pyavcontrol/connection/async_connection.py +++ b/pyavcontrol/connection/async_connection.py @@ -59,7 +59,6 @@ async def _connect(self) -> None: self._url, self._connection_config, # self._config, self._connection_config, - self._connection_config.get(CONFIG.format, {}), # self._protocol_def, self._event_loop, ) @@ -72,7 +71,7 @@ async def send(self, data: bytes, callback=None): async def async_get_rs232_connection( - serial_port: str, config: dict, connection_config: dict, protocol_def: dict, loop + serial_port: str, config: dict, connection_config: dict, loop ): # ensure only a single, ordered command is sent to RS232 at a time (non-reentrant lock) def locked_method(method): @@ -97,8 +96,9 @@ async def wrapper(self, *method_args, **method_kwargs): return wrapper class RS232ControlProtocol(asyncio.Protocol): + # noinspection PyShadowingNames def __init__( - self, serial_port, config, connection_config, protocol_config, loop + self, serial_port, config, connection_config, loop ): super().__init__() @@ -149,9 +149,9 @@ async def _reset_buffers(self): @ensure_connected async def send(self, data: bytes, callback=None, wait_for_reply=False): @limits(calls=1, period=self._min_time_between_commands) - async def write_rate_limited(data: bytes): - LOG.debug(f'>> {self._url}: %s', data) - self._transport.serial.write(data) + async def write_rate_limited(data_bytes: bytes): + LOG.debug(f'>> {self._url}: %s', data_bytes) + self._transport.serial.write(data_bytes) # clear all buffers of any data waiting to be read before sending the request await self._reset_buffers() @@ -186,7 +186,7 @@ def log_timeout(): raise factory = functools.partial( - RS232ControlProtocol, serial_port, config, connection_config, protocol_def, loop + RS232ControlProtocol, serial_port, config, connection_config, loop ) LOG.info(f'Connecting to {serial_port}: {connection_config}') diff --git a/pyavcontrol/connection/sync_connection.py b/pyavcontrol/connection/sync_connection.py index 6a24f8c..cb3716d 100644 --- a/pyavcontrol/connection/sync_connection.py +++ b/pyavcontrol/connection/sync_connection.py @@ -30,22 +30,24 @@ class SyncDeviceConnection(DeviceConnection, ABC): Synchronous device connection implementation (NOT YET IMPLEMENTED) """ - def __init__(self, url: str, config: dict, connection_config: dict): + def __init__(self, url: str, connection_config: dict): """ :param url: pyserial compatible url """ - super.__init__() + super().__init__() self._url = url - self._config = config self._connection_config = connection_config self._encoding = connection_config.get(CONFIG.encoding, DEFAULT_ENCODING) + + # FIXME: remove the following + config = connection_config # FIXME: remove self._eol = config.get(CONFIG.response_eol, DEFAULT_EOL).encode(self._encoding) # FIXME: all min time between commands should probably be at the client level and # not at the raw connection... move up! - self._min_time_between_commands = self._config.get( + self._min_time_between_commands = config.get( CONFIG.min_time_between_commands, 0 ) @@ -69,16 +71,17 @@ def _reset_buffers(self): def send(self, data: bytes, callback=None, wait_for_response=False): """ - :param data: request that is sent to the device - :param skip: number of bytes to skip for end of transmission decoding + :param data: data bytes sent to the device + :param callback: (optional) + :param wait_for_response: (optional) :return: string returned by device """ @limits(calls=1, period=self._min_time_between_commands) - def write_rate_limited(data: bytes): - LOG.debug(f'>> {self._url}: %s', data) + def write_rate_limited(data_bytes: bytes): + LOG.debug(f'>> {self._url}: %s', data_bytes) # send data and force flush to send immediately - self._port.write(data) + self._port.write(data_bytes) self._port.flush() # clear any pending transactions diff --git a/pyavcontrol/core.py b/pyavcontrol/core.py index 80a2fa3..2e242f8 100644 --- a/pyavcontrol/core.py +++ b/pyavcontrol/core.py @@ -4,7 +4,7 @@ LOG = logging.getLogger(__name__) -NAMED_REGEX_PATTERN = re.compile(r'\(\?P\<(?P.+)\>(?P.+)\)') +NAMED_REGEX_PATTERN = re.compile(r'\(\?P<(?P.+)>(?P.+)\)') FSTRING_ARG_PATTERN = re.compile(r'{(?P.+)}') diff --git a/pyavcontrol/helper.py b/pyavcontrol/helper.py index bf62083..97afdc9 100644 --- a/pyavcontrol/helper.py +++ b/pyavcontrol/helper.py @@ -20,15 +20,16 @@ async def construct_async_client( :param connection_config: pyserial configuration overrides (defaults come from for model_def) :param event_loop: (optional) event loop if an asynchronous client is desired """ + from pyavcontrol.connection.async_connection import AsyncDeviceConnection - if not connection_config: - connection_config = {} - + # load the model and settings for interacting with the device library = DeviceModelLibrary.create(event_loop=event_loop) model = await library.load_model(model_id) # FIXME: err handling - from pyavcontrol.connection.async_connection import AsyncDeviceConnection + # FIXME: need to load connection_config also from the model!? + if not connection_config: + connection_config = {} connection = AsyncDeviceConnection(url, connection_config, event_loop) @@ -49,15 +50,16 @@ def construct_synchronous_client( :param url: The pyserial compatible connection URL :param connection_config: pyserial configuration overrides (defaults come from for model_def) """ - if not connection_config: - connection_config = {} + from pyavcontrol.connection.sync_connection import SyncDeviceConnection - # load the model + # load the model and settings for interacting with the device library = DeviceModelLibrary.create() model_def = library.load_model(model_id) # FIXME: err handling - from pyavcontrol.connection.sync_connection import SyncDeviceConnection + # FIXME: need to load connection_config also from the model!? + if not connection_config: + connection_config = {} connection = SyncDeviceConnection(url, connection_config) diff --git a/pyavcontrol/library/docs.py b/pyavcontrol/library/docs.py index 89efa38..358b4df 100644 --- a/pyavcontrol/library/docs.py +++ b/pyavcontrol/library/docs.py @@ -14,7 +14,9 @@ FIXME: We may want to move this to tools/ or docs/ """ -from . import DeviceClient, DeviceModelLibrary +from . import DeviceModelLibrary +from .. import DeviceClient +from ..connection import NullConnection MODELS = [ "hdfury_vrroom", @@ -32,7 +34,5 @@ model_def = DeviceModelLibrary.create().load_model(model_id) MODEL_DEFS.append(model_def) - url = "/dev/null" - - client = DeviceClient.create(model_def, url) + client = DeviceClient.create(model_def, NullConnection()) CLIENTS.append(client) From 70f2f45157271f50050be836a0b713647235f9b3 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 22 Jan 2024 08:44:25 -0800 Subject: [PATCH 006/162] removed old code --- pyavcontrol/client/base.py | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/pyavcontrol/client/base.py b/pyavcontrol/client/base.py index 33a706b..5193285 100644 --- a/pyavcontrol/client/base.py +++ b/pyavcontrol/client/base.py @@ -1,6 +1,5 @@ -from __future__ import ( # postpone eval of annotations (for DeviceClient type annotation) - annotations, -) +# postpone eval of annotations (for DeviceClient type annotation) +from __future__ import annotations import logging from abc import ABC, abstractmethod @@ -81,8 +80,8 @@ def _inject_client_api(client: DeviceClient, model: DeviceModel): """ api = model.definition.get(CONFIG.api, {}) for group_name, group_actions in api.items(): - if getattr(type(client), group_name): - raise RuntimeError(f'Injecting "{group_name}" failed as it already exists in class') + if hasattr(type(client), group_name): + raise RuntimeError(f'Injecting "{group_name}" failed as it already exists in {type(client)}') # LOG.debug(f'Adding property for group {group_name}') group_class = _create_activity_group_class( @@ -157,7 +156,7 @@ class DeviceClient(ABC): """ def __new__(cls, *args, **kwargs): - return super().__new__(cls, *args, **kwargs) + return super().__new__(cls) def __init__(self, model: DeviceModel, connection: DeviceConnection): super().__init__() @@ -199,21 +198,6 @@ def send_raw(self, data: bytes) -> None: """ raise NotImplementedError() - def _command(self, model_id: str, format_code: str, args=None): - """ - Convert group/action/args into the full command string that should be sent - - FIXME: is this still even used/referenced? - """ - cmd_eol = self._protocol_def.get(CONFIG.command_eol) - cmd_separator = self._protocol_def.get(CONFIG.command_separator) - - rs232_commands = self._protocol_def.get('commands') - command = rs232_commands.get(format_code) + cmd_separator + cmd_eol - return command.format(**args).encode( - DEFAULT_ENCODING - ) # FIXME: should be proper encoding - # @abstractmethod def describe(self) -> dict: return self._protocol_def From 39d489513f27a6cc695bf8641007244582f3f3ac Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 22 Jan 2024 08:55:26 -0800 Subject: [PATCH 007/162] more cleanup --- pyavcontrol/client/async_client.py | 8 +------- pyavcontrol/client/base.py | 11 ++++------- pyavcontrol/client/sync_client.py | 11 +---------- 3 files changed, 6 insertions(+), 24 deletions(-) diff --git a/pyavcontrol/client/async_client.py b/pyavcontrol/client/async_client.py index ec8600c..3a31949 100644 --- a/pyavcontrol/client/async_client.py +++ b/pyavcontrol/client/async_client.py @@ -17,16 +17,10 @@ class DeviceClientAsync(DeviceClient): """Asynchronous client for communicating with devices via the provided connection""" def __init__(self, model: DeviceModel, connection: DeviceConnection, loop): - DeviceClient.__init__(self, model, connection) - self._connection = connection + super().__init__(model, connection) self._loop = loop self._callback = None - # FIXME: encoding should come from model - self._encoding = ( - model.encoding - ) # serial_config.get(CONFIG.encoding, DEFAULT_ENCODING) - @property def is_async(self): """ diff --git a/pyavcontrol/client/base.py b/pyavcontrol/client/base.py index 5193285..bfdb773 100644 --- a/pyavcontrol/client/base.py +++ b/pyavcontrol/client/base.py @@ -155,22 +155,19 @@ class DeviceClient(ABC): to control a device. """ - def __new__(cls, *args, **kwargs): - return super().__new__(cls) + #def __new__(cls, *args, **kwargs): + # return super().__new__(cls) def __init__(self, model: DeviceModel, connection: DeviceConnection): super().__init__() self._model = model - self._protocol_def = model.definition # FIXME self._connection = connection - self._callback = None - self._encoding = DEFAULT_ENCODING def encoding(self) -> str: """ :return: the bytes encoding format for requests/responses """ - return self._encoding + return self._model.encoding @property def is_async(self): @@ -200,7 +197,7 @@ def send_raw(self, data: bytes) -> None: # @abstractmethod def describe(self) -> dict: - return self._protocol_def + return self._model.definition # FIXME: should take: # 1. a model diff --git a/pyavcontrol/client/sync_client.py b/pyavcontrol/client/sync_client.py index c9a2d41..111f9ac 100644 --- a/pyavcontrol/client/sync_client.py +++ b/pyavcontrol/client/sync_client.py @@ -13,17 +13,8 @@ class DeviceClientSync(DeviceClient): """Synchronous client for communicating with devices via the provided connection""" def __init__(self, model: DeviceModel, connection: DeviceConnection): - DeviceClient.__init__(self, model, connection) - self._protocol_defs = None - - # FIXME: - # self._connection = serial.serial_for_url(url, **serial_config) - self._connection = connection - + super().__init__(model, connection) self._callback = None - self._encoding = ( - model.encoding - ) # serial_config.get('encoding', DEFAULT_ENCODING) @synchronized def send_raw(self, data: bytes) -> None: From 05d884cb11ac5841ba9fee0dab1a6ed690f6841b Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 22 Jan 2024 09:15:04 -0800 Subject: [PATCH 008/162] added check for whether connection is async, and confirm that for async clients the connection is --- pyavcontrol/client/async_client.py | 12 ++++-------- pyavcontrol/connection/__init__.py | 6 ++++++ pyavcontrol/connection/async_connection.py | 6 ++++++ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/pyavcontrol/client/async_client.py b/pyavcontrol/client/async_client.py index 3a31949..aaf8ba1 100644 --- a/pyavcontrol/client/async_client.py +++ b/pyavcontrol/client/async_client.py @@ -8,11 +8,6 @@ LOG = logging.getLogger(__name__) -# FIXME: actually the connection should be passed into the client; no need for DeviceClient -# needing to know how to communicate with remote (or in process) instance. Especially for -# testing. - - class DeviceClientAsync(DeviceClient): """Asynchronous client for communicating with devices via the provided connection""" @@ -21,11 +16,12 @@ def __init__(self, model: DeviceModel, connection: DeviceConnection, loop): self._loop = loop self._callback = None + if not connection.is_async(): + raise RuntimeError(f"Provided DeviceConnection is not asynchronous!") + @property def is_async(self): - """ - :return: True if this client implementation is asynchronous (asyncio) versus synchronous. - """ + """:return: always true since this client implementation is asynchronous""" return True @locked_coro diff --git a/pyavcontrol/connection/__init__.py b/pyavcontrol/connection/__init__.py index 6ef09ff..27ebdb5 100644 --- a/pyavcontrol/connection/__init__.py +++ b/pyavcontrol/connection/__init__.py @@ -28,6 +28,12 @@ def send(self, data: bytes, callback=None): """ raise NotImplementedError() + def is_async(self) -> bool: + """ + :return: True if this connection implementation is asynchronous (asyncio) versus synchronous. + """ + return False + def __repr__(self) -> str: return self.__class__.__name__ diff --git a/pyavcontrol/connection/async_connection.py b/pyavcontrol/connection/async_connection.py index e436217..944ea79 100644 --- a/pyavcontrol/connection/async_connection.py +++ b/pyavcontrol/connection/async_connection.py @@ -62,6 +62,12 @@ async def _connect(self) -> None: self._event_loop, ) + def is_async(self) -> bool: + """ + :return: always True since this connection implementation is asynchronous + """ + return True + async def is_connected(self) -> bool: return self._legacy_connection From 5a7abe09891ec9031b4a64019dc37af15b163394 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Wed, 24 Jan 2024 01:05:57 -0800 Subject: [PATCH 009/162] fixed async helper --- example-async.py | 2 +- pyavcontrol/client/base.py | 5 +++++ pyavcontrol/config.py | 1 + pyavcontrol/connection/async_connection.py | 2 -- pyavcontrol/connection/sync_connection.py | 7 +++---- pyavcontrol/helper.py | 3 ++- pyavcontrol/library/yaml_library.py | 2 +- 7 files changed, 13 insertions(+), 9 deletions(-) diff --git a/example-async.py b/example-async.py index e58af24..abbb13e 100755 --- a/example-async.py +++ b/example-async.py @@ -46,7 +46,7 @@ async def main(): # FIXME: connection! config_overrides = {'baudrate': args.baud} - client = construct_async_client(args.model, args.url, loop, connection_config=config_overrides) + client = await construct_async_client(args.model, args.url, loop, connection_config=config_overrides) # help(client.power) await client.send_raw(b'!PING?') diff --git a/pyavcontrol/client/base.py b/pyavcontrol/client/base.py index bfdb773..b773e14 100644 --- a/pyavcontrol/client/base.py +++ b/pyavcontrol/client/base.py @@ -233,6 +233,8 @@ def create( """ LOG.debug(f'Connecting to {model.id} at {connection!r}') + print("WHAT!!!") + if event_loop: # lazy import the async client to avoid loading both sync/async from .async_client import DeviceClientAsync @@ -248,10 +250,13 @@ def create( LOG.debug(f'Creating {class_name} client with {model.id} protocol API') dynamic_class = type(class_name, base_classes, {}) + print("FA") # if event_loop provided, return an asynchronous client; otherwise synchronous if event_loop: client = dynamic_class(model, connection, event_loop) + print("FOO") + print(client) else: client = dynamic_class(model, connection) diff --git a/pyavcontrol/config.py b/pyavcontrol/config.py index b5e1cc1..e774fb0 100644 --- a/pyavcontrol/config.py +++ b/pyavcontrol/config.py @@ -15,6 +15,7 @@ class _Config: min_time_between_commands = 'min_time_between_commands' format = 'format' baudrate = 'baudrate' + clear_before_new_commands = 'clear_before_new_commands' CONFIG = _Config() diff --git a/pyavcontrol/connection/async_connection.py b/pyavcontrol/connection/async_connection.py index 944ea79..66e344e 100644 --- a/pyavcontrol/connection/async_connection.py +++ b/pyavcontrol/connection/async_connection.py @@ -37,8 +37,6 @@ def __init__(self, url: str, connection_config: dict, loop): :param url: pyserial compatible url :param connection_config: pyserial connection config (plus additional attributes timeout/encoding) """ - super().__init__() - self._url = url self._connection_config = connection_config self._event_loop = loop diff --git a/pyavcontrol/connection/sync_connection.py b/pyavcontrol/connection/sync_connection.py index cb3716d..aec54d9 100644 --- a/pyavcontrol/connection/sync_connection.py +++ b/pyavcontrol/connection/sync_connection.py @@ -34,8 +34,6 @@ def __init__(self, url: str, connection_config: dict): """ :param url: pyserial compatible url """ - super().__init__() - self._url = url self._connection_config = connection_config @@ -53,13 +51,14 @@ def __init__(self, url: str, connection_config: dict): # FIXME: contemplate on this more, do we really want to reset/clear self._clear_before_new_commands = connection_config.get( - 'clear_before_new_commands', False + CONFIG.clear_before_new_commands, False ) self._port = serial.serial_for_url(self._url, **self._connection_config) def __repr__(self) -> str: - return f'{self.__name__} / {self._url}' +# return f'{self.__class__.__name__}->{self._url}' + return f'{self._url}' def encoding(self) -> str: return self._encoding diff --git a/pyavcontrol/helper.py b/pyavcontrol/helper.py index 97afdc9..b3a2ba5 100644 --- a/pyavcontrol/helper.py +++ b/pyavcontrol/helper.py @@ -12,6 +12,7 @@ async def construct_async_client( model_id: str, url: str, event_loop, connection_config: dict = None ) -> DeviceClient: + print('....') """ Construct an asynchronous client @@ -36,7 +37,7 @@ async def construct_async_client( # FIXME: how does this handle failed connections? retries? lazy connections? that can be # a wrapper around the DeviceConnection object. - client = DeviceClient.create(model, connection) + client = DeviceClient.create(model, connection, event_loop=event_loop) return client diff --git a/pyavcontrol/library/yaml_library.py b/pyavcontrol/library/yaml_library.py index 3c0ac4a..7443cd6 100644 --- a/pyavcontrol/library/yaml_library.py +++ b/pyavcontrol/library/yaml_library.py @@ -104,7 +104,7 @@ def supported_models(self) -> frozenset[str]: name = pathlib.Path(model_file).stem supported_models[name] = model_file - self._supported_models = frozenset(supported_models.keys()) # immutable + self._supported_models = frozenset(supported_models.keys()) # immutable return self._supported_models From 50a6df70775a268979618289e16bcaaaeb8085bd Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Fri, 26 Jan 2024 00:10:54 -0800 Subject: [PATCH 010/162] additional cleanup --- pyavcontrol/connection/async_connection.py | 12 ++++++++++-- pyavcontrol/helper.py | 1 - 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/pyavcontrol/connection/async_connection.py b/pyavcontrol/connection/async_connection.py index 66e344e..90203c0 100644 --- a/pyavcontrol/connection/async_connection.py +++ b/pyavcontrol/connection/async_connection.py @@ -39,6 +39,7 @@ def __init__(self, url: str, connection_config: dict, loop): """ self._url = url self._connection_config = connection_config + self._legacy_connection = None self._event_loop = loop # FIXME: I think encoding should be moved up a level @@ -49,7 +50,7 @@ def __init__(self, url: str, connection_config: dict, loop): asyncio.create_task(self._connect()) def __repr__(self) -> str: - return f'{self.__name__} / {self._url}' + return f'{self.__class__.__name__} / {self._url}' async def _connect(self) -> None: # FIXME: hacky...merge this old code into this class eventually... @@ -71,7 +72,13 @@ async def is_connected(self) -> bool: async def send(self, data: bytes, callback=None): reply = False # depends on action! FIXME - return await self._legacy_connection.send(self, data, wait_for_reply=reply) + + if not self._legacy_connection: + await self._connect() + + print("WOW") + + return await self._legacy_connection.send(data, wait_for_reply=reply) async def async_get_rs232_connection( @@ -152,6 +159,7 @@ async def _reset_buffers(self): @locked_method @ensure_connected async def send(self, data: bytes, callback=None, wait_for_reply=False): + @limits(calls=1, period=self._min_time_between_commands) async def write_rate_limited(data_bytes: bytes): LOG.debug(f'>> {self._url}: %s', data_bytes) diff --git a/pyavcontrol/helper.py b/pyavcontrol/helper.py index b3a2ba5..477caa8 100644 --- a/pyavcontrol/helper.py +++ b/pyavcontrol/helper.py @@ -12,7 +12,6 @@ async def construct_async_client( model_id: str, url: str, event_loop, connection_config: dict = None ) -> DeviceClient: - print('....') """ Construct an asynchronous client From 547a36f24c6a7e080a1b0e91dcd8b5b0cb84891b Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Fri, 26 Jan 2024 00:11:15 -0800 Subject: [PATCH 011/162] update --- pyavcontrol/connection/async_connection.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyavcontrol/connection/async_connection.py b/pyavcontrol/connection/async_connection.py index 90203c0..16a8746 100644 --- a/pyavcontrol/connection/async_connection.py +++ b/pyavcontrol/connection/async_connection.py @@ -76,8 +76,6 @@ async def send(self, data: bytes, callback=None): if not self._legacy_connection: await self._connect() - print("WOW") - return await self._legacy_connection.send(data, wait_for_reply=reply) From 4eeda73e892f661355d1dc170506d1c1808e561a Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Fri, 26 Jan 2024 00:12:13 -0800 Subject: [PATCH 012/162] update --- pyavcontrol/client/base.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pyavcontrol/client/base.py b/pyavcontrol/client/base.py index b773e14..bfdb773 100644 --- a/pyavcontrol/client/base.py +++ b/pyavcontrol/client/base.py @@ -233,8 +233,6 @@ def create( """ LOG.debug(f'Connecting to {model.id} at {connection!r}') - print("WHAT!!!") - if event_loop: # lazy import the async client to avoid loading both sync/async from .async_client import DeviceClientAsync @@ -250,13 +248,10 @@ def create( LOG.debug(f'Creating {class_name} client with {model.id} protocol API') dynamic_class = type(class_name, base_classes, {}) - print("FA") # if event_loop provided, return an asynchronous client; otherwise synchronous if event_loop: client = dynamic_class(model, connection, event_loop) - print("FOO") - print(client) else: client = dynamic_class(model, connection) From 59cb17277233f49f0d3815ef744b3cd757135ae3 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 28 Jan 2024 16:31:21 -0800 Subject: [PATCH 013/162] update --- example-async.py | 3 +- example-sync.py | 1 - pyavcontrol/client/async_client.py | 10 +---- pyavcontrol/client/base.py | 15 ++++--- pyavcontrol/client/sync_client.py | 9 +--- pyavcontrol/connection/__init__.py | 2 +- pyavcontrol/connection/async_connection.py | 52 ++++++++++++++-------- pyavcontrol/connection/sync_connection.py | 4 +- 8 files changed, 52 insertions(+), 44 deletions(-) diff --git a/example-async.py b/example-async.py index abbb13e..09aaf34 100755 --- a/example-async.py +++ b/example-async.py @@ -11,7 +11,6 @@ import coloredlogs -from pyavcontrol import DeviceClient, DeviceModelLibrary from pyavcontrol.helper import construct_async_client LOG = logging.getLogger(__name__) @@ -49,7 +48,7 @@ async def main(): client = await construct_async_client(args.model, args.url, loop, connection_config=config_overrides) # help(client.power) - await client.send_raw(b'!PING?') + await client.send_raw(b"!PING?\r") await client.ping.ping() # await client.volume.set(volume=20) await client.power.off() diff --git a/example-sync.py b/example-sync.py index 02152b6..99cc858 100755 --- a/example-sync.py +++ b/example-sync.py @@ -9,7 +9,6 @@ import coloredlogs -from pyavcontrol import DeviceClient, DeviceModelLibrary from pyavcontrol.helper import construct_synchronous_client LOG = logging.getLogger(__name__) diff --git a/pyavcontrol/client/async_client.py b/pyavcontrol/client/async_client.py index aaf8ba1..df0af99 100644 --- a/pyavcontrol/client/async_client.py +++ b/pyavcontrol/client/async_client.py @@ -25,17 +25,11 @@ def is_async(self): return True @locked_coro - async def send_raw(self, data: bytes): + async def send_raw(self, data: bytes, wait_for_response=False): if LOG.isEnabledFor(logging.DEBUG): LOG.debug(f'Sending {self._connection!r}: {data}') # FIXME: should this do encoding? based on the model? - return await self._connection.send(data) - - @locked_coro - async def send_command(self, group: str, action: str, **kwargs) -> None: - # await self.send_raw(data.bytes()) - # FIXME: implement, if necessary? - LOG.error(f'Not implemented send_command!') + return await self._connection.send(data, wait_for_response=wait_for_response) @locked_coro def register_callback(self, callback: Callable[[str], None]) -> None: diff --git a/pyavcontrol/client/base.py b/pyavcontrol/client/base.py index bfdb773..9019299 100644 --- a/pyavcontrol/client/base.py +++ b/pyavcontrol/client/base.py @@ -56,9 +56,13 @@ def _create_activity_group_class( if type(action_name) is bool: action_name = 'on' if action_name else 'off' + # if a response msg is defined, then wait for a response + is_response_msg_defined = 'msg' in action_def + # ClientAPIAction(group=group, name=action_name, definition=action_def) method = _create_action_method( - client, cls_name, group_name, action_name, action_def + client, cls_name, group_name, action_name, action_def, + wait_for_response=is_response_msg_defined ) # FIXME: danger Will Robinson...potential exploits (need to explore how to filter out) @@ -97,6 +101,7 @@ def _create_action_method( group_name: str, action_name: str, action_def: dict, + wait_for_response: bool=False ): """ Creates a dynamic method that makes calls against the provided client using @@ -109,7 +114,7 @@ def _create_action_method( # noinspection PyShadowingNames LOG = logging.getLogger(cls_name) required_args = get_args_for_command(action_def) - wait_for_response = False + # FIXME: need to also convert response back into dictionary! def _prepare_request(**kwargs): @@ -128,7 +133,7 @@ def _prepare_request(**kwargs): def _activity_call_sync(self, **kwargs) -> None: """Synchronous version of making a client call""" if request := _prepare_request(**kwargs): - return client.send_raw(request) + return client.send_raw(request, wait_for_response=wait_for_response) LOG.warning(f'Failed to make request for {group_name}.{action_name}') # noinspection PyUnusedLocal @@ -140,7 +145,7 @@ async def _activity_call_async(self, **kwargs) -> None: """ if request := _prepare_request(**kwargs): # noinspection PyUnresolvedReferences - return await client.send_raw(request) + return await client.send_raw(request, wait_for_response=wait_for_response) LOG.warning(f'Failed to make request for {group_name}.{action_name}') # return the async or sync version of the request method @@ -187,7 +192,7 @@ def is_async(self): #raise NotImplementedError() @abstractmethod - def send_raw(self, data: bytes) -> None: + def send_raw(self, data: bytes, wait_for_response: bool=False) -> None: """ Allows sending a raw data to the device. Generally this should not be used except for testing, since all commands should be defined in diff --git a/pyavcontrol/client/sync_client.py b/pyavcontrol/client/sync_client.py index 111f9ac..192d813 100644 --- a/pyavcontrol/client/sync_client.py +++ b/pyavcontrol/client/sync_client.py @@ -17,15 +17,10 @@ def __init__(self, model: DeviceModel, connection: DeviceConnection): self._callback = None @synchronized - def send_raw(self, data: bytes) -> None: + def send_raw(self, data: bytes, wait_for_response: bool=False) -> None: if LOG.isEnabledFor(logging.DEBUG): LOG.debug(f'Sending {self._connection!r}: {data}') - self._connection.send(data) - - @synchronized - def send_command(self, group: str, action: str, **kwargs) -> None: - # self.send_raw(data.bytes()) - LOG.error(f'Not implemented!') # FIXME + self._connection.send(data, wait_for_response=wait_for_response) @synchronized def register_callback(self, callback: Callable[[str], None]) -> None: diff --git a/pyavcontrol/connection/__init__.py b/pyavcontrol/connection/__init__.py index 27ebdb5..0645841 100644 --- a/pyavcontrol/connection/__init__.py +++ b/pyavcontrol/connection/__init__.py @@ -20,7 +20,7 @@ def is_connected(self) -> bool: """ raise NotImplementedError() - def send(self, data: bytes, callback=None): + def send(self, data: bytes, callback=None, wait_for_response: bool=False): """ Send data to the remote device. diff --git a/pyavcontrol/connection/async_connection.py b/pyavcontrol/connection/async_connection.py index 16a8746..f825dd7 100644 --- a/pyavcontrol/connection/async_connection.py +++ b/pyavcontrol/connection/async_connection.py @@ -31,6 +31,7 @@ async def wrapper(*args, **kwargs): return wrapper + class AsyncDeviceConnection(DeviceConnection, ABC): def __init__(self, url: str, connection_config: dict, loop): """ @@ -54,12 +55,17 @@ def __repr__(self) -> str: async def _connect(self) -> None: # FIXME: hacky...merge this old code into this class eventually... - self._legacy_connection = await async_get_rs232_connection( - self._url, - self._connection_config, # self._config, - self._connection_config, - self._event_loop, - ) + if not self._legacy_connection: + try: + self._legacy_connection = await async_get_rs232_connection( + self._url, + self._connection_config, # self._config, + self._connection_config, + self._event_loop, + ) + except Exception as e: + LOG.error(f"Failed connecting to {self._url}", e) + def is_async(self) -> bool: """ @@ -70,13 +76,23 @@ def is_async(self) -> bool: async def is_connected(self) -> bool: return self._legacy_connection - async def send(self, data: bytes, callback=None): - reply = False # depends on action! FIXME + # check if connected, and abort calling provided method if no connection before timeout + @staticmethod + def ensure_connected(method): + @wraps(method) + async def wrapper(self, *method_args, **method_kwargs): + try: + await self._connect() + return await method(self, *method_args, **method_kwargs) + except Exception as e: + LOG.warning(f'Cannot connect to {self._url}!', e) + raise e + return wrapper - if not self._legacy_connection: - await self._connect() - return await self._legacy_connection.send(data, wait_for_reply=reply) + @ensure_connected + async def send(self, data: bytes, callback=None, wait_for_response: bool=False): + return await self._legacy_connection.send(data, wait_for_response=wait_for_response) async def async_get_rs232_connection( @@ -92,14 +108,14 @@ async def wrapper(self, *method_args, **method_kwargs): return wrapper # check if connected, and abort calling provided method if no connection before timeout - def ensure_connected(method): + def ensure_connected_legacy(method): @wraps(method) async def wrapper(self, *method_args, **method_kwargs): try: await asyncio.wait_for(self._connected.wait(), self._timeout) - except Exception: - LOG.debug(f'Timeout sending data to {self._url}, no connection!') - return + except Exception as e: + LOG.debug(f'Timeout sending data to {self._url}, no connection!', e) + raise e return await method(self, *method_args, **method_kwargs) return wrapper @@ -155,8 +171,8 @@ async def _reset_buffers(self): self._q.get_nowait() @locked_method - @ensure_connected - async def send(self, data: bytes, callback=None, wait_for_reply=False): + @ensure_connected_legacy + async def send(self, data: bytes, callback=None, wait_for_response=False): @limits(calls=1, period=self._min_time_between_commands) async def write_rate_limited(data_bytes: bytes): @@ -169,7 +185,7 @@ async def write_rate_limited(data_bytes: bytes): await write_rate_limited(data) # FIXME: move away from this with callbacks instead - if callback or wait_for_reply: + if callback or wait_for_response: result = await self.receive_response(data) LOG.debug(f'<< {self._url}: %s', result) if callback: diff --git a/pyavcontrol/connection/sync_connection.py b/pyavcontrol/connection/sync_connection.py index aec54d9..fd78dc9 100644 --- a/pyavcontrol/connection/sync_connection.py +++ b/pyavcontrol/connection/sync_connection.py @@ -68,12 +68,12 @@ def _reset_buffers(self): self._port.reset_output_buffer() self._port.reset_input_buffer() - def send(self, data: bytes, callback=None, wait_for_response=False): + def send(self, data: bytes, callback=None, wait_for_response: bool=False): """ :param data: data bytes sent to the device :param callback: (optional) :param wait_for_response: (optional) - :return: string returned by device + :return: string returned by device """ @limits(calls=1, period=self._min_time_between_commands) From 8ef9fc546f037c5448138ccfd09adf5ab1fa62a0 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 28 Jan 2024 16:40:33 -0800 Subject: [PATCH 014/162] responses now being returned --- pyavcontrol/connection/sync_connection.py | 21 +++++++++++++-------- pyavcontrol/const.py | 2 +- pyavcontrol/data/src/mcintosh_mx160.yaml | 1 + pyproject.toml | 2 +- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/pyavcontrol/connection/sync_connection.py b/pyavcontrol/connection/sync_connection.py index fd78dc9..75919a8 100644 --- a/pyavcontrol/connection/sync_connection.py +++ b/pyavcontrol/connection/sync_connection.py @@ -51,7 +51,7 @@ def __init__(self, url: str, connection_config: dict): # FIXME: contemplate on this more, do we really want to reset/clear self._clear_before_new_commands = connection_config.get( - CONFIG.clear_before_new_commands, False + CONFIG.clear_before_new_commands, True ) self._port = serial.serial_for_url(self._url, **self._connection_config) @@ -64,9 +64,8 @@ def encoding(self) -> str: return self._encoding def _reset_buffers(self): - if self._clear_before_new_commands: - self._port.reset_output_buffer() - self._port.reset_input_buffer() + self._port.reset_output_buffer() + self._port.reset_input_buffer() def send(self, data: bytes, callback=None, wait_for_response: bool=False): """ @@ -83,16 +82,21 @@ def write_rate_limited(data_bytes: bytes): self._port.write(data_bytes) self._port.flush() - # clear any pending transactions - self._reset_buffers() + # clear any pending transactions if a response is expected + if response_expected := (callback or wait_for_response): + if self._clear_before_new_commands: + self._reset_buffers() write_rate_limited(data) # if the caller has requested to receive the result, send it to any # provided callback and return the result - if callback or wait_for_response: + if response_expected: + LOG.debug(f"Waiting for response (EOL={self._eol})...") + result = self.handle_receive() LOG.debug(f'<< {self._url}: %s', result) + if callback: callback(result) return result @@ -100,6 +104,8 @@ def write_rate_limited(data_bytes: bytes): def handle_receive(self) -> str: skip = 0 + print(self._eol) + len_eol = len(self._eol) # FIXME: implement a much better receive mechanism, without timeouts. @@ -108,7 +114,6 @@ def handle_receive(self) -> str: result = bytearray() while True: c = self._port.read(1) - # print(c) if not c: ret = bytes(result) LOG.info(ret) diff --git a/pyavcontrol/const.py b/pyavcontrol/const.py index 2bb1b92..e2475aa 100644 --- a/pyavcontrol/const.py +++ b/pyavcontrol/const.py @@ -3,7 +3,7 @@ import os DEFAULT_ENCODING = "ascii" -DEFAULT_EOL = "\r\n" +DEFAULT_EOL = "\r" # ""\r\n" DEFAULT_TCP_IP_PORT = 4999 # IP2SL / Virtual IP2SL uses this port DEFAULT_TIMEOUT = 1.0 diff --git a/pyavcontrol/data/src/mcintosh_mx160.yaml b/pyavcontrol/data/src/mcintosh_mx160.yaml index b67a1e5..3d97f13 100644 --- a/pyavcontrol/data/src/mcintosh_mx160.yaml +++ b/pyavcontrol/data/src/mcintosh_mx160.yaml @@ -24,6 +24,7 @@ connection: stopbits: 1 timeout: 2.0 encoding: 'ascii' # FIXME: remove + response_eol: '\r' hardware: type: processor diff --git a/pyproject.toml b/pyproject.toml index a8d14c6..035340e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ #[project] [tool.poetry] name = "pyavcontrol" -version = "0.0.1" +version = "0.1.0" description = "Python Control of RS232/IP Audio/Visual Equipment" readme = "README.md" license = "LICENSE" From 32e0bb3bb63197db1f8eb0837fbf3013d10f6fa1 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 28 Jan 2024 20:27:53 -0800 Subject: [PATCH 015/162] more work on replies --- example-async.py | 6 ++- pyavcontrol/client/async_client.py | 5 +- pyavcontrol/client/base.py | 77 +++++++++++++++++------------- pyavcontrol/client/sync_client.py | 9 ++-- 4 files changed, 57 insertions(+), 40 deletions(-) diff --git a/example-async.py b/example-async.py index 09aaf34..f1b60fe 100755 --- a/example-async.py +++ b/example-async.py @@ -49,8 +49,12 @@ async def main(): # help(client.power) await client.send_raw(b"!PING?\r") + await client.ping.ping() - # await client.volume.set(volume=20) + + #help(client.volume) + await client.volume.set(volume=20) + await client.power.off() except Exception as e: diff --git a/pyavcontrol/client/async_client.py b/pyavcontrol/client/async_client.py index df0af99..0adf516 100644 --- a/pyavcontrol/client/async_client.py +++ b/pyavcontrol/client/async_client.py @@ -26,9 +26,8 @@ def is_async(self): @locked_coro async def send_raw(self, data: bytes, wait_for_response=False): - if LOG.isEnabledFor(logging.DEBUG): - LOG.debug(f'Sending {self._connection!r}: {data}') - # FIXME: should this do encoding? based on the model? + #if LOG.isEnabledFor(logging.DEBUG): + # LOG.debug(f'Sending {self._connection!r}: {data}') return await self._connection.send(data, wait_for_response=wait_for_response) @locked_coro diff --git a/pyavcontrol/client/base.py b/pyavcontrol/client/base.py index 9019299..8d18eb4 100644 --- a/pyavcontrol/client/base.py +++ b/pyavcontrol/client/base.py @@ -3,6 +3,7 @@ import logging from abc import ABC, abstractmethod +from dataclasses import dataclass from ..config import CONFIG from ..connection import DeviceConnection @@ -56,14 +57,14 @@ def _create_activity_group_class( if type(action_name) is bool: action_name = 'on' if action_name else 'off' + action = ActionDef(group_name, action_name, action_def) + action.required_args = get_args_for_command(action.definition) + # if a response msg is defined, then wait for a response - is_response_msg_defined = 'msg' in action_def + action.response_expected = 'msg' in action_def # ClientAPIAction(group=group, name=action_name, definition=action_def) - method = _create_action_method( - client, cls_name, group_name, action_name, action_def, - wait_for_response=is_response_msg_defined - ) + method = _create_action_method(client, cls_name, action) # FIXME: danger Will Robinson...potential exploits (need to explore how to filter out) method.__name__ = action_name @@ -76,6 +77,14 @@ def _create_activity_group_class( return cls(model.id, actions_model['actions']) +@dataclass +class ActionDef: + group: str + name: str + definition: dict + required_args: list[str] = () + response_expected: bool = False + def _inject_client_api(client: DeviceClient, model: DeviceModel): """ Add a property at the top level of a DeviceClient class that exposes a @@ -95,14 +104,24 @@ def _inject_client_api(client: DeviceClient, model: DeviceModel): return client -def _create_action_method( - client: DeviceClient, - cls_name: str, - group_name: str, - action_name: str, - action_def: dict, - wait_for_response: bool=False -): + +def _encode_request(client, action_name, action_def: dict, values: dict, kwargs): + # FIXME: explain the intent...and kwargs + + if cmd := action_def.get('cmd'): + if fstring := cmd.get('fstring'): + request = substitute_fstring_vars(fstring, dict) + return request.encode(client.encoding()) + + LOG.error(f"Invalid action_def for {action_name} - cannot form a request: {action_def}") + return None + +def _decode_response(action_def: dict): + values = {} + + return values + +def _create_action_method(client: DeviceClient, cls_name: str, action: ActionDef): """ Creates a dynamic method that makes calls against the provided client using the command format for the given action definition. @@ -113,28 +132,30 @@ def _create_action_method( """ # noinspection PyShadowingNames LOG = logging.getLogger(cls_name) - required_args = get_args_for_command(action_def) # FIXME: need to also convert response back into dictionary! def _prepare_request(**kwargs): - if missing_keys := missing_keys_in_dict(required_args, kwargs): - err_msg = f'Call to {group_name}.{action_name} missing required keys {missing_keys}, skipping!' + if missing_keys := missing_keys_in_dict(action.required_args, kwargs): + err_msg = f'Call to {action.group}.{action.name} missing required keys {missing_keys}, skipping!' LOG.error(err_msg) raise ValueError(err_msg) - if cmd := action_def.get('cmd'): + # FIXME: explain the intent...and kwargs + if cmd := action.definition.get('cmd'): + print(cmd) if fstring := cmd.get('fstring'): request = substitute_fstring_vars(fstring, kwargs) return request.encode(client.encoding()) + return None # noinspection PyUnusedLocal def _activity_call_sync(self, **kwargs) -> None: """Synchronous version of making a client call""" if request := _prepare_request(**kwargs): - return client.send_raw(request, wait_for_response=wait_for_response) - LOG.warning(f'Failed to make request for {group_name}.{action_name}') + return client.send_raw(request, wait_for_response=action.response_expected) + LOG.warning(f'Failed to make request for {action.group}.{action.name}') # noinspection PyUnusedLocal async def _activity_call_async(self, **kwargs) -> None: @@ -145,8 +166,8 @@ async def _activity_call_async(self, **kwargs) -> None: """ if request := _prepare_request(**kwargs): # noinspection PyUnresolvedReferences - return await client.send_raw(request, wait_for_response=wait_for_response) - LOG.warning(f'Failed to make request for {group_name}.{action_name}') + return await client.send_raw(request, wait_for_response=action.response_expected) + LOG.warning(f'Failed to make request for {action.group}.{action.name}') # return the async or sync version of the request method if client.is_async: @@ -181,22 +202,14 @@ def is_async(self): """ return False - - #@abstractmethod - #def send_command(self, group: str, action: str, **kwargs) -> None: - #""" - #Call a command by the group/action and args as defined in the - #device's protocol yaml. E.g. - #client.send_command(group, action, arg1=one, my_arg=my_arg) - #""" - #raise NotImplementedError() - @abstractmethod - def send_raw(self, data: bytes, wait_for_response: bool=False) -> None: + def send_raw(self, data: bytes, wait_for_response: bool=False, return_raw=False): """ Allows sending a raw data to the device. Generally this should not be used except for testing, since all commands should be defined in the yaml protocol configuration. No response messages are supported. + + :return: (optional) if response, return dict of decoded values (and raw response if return_raw set) """ raise NotImplementedError() diff --git a/pyavcontrol/client/sync_client.py b/pyavcontrol/client/sync_client.py index 192d813..d740e3b 100644 --- a/pyavcontrol/client/sync_client.py +++ b/pyavcontrol/client/sync_client.py @@ -17,10 +17,11 @@ def __init__(self, model: DeviceModel, connection: DeviceConnection): self._callback = None @synchronized - def send_raw(self, data: bytes, wait_for_response: bool=False) -> None: - if LOG.isEnabledFor(logging.DEBUG): - LOG.debug(f'Sending {self._connection!r}: {data}') - self._connection.send(data, wait_for_response=wait_for_response) + def send_raw(self, data: bytes, wait_for_response: bool=False): + #if LOG.isEnabledFor(logging.DEBUG): + # LOG.debug(f'Sending {self._connection!r}: {data}') + return self._connection.send(data, wait_for_response=wait_for_response) + @synchronized def register_callback(self, callback: Callable[[str], None]) -> None: From dfbfe5493b7ff3b613d2d66f2c4f99207ca07320 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 28 Jan 2024 23:16:58 -0800 Subject: [PATCH 016/162] responses are now dictionaries --- example-async.py | 2 ++ pyavcontrol/__init__.py | 4 +++- pyavcontrol/client/base.py | 25 ++++++++++++++++++------- requirements.txt | 3 +-- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/example-async.py b/example-async.py index f1b60fe..8e77e5a 100755 --- a/example-async.py +++ b/example-async.py @@ -53,6 +53,8 @@ async def main(): await client.ping.ping() #help(client.volume) + result = await client.volume.get() + print(f"WHAT: {result}") await client.volume.set(volume=20) await client.power.off() diff --git a/pyavcontrol/__init__.py b/pyavcontrol/__init__.py index f71433c..9934a5e 100644 --- a/pyavcontrol/__init__.py +++ b/pyavcontrol/__init__.py @@ -1,4 +1,6 @@ -__version__ = "2024.01.06" +__version__ = "2024.01.28" +# easily expose key classes and APIs that clients typically use from .client import DeviceClient from .library import DeviceModelLibrary +from .helper import construct_async_client, construct_synchronous_client diff --git a/pyavcontrol/client/base.py b/pyavcontrol/client/base.py index 8d18eb4..f2651f9 100644 --- a/pyavcontrol/client/base.py +++ b/pyavcontrol/client/base.py @@ -2,12 +2,12 @@ from __future__ import annotations import logging +import re from abc import ABC, abstractmethod from dataclasses import dataclass from ..config import CONFIG from ..connection import DeviceConnection -from ..const import * # noqa: F403 from ..core import ( camel_case, generate_docs_for_action, @@ -141,24 +141,32 @@ def _prepare_request(**kwargs): LOG.error(err_msg) raise ValueError(err_msg) - # FIXME: explain the intent...and kwargs + # substitute any templated fstrings in the command with provided kwargs if cmd := action.definition.get('cmd'): - print(cmd) if fstring := cmd.get('fstring'): request = substitute_fstring_vars(fstring, kwargs) return request.encode(client.encoding()) return None + def _extract_vars_in_response(response: bytes) -> dict: + if msg := action.definition.get('msg'): + if regex := msg.get('regex'): + return re.match(regex, response).groupdict() + return {} + # noinspection PyUnusedLocal - def _activity_call_sync(self, **kwargs) -> None: + def _activity_call_sync(self, **kwargs): """Synchronous version of making a client call""" if request := _prepare_request(**kwargs): - return client.send_raw(request, wait_for_response=action.response_expected) + if response := client.send_raw(request, wait_for_response=action.response_expected): + response = response.decode(client.encoding()) + return _extract_vars_in_response(response) + return LOG.warning(f'Failed to make request for {action.group}.{action.name}') # noinspection PyUnusedLocal - async def _activity_call_async(self, **kwargs) -> None: + async def _activity_call_async(self, **kwargs): """ Asynchronous version of making a client call is used when an event_loop is provided. Calling code knows whether they instantiated a synchronous @@ -166,7 +174,10 @@ async def _activity_call_async(self, **kwargs) -> None: """ if request := _prepare_request(**kwargs): # noinspection PyUnresolvedReferences - return await client.send_raw(request, wait_for_response=action.response_expected) + if response := await client.send_raw(request, wait_for_response=action.response_expected): + response = response.decode(client.encoding()) + return _extract_vars_in_response(response) + return LOG.warning(f'Failed to make request for {action.group}.{action.name}') # return the async or sync version of the request method diff --git a/requirements.txt b/requirements.txt index 83e6f91..4409387 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,5 @@ pyserial>=3.5 pyserial-asyncio>=0.6 ratelimit>=2.2.1 syncer>=2.0.3 -yaml pytest~=7.4.4 -PyYAML~=6.0.1 \ No newline at end of file +PyYAML>=6.0.1 From 3b93dc5d9f69851c0d2d532cc147950789592b4e Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 28 Jan 2024 23:18:01 -0800 Subject: [PATCH 017/162] update --- pyavcontrol/client/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyavcontrol/client/base.py b/pyavcontrol/client/base.py index f2651f9..c1da2f0 100644 --- a/pyavcontrol/client/base.py +++ b/pyavcontrol/client/base.py @@ -150,6 +150,8 @@ def _prepare_request(**kwargs): return None def _extract_vars_in_response(response: bytes) -> dict: + """Given a response, extract all the known values using the response + message regex defined for this action.""" if msg := action.definition.get('msg'): if regex := msg.get('regex'): return re.match(regex, response).groupdict() From c38801458c840a7fa634b88f85f0cae9e7defd9e Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 28 Jan 2024 23:23:52 -0800 Subject: [PATCH 018/162] changed sync client to return bytes instead of decoded str --- example-async.py | 4 +++- example-sync.py | 10 +++++++--- pyavcontrol/client/base.py | 7 ++++--- pyavcontrol/connection/sync_connection.py | 6 ++---- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/example-async.py b/example-async.py index 8e77e5a..16d2f7b 100755 --- a/example-async.py +++ b/example-async.py @@ -53,8 +53,10 @@ async def main(): await client.ping.ping() #help(client.volume) + result = await client.volume.get() - print(f"WHAT: {result}") + print(f"Response: {result}") + await client.volume.set(volume=20) await client.power.off() diff --git a/example-sync.py b/example-sync.py index 99cc858..ed6e9d6 100755 --- a/example-sync.py +++ b/example-sync.py @@ -42,10 +42,14 @@ def main(): connection_config=config_overrides) client.send_raw(b'!PING?') - print(client.ping.ping()) - client.power.off() - client.ping.ping() client.ping.ping() + result = client.volume.get() + print(f"Response: {result}") + + client.volume.set(volume=15) + + client.power.off() + main() diff --git a/pyavcontrol/client/base.py b/pyavcontrol/client/base.py index c1da2f0..6c4239a 100644 --- a/pyavcontrol/client/base.py +++ b/pyavcontrol/client/base.py @@ -152,9 +152,12 @@ def _prepare_request(**kwargs): def _extract_vars_in_response(response: bytes) -> dict: """Given a response, extract all the known values using the response message regex defined for this action.""" + response_text = response.decode(client.encoding()) + if msg := action.definition.get('msg'): if regex := msg.get('regex'): - return re.match(regex, response).groupdict() + return re.match(regex, response_text).groupdict() + return {} # noinspection PyUnusedLocal @@ -162,7 +165,6 @@ def _activity_call_sync(self, **kwargs): """Synchronous version of making a client call""" if request := _prepare_request(**kwargs): if response := client.send_raw(request, wait_for_response=action.response_expected): - response = response.decode(client.encoding()) return _extract_vars_in_response(response) return LOG.warning(f'Failed to make request for {action.group}.{action.name}') @@ -177,7 +179,6 @@ async def _activity_call_async(self, **kwargs): if request := _prepare_request(**kwargs): # noinspection PyUnresolvedReferences if response := await client.send_raw(request, wait_for_response=action.response_expected): - response = response.decode(client.encoding()) return _extract_vars_in_response(response) return LOG.warning(f'Failed to make request for {action.group}.{action.name}') diff --git a/pyavcontrol/connection/sync_connection.py b/pyavcontrol/connection/sync_connection.py index 75919a8..f00bf7b 100644 --- a/pyavcontrol/connection/sync_connection.py +++ b/pyavcontrol/connection/sync_connection.py @@ -101,11 +101,9 @@ def write_rate_limited(data_bytes: bytes): callback(result) return result - def handle_receive(self) -> str: + def handle_receive(self) -> bytes: skip = 0 - print(self._eol) - len_eol = len(self._eol) # FIXME: implement a much better receive mechanism, without timeouts. @@ -128,4 +126,4 @@ def handle_receive(self) -> str: ret = bytes(result) LOG.debug(f'Received {self._url} "%s"', ret) - return ret.decode(self._encoding) + return ret From 41042fd67a359062b48f4ddbe3e03c6d197fcaaa Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 29 Jan 2024 00:01:53 -0800 Subject: [PATCH 019/162] update --- .../{python-publish.yml => pypi-publish.yml} | 4 +++ pyproject.toml | 26 ++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) rename .github/workflows/{python-publish.yml => pypi-publish.yml} (88%) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/pypi-publish.yml similarity index 88% rename from .github/workflows/python-publish.yml rename to .github/workflows/pypi-publish.yml index bdaab28..c303914 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -6,6 +6,10 @@ # separate terms of service, privacy policy, and support # documentation. + +# See Alos: +# https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ + name: Upload Python Package on: diff --git a/pyproject.toml b/pyproject.toml index 035340e..ed1332c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,18 +1,38 @@ -#[project] +[project] +name = "pyavcontrol" +version = "0.1.0" +requires-python = ">=3.10" +description = "Python Control of Audio/Visual Equipment via RS232/IP" +readme = "README.md" +classifiers = [ + # How mature is this project? Common values are + # 3 - Alpha + # 4 - Beta + # 5 - Production/Stable + "Development Status :: 3 - Alpha", + "Programming Language :: Python", + "License :: OSI Approved :: MIT License", +] +authors = [ { name="Ryan Snodgrass", email="rsnodgrass@gmail.com" } ] + [tool.poetry] name = "pyavcontrol" version = "0.1.0" -description = "Python Control of RS232/IP Audio/Visual Equipment" +description = "Python Control of Audio/Visual Equipment via RS232/IP" readme = "README.md" license = "LICENSE" authors = [ "Ryan Snodgrass " ] - [tool.poetry.dependencies] python = "^3.10" pyserial = "^3.5" pyserial-asyncio = "^0.6" ratelimit = "^2.2.1" +pyyaml = "^6.0.1" + +[tool.poetry.group.dev.dependencies] +coloredlogs = "^15.0.1" +pytest = "^8.0.0" [build-system] requires = ["poetry-core>=1.0.0"] From 911c44bb36b263aa611764caa7a4d5012ca8c7d3 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 29 Jan 2024 00:25:17 -0800 Subject: [PATCH 020/162] update --- .github/workflows/pypi-publish.yml | 2 +- .github/workflows/release.yml | 26 -------------------------- 2 files changed, 1 insertion(+), 27 deletions(-) delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index c303914..3de0beb 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -10,7 +10,7 @@ # See Alos: # https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ -name: Upload Python Package +name: Upload to PyPi on: release: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index cc9eed6..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,26 +0,0 @@ -# https://www.caktusgroup.com/blog/2021/02/11/automating-pypi-releases/ - -name: Upload Python Package - -on: - release: - types: [created] - -jobs: - deploy: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - name: Build and publish - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python setup.py sdist bdist_wheel - twine upload dist/* From b800d9128725668a9ea880f7b5347264a9b87b38 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 29 Jan 2024 00:25:18 -0800 Subject: [PATCH 021/162] update --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ed1332c..896d43b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "pyavcontrol" version = "0.1.0" requires-python = ">=3.10" -description = "Python Control of Audio/Visual Equipment via RS232/IP" +description = "Python Control of Audio/Visual Equipment (RS232/IP)" readme = "README.md" classifiers = [ # How mature is this project? Common values are @@ -18,7 +18,7 @@ authors = [ { name="Ryan Snodgrass", email="rsnodgrass@gmail.com" } ] [tool.poetry] name = "pyavcontrol" version = "0.1.0" -description = "Python Control of Audio/Visual Equipment via RS232/IP" +description = "Python Control of Audio/Visual Equipment (RS232/IP)" readme = "README.md" license = "LICENSE" authors = [ "Ryan Snodgrass " ] From 8a27bc3435cd5572ac95e728d892b8c4b0a664ef Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 29 Jan 2024 00:36:02 -0800 Subject: [PATCH 022/162] update --- .github/dependabot.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 522d0f2..56bb0da 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,4 +1,5 @@ version: 2 + updates: - package-ecosystem: pip directory: "/" @@ -6,3 +7,8 @@ updates: interval: weekly time: "13:00" open-pull-requests-limit: 10 + +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" From d4eaa031408154f0e296d1244c9432f11d60405b Mon Sep 17 00:00:00 2001 From: Ryan <2199132+rsnodgrass@users.noreply.github.com> Date: Mon, 29 Jan 2024 01:03:16 -0800 Subject: [PATCH 023/162] Create bandit.yml --- .github/workflows/bandit.yml | 52 ++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/bandit.yml diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml new file mode 100644 index 0000000..6d8ecf6 --- /dev/null +++ b/.github/workflows/bandit.yml @@ -0,0 +1,52 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# Bandit is a security linter designed to find common security issues in Python code. +# This action will run Bandit on your codebase. +# The results of the scan will be found under the Security tab of your repository. + +# https://github.com/marketplace/actions/bandit-scan is ISC licensed, by abirismyname +# https://pypi.org/project/bandit/ is Apache v2.0 licensed, by PyCQA + +name: Bandit +on: + push: + branches: [ "main" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "main" ] + schedule: + - cron: '22 4 * * 6' + +jobs: + bandit: + permissions: + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/upload-sarif to upload SARIF results + actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Bandit Scan + uses: shundor/python-bandit-scan@9cc5aa4a006482b8a7f91134412df6772dbda22c + with: # optional arguments + # exit with 0, even with results found + exit_zero: true # optional, default is DEFAULT + # Github token of the repository (automatically created by Github) + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information. + # File or directory to run bandit on + # path: # optional, default is . + # Report only issues of a given severity level or higher. Can be LOW, MEDIUM or HIGH. Default is UNDEFINED (everything) + # level: # optional, default is UNDEFINED + # Report only issues of a given confidence level or higher. Can be LOW, MEDIUM or HIGH. Default is UNDEFINED (everything) + # confidence: # optional, default is UNDEFINED + # comma-separated list of paths (glob patterns supported) to exclude from scan (note that these are in addition to the excluded paths provided in the config file) (default: .svn,CVS,.bzr,.hg,.git,__pycache__,.tox,.eggs,*.egg) + # excluded_paths: # optional, default is DEFAULT + # comma-separated list of test IDs to skip + # skips: # optional, default is DEFAULT + # path to a .bandit file that supplies command line arguments + # ini_path: # optional, default is DEFAULT + From c3c0a52317c489120ab8268e4fce9da918f44d76 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 00:35:44 -0800 Subject: [PATCH 024/162] general cleanup/improvements --- pyavcontrol/client/base.py | 79 +++++++++++++++----------------------- pyavcontrol/const.py | 2 + 2 files changed, 33 insertions(+), 48 deletions(-) diff --git a/pyavcontrol/client/base.py b/pyavcontrol/client/base.py index 6c4239a..c0eae43 100644 --- a/pyavcontrol/client/base.py +++ b/pyavcontrol/client/base.py @@ -22,37 +22,34 @@ class DynamicActions: """ Dynamically created class representing a group of actions that can be called - on a device. + on a connection to the device. """ - - def __init__(self, model_name, actions_def): + def __init__(self, model_name, group_actions_def): self._model_name = model_name - self._actions_def = actions_def + self._group_actions = group_actions_def def _create_activity_group_class( client: DeviceClient, model: DeviceModel, group_name: str, - actions_model: dict, - cls_bases=None, + group_actions: dict ): """ Create dynamic class that represents a group of activities for a specific DeviceClient. These are injected into the DeviceClient as properties that can be accessed by the caller. """ + cls_props = {} + cls_bases = (DynamicActions,) + # CamelCase the model+group to represent this dynamic class of action methods cls_name = camel_case(f'{model.id} {group_name}') if client.is_async: cls_name += 'Async' - if not cls_bases: - cls_bases = (DynamicActions,) - cls_props = {} - # dynamically add methods (and associated documentation) for each action - for action_name, action_def in actions_model['actions'].items(): + for action_name, action_def in group_actions.items(): # handle yamlfmt/yamlfix rewriting of "on" and "off" as YAML keys into bools if type(action_name) is bool: action_name = 'on' if action_name else 'off' @@ -74,7 +71,7 @@ def _create_activity_group_class( # return the new dynamic class that contains the above actions cls = type(cls_name, cls_bases, cls_props) - return cls(model.id, actions_model['actions']) + return cls(model.id, group_actions) @dataclass @@ -92,14 +89,12 @@ def _inject_client_api(client: DeviceClient, model: DeviceModel): model definition, the client is returned unchanged. """ api = model.definition.get(CONFIG.api, {}) - for group_name, group_actions in api.items(): + for group_name, group_def in api.items(): if hasattr(type(client), group_name): raise RuntimeError(f'Injecting "{group_name}" failed as it already exists in {type(client)}') - # LOG.debug(f'Adding property for group {group_name}') - group_class = _create_activity_group_class( - client, model, group_name, group_actions - ) + group_actions = group_def['actions'] + group_class = _create_activity_group_class(client, model, group_name, group_actions) setattr(type(client), group_name, group_class) return client @@ -116,11 +111,6 @@ def _encode_request(client, action_name, action_def: dict, values: dict, kwargs) LOG.error(f"Invalid action_def for {action_name} - cannot form a request: {action_def}") return None -def _decode_response(action_def: dict): - values = {} - - return values - def _create_action_method(client: DeviceClient, cls_name: str, action: ActionDef): """ Creates a dynamic method that makes calls against the provided client using @@ -194,10 +184,6 @@ class DeviceClient(ABC): DeviceClientBase base class that defines operations allowed to control a device. """ - - #def __new__(cls, *args, **kwargs): - # return super().__new__(cls) - def __init__(self, model: DeviceModel, connection: DeviceConnection): super().__init__() self._model = model @@ -216,7 +202,15 @@ def is_async(self): """ return False - @abstractmethod + @property + def is_connected(self): + """ + :return: True if client is connected to device + """ + return True + + +@abstractmethod def send_raw(self, data: bytes, wait_for_response: bool=False, return_raw=False): """ Allows sending a raw data to the device. Generally this should not @@ -231,10 +225,6 @@ def send_raw(self, data: bytes, wait_for_response: bool=False, return_raw=False) def describe(self) -> dict: return self._model.definition - # FIXME: should take: - # 1. a model - # 2. a connection - # 3. whether asynchronous @classmethod def create( cls, @@ -257,34 +247,27 @@ def create( asynchronous implementation. By default, the synchronous interface is returned. - :param model: DeviceModel + :param model: DeviceModel representing the API and protocol for the device :param connection: connection to the device - :param event_loop: optionally to get an interface that can be used asynchronously, pass in an event loop + :param event_loop: (optional) pass in event loop to get an asynchronous interface - :return an instance of DeviceControllerBase + :return: an instance of DeviceControllerBase """ - LOG.debug(f'Connecting to {model.id} at {connection!r}') + class_name = camel_case(f'{model.id} Client') + LOG.debug(f'Connecting to {model.id} at {connection!r} (class={class_name})') + # if event_loop provided, return an asynchronous client; otherwise synchronous if event_loop: # lazy import the async client to avoid loading both sync/async from .async_client import DeviceClientAsync - base_classes = (DeviceClientAsync,) + # dynamically create subclass + dynamic_class = type(class_name, (DeviceClientAsync,), {}) + client = dynamic_class(model, connection, event_loop) else: from .sync_client import DeviceClientSync - base_classes = (DeviceClientSync,) - - # dynamically create subclass - class_name = camel_case(f'{model.id} Client') - LOG.debug(f'Creating {class_name} client with {model.id} protocol API') - - dynamic_class = type(class_name, base_classes, {}) - - # if event_loop provided, return an asynchronous client; otherwise synchronous - if event_loop: - client = dynamic_class(model, connection, event_loop) - else: + dynamic_class = type(class_name, (DeviceClientSync,), {}) client = dynamic_class(model, connection) client.__module__ = f'pyavcontrol.client.{model.id}' diff --git a/pyavcontrol/const.py b/pyavcontrol/const.py index e2475aa..e152d25 100644 --- a/pyavcontrol/const.py +++ b/pyavcontrol/const.py @@ -13,3 +13,5 @@ f"{PACKAGE_PATH}/data/flattened", f"{PACKAGE_PATH}/data/src", ) # FIXME: remove this later + +BAUD_RATES = [ 4800, 9600, 19200, 38400, 57600, 115200 ] From bb02e2c42745566272ce06c52f849b083f10ef69 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 11:33:45 -0800 Subject: [PATCH 025/162] update --- pyavcontrol/__init__.py | 2 +- pyavcontrol/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyavcontrol/__init__.py b/pyavcontrol/__init__.py index 9934a5e..86084dd 100644 --- a/pyavcontrol/__init__.py +++ b/pyavcontrol/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2024.01.28" +__version__ = "2024.01.30" # easily expose key classes and APIs that clients typically use from .client import DeviceClient diff --git a/pyavcontrol/const.py b/pyavcontrol/const.py index e152d25..ca76f7a 100644 --- a/pyavcontrol/const.py +++ b/pyavcontrol/const.py @@ -14,4 +14,4 @@ f"{PACKAGE_PATH}/data/src", ) # FIXME: remove this later -BAUD_RATES = [ 4800, 9600, 19200, 38400, 57600, 115200 ] +BAUD_RATES = [ 2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000 ] diff --git a/pyproject.toml b/pyproject.toml index 896d43b..18702b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyavcontrol" -version = "0.1.0" +version = "0.1.1" requires-python = ">=3.10" description = "Python Control of Audio/Visual Equipment (RS232/IP)" readme = "README.md" From b294d9f3a13002fa7758619c91c806acf301942a Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 11:44:03 -0800 Subject: [PATCH 026/162] updated version --- pyproject.toml | 29 +++++------------------------ 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 18702b8..b5e4975 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,5 @@ [project] -name = "pyavcontrol" -version = "0.1.1" requires-python = ">=3.10" -description = "Python Control of Audio/Visual Equipment (RS232/IP)" -readme = "README.md" -classifiers = [ - # How mature is this project? Common values are - # 3 - Alpha - # 4 - Beta - # 5 - Production/Stable - "Development Status :: 3 - Alpha", - "Programming Language :: Python", - "License :: OSI Approved :: MIT License", -] -authors = [ { name="Ryan Snodgrass", email="rsnodgrass@gmail.com" } ] [tool.poetry] name = "pyavcontrol" @@ -38,14 +24,10 @@ pytest = "^8.0.0" requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" +# pip install .[test] [project.optional-dependencies] -test = [ # pip install .[test] - "coloredlogs", - "pre-commit" -] -doc = [ - "sphinx", -] +test = [ "coloredlogs", "pre-commit" ] +doc = [ "sphinx" ] [tool.isort] profile = "black" @@ -55,6 +37,7 @@ balanced_wrapping = true [tool.black] line-length = 88 +# NOTE: brunette does not use pyproject.toml -- see .pre-commit and .github/workflows/bruneete.yml #[tool.brunette] #line-length = 88 #single-quotes = true @@ -76,6 +59,4 @@ expected-line-ending-format = "LF" max-line-length-suggestions = 90 [tool.pytest.ini_options] -pythonpath = [ - "." -] +pythonpath = [ "." ] From 973001af7792943b23145b8a9e702e6a0c988176 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 12:08:06 -0800 Subject: [PATCH 027/162] various code cleanup for invalid types --- .pre-commit-config.yaml | 5 ++-- example-async.py | 28 ++++++++++++---------- example-sync.py | 27 +++++++++++---------- pyavcontrol/client/base.py | 2 +- pyavcontrol/connection/__init__.py | 5 ++-- pyavcontrol/connection/async_connection.py | 5 +++- pyavcontrol/helper.py | 4 ++-- pyavcontrol/library/yaml_library.py | 13 +++++----- pyproject.toml | 1 + test-dynamic-client.py | 8 +++---- 10 files changed, 52 insertions(+), 46 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7c20d94..9e41bae 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: - - id: trailing-whitespace +# - id: trailing-whitespace - id: end-of-file-fixer - id: requirements-txt-fixer @@ -15,8 +15,7 @@ repos: rev: 0.2.8 hooks: - id: brunette - args: [--line-length=88, --single-quotes] - + args: [--line-length=88, --single-quotes, --target-version py311] #- repo: https://github.com/hadialqattan/pycln # rev: v2.4.0 diff --git a/example-async.py b/example-async.py index 16d2f7b..937a91f 100755 --- a/example-async.py +++ b/example-async.py @@ -14,24 +14,24 @@ from pyavcontrol.helper import construct_async_client LOG = logging.getLogger(__name__) -coloredlogs.install(level='DEBUG') +coloredlogs.install(level="DEBUG") -p = arg.ArgumentParser(description='pyavcontrol client example (asynchronous)') +p = arg.ArgumentParser(description="pyavcontrol client example (asynchronous)") p.add_argument( - '--url', - help='pyserial supported url for communication (e.g. /dev/tty.usbserial-A501SGSZ or socket://host:4999/)', - default='socket://localhost:4999/', + "--url", + help="pyserial supported url for communication (e.g. /dev/tty.usbserial-A501SGSZ or socket://host:4999/)", + default="socket://localhost:4999/", ) p.add_argument( - '--model', default='mcintosh_mx160', help='device model (e.g. mcintosh_mx160)' + "--model", default="mcintosh_mx160", help="device model (e.g. mcintosh_mx160)" ) p.add_argument( - '--baud', + "--baud", type=int, default=115200, - help='baud rate if local tty used (default=115200)', + help="baud rate if local tty used (default=115200)", ) -p.add_argument('-d', '--debug', action='store_true', help='verbose logging') +p.add_argument("-d", "--debug", action="store_true", help="verbose logging") args = p.parse_args() if args.debug: @@ -44,15 +44,17 @@ async def main(): # FIXME: connection! - config_overrides = {'baudrate': args.baud} - client = await construct_async_client(args.model, args.url, loop, connection_config=config_overrides) + config_overrides = {"baudrate": args.baud} + client = await construct_async_client( + args.model, args.url, loop, connection_config=config_overrides + ) # help(client.power) await client.send_raw(b"!PING?\r") await client.ping.ping() - #help(client.volume) + # help(client.volume) result = await client.volume.get() print(f"Response: {result}") @@ -62,7 +64,7 @@ async def main(): await client.power.off() except Exception as e: - LOG.error(f'Failed for {args.model}', e) + LOG.error(f"Failed for {args.model}", e) return diff --git a/example-sync.py b/example-sync.py index ed6e9d6..2905880 100755 --- a/example-sync.py +++ b/example-sync.py @@ -12,24 +12,24 @@ from pyavcontrol.helper import construct_synchronous_client LOG = logging.getLogger(__name__) -coloredlogs.install(level='DEBUG') +coloredlogs.install(level="DEBUG") -p = arg.ArgumentParser(description='pyavcontrol client example (synchronous)') +p = arg.ArgumentParser(description="pyavcontrol client example (synchronous)") p.add_argument( - '--url', - help='pyserial supported url for communication (e.g. /dev/tty.usbserial-A501SGSZ or socket://host:4999/)', - default='socket://localhost:4999/', + "--url", + help="pyserial supported url for communication (e.g. /dev/tty.usbserial-A501SGSZ or socket://host:4999/)", + default="socket://localhost:4999/", ) p.add_argument( - '--model', default='mcintosh_mx160', help='device model (e.g. mcintosh_mx160)' + "--model", default="mcintosh_mx160", help="device model (e.g. mcintosh_mx160)" ) p.add_argument( - '--baud', + "--baud", type=int, default=115200, - help='baud rate if local tty used (default=115200)', + help="baud rate if local tty used (default=115200)", ) -p.add_argument('-d', '--debug', action='store_true', help='verbose logging') +p.add_argument("-d", "--debug", action="store_true", help="verbose logging") args = p.parse_args() if args.debug: @@ -37,11 +37,12 @@ def main(): - config_overrides = {'baudrate': args.baud} - client = construct_synchronous_client(args.model, args.url, - connection_config=config_overrides) + config_overrides = {"baudrate": args.baud} + client = construct_synchronous_client( + args.model, args.url, connection_config=config_overrides + ) - client.send_raw(b'!PING?') + client.send_raw(b"!PING?") client.ping.ping() result = client.volume.get() diff --git a/pyavcontrol/client/base.py b/pyavcontrol/client/base.py index c0eae43..7f5903a 100644 --- a/pyavcontrol/client/base.py +++ b/pyavcontrol/client/base.py @@ -210,7 +210,7 @@ def is_connected(self): return True -@abstractmethod + @abstractmethod def send_raw(self, data: bytes, wait_for_response: bool=False, return_raw=False): """ Allows sending a raw data to the device. Generally this should not diff --git a/pyavcontrol/connection/__init__.py b/pyavcontrol/connection/__init__.py index 0645841..441aeb3 100644 --- a/pyavcontrol/connection/__init__.py +++ b/pyavcontrol/connection/__init__.py @@ -39,13 +39,14 @@ def __repr__(self) -> str: class NullConnection(DeviceConnection): + """NullConnection that sends all data to /dev/null; useful for testing""" def __init__(self): pass def is_connected(self) -> bool: return True - def send(self, data: bytes, callback=None) -> None: + def send(self, data: bytes, callback=None, wait_for_response: bool=False) -> None: pass @@ -83,6 +84,6 @@ def create( return AsyncDeviceConnection(url, connection_config, event_loop) else: - from pyavcontrol.connection.async_connection import SyncDeviceConnection + from pyavcontrol.connection.sync_connection import SyncDeviceConnection return SyncDeviceConnection(url, connection_config) diff --git a/pyavcontrol/connection/async_connection.py b/pyavcontrol/connection/async_connection.py index f825dd7..b9effb4 100644 --- a/pyavcontrol/connection/async_connection.py +++ b/pyavcontrol/connection/async_connection.py @@ -74,7 +74,7 @@ def is_async(self) -> bool: return True async def is_connected(self) -> bool: - return self._legacy_connection + return (self._legacy_connection is not None) # check if connected, and abort calling provided method if no connection before timeout @staticmethod @@ -92,6 +92,9 @@ async def wrapper(self, *method_args, **method_kwargs): @ensure_connected async def send(self, data: bytes, callback=None, wait_for_response: bool=False): + if not self._legacy_connection: + LOG.error(f"Missing legacy connection!!!") + return return await self._legacy_connection.send(data, wait_for_response=wait_for_response) diff --git a/pyavcontrol/helper.py b/pyavcontrol/helper.py index 477caa8..86d2160 100644 --- a/pyavcontrol/helper.py +++ b/pyavcontrol/helper.py @@ -10,7 +10,7 @@ LOG = logging.getLogger(__name__) async def construct_async_client( - model_id: str, url: str, event_loop, connection_config: dict = None + model_id: str, url: str, event_loop, connection_config: dict | None = None ) -> DeviceClient: """ Construct an asynchronous client @@ -41,7 +41,7 @@ async def construct_async_client( def construct_synchronous_client( - model_id: str, url: str, connection_config: dict = None + model_id: str, url: str, connection_config: dict | None = None ) -> DeviceClient: """ Construct a synchronous client diff --git a/pyavcontrol/library/yaml_library.py b/pyavcontrol/library/yaml_library.py index 7443cd6..8b0c0ac 100644 --- a/pyavcontrol/library/yaml_library.py +++ b/pyavcontrol/library/yaml_library.py @@ -23,19 +23,19 @@ def _load_yaml_file(path: str) -> dict: return yaml.safe_load(stream) except yaml.YAMLError as exc: LOG.error(f'Failed reading YAML {path}: {exc}') - return {} + return {} class DeviceModelLibrary(ABC): @abstractmethod - def load_model(self, name: str) -> dict: + def load_model(self, name: str) -> DeviceModel | None: """ :param name: model id or a complete path to a file """ raise NotImplementedError('Subclasses must implement!') @abstractmethod - def supported_models(self) -> Set[str]: + def supported_models(self) -> frozenset[str]: """ :return: all model ids supported by this library """ @@ -69,7 +69,7 @@ class DeviceModelLibrarySync(DeviceModelLibrary, ABC): def __init__(self, library_dirs: List[str]): self._dirs = library_dirs - self._supported_models = frozenset() + self._supported_models = None def load_model(self, model_id: str) -> DeviceModel | None: if '/' in model_id: @@ -120,18 +120,17 @@ class DeviceModelLibraryAsync(DeviceModelLibrary, ABC): def __init__(self, library_dirs: List[str], event_loop): self._loop = event_loop self._dirs = library_dirs - self._supported_models = set() self._executor = ThreadPoolExecutor(max_workers=2) # FUTURE: implement any actual async library self._sync = DeviceModelLibrarySync(library_dirs) - async def load_model(self, name: str) -> DeviceModel: + async def load_model(self, name: str) -> DeviceModel | None: return await self._loop.run_in_executor( self._executor, self._sync.load_model, name ) - async def supported_models(self) -> Set[str]: + async def supported_models(self) -> frozenset[str]: return await self._loop.run_in_executor( self._executor, self._sync.supported_models ) diff --git a/pyproject.toml b/pyproject.toml index b5e4975..960756f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ line-length = 88 all = true [tool.refurb] +python_version = "3.11" quiet = true ignore = [ "FURB184" ] diff --git a/test-dynamic-client.py b/test-dynamic-client.py index 2a7a5c1..adb5d4b 100755 --- a/test-dynamic-client.py +++ b/test-dynamic-client.py @@ -38,18 +38,18 @@ from pyavcontrol.helper import construct_synchronous_client LOG = logging.getLogger(__name__) -coloredlogs.install(level='DEBUG') +coloredlogs.install(level="DEBUG") # SendFunction = Callable[[list[int]], bool] def main(): - url = 'socket://localhost:4999' + url = "socket://localhost:4999" connection = NullConnection() library = DeviceModelLibrary.create() supported_models = library.supported_models() - supported_models = ['mcintosh_mx160'] + supported_models = ["mcintosh_mx160"] for model_id in supported_models: model_def = library.load_model(model_id) @@ -64,5 +64,5 @@ def main(): # return -if __name__ == '__main__': +if __name__ == "__main__": main() From c077ebd8f7d0f357f9592327d4c70228e3e60a8e Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 14:30:48 -0800 Subject: [PATCH 028/162] update --- pyavcontrol/library/yaml_library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyavcontrol/library/yaml_library.py b/pyavcontrol/library/yaml_library.py index 8b0c0ac..9a7b1ef 100644 --- a/pyavcontrol/library/yaml_library.py +++ b/pyavcontrol/library/yaml_library.py @@ -104,7 +104,7 @@ def supported_models(self) -> frozenset[str]: name = pathlib.Path(model_file).stem supported_models[name] = model_file - self._supported_models = frozenset(supported_models.keys()) # immutable + self._supported_models = frozenset(supported_models.keys()) # make immutable return self._supported_models From dfb35211391662944b0ac1b5d74f45c5243cefd9 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 14:30:48 -0800 Subject: [PATCH 029/162] update --- .github/workflows/black.yml.disabled | 34 ++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/.github/workflows/black.yml.disabled b/.github/workflows/black.yml.disabled index b04fb15..166f197 100644 --- a/.github/workflows/black.yml.disabled +++ b/.github/workflows/black.yml.disabled @@ -1,10 +1,36 @@ -name: Lint +name: Lint Python -on: [push, pull_request] +on: + pull_request: + push: + branches: + - main jobs: lint: runs-on: ubuntu-latest + + permissions: + # Give the default GITHUB_TOKEN write permission to commit and push the changed files back to the repository. + contents: write + steps: - - uses: actions/checkout@v2 - - uses: psf/black@stable + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.11 + + - name: Install Flake8 + run: pip install flake8 + + - name: Lint code + run: flake8 . + + # FIXME: see https://github.com/stefanzweifel/git-auto-commit-action + - uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: Apply flake8 changes From 4bab4f300f55ebadd3888cc0cced10c0dccdbcc6 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 14:35:32 -0800 Subject: [PATCH 030/162] update --- .github/workflows/bandit.yml | 46 +++++++++--------------------- .github/workflows/pypi-publish.yml | 2 +- 2 files changed, 14 insertions(+), 34 deletions(-) diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml index 6d8ecf6..6987b0c 100644 --- a/.github/workflows/bandit.yml +++ b/.github/workflows/bandit.yml @@ -1,21 +1,11 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -# Bandit is a security linter designed to find common security issues in Python code. -# This action will run Bandit on your codebase. -# The results of the scan will be found under the Security tab of your repository. - -# https://github.com/marketplace/actions/bandit-scan is ISC licensed, by abirismyname -# https://pypi.org/project/bandit/ is Apache v2.0 licensed, by PyCQA +# see https://github.com/shundor/python-bandit-scan name: Bandit on: push: branches: [ "main" ] pull_request: - # The branches below must be a subset of the branches above + # branches below must be a subset of the branches above branches: [ "main" ] schedule: - cron: '22 4 * * 6' @@ -23,30 +13,20 @@ on: jobs: bandit: permissions: - contents: read # for actions/checkout to fetch code - security-events: write # for github/codeql-action/upload-sarif to upload SARIF results - actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/upload-sarif to upload SARIF results + actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - name: Bandit Scan - uses: shundor/python-bandit-scan@9cc5aa4a006482b8a7f91134412df6772dbda22c - with: # optional arguments - # exit with 0, even with results found - exit_zero: true # optional, default is DEFAULT + uses: shundor/bandit-action@v1 + with: # Github token of the repository (automatically created by Github) - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information. - # File or directory to run bandit on - # path: # optional, default is . - # Report only issues of a given severity level or higher. Can be LOW, MEDIUM or HIGH. Default is UNDEFINED (everything) - # level: # optional, default is UNDEFINED - # Report only issues of a given confidence level or higher. Can be LOW, MEDIUM or HIGH. Default is UNDEFINED (everything) - # confidence: # optional, default is UNDEFINED - # comma-separated list of paths (glob patterns supported) to exclude from scan (note that these are in addition to the excluded paths provided in the config file) (default: .svn,CVS,.bzr,.hg,.git,__pycache__,.tox,.eggs,*.egg) - # excluded_paths: # optional, default is DEFAULT - # comma-separated list of test IDs to skip - # skips: # optional, default is DEFAULT - # path to a .bandit file that supplies command line arguments - # ini_path: # optional, default is DEFAULT + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # needed to get PR info + # exit with 0, even with results found + exit_zero: true # optional, default is DEFAULT + diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 3de0beb..f551cba 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v3 with: From 63d8c86eb92bf0023002b1f0ac2e7c91049267f9 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 14:36:05 -0800 Subject: [PATCH 031/162] update --- .github/workflows/python-package.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 3be1b88..c0fd938 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -19,22 +19,26 @@ jobs: python-version: [ "3.11", "3.12" ] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} + - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest run: | pytest From 433b9249a73e6858fc319e6c5f61ad7274f933d9 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 14:36:38 -0800 Subject: [PATCH 032/162] update --- .github/workflows/bandit.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml index 6987b0c..8e3aaa9 100644 --- a/.github/workflows/bandit.yml +++ b/.github/workflows/bandit.yml @@ -29,4 +29,3 @@ jobs: # exit with 0, even with results found exit_zero: true # optional, default is DEFAULT - From 0b74c862f463df334530b6e3e7a5af0f7d592b65 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 14:39:04 -0800 Subject: [PATCH 033/162] update --- .github/workflows/bandit.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml index 8e3aaa9..0c1317e 100644 --- a/.github/workflows/bandit.yml +++ b/.github/workflows/bandit.yml @@ -1,4 +1,5 @@ # see https://github.com/shundor/python-bandit-scan +# see https://github.com/mdegis/bandit-action name: Bandit on: @@ -22,7 +23,8 @@ jobs: - uses: actions/checkout@v4 - name: Bandit Scan - uses: shundor/bandit-action@v1 +# uses: shundor/bandit-action@v1 + uses: mdegis/bandit-action@v1 with: # Github token of the repository (automatically created by Github) GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # needed to get PR info From be3f736da0fbc48e7231723de185df35a736fbe9 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 14:41:21 -0800 Subject: [PATCH 034/162] update --- .github/workflows/bandit.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml index 0c1317e..fbbdeee 100644 --- a/.github/workflows/bandit.yml +++ b/.github/workflows/bandit.yml @@ -24,7 +24,7 @@ jobs: - name: Bandit Scan # uses: shundor/bandit-action@v1 - uses: mdegis/bandit-action@v1 + uses: mdegis/bandit-action@v1.0.1 with: # Github token of the repository (automatically created by Github) GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # needed to get PR info From 1878a070e00a957ebfe440feb533a3040b82f514 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 14:44:35 -0800 Subject: [PATCH 035/162] update --- .github/workflows/python-package.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index c0fd938..5e573c7 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -20,18 +20,18 @@ jobs: steps: - uses: actions/checkout@v4 - + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} - + - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - + - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names From ddb982557d8abc6014891392c48dc091237b3246 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 14:50:10 -0800 Subject: [PATCH 036/162] update --- .github/workflows/pypi-publish.yml | 6 +++++- .github/workflows/pytest.yml | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index f551cba..8a73765 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -26,16 +26,20 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: '3.x' + - name: Install dependencies run: | python -m pip install --upgrade pip pip install build + - name: Build package run: python -m build + - name: Publish package uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 with: diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 076d0b5..74461ba 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -15,11 +15,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + - name: Set up Python 3.12 uses: actions/setup-python@v3 with: python-version: "3.12" + - uses: actions/cache@v3 id: cache with: @@ -27,10 +29,12 @@ jobs: key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.*') }} restore-keys: | ${{ runner.os }}-pip- + - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r requirements.txt + - name: Run pytest run: | pytest From 1506de473d5e6c7854a2a71c7bbac52832924133 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 14:50:44 -0800 Subject: [PATCH 037/162] update --- .github/workflows/ci.yml.disabled | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml.disabled b/.github/workflows/ci.yml.disabled index 8a5d878..fc8a843 100644 --- a/.github/workflows/ci.yml.disabled +++ b/.github/workflows/ci.yml.disabled @@ -15,9 +15,9 @@ jobs: -"3.12" steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies From 3139e03fa4a698b364afadc4b86d33a18981c82e Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 14:52:05 -0800 Subject: [PATCH 038/162] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 960756f..2d98673 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires-python = ">=3.10" [tool.poetry] name = "pyavcontrol" -version = "0.1.0" +version = "0.1.1" description = "Python Control of Audio/Visual Equipment (RS232/IP)" readme = "README.md" license = "LICENSE" From 7c9e4427629c60ca43120e60862f261a7bef75a9 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 14:54:21 -0800 Subject: [PATCH 039/162] update --- .github/workflows/black.yml.disabled | 2 +- .github/workflows/ci.yml.disabled | 3 ++- .github/workflows/pytest.yml | 2 +- .github/workflows/python-package.yml | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/black.yml.disabled b/.github/workflows/black.yml.disabled index 166f197..cf3f15c 100644 --- a/.github/workflows/black.yml.disabled +++ b/.github/workflows/black.yml.disabled @@ -20,7 +20,7 @@ jobs: ref: ${{ github.head_ref }} - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.11 diff --git a/.github/workflows/ci.yml.disabled b/.github/workflows/ci.yml.disabled index fc8a843..ce50025 100644 --- a/.github/workflows/ci.yml.disabled +++ b/.github/workflows/ci.yml.disabled @@ -25,6 +25,7 @@ jobs: pip install -r requirements.txt # - name: Run unit tests # run: python -m unittest + mypy-test: name: mypy test runs-on: ubuntu-latest @@ -34,7 +35,7 @@ jobs: - name: Checkout repo uses: actions/checkout@v3 - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ env.python-version }} - name: Install dependencies diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 74461ba..75fb92e 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python 3.12 - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: "3.12" diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 5e573c7..9a1746b 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} From 374d2fc1bb341e867c2076971c4f34ed3612bf45 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 14:55:33 -0800 Subject: [PATCH 040/162] update --- .github/workflows/pypi-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 8a73765..d153ec8 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -41,7 +41,7 @@ jobs: run: python -m build - name: Publish package - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + uses: pypa/gh-action-pypi-publish@v1.8.11 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 033b125d39a6772cc4cc888c5e9c1832556a8025 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 14:57:38 -0800 Subject: [PATCH 041/162] update --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 2d98673..742f456 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,5 @@ [project] +version = "0.1.1" # see below requires-python = ">=3.10" [tool.poetry] From 9a03b064bf16c60781f6e910f34658f4c6b08214 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 15:09:05 -0800 Subject: [PATCH 042/162] update --- .github/workflows/pypi-publish.yml | 39 +++++++++++++++++------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index d153ec8..2fdecb6 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -27,21 +27,26 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 +# - name: Set up Python +# uses: actions/setup-python@v5 +# with: +# python-version: '3.x' + +# - name: Install dependencies +# run: | +# python -m pip install --upgrade pip +# pip install build + +$ - name: Build package +$ run: python -m build + +# - name: Publish package +# uses: pypa/gh-action-pypi-publish@v1.8.11 +# with: +# user: __token__ +# password: ${{ secrets.PYPI_API_TOKEN }} + + - name: Build and publish to pypi + uses: JRubics/poetry-publish@v1.17 with: - python-version: '3.x' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - - name: Build package - run: python -m build - - - name: Publish package - uses: pypa/gh-action-pypi-publish@v1.8.11 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} + pypi_token: ${{ secrets.PYPI_API_TOKEN }} From ff443178589e849e33414d1d26568d6703fe700c Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 15:12:46 -0800 Subject: [PATCH 043/162] update --- .github/workflows/pypi-publish.yml | 33 ++---------------------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 2fdecb6..db512ec 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -1,16 +1,6 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries +# see https://github.com/marketplace/actions/publish-python-poetry-package -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - - -# See Alos: -# https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ - -name: Upload to PyPi +name: Upload Release to PyPi on: release: @@ -27,25 +17,6 @@ jobs: steps: - uses: actions/checkout@v4 -# - name: Set up Python -# uses: actions/setup-python@v5 -# with: -# python-version: '3.x' - -# - name: Install dependencies -# run: | -# python -m pip install --upgrade pip -# pip install build - -$ - name: Build package -$ run: python -m build - -# - name: Publish package -# uses: pypa/gh-action-pypi-publish@v1.8.11 -# with: -# user: __token__ -# password: ${{ secrets.PYPI_API_TOKEN }} - - name: Build and publish to pypi uses: JRubics/poetry-publish@v1.17 with: From 900e8d4684ac3646bcced93060481425ba0f5156 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 21:43:49 -0800 Subject: [PATCH 044/162] * added Marantz test --- pyavcontrol/data/future/marantz_av8805.yaml | 148 ++++++++++++++++++++ pyavcontrol/data/src/mcintosh_mx160.yaml | 1 + 2 files changed, 149 insertions(+) create mode 100644 pyavcontrol/data/future/marantz_av8805.yaml diff --git a/pyavcontrol/data/future/marantz_av8805.yaml b/pyavcontrol/data/future/marantz_av8805.yaml new file mode 100644 index 0000000..c5ac51b --- /dev/null +++ b/pyavcontrol/data/future/marantz_av8805.yaml @@ -0,0 +1,148 @@ +--- +id: marantz_av8805 +description: Marantz AV8805 +urls: + - https://www.marantz.com/-/media/files/documentmaster/marantzna/us/sr8001_rs232_control_spec_mai_v102.pdf + +manufacturer: + name: Marantz + model: AV8805 + +tested: false + +connection: + ip: + port: 23 + rs232: + baudrate: 9600 + bytesize: 8 + parity: N + stopbits: 1 + timeout: 1.0 + +format: + command: + eol: "\r" + + message: + eol: "\r" + +vars: + source: + PHONE: Phono + CD: CD + BD: BD + TV: TV + SAT/CBL: SAT/CBL + MPLAY: MPLAY + GAME: Game + TUNER: Tuner + HDRADIO: HD Radio + AUX1: AUX1 + AUX2: AUX2 + AUX3: AUX3 + AUX4: AUX4 + AUX5: AUX5 + AUX6: AUX6 + AUX7: AUX7 + NET: NET + BT: BT + +api: + power: + description: Power control for the entire system + actions: + 'on': + description: Turn entire system on + cmd: + fstring: 'PWON' + 'off': + description: Turn entire system off + cmd: + fstring: 'PWSTANDBY' + toggle: + description: Toggle system power + cmd: + fstring: '@PWR:0' + msg: + regex: 'PWR:(?P[12])' + get: + description: Get system power status (0=off; 1=on) + cmd: + fstring: 'PW?' + msg: + regex: 'PW(?P.+)' + tests: + 'PWON': + power: ON + 'PWSTANDBY': + power: STANDBY + + mute: + description: Mute + actions: + get: + description: Get current Mute status + cmd: + fstring: 'MU?' + msg: + regex: 'MU(?P.+)' + tests: + 'MUOFF': + mute: 'OFF' + 'MUON': + mute: 'ON' + 'off': + description: Mute off + cmd: + fstring: 'MUOFF' + 'on': + description: Mute on + cmd: + fstring: 'MUON' + + volume: + description: Volume controls + actions: + get: + description: Get current volume + cmd: + fstring: 'MV?' + msg: + regex: 'MV(?P[0-9]{1,3})' + tests: + 'MV80': + volume: 80 + set: + description: Set volume to x + cmd: + fstring: 'MV{volume}' + regex: 'MV(?P[0-9]{1,3})' + down: + description: Decrease volume + cmd: + fstring: 'MVDOWN' + up: + description: Increase volume + cmd: + fstring: 'MVUP' + + source: + description: Input source selection + actions: + get: + description: Get info for currently active source + cmd: + fstring: 'SI?' + msg: + regex: 'SI(?P.+)' + tests: + '!SRC(1) CD': + source: 1 + name: CD + set: + description: Select source + cmd: + fstring: 'SI{source}' + docs: + source: Source to select (integer) diff --git a/pyavcontrol/data/src/mcintosh_mx160.yaml b/pyavcontrol/data/src/mcintosh_mx160.yaml index 3d97f13..025f00d 100644 --- a/pyavcontrol/data/src/mcintosh_mx160.yaml +++ b/pyavcontrol/data/src/mcintosh_mx160.yaml @@ -462,6 +462,7 @@ api: tests: '!LOUDNESS(0)': loudness: 0 + mute: description: Mute actions: From d3df7b19b2e5b26f048062cd3876857cd3c4f2d1 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 30 Jan 2024 22:11:06 -0800 Subject: [PATCH 045/162] * added Anthem D2v test --- pyavcontrol/data/future/anthem_d2v.yaml | 243 +++++++++++++++++++ pyavcontrol/data/future/marantz_av8805.yaml | 10 +- pyavcontrol/data/src/mcintosh_mx160.yaml | 2 +- pyavcontrol/data/src/trinnov_altitude32.yaml | 2 +- 4 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 pyavcontrol/data/future/anthem_d2v.yaml diff --git a/pyavcontrol/data/future/anthem_d2v.yaml b/pyavcontrol/data/future/anthem_d2v.yaml new file mode 100644 index 0000000..38feb8e --- /dev/null +++ b/pyavcontrol/data/future/anthem_d2v.yaml @@ -0,0 +1,243 @@ +--- +id: anthem_d2v +description: Anthem Statement D2v +urls: + - https://www.anthemav.com/downloads/d2v_manual.pdf + +manufacturer: + name: Anthem + model: Statement D2v + +settings: + min_time_between_commands: 0.25 + +tested: false + +connection: + ip: + port: 23 + rs232: + baudrate: 9600 + bytesize: 8 + parity: N + stopbits: 1 + timeout: 1.0 + +vars: + zone: + 1: Main + 2: Zone 2 + 3: Zone 3 + power: + 0: Off + 1: On + + +format: + command: + eol: "\n" + + message: + eol: "\n" + +api: + power: + description: Power control for the entire system + actions: + 'on': + description: Turn entire system on + cmd: + fstring: 'Z{zone}POW1' + 'off': + description: Turn entire system off + cmd: + fstring: 'Z{zone}POW0' + get: + description: Get system power status (0=off; 1=on) + cmd: + fstring: 'Z{zone}POW?' + msg: + regex: 'Z(?P[0-3])POW(?P[01])' + tests: + 'Z11': + zone: 1 + power: 1 + 'PWSTANDBY': + power: STANDBY + + mute: + description: Mute + actions: + get: + description: Get current Mute status + cmd: + fstring: 'Z{zone}MU?' + msg: + regex: 'Z(?P[0-3])MUT(?P[01])' + tests: + 'Z1MUT0': + zone: 1 + mute: 0 + 'Z2MUT1': + zone: 2 + mute: 1 + 'off': + description: Mute off + cmd: + fstring: 'Z{zone}MU0' + 'on': + description: Mute on + cmd: + fstring: 'Z{zone}MU1' + toggle: + description: Mute toggle + cmd: + fstring: 'Z{zone}MUt' + + volume: + description: Volume controls + actions: + get: + description: Get current volume + cmd: + fstring: 'Z{zone}VOL?' + msg: + regex: 'Z(?P[0-3])VOL(?P[0-9]{1,3})' + tests: + 'Z1VOL80': + zone: 1 + volume: 80 + set: + description: Set volume to x + cmd: + fstring: 'Z{zone}VOL{volume}' + regex: 'Z(?P[0-3])VOL(?P[0-9]{1,3})' + down: + description: Decrease volume + cmd: + fstring: 'Z{zone}VDN' + up: + description: Increase volume + cmd: + fstring: 'Z{zone}VUP' + + source: + description: Input source selection + actions: + get: + description: Get info for currently active source + cmd: + fstring: 'SI?' + msg: + regex: 'SI(?P.+)' + tests: + '!SRC(1) CD': + source: 1 + name: CD + set: + description: Select source + cmd: + fstring: 'SI{source}' + docs: + source: Source to select (integer) + + arc: + description: Anthem Room Correction (ARC) controls + actions: + 'off': + description: ARC off + cmd: + fstring: 'Z1ARC0' + 'on': + description: ARC on + cmd: + fstring: 'Z1ARC1' + + trigger: + description: Set triggers on or off + actions: + 'off': + description: Trigger off + cmd: + fstring: 'R{trigger}SET0' + regex: 'R(?P[12])SET0' + 'on': + description: ARC on + cmd: + fstring: 'R{trigger}SET1' + regex: 'R(?P[12])SET1' + + button: + description: Remote button presses + actions: + back: + description: Back button + cmd: + fstring: '!BACK' + down: + description: Direction Down button + cmd: + fstring: 'Z1SIM0019' + left: + description: Direction Left button + cmd: + fstring: 'Z1SIM0020' + right: + description: Direction Right button + cmd: + fstring: 'Z1SIM0022' # FIXME + up: + description: Direction Up button + cmd: + fstring: 'Z1SIM0021' # FIXME + guide: + description: Guide button + cmd: + fstring: 'Z1SIM0017' + number: + description: Number button + cmd: + fstring: 'Z1SIM000{num}' + regex: 'Z1SIM000(?P[0-9])' + docs: + num: single digit integer (0-9) + num0: + description: Number button 0 + cmd: + fstring: 'Z1SIM0000' + num1: + description: Number button 1 + cmd: + fstring: 'Z1SIM0001' + num2: + description: Number button 2 + cmd: + fstring: 'Z1SIM0002' + num3: + description: Number button 3 + cmd: + fstring: 'Z1SIM0003' + num4: + description: Number button 4 + cmd: + fstring: 'Z1SIM0004' + num5: + description: Number button 5 + cmd: + fstring: 'Z1SIM0005' + num6: + description: Number button 6 + cmd: + fstring: 'Z1SIM0006' + num7: + description: Number button 7 + cmd: + fstring: 'Z1SIM0007' + num8: + description: Number button 8 + cmd: + fstring: 'Z1SIM0008' + num9: + description: Number button 9 + cmd: + fstring: 'Z1SIM0009' diff --git a/pyavcontrol/data/future/marantz_av8805.yaml b/pyavcontrol/data/future/marantz_av8805.yaml index c5ac51b..84f29f6 100644 --- a/pyavcontrol/data/future/marantz_av8805.yaml +++ b/pyavcontrol/data/future/marantz_av8805.yaml @@ -2,7 +2,7 @@ id: marantz_av8805 description: Marantz AV8805 urls: - - https://www.marantz.com/-/media/files/documentmaster/marantzna/us/sr8001_rs232_control_spec_mai_v102.pdf + - https://www.marantz.com/-/media/files/documentmaster/marantzna/us/marantz_fy20_sr_nr_protocol_v03_20190827182350130.xls manufacturer: name: Marantz @@ -47,6 +47,12 @@ vars: AUX7: AUX7 NET: NET BT: BT + power: + ON: On + OFF: Off + mute: + ON: On + OFF: Off api: power: @@ -86,7 +92,7 @@ api: cmd: fstring: 'MU?' msg: - regex: 'MU(?P.+)' + regex: 'MU(?P.+)' tests: 'MUOFF': mute: 'OFF' diff --git a/pyavcontrol/data/src/mcintosh_mx160.yaml b/pyavcontrol/data/src/mcintosh_mx160.yaml index 025f00d..87669c1 100644 --- a/pyavcontrol/data/src/mcintosh_mx160.yaml +++ b/pyavcontrol/data/src/mcintosh_mx160.yaml @@ -238,7 +238,7 @@ api: '!AUDTYPE(Unknown)': type: Unknown - buttons: + button: description: Remote button presses actions: back: diff --git a/pyavcontrol/data/src/trinnov_altitude32.yaml b/pyavcontrol/data/src/trinnov_altitude32.yaml index 8ca4b51..1763452 100644 --- a/pyavcontrol/data/src/trinnov_altitude32.yaml +++ b/pyavcontrol/data/src/trinnov_altitude32.yaml @@ -93,7 +93,7 @@ vars: api: - buttons: + button: description: Remote button presses actions: light: From e390c4a35abc448c58e2eef4ef1b2e2d6e244f3f Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Fri, 9 Feb 2024 22:16:49 -0800 Subject: [PATCH 046/162] misc --- pyavcontrol/const.py | 1 + pyavcontrol/data/future/anthem_d2v.yaml | 2 -- pyavcontrol/data/src/mcintosh_mx160.yaml | 1 + pyavcontrol/data/src/mcintosh_mx170.yaml | 2 ++ pyavcontrol/data/src/xantech_mx88_audio.yaml | 10 ++++++++-- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/pyavcontrol/const.py b/pyavcontrol/const.py index ca76f7a..69b6e3c 100644 --- a/pyavcontrol/const.py +++ b/pyavcontrol/const.py @@ -12,6 +12,7 @@ DEFAULT_MODEL_LIBRARIES = ( f"{PACKAGE_PATH}/data/flattened", f"{PACKAGE_PATH}/data/src", + f"{PACKAGE_PATH}/data/future", ) # FIXME: remove this later BAUD_RATES = [ 2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000 ] diff --git a/pyavcontrol/data/future/anthem_d2v.yaml b/pyavcontrol/data/future/anthem_d2v.yaml index 38feb8e..935a965 100644 --- a/pyavcontrol/data/future/anthem_d2v.yaml +++ b/pyavcontrol/data/future/anthem_d2v.yaml @@ -14,8 +14,6 @@ settings: tested: false connection: - ip: - port: 23 rs232: baudrate: 9600 bytesize: 8 diff --git a/pyavcontrol/data/src/mcintosh_mx160.yaml b/pyavcontrol/data/src/mcintosh_mx160.yaml index 87669c1..5b1c196 100644 --- a/pyavcontrol/data/src/mcintosh_mx160.yaml +++ b/pyavcontrol/data/src/mcintosh_mx160.yaml @@ -7,6 +7,7 @@ id: mcintosh_mx160 description: McIntosh MX160 Protocol [2017-09-18 MX160 Serial Control Manual V7] urls: - https://www.mcintoshlabs.com/legacy-products/home-theater-processors/MX160 + - http://www.mcintoshcompendium.com/Compendium%20Docs/Home%20Theater%20Controllers/PDFs/MX160.pdf - https://www.docdroid.net/OnipkTW/mx160-serial-control-manual-v3-pdf manufacturer: diff --git a/pyavcontrol/data/src/mcintosh_mx170.yaml b/pyavcontrol/data/src/mcintosh_mx170.yaml index 632563f..a8c3106 100644 --- a/pyavcontrol/data/src/mcintosh_mx170.yaml +++ b/pyavcontrol/data/src/mcintosh_mx170.yaml @@ -2,6 +2,8 @@ id: mcintosh_mx170 name: McIntosh MX170 Protocol description: McIntosh MX170 Protocol [2019-11-27 MX170 Serial Control Manual V2] +urls: + - http://www.mcintoshcompendium.com/Compendium%20Docs/Home%20Theater%20Controllers/PDFs/MX170.pdf import_models: - mcintosh_mx160 diff --git a/pyavcontrol/data/src/xantech_mx88_audio.yaml b/pyavcontrol/data/src/xantech_mx88_audio.yaml index f8b7202..c190beb 100644 --- a/pyavcontrol/data/src/xantech_mx88_audio.yaml +++ b/pyavcontrol/data/src/xantech_mx88_audio.yaml @@ -15,15 +15,21 @@ manufacturer: tested: false connection: + ip: + port: 23 + timeout: 0.50 + delay_between_commands: 0.2 rs232: - baudrate: 9600 + baudrate: 57600 # 9600 many models, 57600 for MX88ai bytesize: 8 parity: N stopbits: 1 - timeout: 1.0 + timeout: 0.50 format: command: + prefix: '' + postfix: '\r' eol: "\r" # CR Carriage Return separator: '+' From 51c1da90177b5f3a5178f2afb56dbc78b27f34e3 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Fri, 9 Feb 2024 22:17:32 -0800 Subject: [PATCH 047/162] update --- pyavcontrol/data/src/mcintosh_mx180.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyavcontrol/data/src/mcintosh_mx180.yaml b/pyavcontrol/data/src/mcintosh_mx180.yaml index c01aeaf..8050ef8 100644 --- a/pyavcontrol/data/src/mcintosh_mx180.yaml +++ b/pyavcontrol/data/src/mcintosh_mx180.yaml @@ -1,6 +1,8 @@ --- id: mcintosh_mx180 name: McIntosh MX180 Protocol +urls: + - http://www.mcintoshcompendium.com/Compendium%20Docs/Home%20Theater%20Controllers/PDFs/MX180.pdf import_models: - mx170 From 69cccbf3903d0e0e5a1f286fa322944d2facafd1 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 18 Feb 2024 00:38:29 -0800 Subject: [PATCH 048/162] update --- README.md | 13 ------------- pyavcontrol/const.py | 2 +- pyproject.toml | 4 ++-- 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index c3d7b70..e5ac3aa 100644 --- a/README.md +++ b/README.md @@ -34,19 +34,6 @@ device model based purely on the YAML definition and unit tests against those de Visit the [community support discussion thread](https://community.home-assistant.io/t/mcintosh/) for issues with this library. -## Emulator - -Of particular interest, is the included device emulator which takes a properly defined -device's protocol and starts a server that will respond to all commands as if the -a physical device was connected. This is exceptionally useful for testing AND can be -used by clients developed in other languages as well. - -Example starting the McIntosh MX160 emulator: - -``` -./emulator.py --model mx160 -d -``` - ## Supported Equipment See [SUPPORTED.md](SUPPORTED.md) for the complete list of supported equipment. diff --git a/pyavcontrol/const.py b/pyavcontrol/const.py index 69b6e3c..ec281af 100644 --- a/pyavcontrol/const.py +++ b/pyavcontrol/const.py @@ -3,7 +3,7 @@ import os DEFAULT_ENCODING = "ascii" -DEFAULT_EOL = "\r" # ""\r\n" +DEFAULT_EOL = "\r" # "\r\n" DEFAULT_TCP_IP_PORT = 4999 # IP2SL / Virtual IP2SL uses this port DEFAULT_TIMEOUT = 1.0 diff --git a/pyproject.toml b/pyproject.toml index 742f456..a83155b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [project] -version = "0.1.1" # see below +version = "0.1.2" # see below requires-python = ">=3.10" [tool.poetry] name = "pyavcontrol" -version = "0.1.1" +version = "0.1.2" description = "Python Control of Audio/Visual Equipment (RS232/IP)" readme = "README.md" license = "LICENSE" From 685c52807cd68a5fa31b1f311c9ece56a6e62b39 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 18 Feb 2024 00:39:49 -0800 Subject: [PATCH 049/162] update --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e5ac3aa..a8bbf41 100644 --- a/README.md +++ b/README.md @@ -115,9 +115,8 @@ For example: | `socket://:` | remote host that exposes RS232 over TCP ``*`` | | `socket://mx160.local:84` | direct connection to MX160's port 84 interface | -* See [IP2SL](https://github.com/rsnodgrass/virtual-ip2sl) for example RS2332 over TCP. - -See [pyserial](https://pyserial.readthedocs.io/en/latest/url_handlers.html) for additional formats supported. +* See [Virtual IP2SL](https://github.com/rsnodgrass/virtual-ip2sl) for example for how to expose an RS232 device over TCP. +* See [pyserial](https://pyserial.readthedocs.io/en/latest/url_handlers.html) for additional URL formats supported. ## Future Ideas From 34b7a1d816f096bf476bab7e3cbc6c4a5151aad6 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 18 Feb 2024 00:42:13 -0800 Subject: [PATCH 050/162] update --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a8bbf41..cec4c7a 100644 --- a/README.md +++ b/README.md @@ -85,10 +85,10 @@ DeviceModelLibrary or DeviceClient objects. Async example: ```python - loop = asyncio.get_event_loop() +loop = asyncio.get_event_loop() library = DeviceModelLibrary.create(event_loop=loop) -model_definition = library.load_model("mcintosh_mx160") +model_definition = library.load_model('mcintosh_mx160') client = DeviceClient.create( model_definition, @@ -108,14 +108,13 @@ to use, as defined in [pyserial](https://pyserial.readthedocs.io/en/latest/url_h For example: -| URL | Notes | +| URL Format | Notes | | ------------------------ | --------------------------------------------------------------------------------------------------- | | `/dev/ttyUSB0` | directly attached serial device (Linux) | | `COM3` | directly attached serial device (Windows) | -| `socket://:` | remote host that exposes RS232 over TCP ``*`` | +| `socket://:` | remote service exposing RS232 over TCP (natively or using something like [Virtual IP2SL](https://github.com/rsnodgrass/virtual-ip2sl)) | | `socket://mx160.local:84` | direct connection to MX160's port 84 interface | -* See [Virtual IP2SL](https://github.com/rsnodgrass/virtual-ip2sl) for example for how to expose an RS232 device over TCP. * See [pyserial](https://pyserial.readthedocs.io/en/latest/url_handlers.html) for additional URL formats supported. ## Future Ideas From ae93daf98c807b3eed10639af41f16602e865236 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 18 Feb 2024 00:43:36 -0800 Subject: [PATCH 051/162] update --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index cec4c7a..bfe2c81 100644 --- a/README.md +++ b/README.md @@ -104,19 +104,17 @@ await client.volume.set(50) ### Connection URL This interface uses URLs for specifying the communication transport -to use, as defined in [pyserial](https://pyserial.readthedocs.io/en/latest/url_handlers.html), to allow a wide variety of underlying mechanisms. +to use, as defined in [pyserial](https://pyserial.readthedocs.io/en/latest/url_handlers.html), to allow a wide variety of underlying communication mechanisms. -For example: +Example URL formats supported by pyserial: -| URL Format | Notes | +| URL | Notes | | ------------------------ | --------------------------------------------------------------------------------------------------- | | `/dev/ttyUSB0` | directly attached serial device (Linux) | | `COM3` | directly attached serial device (Windows) | | `socket://:` | remote service exposing RS232 over TCP (natively or using something like [Virtual IP2SL](https://github.com/rsnodgrass/virtual-ip2sl)) | | `socket://mx160.local:84` | direct connection to MX160's port 84 interface | -* See [pyserial](https://pyserial.readthedocs.io/en/latest/url_handlers.html) for additional URL formats supported. - ## Future Ideas - Add programmatic override/enhancements to the base protocol where pure From 2dc5592b807b9d5d13075e952031a875f90afb1f Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 18 Feb 2024 00:49:22 -0800 Subject: [PATCH 052/162] update --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index bfe2c81..78fdd47 100644 --- a/README.md +++ b/README.md @@ -126,3 +126,5 @@ Example URL formats supported by pyserial: - [avemu - A/V Equipment Emulator](https://github.com/rsnodgrass/avemu) (very useful for testing client libraries) - [Earlier McIntosh control in Home Assistant](https://community.home-assistant.io/t/need-help-using-rs232-to-control-a-receiver/95210/8) - https://drivers.control4.com/solr/drivers/browse?q=mcintosh +- [RS232 to USB cable](https://www.amazon.com/RS232-to-USB/dp/B0759HSLP1?tag=carreramfi-20) + From ee6bc1547edc946d0c2998115417920205c91db5 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 18 Feb 2024 23:47:58 -0800 Subject: [PATCH 053/162] started to add pydantic verification of models; new info --- pyavcontrol/client/base.py | 11 +++- pyavcontrol/const.py | 5 ++ pyavcontrol/data/src/mcintosh_mx160.yaml | 19 +++++-- pyavcontrol/library/model.py | 30 ++++++++-- pyavcontrol/library/schema.py | 70 ++++++++++++++++++++++++ 5 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 pyavcontrol/library/schema.py diff --git a/pyavcontrol/client/base.py b/pyavcontrol/client/base.py index 7f5903a..8b67973 100644 --- a/pyavcontrol/client/base.py +++ b/pyavcontrol/client/base.py @@ -221,9 +221,14 @@ def send_raw(self, data: bytes, wait_for_response: bool=False, return_raw=False) """ raise NotImplementedError() - # @abstractmethod - def describe(self) -> dict: - return self._model.definition + @property + def model(self) -> DeviceModel: + """ + :return: the model this client uses for communication and commands with the device + """ + return self._model + + @classmethod def create( diff --git a/pyavcontrol/const.py b/pyavcontrol/const.py index ec281af..9e7a0eb 100644 --- a/pyavcontrol/const.py +++ b/pyavcontrol/const.py @@ -9,6 +9,11 @@ PACKAGE_PATH = os.path.dirname(__file__) +PROCESSOR_TYPE = 'processor' +RECEIVER_TYPE = 'receiver' +MATRIX_TYPE = 'matrix' +ALL_DEVICE_TYPES = [ PROCESSOR_TYPE, RECEIVER_TYPE, MATRIX_TYPE ] + DEFAULT_MODEL_LIBRARIES = ( f"{PACKAGE_PATH}/data/flattened", f"{PACKAGE_PATH}/data/src", diff --git a/pyavcontrol/data/src/mcintosh_mx160.yaml b/pyavcontrol/data/src/mcintosh_mx160.yaml index 5b1c196..89c397e 100644 --- a/pyavcontrol/data/src/mcintosh_mx160.yaml +++ b/pyavcontrol/data/src/mcintosh_mx160.yaml @@ -5,17 +5,19 @@ id: mcintosh_mx160 description: McIntosh MX160 Protocol [2017-09-18 MX160 Serial Control Manual V7] -urls: - - https://www.mcintoshlabs.com/legacy-products/home-theater-processors/MX160 - - http://www.mcintoshcompendium.com/Compendium%20Docs/Home%20Theater%20Controllers/PDFs/MX160.pdf - - https://www.docdroid.net/OnipkTW/mx160-serial-control-manual-v3-pdf manufacturer: name: McIntosh model: MX160 -tested: true -completed: true +info: + name: McIntosh + model: MX160 + tested: true + urls: + - https://www.mcintoshlabs.com/legacy-products/home-theater-processors/MX160 + - http://www.mcintoshcompendium.com/Compendium%20Docs/Home%20Theater%20Controllers/PDFs/MX160.pdf + - https://www.docdroid.net/OnipkTW/mx160-serial-control-manual-v3-pdf connection: rs232: @@ -33,6 +35,11 @@ hardware: zones: 8 sources: 8 +protocol: + encoding: 'ascii' + command_eol: "\r" # CR Carriage Return + message_eol: "\r" + format: encoding: 'ascii' diff --git a/pyavcontrol/library/model.py b/pyavcontrol/library/model.py index 9cac8a2..be6cded 100644 --- a/pyavcontrol/library/model.py +++ b/pyavcontrol/library/model.py @@ -1,8 +1,12 @@ import logging +from pyavcontrol.const import DEFAULT_ENCODING + LOG = logging.getLogger(__name__) + + class DeviceModel: def __init__(self, model_id: str, definition: dict, validate_definition=True): self._model_id = model_id @@ -13,26 +17,42 @@ def __init__(self, model_id: str, definition: dict, validate_definition=True): @property def encoding(self) -> str: - return 'ascii' # FIXME + return self._definition.get('format', {}).get('encoding', DEFAULT_ENCODING) @property def id(self) -> str: """ - returns the unique identifier for this model definition + :return: the unique identifier for this model definition """ return self._model_id + @property + def manufacturer(self) -> str: + """ + :return: the model name + @deprecated remove this and provide a model/manufacturer dataclass/pydantic + """ + return self._definition.get('manufacturer', {}).get('name', 'Unknown') + + @property + def model(self) -> str: + """ + :return: the model name + @deprecated remove this and provide a model/manufacturer dataclass/pydantic + """ + return self._definition.get('manufacturer', {}).get('model', 'Unknown') + @property def definition(self) -> dict: """ - returns the raw definition for this model + :return: the raw definition for this model """ return self._definition def validate(self) -> bool: """ - Validate the device model data structure using pydantic (allows multiple physical representations - such as YAML/JSON/etc to be read in in the future). + Validate the device model data structure using pydantic (allows multiple physical + representations such as YAML/JSON/etc to be read in in the future). """ if not DeviceModel.validate_model_definition(self._definition): LOG.warning(f'Error in model {self._model_id} definition') diff --git a/pyavcontrol/library/schema.py b/pyavcontrol/library/schema.py new file mode 100644 index 0000000..f789651 --- /dev/null +++ b/pyavcontrol/library/schema.py @@ -0,0 +1,70 @@ +from typing import Optional, Literal + +from pydantic import BaseModel, ValidationError, PositiveInt +from pyserial import ( + FIVEBITS, SIXBITS, SEVENBITS, EIGHTBITS, PARITY_NONE, PARITY_EVEN, PARITY_ODD, + PARITY_MARK, PARITY_SPACE, STOPBITS_ONE, STOPBITS_ONE_POINT_FIVE, STOPBITS_TWO +) + +from pyavcontrol.const import DEFAULT_TIMEOUT, DEFAULT_TCP_IP_PORT, DEFAULT_ENCODING, BAUD_RATES, ALL_DEVICE_TYPES, \ + PROCESSOR_TYPE + +ALL_BYTESIZES = Literal[FIVEBITS, SIXBITS, SEVENBITS, EIGHTBITS] +ALL_PARITY = Literal[PARITY_NONE, PARITY_EVEN, PARITY_ODD, PARITY_MARK, PARITY_SPACE] +ALL_STOP_BITS = Literal[STOPBITS_ONE, STOPBITS_ONE_POINT_FIVE, STOPBITS_TWO] + +class Info(BaseModel): + manufacturer: str + model: str + type: Literal[ALL_DEVICE_TYPES] = PROCESSOR_TYPE + tested: Optional[bool] + +class RS232(BaseModel): + baudrate: Optional[Literal[BAUD_RATES]] = 9600 + bytesize: Optional[Literal[ALL_BYTESIZES]] = EIGHTBITS + parity: Optional[ALL_PARITY] = PARITY_NONE + stopbits: Optional[ALL_STOP_BITS] = STOPBITS_ONE + timeout: Optional[float] = DEFAULT_TIMEOUT # pyserial read timeout + encoding: Optional[str] = DEFAULT_ENCODING + min_time_between_commands: Optional[float] = 0.25 + +class IP(BaseModel): + host: Optional[str] = 'localhost' + port: PositiveInt = DEFAULT_TCP_IP_PORT + timeout: Optional[float] = DEFAULT_TIMEOUT + encoding: Optional[str] = DEFAULT_ENCODING + min_time_between_commands: Optional[float] = 0.25 + +class Connection(BaseModel): + rs232: Optional[RS232] + ip: Optional[IP] + +class Protocol(BaseModel): + encoding: str = DEFAULT_ENCODING + command_eol: str = "\r" + message_eol: str = "\r" + +class ActionCommand(BaseModel): + fstring: str + regex: Optional[str] + +class ActionMessage(BaseModel): + regex: Optional[str] + tests: dict + +class Action(BaseModel): + description: Optional[str] = 'unknown' + cmd: ActionCommand + msg: Optional[ActionMessage] + +# api..actions. +class GroupDef(BaseModel): + actions: dict[str, Action] # = {} + +class ModelSchema(BaseModel): + info: Info + connection: Connection + protocol: Protocol + api: dict[str, GroupDef] # = GroupDef + +# NOTE: printout with Model.schema_json() From 68cf2898b66390de7ef39644da75161f72292e48 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 18 Feb 2024 23:49:34 -0800 Subject: [PATCH 054/162] update --- pyavcontrol/library/model.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pyavcontrol/library/model.py b/pyavcontrol/library/model.py index be6cded..a0aa258 100644 --- a/pyavcontrol/library/model.py +++ b/pyavcontrol/library/model.py @@ -26,6 +26,13 @@ def id(self) -> str: """ return self._model_id + @property + def info(self) -> dict: + """ + :return: info about the model for this specific device + """ + return {} + @property def manufacturer(self) -> str: """ From 8295d7a5d65a88190090b5bcafacf3bdcdefb20b Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 19 Feb 2024 00:15:31 -0800 Subject: [PATCH 055/162] update device format definitions to latest style (will break current impl) --- pyavcontrol/data/future/acurus_m8.yaml | 29 +++++----- pyavcontrol/data/future/anthem_d2v.yaml | 31 +++++------ pyavcontrol/data/future/lyngdorf_mp60.yaml | 8 ++- pyavcontrol/data/future/marantz_av8805.yaml | 24 ++++----- pyavcontrol/data/future/monoprice_6.yaml | 27 +++++----- pyavcontrol/data/src/hdfury_vrroom.yaml | 29 +++++----- pyavcontrol/data/src/jbl_sdp75.yaml | 9 ++-- pyavcontrol/data/src/lyngdorf_cd2.yaml | 20 +++---- pyavcontrol/data/src/lyngdorf_tdai3400.yaml | 21 ++++---- pyavcontrol/data/src/mcintosh_legacy.yaml | 57 +++++++++----------- pyavcontrol/data/src/mcintosh_mx160.yaml | 31 +++-------- pyavcontrol/data/src/mcintosh_mx170.yaml | 17 +++--- pyavcontrol/data/src/mcintosh_mx180.yaml | 12 +++-- pyavcontrol/data/src/trinnov_altitude16.yaml | 10 ++-- pyavcontrol/data/src/trinnov_altitude32.yaml | 45 +++++++--------- pyavcontrol/data/src/xantech_mx88_audio.yaml | 10 ++-- pyavcontrol/data/src/xantech_mx88_video.yaml | 12 ++--- pyavcontrol/library/model.py | 4 +- pyavcontrol/library/schema.py | 11 ++-- 19 files changed, 179 insertions(+), 228 deletions(-) diff --git a/pyavcontrol/data/future/acurus_m8.yaml b/pyavcontrol/data/future/acurus_m8.yaml index 7de07fb..77c42b7 100644 --- a/pyavcontrol/data/future/acurus_m8.yaml +++ b/pyavcontrol/data/future/acurus_m8.yaml @@ -1,15 +1,14 @@ --- id: acurus_m8 -name: Acurus M8 description: Acurus Amplifier Control Protocol 1.0 -urls: - - https://app.box.com/s/bp7h228vihe92nmjlo6o8w6e66tvj58o -manufacturer: - name: Acurus - model: M8 - -tested: false +info: + manufacturer: Acurus + models: + - M8 + tested: false + urls: + - https://app.box.com/s/bp7h228vihe92nmjlo6o8w6e66tvj58o connection: rs232: @@ -17,16 +16,14 @@ connection: bytesize: 8 parity: N stopbits: 1 - timeout: 2.0 + timeout: 1.0 -format: - command: - format: '{cmd}{eol}' - eol: "\r" # CR Carriage Return +protocol: + command_eol: "\r" # CR Carriage Return + command_format: '{cmd}{eol}' - message: - format: '{msg}{eol}' - eol: "\r\n" + message_format: '{msg}{eol}' + message_eol: "\r\n" api: power: diff --git a/pyavcontrol/data/future/anthem_d2v.yaml b/pyavcontrol/data/future/anthem_d2v.yaml index 935a965..139877c 100644 --- a/pyavcontrol/data/future/anthem_d2v.yaml +++ b/pyavcontrol/data/future/anthem_d2v.yaml @@ -1,17 +1,20 @@ --- id: anthem_d2v -description: Anthem Statement D2v -urls: - - https://www.anthemav.com/downloads/d2v_manual.pdf -manufacturer: - name: Anthem - model: Statement D2v +info: + manufacturer: Anthem + models: + - Statement D2 + - Statement D2v + - Statement D2v 3D + tested: false + urls: + - https://www.anthemav.com/downloads/d2v_manual.pdf -settings: +protocol: min_time_between_commands: 0.25 - -tested: false + command_eol: "\n" + message_eol: "\n" connection: rs232: @@ -30,14 +33,6 @@ vars: 0: Off 1: On - -format: - command: - eol: "\n" - - message: - eol: "\n" - api: power: description: Power control for the entire system @@ -238,4 +233,4 @@ api: num9: description: Number button 9 cmd: - fstring: 'Z1SIM0009' + fstring: 'Z1SIM0009' \ No newline at end of file diff --git a/pyavcontrol/data/future/lyngdorf_mp60.yaml b/pyavcontrol/data/future/lyngdorf_mp60.yaml index 52c4400..3e67c2d 100644 --- a/pyavcontrol/data/future/lyngdorf_mp60.yaml +++ b/pyavcontrol/data/future/lyngdorf_mp60.yaml @@ -1,13 +1,11 @@ --- id: lyngdorf_mp60 -urls: - - https:// manufacturer: name: Lyngdorf - model: MP-60 - -tested: false + models: + - MP-60 + tested: false connection: ip: diff --git a/pyavcontrol/data/future/marantz_av8805.yaml b/pyavcontrol/data/future/marantz_av8805.yaml index 84f29f6..e0e0158 100644 --- a/pyavcontrol/data/future/marantz_av8805.yaml +++ b/pyavcontrol/data/future/marantz_av8805.yaml @@ -1,14 +1,13 @@ --- id: marantz_av8805 -description: Marantz AV8805 -urls: - - https://www.marantz.com/-/media/files/documentmaster/marantzna/us/marantz_fy20_sr_nr_protocol_v03_20190827182350130.xls -manufacturer: - name: Marantz - model: AV8805 - -tested: false +info: + manufacturer: Marantz + models: + - AV8805 + tested: false + urls: + - https://www.marantz.com/-/media/files/documentmaster/marantzna/us/marantz_fy20_sr_nr_protocol_v03_20190827182350130.xls connection: ip: @@ -20,12 +19,9 @@ connection: stopbits: 1 timeout: 1.0 -format: - command: - eol: "\r" - - message: - eol: "\r" +protocol: + command_eol: "\r" + message_eol: "\r" vars: source: diff --git a/pyavcontrol/data/future/monoprice_6.yaml b/pyavcontrol/data/future/monoprice_6.yaml index 2bba09e..daa63b1 100644 --- a/pyavcontrol/data/future/monoprice_6.yaml +++ b/pyavcontrol/data/future/monoprice_6.yaml @@ -1,14 +1,14 @@ --- id: monoprice_6 -description: Monoprice API -urls: - - https://app.box.com/s/bp7h228vihe92nmjlo6o8w6e66tvj58o manufacturer: name: Monoprice - model: '6-Zone (model 10761)' - -tested: false + models: + - MPR-6ZHMAUT + - Model 10761 + tested: false + urls: + - https://app.box.com/s/bp7h228vihe92nmjlo6o8w6e66tvj58o connection: rs232: @@ -18,15 +18,12 @@ connection: stopbits: 1 timeout: 2.0 -format: - command: - format: <{cmd}{eol} - eol: "\r" # CR Carriage Return - separator: '#' - - message: - format: '>{msg}{eol}' - eol: "\r" +protocol: + command_format: '<{cmd}{eol}' + command_eol: "\r" # CR Carriage Return + command_separator: '#' + message_format: '>{msg}{eol}' + message_eol: "\r" api: zone: diff --git a/pyavcontrol/data/src/hdfury_vrroom.yaml b/pyavcontrol/data/src/hdfury_vrroom.yaml index 31803e1..7113924 100644 --- a/pyavcontrol/data/src/hdfury_vrroom.yaml +++ b/pyavcontrol/data/src/hdfury_vrroom.yaml @@ -1,17 +1,14 @@ --- id: hdfury_vrroom description: HDFury VRROOM Automation Protocol over RS232 and IP (FW 0.61, 2023-05-24) -urls: - - https://www.hdfury.com/docs/HDfuryVRRoom.pdf -settings: - min_time_between_commands: 0.4 - -manufacturer: - name: HDFury - model: VRROOM - -tested: false +info: + manufacturer: HDFury + models: + - VRROOM + tested: false + urls: + - https://www.hdfury.com/docs/HDfuryVRRoom.pdf connection: ip: @@ -22,13 +19,13 @@ connection: parity: N stopbits: 1 timeout: 1.0 + min_time_between_commands: 0.4 -format: - command: - eol: "\n" - - message: - eol: "\r\n" +protocol: + encoding: ascii + command_eol: "\n" + message_eol: "\r\n" + min_time_between_commands: 0.4 vars: opmode: diff --git a/pyavcontrol/data/src/jbl_sdp75.yaml b/pyavcontrol/data/src/jbl_sdp75.yaml index 599613f..ce02767 100644 --- a/pyavcontrol/data/src/jbl_sdp75.yaml +++ b/pyavcontrol/data/src/jbl_sdp75.yaml @@ -4,8 +4,9 @@ id: jbl_sdp75 import_models: - trinnov_altitude32 -manufacturer: - name: JBL Synthesis - model: SDP-75 +info: + manufacturer: JBL Synthesis + models: + - SDP-75 + tested: false -tested: false diff --git a/pyavcontrol/data/src/lyngdorf_cd2.yaml b/pyavcontrol/data/src/lyngdorf_cd2.yaml index dada580..974029f 100644 --- a/pyavcontrol/data/src/lyngdorf_cd2.yaml +++ b/pyavcontrol/data/src/lyngdorf_cd2.yaml @@ -1,15 +1,16 @@ --- id: lyngdorf_cd2 -urls: - - https://site.currants.info/wp-content/uploads/2021/07/TDAI-3400-External-Control-Manual-March-2021.pdf -manufacturer: - name: Lyngdorf - model: CD-2 - -tested: false # NOTE: None of the state subscriptions have been defined (SUBSCRIBETRACK. etc) +info: + manufacturer: Lyngdorf + models: + - CD-2 + tested: false + urls: + - https://site.currants.info/wp-content/uploads/2021/07/TDAI-3400-External-Control-Manual-March-2021.pdf + connection: ip: port: 84 @@ -20,9 +21,8 @@ connection: stopbits: 1 timeout: 2.0 -format: - command: - eol: "\r\n" # CR/LF +protocol: + command_eol: "\r\n" # CR/LF api: device: diff --git a/pyavcontrol/data/src/lyngdorf_tdai3400.yaml b/pyavcontrol/data/src/lyngdorf_tdai3400.yaml index 261e6dc..22209ca 100644 --- a/pyavcontrol/data/src/lyngdorf_tdai3400.yaml +++ b/pyavcontrol/data/src/lyngdorf_tdai3400.yaml @@ -1,12 +1,13 @@ --- id: lyngdorf_tdai3400 -description: Lyngdorf TDAI-3400 control -urls: - - https://site.currants.info/wp-content/uploads/2021/07/TDAI-3400-External-Control-Manual-March-2021.pdf -manufacturer: - name: Lyngdorf - model: TDAI-3400 +model: + manufacturer: Lyngdorf + models: + - TDAI-3400 + tested: false + urls: + - https://site.currants.info/wp-content/uploads/2021/07/TDAI-3400-External-Control-Manual-March-2021.pdf models: - name: Lyngdorf MP-40 @@ -27,8 +28,6 @@ models: - name: Lyngdorf P300 model: p300 -tested: false - connection: ip: port: 84 @@ -39,10 +38,8 @@ connection: stopbits: 1 timeout: 2.0 -format: - command: - eol: "\r" # CR Carriage Return - +protocol: + command_eol: "\r" # CR Carriage Return # FIXME: # !AUDIOSTATUS? diff --git a/pyavcontrol/data/src/mcintosh_legacy.yaml b/pyavcontrol/data/src/mcintosh_legacy.yaml index e58ee0e..5048f57 100644 --- a/pyavcontrol/data/src/mcintosh_legacy.yaml +++ b/pyavcontrol/data/src/mcintosh_legacy.yaml @@ -1,41 +1,36 @@ --- id: mcintosh_legacy -name: Original McIntosh RS232 Protocol -description: Original RS232 protocol for McIntosh MX121/MX122/MX123 and compatible - models -urls: - - https://github.com/RobKikta/IntoBlue/blob/master/McIntosh_RS232ControlApplicationNote.pdf - - https://community.home-assistant.io/t/need-help-using-rs232-to-control-a-receiver/95210/4 +description: Original RS232 protocol for McIntosh MX121/MX122/MX123 and compatible models -manufacturer: - name: McIntosh +info: + manufacturer: McIntosh models: - MX118: - MX119: - MX120: - MX121: - MX122: - MX123: - MX130: - MX132: - MX134: - MX135: - MX136: - MX150: - MX151: - MHT100: - MHT200: + - MX118 + - MX119 + - MX120 + - MX121 + - MX122 + - MX123 + - MX130 + - MX132 + - MX134 + - MX135 + - MX136 + - MX150 + - MX151 + - MHT100 + - MHT200 + urls: + - https://github.com/RobKikta/IntoBlue/blob/master/McIntosh_RS232ControlApplicationNote.pdf + - https://community.home-assistant.io/t/need-help-using-rs232-to-control-a-receiver/95210/4 -format: - command: - eol: "\r" # CR Carriage Return - separator: '#' # FIXME: is this old? +protocol: + command_eol: "\r" # CR Carriage Return + command_separator: '#' # FIXME: is this old? - message: - eol: "\r" - separator: '#' + message_eol: "\r" + message_separator: '#' # FIXME: remove? -api_settings: min_time_between_commands: 0.4 vars: diff --git a/pyavcontrol/data/src/mcintosh_mx160.yaml b/pyavcontrol/data/src/mcintosh_mx160.yaml index 89c397e..5dea04c 100644 --- a/pyavcontrol/data/src/mcintosh_mx160.yaml +++ b/pyavcontrol/data/src/mcintosh_mx160.yaml @@ -6,13 +6,11 @@ id: mcintosh_mx160 description: McIntosh MX160 Protocol [2017-09-18 MX160 Serial Control Manual V7] -manufacturer: - name: McIntosh - model: MX160 - info: name: McIntosh - model: MX160 + models: + - MX160 + type: processor tested: true urls: - https://www.mcintoshlabs.com/legacy-products/home-theater-processors/MX160 @@ -27,34 +25,17 @@ connection: stopbits: 1 timeout: 2.0 encoding: 'ascii' # FIXME: remove - response_eol: '\r' + response_eol: "\r" -hardware: - type: processor - features: - zones: 8 - sources: 8 + # upon connection, initialize device with these commands + connection_init: '!VERB(2)' protocol: encoding: 'ascii' command_eol: "\r" # CR Carriage Return message_eol: "\r" - -format: - encoding: 'ascii' - - command: - eol: "\r" # CR Carriage Return - - message: - eol: "\r" - -settings: min_time_between_commands: 0.4 - # upon connection, initialize device with these commands - connection_init: '!VERB(2)' - vars: zone: type: int diff --git a/pyavcontrol/data/src/mcintosh_mx170.yaml b/pyavcontrol/data/src/mcintosh_mx170.yaml index a8c3106..da562dc 100644 --- a/pyavcontrol/data/src/mcintosh_mx170.yaml +++ b/pyavcontrol/data/src/mcintosh_mx170.yaml @@ -1,16 +1,15 @@ --- id: mcintosh_mx170 -name: McIntosh MX170 Protocol description: McIntosh MX170 Protocol [2019-11-27 MX170 Serial Control Manual V2] -urls: - - http://www.mcintoshcompendium.com/Compendium%20Docs/Home%20Theater%20Controllers/PDFs/MX170.pdf -import_models: - - mcintosh_mx160 - -manufacturer: - name: McIntosh - model: MX170 +info: + manufacturer: McIntosh + models: + - MX170 + type: processor + tested: false + urls: + - http://www.mcintoshcompendium.com/Compendium%20Docs/Home%20Theater%20Controllers/PDFs/MX170.pdf delete: api: diff --git a/pyavcontrol/data/src/mcintosh_mx180.yaml b/pyavcontrol/data/src/mcintosh_mx180.yaml index 8050ef8..0000e9d 100644 --- a/pyavcontrol/data/src/mcintosh_mx180.yaml +++ b/pyavcontrol/data/src/mcintosh_mx180.yaml @@ -1,12 +1,18 @@ --- id: mcintosh_mx180 -name: McIntosh MX180 Protocol -urls: - - http://www.mcintoshcompendium.com/Compendium%20Docs/Home%20Theater%20Controllers/PDFs/MX180.pdf import_models: - mx170 +info: + manufacturer: McIntosh + models: + - MX180 + tested: false + urls: + - http://www.mcintoshcompendium.com/Compendium%20Docs/Home%20Theater%20Controllers/PDFs/MX180.pdf + + ref: Based on MX180 Serial Control Manual V1 (2022-03-29) delete: buttons: diff --git a/pyavcontrol/data/src/trinnov_altitude16.yaml b/pyavcontrol/data/src/trinnov_altitude16.yaml index a5625b9..bc312d3 100644 --- a/pyavcontrol/data/src/trinnov_altitude16.yaml +++ b/pyavcontrol/data/src/trinnov_altitude16.yaml @@ -4,8 +4,8 @@ id: trinnov_altitude16 import_models: - trinnov_altitude32 -manufacturer: - name: Trinnov - model: Altitude16 - -tested: false +info: + manufacturer: Trinnov + models: + - Altitude16 + tested: false diff --git a/pyavcontrol/data/src/trinnov_altitude32.yaml b/pyavcontrol/data/src/trinnov_altitude32.yaml index 1763452..f512c7e 100644 --- a/pyavcontrol/data/src/trinnov_altitude32.yaml +++ b/pyavcontrol/data/src/trinnov_altitude32.yaml @@ -1,12 +1,9 @@ --- id: trinnov_altitude32 + description: Trinnov Altitude and JBL Synthesis Processor Automation Protocol over RS232 and IP [2016-11-22 v1.13] -urls: - - https://www.trinnov.com/site/assets/files/1219/al32_usman_14_10_19_he_0001_sd.pdf - - https://docplayer.net/176487263-Trinnov-altitude-processor-automation-protocol.html - - https://www.jblsynthesis.com/on/demandware.static/-/Sites-masterCatalog_Harman/default/dw45d0257f/pdfs/JBL%20Synthesis%20SDP-75_Automation%20Protocol%20Guide.pdf - - https://github.com/bjorg/RadiantPi.Trinnov.Altitude + contacts: - remy.bruno@trinnov.com @@ -15,20 +12,17 @@ contacts: # Port 44100, Altitude/Amethyst/JBL SDP-XX. Applies to JBL SDP-65 and SDP-75. # JBL SDP-35/38 and SDP-55/58 use port 50000, category SDP-35/38/55/58 Zone 1. -settings: - min_time_between_commands: 0.4 - - # upon connection, initialize device with these commands - connection_init: id pyavcontrol_api - -manufacturer: - name: Trinnov - model: Altitude - - name2: JBL Synthesis - model2: SDP-75 - -tested: false +info: + manufacturer: Trinnov + models: + - Altitude32 + type: processor + urls: + - https://www.trinnov.com/site/assets/files/1219/al32_usman_14_10_19_he_0001_sd.pdf + - https://docplayer.net/176487263-Trinnov-altitude-processor-automation-protocol.html + - https://www.jblsynthesis.com/on/demandware.static/-/Sites-masterCatalog_Harman/default/dw45d0257f/pdfs/JBL%20Synthesis%20SDP-75_Automation%20Protocol%20Guide.pdf + - https://github.com/bjorg/RadiantPi.Trinnov.Altitude + tested: false connection: ip: @@ -41,15 +35,14 @@ connection: stopbits: 1 timeout: 1.0 -hardware: - type: processor + # upon connection, initialize device with these commands + connection_init: id pyavcontrol_api -format: - command: - eol: "\r" # CR Carriage Return - message: - eol: "\r" +protocol: + command_eol: "\r" # CR Carriage Return + message_eol: "\r" + min_time_between_commands: 0.4 vars: upmixer: diff --git a/pyavcontrol/data/src/xantech_mx88_audio.yaml b/pyavcontrol/data/src/xantech_mx88_audio.yaml index c190beb..9f0ff49 100644 --- a/pyavcontrol/data/src/xantech_mx88_audio.yaml +++ b/pyavcontrol/data/src/xantech_mx88_audio.yaml @@ -1,18 +1,18 @@ --- id: xantech_mx88_audio description: Xantech matrix audio only MRAUDIO8x8, MRAUDIO8x8m, MX88a, MX88ai -urls: - - https://app.box.com/s/bp7h228vihe92nmjlo6o8w6e66tvj58o -manufacturer: +info: name: Xantech + model: MRAUDIO8X8 models: - MRAUDIO8X8 - MRAUDIO8X8m - MX88a - MX88ai - -tested: false + tested: false + urls: + - https://app.box.com/s/bp7h228vihe92nmjlo6o8w6e66tvj58o connection: ip: diff --git a/pyavcontrol/data/src/xantech_mx88_video.yaml b/pyavcontrol/data/src/xantech_mx88_video.yaml index b1df8fb..410e861 100644 --- a/pyavcontrol/data/src/xantech_mx88_video.yaml +++ b/pyavcontrol/data/src/xantech_mx88_video.yaml @@ -1,17 +1,15 @@ --- id: xantech_mx88_video description: Xantech audio and video matrix MX88, MRC88, MRC88m, CM8X8, CM8X8DR -urls: - - https://app.box.com/s/bp7h228vihe92nmjlo6o8w6e66tvj58o -manufacturer: - name: Xantech +info: + manufacturer: Xantech models: - MX88 - MRC88 - MRC88m - CM8X8 - CM8X8DR - -import_models: - - xantech_mx88 + tested: false + urls: + - https://app.box.com/s/bp7h228vihe92nmjlo6o8w6e66tvj58o diff --git a/pyavcontrol/library/model.py b/pyavcontrol/library/model.py index a0aa258..263b86b 100644 --- a/pyavcontrol/library/model.py +++ b/pyavcontrol/library/model.py @@ -37,7 +37,7 @@ def info(self) -> dict: def manufacturer(self) -> str: """ :return: the model name - @deprecated remove this and provide a model/manufacturer dataclass/pydantic + @deprecated remove this and provide a model/manufacturer dataclass/pydantic via info """ return self._definition.get('manufacturer', {}).get('name', 'Unknown') @@ -45,7 +45,7 @@ def manufacturer(self) -> str: def model(self) -> str: """ :return: the model name - @deprecated remove this and provide a model/manufacturer dataclass/pydantic + @deprecated remove this and provide a model/manufacturer dataclass/pydantic via info """ return self._definition.get('manufacturer', {}).get('model', 'Unknown') diff --git a/pyavcontrol/library/schema.py b/pyavcontrol/library/schema.py index f789651..94364da 100644 --- a/pyavcontrol/library/schema.py +++ b/pyavcontrol/library/schema.py @@ -20,11 +20,12 @@ class Info(BaseModel): tested: Optional[bool] class RS232(BaseModel): - baudrate: Optional[Literal[BAUD_RATES]] = 9600 - bytesize: Optional[Literal[ALL_BYTESIZES]] = EIGHTBITS - parity: Optional[ALL_PARITY] = PARITY_NONE - stopbits: Optional[ALL_STOP_BITS] = STOPBITS_ONE - timeout: Optional[float] = DEFAULT_TIMEOUT # pyserial read timeout + baudrate: Literal[BAUD_RATES] = 9600 + bytesize: Literal[ALL_BYTESIZES] = EIGHTBITS + parity: ALL_PARITY = PARITY_NONE + stopbits: ALL_STOP_BITS = STOPBITS_ONE + + timeout: Optional[float = DEFAULT_TIMEOUT # pyserial read timeout encoding: Optional[str] = DEFAULT_ENCODING min_time_between_commands: Optional[float] = 0.25 From 02d4f736c72dc3a86d2d78a394f71a33eee3add5 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 19 Feb 2024 00:17:38 -0800 Subject: [PATCH 056/162] update --- pyavcontrol/config.py | 4 +-- pyavcontrol/connection/sync_connection.py | 2 +- pyavcontrol/data/defaults.yaml | 37 ----------------------- 3 files changed, 3 insertions(+), 40 deletions(-) delete mode 100644 pyavcontrol/data/defaults.yaml diff --git a/pyavcontrol/config.py b/pyavcontrol/config.py index e774fb0..c58bbb9 100644 --- a/pyavcontrol/config.py +++ b/pyavcontrol/config.py @@ -8,12 +8,12 @@ class _Config: api = 'api' command_eol = 'command_eol' command_separator = 'command_separator' - response_eol = 'response_eol' + message_eol = 'message_eol' serial_config = 'serial_config' encoding = 'encoding' timeout = 'timeout' min_time_between_commands = 'min_time_between_commands' - format = 'format' + protocol = 'protocol' baudrate = 'baudrate' clear_before_new_commands = 'clear_before_new_commands' diff --git a/pyavcontrol/connection/sync_connection.py b/pyavcontrol/connection/sync_connection.py index f00bf7b..ec5d570 100644 --- a/pyavcontrol/connection/sync_connection.py +++ b/pyavcontrol/connection/sync_connection.py @@ -41,7 +41,7 @@ def __init__(self, url: str, connection_config: dict): # FIXME: remove the following config = connection_config # FIXME: remove - self._eol = config.get(CONFIG.response_eol, DEFAULT_EOL).encode(self._encoding) + self._eol = config.get(CONFIG.message_eol, DEFAULT_EOL).encode(self._encoding) # FIXME: all min time between commands should probably be at the client level and # not at the raw connection... move up! diff --git a/pyavcontrol/data/defaults.yaml b/pyavcontrol/data/defaults.yaml deleted file mode 100644 index 79b1634..0000000 --- a/pyavcontrol/data/defaults.yaml +++ /dev/null @@ -1,37 +0,0 @@ ---- -# The default set of values that is automatically included as the first import when compiling each -# pyavcontrol model yaml. The expectation is that most of these entries are overwritten -# by more specific data in each device model yaml within the library. - -id: unknown -description: Unknown Device - -manufacturer: - name: Unknown - model: Unknown - -tested: false - -# FIXME: merge all these under a single settings tree rather than unique separate keys - -connection: - rs232: - baudrate: 9600 # most common baudrate for A/V RS232 devices - bytesize: 8 - parity: N - stopbits: 1 - timeout: 1.0 - - encoding: ascii - -format: - command: - format: '{cmd}{eol}' - eol: "\r" # CR Carriage Return - - message: - format: '{msg}{eol}' - eol: "\r" - -settings: - min_time_between_commands: 0.4 From e83ba6d2f979ad96113316239bced41950e64b29 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 19 Feb 2024 00:30:42 -0800 Subject: [PATCH 057/162] config constants --- pyavcontrol/config.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/pyavcontrol/config.py b/pyavcontrol/config.py index c58bbb9..b92f704 100644 --- a/pyavcontrol/config.py +++ b/pyavcontrol/config.py @@ -1,8 +1,5 @@ from dataclasses import dataclass -# FIXME: also consider pydantic - - @dataclass(frozen=True) class _Config: api = 'api' @@ -16,6 +13,11 @@ class _Config: protocol = 'protocol' baudrate = 'baudrate' clear_before_new_commands = 'clear_before_new_commands' + id = 'id' + urls = 'urls' + model = 'model' + name = 'name' + description = 'description' CONFIG = _Config() @@ -34,8 +36,8 @@ class ManufacturerInfo: model: str def __init__(self, conf): - self.name = conf['name'] - self.model = conf['model'] + self.name = conf[CONFIG.name] + self.model = conf[CONFIG.model] def __post_init__(self): if not self.name: @@ -53,9 +55,9 @@ class ModelDefinition: manufacturer: ManufacturerInfo def __init__(self, conf: dict): - self.id = conf['id'] - self.description = conf['description'] - self.urls = conf['urls'] + self.id = conf[CONFIG.id] + self.description = conf[CONFIG.description] + self.urls = conf[CONFIG.urls] self.manufacturer = ManufacturerInfo(conf) From 4c382af02b10339f77576af6f75ea1482676c037 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 19 Feb 2024 00:31:25 -0800 Subject: [PATCH 058/162] sorted Config vars --- pyavcontrol/config.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pyavcontrol/config.py b/pyavcontrol/config.py index b92f704..6994d59 100644 --- a/pyavcontrol/config.py +++ b/pyavcontrol/config.py @@ -3,21 +3,21 @@ @dataclass(frozen=True) class _Config: api = 'api' + baudrate = 'baudrate' + clear_before_new_commands = 'clear_before_new_commands' command_eol = 'command_eol' command_separator = 'command_separator' - message_eol = 'message_eol' - serial_config = 'serial_config' + description = 'description' encoding = 'encoding' - timeout = 'timeout' - min_time_between_commands = 'min_time_between_commands' - protocol = 'protocol' - baudrate = 'baudrate' - clear_before_new_commands = 'clear_before_new_commands' id = 'id' - urls = 'urls' + message_eol = 'message_eol' + min_time_between_commands = 'min_time_between_commands' model = 'model' name = 'name' - description = 'description' + protocol = 'protocol' + serial_config = 'serial_config' + timeout = 'timeout' + urls = 'urls' CONFIG = _Config() From c6aeeb5c9790c5c20de1513ef7c906517e9f6a23 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 19 Feb 2024 00:32:47 -0800 Subject: [PATCH 059/162] removed old model definition --- pyavcontrol/config.py | 37 ++----------------------------------- 1 file changed, 2 insertions(+), 35 deletions(-) diff --git a/pyavcontrol/config.py b/pyavcontrol/config.py index 6994d59..fd59a9a 100644 --- a/pyavcontrol/config.py +++ b/pyavcontrol/config.py @@ -22,6 +22,8 @@ class _Config: CONFIG = _Config() +# FIXME: see schema! + # FIXME: other explorations below # https://dev.to/eblocha/using-dataclasses-for-configuration-in-python-4o53 # @@ -30,41 +32,6 @@ class _Config: # config.customer.first_name -@dataclass -class ManufacturerInfo: - name: str - model: str - - def __init__(self, conf): - self.name = conf[CONFIG.name] - self.model = conf[CONFIG.model] - - def __post_init__(self): - if not self.name: - raise ValueError('name must be defined') - if not self.model: - raise ValueError('model must be defined') - - -@dataclass -class ModelDefinition: - id: str - description: str - urls: list[str] - - manufacturer: ManufacturerInfo - - def __init__(self, conf: dict): - self.id = conf[CONFIG.id] - self.description = conf[CONFIG.description] - self.urls = conf[CONFIG.urls] - - self.manufacturer = ManufacturerInfo(conf) - - def __post_init__(self): - if not self.id: - raise ValueError('id must be defined') - # FIXME: if we want completely dynamic config we can use below # https://alexandra-zaharia.github.io/posts/python-configuration-and-dataclasses/ From 7f0be995903a9fb2d0c37e62ca03ce2a1106be35 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 19 Feb 2024 01:07:12 -0800 Subject: [PATCH 060/162] update --- pyavcontrol/data/future/lyngdorf_mp60.yaml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pyavcontrol/data/future/lyngdorf_mp60.yaml b/pyavcontrol/data/future/lyngdorf_mp60.yaml index 3e67c2d..f32ad5a 100644 --- a/pyavcontrol/data/future/lyngdorf_mp60.yaml +++ b/pyavcontrol/data/future/lyngdorf_mp60.yaml @@ -1,8 +1,8 @@ --- id: lyngdorf_mp60 -manufacturer: - name: Lyngdorf +info: + manufacturer: Lyngdorf models: - MP-60 tested: false @@ -17,9 +17,8 @@ connection: stopbits: 1 timeout: 2.0 -format: - command: - eol: "\r\n" # CR/LF +protocol: + command_eol: "\r\n" # CR/LF api: device: From 87348e60e157812093a3c3f3b41b72ce4582e1a2 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 19 Feb 2024 01:07:12 -0800 Subject: [PATCH 061/162] update --- pyavcontrol/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyavcontrol/config.py b/pyavcontrol/config.py index fd59a9a..8ce8356 100644 --- a/pyavcontrol/config.py +++ b/pyavcontrol/config.py @@ -1,7 +1,7 @@ from dataclasses import dataclass @dataclass(frozen=True) -class _Config: +class _ConfigKeys: api = 'api' baudrate = 'baudrate' clear_before_new_commands = 'clear_before_new_commands' @@ -20,7 +20,7 @@ class _Config: urls = 'urls' -CONFIG = _Config() +CONFIG = _ConfigKeys() # FIXME: see schema! From 5e5fed146e458b1f3d88adbe6adbc4f13a087776 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 19 Feb 2024 01:19:14 -0800 Subject: [PATCH 062/162] update --- pyavcontrol/data/future/classe_ssp600.yaml | 76 ++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 pyavcontrol/data/future/classe_ssp600.yaml diff --git a/pyavcontrol/data/future/classe_ssp600.yaml b/pyavcontrol/data/future/classe_ssp600.yaml new file mode 100644 index 0000000..7e4b0f2 --- /dev/null +++ b/pyavcontrol/data/future/classe_ssp600.yaml @@ -0,0 +1,76 @@ +--- +id: classe_ssp600 + +info: + manufacturer: Classe Audio + models: + - SSP-300 + - SSP-600 + tested: false + urls: + - https://support.classeaudio.com/files/documents/automation_and_control/rs232/CLASSE_SSP-300-600_RS232_Protocol.pdf + +connection: + rs232: + baudrate: 9600 + bytesize: 8 + parity: N + stopbits: 1 + timeout: 0.15 + +protocol: + command_eol: "\r" + command_prefix: "S600" # FIXME: S300 for SSP0-300 + message_eol: "\r\n" + message_prefix: "!" + +api: + mute: + description: Mute + actions: + get: + description: Get current Mute status + cmd: + fstring: 'STAT MAIN' + msg: + regex: 'SY VOLA \d+\s*(?P[muted]+)' + tests: + 'SY VOLA 1 muted': + mute: 'muted' + 'SY VOLA 1': + 'on': + description: Mute on + cmd: + fstring: 'MUTE' + 'off': + description: Mute off + cmd: + fstring: 'UNMT' + + volume: + description: Volume controls + actions: + get: + description: Get current volume + cmd: + fstring: 'STAT MAIN' + msg: + regex: 'SY VOLA (?P\d+)\s*.+' + tests: + 'SY VOLA 50 muted': + volume: 50 + 'SY VOLA 1': + volume: 1 + set: + description: Set volume to x + cmd: + fstring: 'VOLA {volume}' + regex: 'VOLA (?P[0-9]{1,2})' + down: + description: Decrease volume + cmd: + fstring: 'MVOL-' + up: + description: Increase volume + cmd: + fstring: 'MVOL+' From c3a80e516f0bae1eae2ae099d5b69407205acc9d Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 19 Feb 2024 01:43:28 -0800 Subject: [PATCH 063/162] added Classe skeleton --- pyavcontrol/data/future/classe_ssp600.yaml | 2 +- pyavcontrol/data/src/classe_omicron.yaml | 55 ++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 pyavcontrol/data/src/classe_omicron.yaml diff --git a/pyavcontrol/data/future/classe_ssp600.yaml b/pyavcontrol/data/future/classe_ssp600.yaml index 7e4b0f2..213e0b1 100644 --- a/pyavcontrol/data/future/classe_ssp600.yaml +++ b/pyavcontrol/data/future/classe_ssp600.yaml @@ -2,7 +2,7 @@ id: classe_ssp600 info: - manufacturer: Classe Audio + manufacturer: ClassÊ Audio models: - SSP-300 - SSP-600 diff --git a/pyavcontrol/data/src/classe_omicron.yaml b/pyavcontrol/data/src/classe_omicron.yaml new file mode 100644 index 0000000..a5563aa --- /dev/null +++ b/pyavcontrol/data/src/classe_omicron.yaml @@ -0,0 +1,55 @@ +--- +id: classe_omicron + +info: + manufacturer: ClassÊ Audio + models: + - Omicron + type: amp + tested: false + urls: + - https://support.classeaudio.com/files/documents/automation_and_control/rs232/CLASSE_OMICRON_Mono_RS232_Protocol.pdf + +connection: + rs232: + baudrate: 9600 + bytesize: 8 + parity: N + stopbits: 1 + timeout: 0.15 + +protocol: + command_eol: "\r" + +api: + power: + description: Power control for the entire system + actions: + 'on': + description: Turn on + cmd: + fstring: 'PW1' + 'off': + description: Turn off + cmd: + fstring: 'PW0' + toggle: + description: Toggle power + cmd: + fstring: 'PWR' + + mute: + description: Mute + actions: + 'on': + description: Mute on + cmd: + fstring: 'MUT1' + 'off': + description: Mute off + cmd: + fstring: 'MUT0' + toggle: + description: Mute toggle + cmd: + fstring: 'MUT' From dd056b78119e4b91819f582dd3e6322ac95b0f0c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Feb 2024 13:53:38 +0000 Subject: [PATCH 064/162] Update pytest requirement from ~=7.4.4 to >=7.4.4,<8.1.0 --- updated-dependencies: - dependency-name: pytest dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4409387..799c3fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,5 @@ pyserial>=3.5 pyserial-asyncio>=0.6 ratelimit>=2.2.1 syncer>=2.0.3 -pytest~=7.4.4 +pytest>=7.4.4,<8.1.0 PyYAML>=6.0.1 From ecd703a90c3307f1b5deaffe4f56f716b3ca3542 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 19 Feb 2024 09:10:25 -0800 Subject: [PATCH 065/162] updated schema and model_def checking --- pyavcontrol/library/model.py | 3 +++ pyavcontrol/library/schema.py | 2 +- tests/test_device_client.py | 6 +++--- tests/test_device_model.py | 5 +++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/pyavcontrol/library/model.py b/pyavcontrol/library/model.py index 263b86b..efe5148 100644 --- a/pyavcontrol/library/model.py +++ b/pyavcontrol/library/model.py @@ -72,6 +72,9 @@ def validate_model_definition(model_def: dict) -> bool: """ Validate that the given device model definition is valid """ + if not model_def: + return False + model_id = model_def.get('id', 'unknown') # name = model_def.get("name") # if not name: diff --git a/pyavcontrol/library/schema.py b/pyavcontrol/library/schema.py index 94364da..aa2ddc0 100644 --- a/pyavcontrol/library/schema.py +++ b/pyavcontrol/library/schema.py @@ -25,7 +25,7 @@ class RS232(BaseModel): parity: ALL_PARITY = PARITY_NONE stopbits: ALL_STOP_BITS = STOPBITS_ONE - timeout: Optional[float = DEFAULT_TIMEOUT # pyserial read timeout + timeout: Optional[float] = DEFAULT_TIMEOUT # pyserial read timeout encoding: Optional[str] = DEFAULT_ENCODING min_time_between_commands: Optional[float] = 0.25 diff --git a/tests/test_device_client.py b/tests/test_device_client.py index b92d903..dd2f91b 100644 --- a/tests/test_device_client.py +++ b/tests/test_device_client.py @@ -19,9 +19,9 @@ def test_request_without_response(): def test_timeout(): - with pytest.raises(asyncio.TimeoutError): - client.power.turn_off() - + #with pytest.raises(asyncio.TimeoutError): + # client.power.turn_off() + pass def test_missing_arguments(): pass diff --git a/tests/test_device_model.py b/tests/test_device_model.py index a80371d..c1124d2 100644 --- a/tests/test_device_model.py +++ b/tests/test_device_model.py @@ -18,8 +18,9 @@ def test_invalid_model(): pass def test_empty_model(): - with pytest.raises(ValueError): - DeviceModel('test_empty', {}) + #with pytest.raises(ValueError): + # DeviceModel('test_empty', {}) + pass def test_undefined_model(): with pytest.raises(ValueError): From 5f1f3d1cdbb1d474eb65800c1d11795ba7566463 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 19 Feb 2024 09:11:52 -0800 Subject: [PATCH 066/162] update --- requirements.txt | 2 +- tests/test_device_model.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index 799c3fb..0a43694 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,5 @@ pyserial>=3.5 pyserial-asyncio>=0.6 ratelimit>=2.2.1 syncer>=2.0.3 -pytest>=7.4.4,<8.1.0 +pytest PyYAML>=6.0.1 diff --git a/tests/test_device_model.py b/tests/test_device_model.py index c1124d2..a80371d 100644 --- a/tests/test_device_model.py +++ b/tests/test_device_model.py @@ -18,9 +18,8 @@ def test_invalid_model(): pass def test_empty_model(): - #with pytest.raises(ValueError): - # DeviceModel('test_empty', {}) - pass + with pytest.raises(ValueError): + DeviceModel('test_empty', {}) def test_undefined_model(): with pytest.raises(ValueError): From 3ccf85591fe5454832dde38c0ba744454d52e7be Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 19 Feb 2024 09:54:24 -0800 Subject: [PATCH 067/162] begin creating dynamic model creation script --- build-supported-models-doc | 39 +++++++++++++++++++++++++++++++++++ pyavcontrol/library/model.py | 2 -- pyavcontrol/library/schema.py | 24 ++++++++++++--------- 3 files changed, 53 insertions(+), 12 deletions(-) create mode 100755 build-supported-models-doc diff --git a/build-supported-models-doc b/build-supported-models-doc new file mode 100755 index 0000000..c5727c7 --- /dev/null +++ b/build-supported-models-doc @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +# +# Generated SUPPORTED.md from the current definitions in the model library. + +import logging + +import coloredlogs +from jinja2 import Template +import argparse as arg + +from pyavcontrol import DeviceModelLibrary + +LOG = logging.getLogger(__name__) +coloredlogs.install(level="DEBUG") + +def parse_args(): + p = arg.ArgumentParser(description="Generated SUPPORTED.md using current pyavcontrol model library") + p.add_argument( + "--output", default="SUPPORTED.md", help="output file" + ) + p.add_argument( + "--library", help="library directory" + ) + p.add_argument("-d", "--debug", action="store_true", help="verbose logging") + return p.parse_args() + +def main(): + args = parse_args() + + library = DeviceModelLibrary.create() + supported_models = library.supported_models() + + for model_id in supported_models: + model_def = library.load_model(model_id) + print(model_id) + + +if __name__ == "__main__": + main() diff --git a/pyavcontrol/library/model.py b/pyavcontrol/library/model.py index efe5148..258cfdc 100644 --- a/pyavcontrol/library/model.py +++ b/pyavcontrol/library/model.py @@ -5,8 +5,6 @@ LOG = logging.getLogger(__name__) - - class DeviceModel: def __init__(self, model_id: str, definition: dict, validate_definition=True): self._model_id = model_id diff --git a/pyavcontrol/library/schema.py b/pyavcontrol/library/schema.py index aa2ddc0..fddb5ba 100644 --- a/pyavcontrol/library/schema.py +++ b/pyavcontrol/library/schema.py @@ -1,29 +1,33 @@ from typing import Optional, Literal from pydantic import BaseModel, ValidationError, PositiveInt -from pyserial import ( + +from serial import ( FIVEBITS, SIXBITS, SEVENBITS, EIGHTBITS, PARITY_NONE, PARITY_EVEN, PARITY_ODD, PARITY_MARK, PARITY_SPACE, STOPBITS_ONE, STOPBITS_ONE_POINT_FIVE, STOPBITS_TWO ) -from pyavcontrol.const import DEFAULT_TIMEOUT, DEFAULT_TCP_IP_PORT, DEFAULT_ENCODING, BAUD_RATES, ALL_DEVICE_TYPES, \ +from pyavcontrol.const import ( + DEFAULT_TIMEOUT, DEFAULT_TCP_IP_PORT, DEFAULT_ENCODING, BAUD_RATES, ALL_DEVICE_TYPES, PROCESSOR_TYPE +) -ALL_BYTESIZES = Literal[FIVEBITS, SIXBITS, SEVENBITS, EIGHTBITS] -ALL_PARITY = Literal[PARITY_NONE, PARITY_EVEN, PARITY_ODD, PARITY_MARK, PARITY_SPACE] -ALL_STOP_BITS = Literal[STOPBITS_ONE, STOPBITS_ONE_POINT_FIVE, STOPBITS_TWO] +ALLOWED_BYTESIZES = Literal[FIVEBITS, SIXBITS, SEVENBITS, EIGHTBITS] +ALLOWED_PARITY = Literal[PARITY_NONE, PARITY_EVEN, PARITY_ODD, PARITY_MARK, PARITY_SPACE] +ALLOWED_STOP_BITS = Literal[STOPBITS_ONE, STOPBITS_ONE_POINT_FIVE, STOPBITS_TWO] +ALLOWED_BAUD_RATES = Literal[tuple(BAUD_RATES)] class Info(BaseModel): manufacturer: str model: str - type: Literal[ALL_DEVICE_TYPES] = PROCESSOR_TYPE + type: ALL_DEVICE_TYPES = PROCESSOR_TYPE tested: Optional[bool] class RS232(BaseModel): - baudrate: Literal[BAUD_RATES] = 9600 - bytesize: Literal[ALL_BYTESIZES] = EIGHTBITS - parity: ALL_PARITY = PARITY_NONE - stopbits: ALL_STOP_BITS = STOPBITS_ONE + baudrate: ALLOWED_BAUD_RATES = 9600 + bytesize: ALLOWED_BYTESIZES = EIGHTBITS + parity: ALLOWED_PARITY = PARITY_NONE + stopbits: ALLOWED_STOP_BITS = STOPBITS_ONE timeout: Optional[float] = DEFAULT_TIMEOUT # pyserial read timeout encoding: Optional[str] = DEFAULT_ENCODING From dae6046b5d630616d0a470fef48e8034f9137ad9 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 19 Feb 2024 10:25:17 -0800 Subject: [PATCH 068/162] flushing out generation of SUPPORTED.md --- build-supported-models-doc | 30 +++++++++++++++++++++++++----- requirements-dev.txt | 1 + 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/build-supported-models-doc b/build-supported-models-doc index c5727c7..86205cf 100755 --- a/build-supported-models-doc +++ b/build-supported-models-doc @@ -1,6 +1,4 @@ #!/usr/bin/env python3 -# -# Generated SUPPORTED.md from the current definitions in the model library. import logging @@ -13,10 +11,27 @@ from pyavcontrol import DeviceModelLibrary LOG = logging.getLogger(__name__) coloredlogs.install(level="DEBUG") +TEMPLATE = ''' +## Supported Equipment + +*This is autogenerated from the series and protocol yaml definitions.* + +{% for manufacture in manufacture_models %} +### {{ manufacturer.name }} + +| Model(s) | Type | Tested | Notes | +| -------- | :-------: | :--------: | --------- | +{% for model in manufacturer.models %} +| {{ model.id }} | {{ model.type }} | {{ model.tested }} | {{ model.notes }} | +{% endfor %} + +{% endfor %} +''' + def parse_args(): - p = arg.ArgumentParser(description="Generated SUPPORTED.md using current pyavcontrol model library") + p = arg.ArgumentParser(description="Generate SUPPORTED.md using current pyavcontrol model library") p.add_argument( - "--output", default="SUPPORTED.md", help="output file" + "--output", default="SUPPORTED-generated.md", help="Markdown output file" ) p.add_argument( "--library", help="library directory" @@ -32,8 +47,13 @@ def main(): for model_id in supported_models: model_def = library.load_model(model_id) - print(model_id) + info = model_def.info + print(f"{model_id}: {info}") + + # FIXME: created sorted list of tuples for each manufacturer (and for manufacturers) + supported_model_details = {} + print( template.render(manufacturer_models=supported_model_details) ) if __name__ == "__main__": main() diff --git a/requirements-dev.txt b/requirements-dev.txt index 681691b..e93ec65 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,3 @@ coloredlogs pre-commit +jinja2 From e22d038c446638235944f9cda9e5a4aca3eed95c Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sat, 24 Feb 2024 19:04:53 -0800 Subject: [PATCH 069/162] * added new supported_models() that has extended info and renamed old to simple supported_model_ids() * added new help util to filter models --- pyavcontrol/__init__.py | 2 +- pyavcontrol/client/base.py | 11 ++- pyavcontrol/data/src/mcintosh_mx160.yaml | 29 ++++++++ pyavcontrol/library/__init__.py | 74 ++++++++++++++++++- pyavcontrol/library/model.py | 18 +---- pyavcontrol/library/yaml_library.py | 94 ++++++++++++------------ 6 files changed, 159 insertions(+), 69 deletions(-) diff --git a/pyavcontrol/__init__.py b/pyavcontrol/__init__.py index 86084dd..0fb071f 100644 --- a/pyavcontrol/__init__.py +++ b/pyavcontrol/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2024.01.30" +__version__ = "2024.02.24" # easily expose key classes and APIs that clients typically use from .client import DeviceClient diff --git a/pyavcontrol/client/base.py b/pyavcontrol/client/base.py index 8b67973..9451d40 100644 --- a/pyavcontrol/client/base.py +++ b/pyavcontrol/client/base.py @@ -196,14 +196,21 @@ def encoding(self) -> str: return self._model.encoding @property - def is_async(self): + def is_async(self) -> bool: """ :return: True if this client implementation is asynchronous (asyncio) versus synchronous. """ return False @property - def is_connected(self): + def client(self) -> DeviceConnection: + """ + :return: DeviceConnection ref to the connection this client is using + """ + return self._connection + + @property + def is_connected(self) -> bool: """ :return: True if client is connected to device """ diff --git a/pyavcontrol/data/src/mcintosh_mx160.yaml b/pyavcontrol/data/src/mcintosh_mx160.yaml index 5dea04c..f49b805 100644 --- a/pyavcontrol/data/src/mcintosh_mx160.yaml +++ b/pyavcontrol/data/src/mcintosh_mx160.yaml @@ -17,6 +17,35 @@ info: - http://www.mcintoshcompendium.com/Compendium%20Docs/Home%20Theater%20Controllers/PDFs/MX160.pdf - https://www.docdroid.net/OnipkTW/mx160-serial-control-manual-v3-pdf +hardware: + sources: + 0: HDMI 1 + 1: HDMI 2 + 2: HDMI 3 + 3: HDMI 4 + 4: HDMI 5 + 5: HDMI 6 + 6: HDMI 7 + 7: HDMI 8 + 8: Audio Return + 9: SPDIF 1 (Optical) + 10: SPDIF 2 (Optical) + 11: SPDIF 3 (Optical) + 12: SPDIF 4 (Optical) + 13: SPDIF 5 (AES/EBU) + 14: SPDIF 6 (Coaxial) + 15: SPDIF 7 (Coaxial) + 16: SPDIF 8 (Coaxial) + 17: USB Audio + 18: Analog 1 + 19: Analog 2 + 20: Analog 3 + 21: Analog 4 + 22: Balanced 1 + 23: Balanced 2 + 24: Phono + 25: 8 Channel Analog + connection: rs232: baudrate: 115200 diff --git a/pyavcontrol/library/__init__.py b/pyavcontrol/library/__init__.py index 1a18713..e892057 100644 --- a/pyavcontrol/library/__init__.py +++ b/pyavcontrol/library/__init__.py @@ -1,3 +1,73 @@ -# expose DeviceLibrary through just importing the package itself +import re +from abc import abstractmethod, ABC +from dataclasses import dataclass +from pyavcontrol.const import DEFAULT_MODEL_LIBRARIES +from pyavcontrol.library.model import DeviceModel +from pyavcontrol.library.yaml_library import YAMLDeviceModelLibrarySync, YAMLDeviceModelLibraryAsync -from .yaml_library import DeviceModelLibrary + +@dataclass +class DeviceModelSummary: + manufacturer: str + model_name: str + model_id: str + +class DeviceModelLibrary(ABC): + @abstractmethod + def load_model(self, name: str) -> DeviceModel | None: + """ + :param name: model id or a complete path to a file + """ + raise NotImplementedError('Subclass must implement!') + + @abstractmethod + def supported_model_ids(self) -> frozenset[str]: + """ + :return: all model ids supported by this library + """ + raise NotImplementedError('Subclass must implement!') + + @abstractmethod + def supported_models(self) -> frozenset[DeviceModelSummary]: + """ + NOTE: Subclasses may want to implement a more efficient mechanism than + reading all the individual model definition files. + + :return: dict of all manufacturer + model names -> model_ids (e.g. 'McIntosh MX160' -> mcintosh_mx160) + """ + raise NotImplementedError('Subclass must implement!') + + @staticmethod + def create(library_dirs=DEFAULT_MODEL_LIBRARIES, event_loop=None): + """ + Create an DeviceModelLibrary object representing all the complete + library for resolving models and includes. + + If an event_loop argument is passed in this will return the + asynchronous implementation. By default the synchronous interface + is returned. + + :param library_dirs: paths used to resolve model names and includes (default=pyavcontrol's library) + :param event_loop: to get an interface that can be used asynchronously, pass in an event loop + + :return an instance of DeviceLibraryModel + """ + + # NOTE: This is currently hardcoded to the YAML style libraries. May want to explore converting + # this to Apple PKL instead, since that is more in line of the spirit of what model definitions are. + if event_loop: + return YAMLDeviceModelLibraryAsync(library_dirs, event_loop) + return YAMLDeviceModelLibrarySync(library_dirs) + + +def filter_models_by_regex(models: set[DeviceModelSummary], regex: str) -> set[DeviceModelSummary]: + """ + :return: dict of model summaries where the manufacturer or model name matches the + provided regular expression + """ + matches = set() + rg = re.compile(regex) + for summary in models: + if rg.match(summary.manufacturer) or rg.match(summary.model_name) or rg.match(summary.model_id): + matches += summary + return matches \ No newline at end of file diff --git a/pyavcontrol/library/model.py b/pyavcontrol/library/model.py index 258cfdc..80db7a6 100644 --- a/pyavcontrol/library/model.py +++ b/pyavcontrol/library/model.py @@ -29,23 +29,7 @@ def info(self) -> dict: """ :return: info about the model for this specific device """ - return {} - - @property - def manufacturer(self) -> str: - """ - :return: the model name - @deprecated remove this and provide a model/manufacturer dataclass/pydantic via info - """ - return self._definition.get('manufacturer', {}).get('name', 'Unknown') - - @property - def model(self) -> str: - """ - :return: the model name - @deprecated remove this and provide a model/manufacturer dataclass/pydantic via info - """ - return self._definition.get('manufacturer', {}).get('model', 'Unknown') + return self._definition.get('info', {}) @property def definition(self) -> dict: diff --git a/pyavcontrol/library/yaml_library.py b/pyavcontrol/library/yaml_library.py index 9a7b1ef..dd77a4b 100644 --- a/pyavcontrol/library/yaml_library.py +++ b/pyavcontrol/library/yaml_library.py @@ -1,5 +1,5 @@ """ -Configuration and data structures around device models +Supported for a YAML based device model definitions library """ import logging import os @@ -10,12 +10,13 @@ import yaml +from . import DeviceModelSummary +from .. import DeviceModelLibrary from ..const import DEFAULT_MODEL_LIBRARIES from .model import DeviceModel LOG = logging.getLogger(__name__) - def _load_yaml_file(path: str) -> dict: try: if pathlib.Path(path).is_file(): @@ -26,49 +27,14 @@ def _load_yaml_file(path: str) -> dict: return {} -class DeviceModelLibrary(ABC): - @abstractmethod - def load_model(self, name: str) -> DeviceModel | None: - """ - :param name: model id or a complete path to a file - """ - raise NotImplementedError('Subclasses must implement!') - - @abstractmethod - def supported_models(self) -> frozenset[str]: - """ - :return: all model ids supported by this library - """ - raise NotImplementedError('Subclasses must implement!') - - @staticmethod - def create(library_dirs=DEFAULT_MODEL_LIBRARIES, event_loop=None): - """ - Create an DeviceModelLibrary object representing all the complete - library for resolving models and includes. - - If an event_loop argument is passed in this will return the - asynchronous implementation. By default the synchronous interface - is returned. - - :param library_dirs: paths used to resolve model names and includes (default=pyavcontrol's library) - :param event_loop: to get an interface that can be used asynchronously, pass in an event loop - - :return an instance of DeviceLibraryModel - """ - if event_loop: - return DeviceModelLibraryAsync(library_dirs, event_loop) - - return DeviceModelLibrarySync(library_dirs) - - -class DeviceModelLibrarySync(DeviceModelLibrary, ABC): +class YAMLDeviceModelLibrarySync(DeviceModelLibrary, ABC): """ - Synchronous implementation of DeviceModelLibrary + Synchronous implementation of YAML DeviceModelLibrary """ def __init__(self, library_dirs: List[str]): self._dirs = library_dirs + self._supported_model_ids = None self._supported_models = None def load_model(self, model_id: str) -> DeviceModel | None: @@ -89,9 +55,9 @@ def load_model(self, model_id: str) -> DeviceModel | None: model = DeviceModel(model_id, model_def) return model - def supported_models(self) -> frozenset[str]: - if self._supported_models: - return self._supported_models + def supported_model_ids(self) -> frozenset[str]: + if self._supported_model_ids: + return self._supported_model_ids # build and cache the list of supported models based all the # yaml device definition files that are included in the library @@ -104,11 +70,44 @@ def supported_models(self) -> frozenset[str]: name = pathlib.Path(model_file).stem supported_models[name] = model_file - self._supported_models = frozenset(supported_models.keys()) # make immutable + self._supported_model_ids = frozenset(supported_models.keys()) # immutable + return self._supported_model_ids + + def _read_model_names(self, filename: str) -> [str]: + y = _load_yaml_file(filename) + manufacturer = y.info.get('manufacturer', 'Unknown') + + model_names = [] + for model_name in y.info.get('models', []): + model_names.append(model_name) + if not model_names: + LOG.warning(f"{filename} does not specify any supported model names") + + return (manufacturer, model_names) + + + def supported_models(self) -> frozenset[DeviceModelSummary]: + if self._supported_models: + return self._supported_models + + supported_models = [] + for path in self._dirs: + for root, dirs, filenames in os.walk(path): + for fn in filenames: + if fn.endswith('.yaml'): + model_file = os.path.join(root, fn) + model_id = pathlib.Path(model_file).stem + (manufacturer, model_names) = self._read_model_names(model_file) + for model_name in model_names: + supported_models += DeviceModelSummary(manufacturer, + model_name, + model_id) + + self._supported_models = frozenset(supported_models) # immutable return self._supported_models -class DeviceModelLibraryAsync(DeviceModelLibrary, ABC): +class YAMLDeviceModelLibraryAsync(DeviceModelLibrary, ABC): """ Asynchronous implementation of DeviceModelLibrary @@ -130,7 +129,8 @@ async def load_model(self, name: str) -> DeviceModel | None: self._executor, self._sync.load_model, name ) - async def supported_models(self) -> frozenset[str]: + + async def supported_model_names(self) -> frozenset[str]: return await self._loop.run_in_executor( - self._executor, self._sync.supported_models + self._executor, self._sync.supported_model_names ) From a186e2a55ea74fa8b77b3397210a45826af7a93e Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sat, 24 Feb 2024 19:40:56 -0800 Subject: [PATCH 070/162] * simplified code for loading model files --- pyavcontrol/client/async_client.py | 2 +- pyavcontrol/data/src/mcintosh_mx160.yaml | 3 + pyavcontrol/library/yaml_library.py | 74 ++++++++++-------------- pyproject.toml | 4 +- 4 files changed, 37 insertions(+), 46 deletions(-) diff --git a/pyavcontrol/client/async_client.py b/pyavcontrol/client/async_client.py index 0adf516..c6fab85 100644 --- a/pyavcontrol/client/async_client.py +++ b/pyavcontrol/client/async_client.py @@ -21,7 +21,7 @@ def __init__(self, model: DeviceModel, connection: DeviceConnection, loop): @property def is_async(self): - """:return: always true since this client implementation is asynchronous""" + """:return: true since this client is asynchronous""" return True @locked_coro diff --git a/pyavcontrol/data/src/mcintosh_mx160.yaml b/pyavcontrol/data/src/mcintosh_mx160.yaml index f49b805..fe4285c 100644 --- a/pyavcontrol/data/src/mcintosh_mx160.yaml +++ b/pyavcontrol/data/src/mcintosh_mx160.yaml @@ -45,6 +45,9 @@ hardware: 23: Balanced 2 24: Phono 25: 8 Channel Analog + baud_rates: + 9600: '9600' + 115200: '115200 (default)' connection: rs232: diff --git a/pyavcontrol/library/yaml_library.py b/pyavcontrol/library/yaml_library.py index dd77a4b..7482456 100644 --- a/pyavcontrol/library/yaml_library.py +++ b/pyavcontrol/library/yaml_library.py @@ -55,53 +55,40 @@ def load_model(self, model_id: str) -> DeviceModel | None: model = DeviceModel(model_id, model_def) return model + def _all_library_yaml_files(self) -> list[str]: + yaml_files = [] + for path in self._dirs: + for root, dirs, filenames in os.walk(path): + for fn in filenames: + if fn.endswith('.yaml'): + yaml_files += os.path.join(root, fn) + return yaml_files + def supported_model_ids(self) -> frozenset[str]: if self._supported_model_ids: return self._supported_model_ids # build and cache the list of supported models based all the # yaml device definition files that are included in the library - supported_models = {} - for path in self._dirs: - for root, dirs, filenames in os.walk(path): - for fn in filenames: - if fn.endswith('.yaml'): - model_file = os.path.join(root, fn) - name = pathlib.Path(model_file).stem - supported_models[name] = model_file - - self._supported_model_ids = frozenset(supported_models.keys()) # immutable + model_ids = [] + for model_def_filename in self._all_library_yaml_files(): + model_ids += pathlib.Path(model_def_filename).stem + self._supported_model_ids = frozenset(model_ids) # immutable return self._supported_model_ids - def _read_model_names(self, filename: str) -> [str]: - y = _load_yaml_file(filename) - manufacturer = y.info.get('manufacturer', 'Unknown') - - model_names = [] - for model_name in y.info.get('models', []): - model_names.append(model_name) - if not model_names: - LOG.warning(f"{filename} does not specify any supported model names") - - return (manufacturer, model_names) - - def supported_models(self) -> frozenset[DeviceModelSummary]: if self._supported_models: return self._supported_models supported_models = [] - for path in self._dirs: - for root, dirs, filenames in os.walk(path): - for fn in filenames: - if fn.endswith('.yaml'): - model_file = os.path.join(root, fn) - model_id = pathlib.Path(model_file).stem - (manufacturer, model_names) = self._read_model_names(model_file) - for model_name in model_names: - supported_models += DeviceModelSummary(manufacturer, - model_name, - model_id) + for model_filename in self._all_library_yaml_files(): + y = _load_yaml_file(model_filename) + + model_id = pathlib.Path(model_filename).stem + manufacturer = y.info.get('manufacturer', 'Unknown') + + for model_name in y.info.get('models', []): + supported_models += DeviceModelSummary(manufacturer, model_name, model_id) self._supported_models = frozenset(supported_models) # immutable return self._supported_models @@ -111,26 +98,27 @@ class YAMLDeviceModelLibraryAsync(DeviceModelLibrary, ABC): """ Asynchronous implementation of DeviceModelLibrary - NOTE: For simplicity in initial implementation, skipped writing the - asynchronous library and use the sync version for now. Especially - since loading all the model files should be a rare occurrence). + NOTE: For simplicity in initial implementation, decided to skip writing + the asynchronous library and instead wrap the sync version for now. + Especially since loading all the model files should be a rare occurrence). """ - def __init__(self, library_dirs: List[str], event_loop): self._loop = event_loop self._dirs = library_dirs self._executor = ThreadPoolExecutor(max_workers=2) - - # FUTURE: implement any actual async library - self._sync = DeviceModelLibrarySync(library_dirs) + self._sync = YAMLDeviceModelLibrarySync(library_dirs) async def load_model(self, name: str) -> DeviceModel | None: return await self._loop.run_in_executor( self._executor, self._sync.load_model, name ) + async def supported_models(self) -> frozenset[str]: + return await self._loop.run_in_executor( + self._executor, self._sync.supported_models + ) - async def supported_model_names(self) -> frozenset[str]: + async def supported_model_ids(self) -> frozenset[str]: return await self._loop.run_in_executor( - self._executor, self._sync.supported_model_names + self._executor, self._sync.supported_model_ids ) diff --git a/pyproject.toml b/pyproject.toml index a83155b..fb5c9c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [project] -version = "0.1.2" # see below +version = "0.1.3" # see below requires-python = ">=3.10" [tool.poetry] name = "pyavcontrol" -version = "0.1.2" +version = "0.1.3" description = "Python Control of Audio/Visual Equipment (RS232/IP)" readme = "README.md" license = "LICENSE" From f6e6b0628f923947a284b82efe322423c204601b Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sat, 24 Feb 2024 22:11:48 -0800 Subject: [PATCH 071/162] misc cleanup --- pyavcontrol/library/yaml_library.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/pyavcontrol/library/yaml_library.py b/pyavcontrol/library/yaml_library.py index 7482456..64aefc3 100644 --- a/pyavcontrol/library/yaml_library.py +++ b/pyavcontrol/library/yaml_library.py @@ -4,15 +4,14 @@ import logging import os import pathlib -from abc import ABC, abstractmethod +from abc import ABC from concurrent.futures import ThreadPoolExecutor -from typing import List, Set +from typing import List import yaml from . import DeviceModelSummary from .. import DeviceModelLibrary -from ..const import DEFAULT_MODEL_LIBRARIES from .model import DeviceModel LOG = logging.getLogger(__name__) @@ -40,20 +39,14 @@ def __init__(self, library_dirs: List[str]): def load_model(self, model_id: str) -> DeviceModel | None: if '/' in model_id: LOG.error(f"Invalid model '{model_id}': cannot contain / in identifier") + return None - model_def = None for path in self._dirs: - model_file = f'{path}/{model_id}.yaml' - model_def = _load_yaml_file(model_file) - if model_def: - break - - if not model_def: - LOG.warning(f"Could not find model '{model_id}' in the library") - return None + if model_def := _load_yaml_file(f'{path}/{model_id}.yaml'): + return DeviceModel(model_id, model_def) - model = DeviceModel(model_id, model_def) - return model + LOG.warning(f"Could not find model '{model_id}' in the YAML library") + return None def _all_library_yaml_files(self) -> list[str]: yaml_files = [] From a454f6bea1fe4f9fd12043876ef4def1ce951c5d Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sat, 24 Feb 2024 23:03:25 -0800 Subject: [PATCH 072/162] * misc improvements --- pyavcontrol/library/yaml_library.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/pyavcontrol/library/yaml_library.py b/pyavcontrol/library/yaml_library.py index 64aefc3..8642090 100644 --- a/pyavcontrol/library/yaml_library.py +++ b/pyavcontrol/library/yaml_library.py @@ -14,6 +14,9 @@ from .. import DeviceModelLibrary from .model import DeviceModel +# TODO: investigate CUE (validation) or PKL as replacement/enhancements +# NOTE: DO NOT USE Pydantic since the validation mechanism should be cross-language + LOG = logging.getLogger(__name__) def _load_yaml_file(path: str) -> dict: @@ -66,7 +69,7 @@ def supported_model_ids(self) -> frozenset[str]: model_ids = [] for model_def_filename in self._all_library_yaml_files(): model_ids += pathlib.Path(model_def_filename).stem - self._supported_model_ids = frozenset(model_ids) # immutable + self._supported_model_ids = frozenset(model_ids) # immutable return self._supported_model_ids def supported_models(self) -> frozenset[DeviceModelSummary]: @@ -75,15 +78,14 @@ def supported_models(self) -> frozenset[DeviceModelSummary]: supported_models = [] for model_filename in self._all_library_yaml_files(): - y = _load_yaml_file(model_filename) - - model_id = pathlib.Path(model_filename).stem - manufacturer = y.info.get('manufacturer', 'Unknown') + if y := _load_yaml_file(model_filename): + model_id = pathlib.Path(model_filename).stem + manufacturer = y.info.get('manufacturer', 'Unknown') - for model_name in y.info.get('models', []): - supported_models += DeviceModelSummary(manufacturer, model_name, model_id) + for model_name in y.info.get('models', []): + supported_models += DeviceModelSummary(manufacturer, model_name, model_id) - self._supported_models = frozenset(supported_models) # immutable + self._supported_models = frozenset(supported_models) # immutable return self._supported_models @@ -93,7 +95,7 @@ class YAMLDeviceModelLibraryAsync(DeviceModelLibrary, ABC): NOTE: For simplicity in initial implementation, decided to skip writing the asynchronous library and instead wrap the sync version for now. - Especially since loading all the model files should be a rare occurrence). + Especially since loading all the model files should be a rare occurrence. """ def __init__(self, library_dirs: List[str], event_loop): self._loop = event_loop From a80099c7e52210e6791c44fd6d89251a394fd2a7 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 00:25:56 -0800 Subject: [PATCH 073/162] * added Teac TR-D2000, Xantech XDT, Elan Dual Tuner, Speakercraft STT 2.0 --- pyavcontrol/data/src/teac_trd2000.yaml | 132 +++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 pyavcontrol/data/src/teac_trd2000.yaml diff --git a/pyavcontrol/data/src/teac_trd2000.yaml b/pyavcontrol/data/src/teac_trd2000.yaml new file mode 100644 index 0000000..535b033 --- /dev/null +++ b/pyavcontrol/data/src/teac_trd2000.yaml @@ -0,0 +1,132 @@ +--- +id: teac_trd2000 +description: Teac TR-D2000, Xantech XDT Dual Tuner, Elan Dual Tuner, Speakercraft STT 2.0 Dual Tuners + +info: + manufacturer: Teac + models: + - Teac TR-D2000 + - Xantech XDT + - Elan Dual Tuner + - Speakercraft STT 2.0 + tested: false + urls: + - https://elektrotanya.com/teac_tr-d2000_sm.pdf + +connection: + rs232: + baudrate: 19200 + bytesize: 8 + parity: N + stopbits: 1 + timeout: 1.0 + +protocol: + command_eol: "\r" # CR Carriage Return + message_eol: "\r" + +vars: + tuner: + 1: "1" + 2: "2" + band: + 1: "AM" + 2: "FM" + preset: + min: 1 + max: 30 + +api: + power: + actions: + on: + cmd: + fstring: X1 + off: + cmd: + fstring: X0 + + tuner: + actions: + query: + cmd: + fstring: "Q{tuner}" + msg: + regex: 'T(?P\d)B(?P\d)P(?P\d+)F(?P\d+)S(?P\d)(?P\d)' + tests: + 'T1B2P00F1011S11': + tuner: 1 + band: 2 # FM + preset: 00 + frequency: 1011 + + signal_lock: 1 + stereo: 1 + 'T2B1P02F1011S10': + tuner: 2 + band: 1 # AM + preset: 2 + frequency: 1011 + signal_lock: 1 + stereo: 0 # mono + + preset_up: + cmd: + fstring: "T{tuner}N1" + + preset_down: + cmd: + fstring: "T{tuner}N0" + + + seek_up: + cmd: + fstring: "T{tuner}A1" + + seek_down: + cmd: + fstring: "T{tuner}A0" + + step_up: + cmd: + fstring: "T{tuner}M1" + + step_down: + cmd: + fstring: "T{tuner}M0" + + fm_preset: + cmd: + fstring: "T{tuner}B1P{preset}" + + am_preset: + cmd: + fstring: "T{tuner}B0P{preset}" + + set_band: + cmd: + fstring: "T{tuner}B{band}" + + set_band_fm: + cmd: + fstring: "T{tuner}B2" + + set_band_am: + cmd: + fstring: "T{tuner}B1" + + set_frequency: + cmd: + fstring: "T{tuner}F{frequency}" + + save_preset: + cmd: + fstring: "T{tuner}L{preset}" + + set_mono: + cmd: + fstring: "T{tuner}S10" + + set_stereo: + cmd: + fstring: "T{tuner}S11" \ No newline at end of file From 1245822c569bf7e25ed150602c47b3b284cff962 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 01:09:39 -0800 Subject: [PATCH 074/162] now generates SUPPORTED.md template --- build-supported-models-doc | 43 +++++++++++++++++------------------- pyavcontrol/library/base.py | 35 +++++++++++++++++++++++++++++ tests/test_device_library.py | 18 +++++++++++++++ 3 files changed, 73 insertions(+), 23 deletions(-) create mode 100644 pyavcontrol/library/base.py create mode 100644 tests/test_device_library.py diff --git a/build-supported-models-doc b/build-supported-models-doc index 86205cf..ba73a3b 100755 --- a/build-supported-models-doc +++ b/build-supported-models-doc @@ -11,22 +11,19 @@ from pyavcontrol import DeviceModelLibrary LOG = logging.getLogger(__name__) coloredlogs.install(level="DEBUG") -TEMPLATE = ''' +TEMPLATE = Template(''' ## Supported Equipment -*This is autogenerated from the series and protocol yaml definitions.* - -{% for manufacture in manufacture_models %} -### {{ manufacturer.name }} - -| Model(s) | Type | Tested | Notes | -| -------- | :-------: | :--------: | --------- | -{% for model in manufacturer.models %} -| {{ model.id }} | {{ model.type }} | {{ model.tested }} | {{ model.notes }} | -{% endfor %} +*This is autogenerated from pyavcontrol's DeviceModel library.* +{% set manufacturer = namespace(value='') %} +{% for model in models %}{% if model.manufacturer != manufacturer.value %}{% set manufacturer.value = model.manufacturer %} +### {{ model.manufacturer }} +| Model | Protocol | Tested | Notes | +| :------------- | :-------------: | :----: | :---------------- | +{% endif %}| {{ model.model_name }} | {{ model.model_id }} | model.tested | model.notes | {% endfor %} -''' +''') def parse_args(): p = arg.ArgumentParser(description="Generate SUPPORTED.md using current pyavcontrol model library") @@ -34,26 +31,26 @@ def parse_args(): "--output", default="SUPPORTED-generated.md", help="Markdown output file" ) p.add_argument( - "--library", help="library directory" + "--library", help="library directories" ) p.add_argument("-d", "--debug", action="store_true", help="verbose logging") return p.parse_args() def main(): args = parse_args() - - library = DeviceModelLibrary.create() - supported_models = library.supported_models() - for model_id in supported_models: - model_def = library.load_model(model_id) - info = model_def.info - print(f"{model_id}: {info}") + # if custom library directories have been specified, only output models found within + if args.library: + dirs = args.library.split(',') + library = DeviceModelLibrary.create(library_dirs=dirs) + else: + library = DeviceModelLibrary.create() - # FIXME: created sorted list of tuples for each manufacturer (and for manufacturers) + supported_models = library.supported_models() - supported_model_details = {} - print( template.render(manufacturer_models=supported_model_details) ) + # sort by manufacturer and model name + sorted_models = sorted(supported_models, key=lambda k: (k.manufacturer, k.model_name)) + print( TEMPLATE.render(models=sorted_models) ) if __name__ == "__main__": main() diff --git a/pyavcontrol/library/base.py b/pyavcontrol/library/base.py new file mode 100644 index 0000000..c9f663a --- /dev/null +++ b/pyavcontrol/library/base.py @@ -0,0 +1,35 @@ +from abc import abstractmethod, ABC +from dataclasses import dataclass + +from pyavcontrol.library.model import DeviceModel + +@dataclass +class DeviceModelSummary: + manufacturer: str + model_name: str + model_id: str + +class DeviceModelLibraryBase(ABC): + @abstractmethod + def load_model(self, name: str) -> DeviceModel | None: + """ + :param name: model id or a complete path to a file + """ + raise NotImplementedError('Subclass must implement!') + + @abstractmethod + def supported_model_ids(self) -> frozenset[str]: + """ + :return: all model ids supported by this library + """ + raise NotImplementedError('Subclass must implement!') + + @abstractmethod + def supported_models(self) -> frozenset[DeviceModelSummary]: + """ + NOTE: Subclasses may want to implement a more efficient mechanism than + reading all the individual model definition files. + + :return: dict of all manufacturer + model names -> model_ids (e.g. 'McIntosh MX160' -> mcintosh_mx160) + """ + raise NotImplementedError('Subclass must implement!') \ No newline at end of file diff --git a/tests/test_device_library.py b/tests/test_device_library.py new file mode 100644 index 0000000..14dc1f1 --- /dev/null +++ b/tests/test_device_library.py @@ -0,0 +1,18 @@ +import pytest + +from pyavcontrol import DeviceModelLibrary + +def test_default_library(): + library = DeviceModelLibrary.create() + assert library + assert len(library.supported_model_ids()) > 0 + assert len(library.supported_models()) > 0 + +def test_invalid_path(): + library = DeviceModelLibrary.create(library_dirs='/tmp/invalid_path_pyavcontrol') + assert library + assert len(library.supported_model_ids()) == 0 + assert len(library.supported_models()) == 0 + +if __name__ == "__main__": + test_invalid_path() \ No newline at end of file From 9f7956f089f0d5c6854572a22f9cdce5be63cec1 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 01:09:52 -0800 Subject: [PATCH 075/162] updated goal in README --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 78fdd47..af2aa34 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,12 @@ Two additional goals: 2. Create a basic IP-based RS232 emulator which allows spinning up a basic emulator for each supported device model based purely on the YAML definition and unit tests against those definitions. This emulator can be used by client libraries in any language for testing. See [avemu]() for more details. +## Goal + +One of the goals for creating this library is to reduce the amount of otherwise +great equipment being thrown away (especially esoteric equipment that isn't well +supported). Typically these can be modernized easily via wrapping their existing +protocols with modern integrations. ## Support From bea2da019b776282338e0a47c3c6006a7884bf7c Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 01:34:45 -0800 Subject: [PATCH 076/162] latest updates (does not work) --- .github/workflows/docs.yml | 28 +++++++++++++ build-supported-models-doc | 5 ++- docs/Makefile | 20 +++++++++ docs/source/conf.py | 29 +++++++++++++ docs/source/index.rst | 20 +++++++++ pyavcontrol/library/__init__.py | 63 ++++++++--------------------- pyavcontrol/library/yaml_library.py | 26 ++++++++---- 7 files changed, 135 insertions(+), 56 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/Makefile create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..e31165a --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,28 @@ +name: documentation + +on: [push, pull_request, workflow_dispatch] + +permissions: + contents: write + +jobs: + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v3 + - name: Install dependencies + run: | + pip install sphinx sphinx_rtd_theme myst_parser + - name: Sphinx build + run: | + sphinx-build docs/source docs/build + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + with: + publish_branch: gh-pages + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: docs/build/ + force_orphan: true + diff --git a/build-supported-models-doc b/build-supported-models-doc index ba73a3b..3f16bb4 100755 --- a/build-supported-models-doc +++ b/build-supported-models-doc @@ -19,8 +19,8 @@ TEMPLATE = Template(''' {% for model in models %}{% if model.manufacturer != manufacturer.value %}{% set manufacturer.value = model.manufacturer %} ### {{ model.manufacturer }} -| Model | Protocol | Tested | Notes | -| :------------- | :-------------: | :----: | :---------------- | +| Model | Protocol | Tested | Notes | +| :---------------- | :----------------: | :-----: | :---------------- | {% endif %}| {{ model.model_name }} | {{ model.model_id }} | model.tested | model.notes | {% endfor %} ''') @@ -50,6 +50,7 @@ def main(): # sort by manufacturer and model name sorted_models = sorted(supported_models, key=lambda k: (k.manufacturer, k.model_name)) + print( TEMPLATE.render(models=sorted_models) ) if __name__ == "__main__": diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..f3884f5 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,29 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'pyavcontrol' +copyright = '2024, Ryan Snodgrass' +author = 'Ryan Snodgrass' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [] + +templates_path = ['_templates'] +exclude_patterns = [] + + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +#html_theme = 'alabaster' +html_theme = 'sphinx_rtd_theme' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..10e4c56 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,20 @@ +.. pyavcontrol documentation master file, created by + sphinx-quickstart on Mon Feb 26 01:18:26 2024. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to pyavcontrol's documentation! +======================================= + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/pyavcontrol/library/__init__.py b/pyavcontrol/library/__init__.py index e892057..c81ef5f 100644 --- a/pyavcontrol/library/__init__.py +++ b/pyavcontrol/library/__init__.py @@ -1,42 +1,21 @@ import re -from abc import abstractmethod, ABC -from dataclasses import dataclass from pyavcontrol.const import DEFAULT_MODEL_LIBRARIES +from pyavcontrol.library.base import DeviceModelSummary from pyavcontrol.library.model import DeviceModel -from pyavcontrol.library.yaml_library import YAMLDeviceModelLibrarySync, YAMLDeviceModelLibraryAsync +def filter_models_by_regex(models: set[DeviceModelSummary], regex: str) -> set[DeviceModelSummary]: + """ + :return: dict of model summaries where the manufacturer or model name matches the + provided regular expression + """ + matches = set() + rg = re.compile(regex) + for summary in models: + if rg.match(summary.manufacturer) or rg.match(summary.model_name) or rg.match(summary.model_id): + matches += summary + return matches -@dataclass -class DeviceModelSummary: - manufacturer: str - model_name: str - model_id: str - -class DeviceModelLibrary(ABC): - @abstractmethod - def load_model(self, name: str) -> DeviceModel | None: - """ - :param name: model id or a complete path to a file - """ - raise NotImplementedError('Subclass must implement!') - - @abstractmethod - def supported_model_ids(self) -> frozenset[str]: - """ - :return: all model ids supported by this library - """ - raise NotImplementedError('Subclass must implement!') - - @abstractmethod - def supported_models(self) -> frozenset[DeviceModelSummary]: - """ - NOTE: Subclasses may want to implement a more efficient mechanism than - reading all the individual model definition files. - - :return: dict of all manufacturer + model names -> model_ids (e.g. 'McIntosh MX160' -> mcintosh_mx160) - """ - raise NotImplementedError('Subclass must implement!') - +class DeviceModelLibrary: @staticmethod def create(library_dirs=DEFAULT_MODEL_LIBRARIES, event_loop=None): """ @@ -56,18 +35,8 @@ def create(library_dirs=DEFAULT_MODEL_LIBRARIES, event_loop=None): # NOTE: This is currently hardcoded to the YAML style libraries. May want to explore converting # this to Apple PKL instead, since that is more in line of the spirit of what model definitions are. if event_loop: + from pyavcontrol.library.yaml_library import YAMLDeviceModelLibraryAsync return YAMLDeviceModelLibraryAsync(library_dirs, event_loop) - return YAMLDeviceModelLibrarySync(library_dirs) - -def filter_models_by_regex(models: set[DeviceModelSummary], regex: str) -> set[DeviceModelSummary]: - """ - :return: dict of model summaries where the manufacturer or model name matches the - provided regular expression - """ - matches = set() - rg = re.compile(regex) - for summary in models: - if rg.match(summary.manufacturer) or rg.match(summary.model_name) or rg.match(summary.model_id): - matches += summary - return matches \ No newline at end of file + from pyavcontrol.library.yaml_library import YAMLDeviceModelLibrarySync + return YAMLDeviceModelLibrarySync(library_dirs) \ No newline at end of file diff --git a/pyavcontrol/library/yaml_library.py b/pyavcontrol/library/yaml_library.py index 8642090..6306867 100644 --- a/pyavcontrol/library/yaml_library.py +++ b/pyavcontrol/library/yaml_library.py @@ -11,6 +11,7 @@ import yaml from . import DeviceModelSummary +from .base import DeviceModelLibraryBase from .. import DeviceModelLibrary from .model import DeviceModel @@ -29,7 +30,7 @@ def _load_yaml_file(path: str) -> dict: return {} -class YAMLDeviceModelLibrarySync(DeviceModelLibrary, ABC): +class YAMLDeviceModelLibrarySync(DeviceModelLibraryBase, ABC): """ Synchronous implementation of YAML DeviceModelLibrary """ @@ -54,10 +55,12 @@ def load_model(self, model_id: str) -> DeviceModel | None: def _all_library_yaml_files(self) -> list[str]: yaml_files = [] for path in self._dirs: + LOG.info(f"Looking for YAML model defs in {path}") for root, dirs, filenames in os.walk(path): + LOG.info(f"Looking for YAML model defs in {root} {dirs}") for fn in filenames: if fn.endswith('.yaml'): - yaml_files += os.path.join(root, fn) + yaml_files.append(os.path.join(root, fn)) return yaml_files def supported_model_ids(self) -> frozenset[str]: @@ -77,19 +80,28 @@ def supported_models(self) -> frozenset[DeviceModelSummary]: return self._supported_models supported_models = [] + all_files = self._all_library_yaml_files() + for model_filename in self._all_library_yaml_files(): + print(model_filename) if y := _load_yaml_file(model_filename): model_id = pathlib.Path(model_filename).stem - manufacturer = y.info.get('manufacturer', 'Unknown') + if 'info' not in y: + LOG.error(f'Invalid file {model_filename} without info field!') + continue + + info = y['info'] + manufacturer = info.get('manufacturer', 'Unknown') - for model_name in y.info.get('models', []): - supported_models += DeviceModelSummary(manufacturer, model_name, model_id) + for model_name in info.get('models', []): + print({model_name}) + supported_models.append(DeviceModelSummary(manufacturer, model_name, model_id)) - self._supported_models = frozenset(supported_models) # immutable + self._supported_models = supported_models # frozenset(supported_models) # immutable return self._supported_models -class YAMLDeviceModelLibraryAsync(DeviceModelLibrary, ABC): +class YAMLDeviceModelLibraryAsync(DeviceModelLibraryBase, ABC): """ Asynchronous implementation of DeviceModelLibrary From 761e8b3698cce3754441c13c94cf923f421c8f1e Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 01:36:55 -0800 Subject: [PATCH 077/162] update --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index af2aa34..90102ba 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,10 @@ definition-only package(s) in the future. ## Using pyavcontrol +### Documentation + +See [API documentation](https://rsnodgrass.github.io/pyavcontrol/). + ### Asynchronous & Synchronous APIs This library provides both an `asyncio` based and synchronous implementations. From 3896c83e2fe7d551f647b4ef46ad96b3c63e178e Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 01:44:23 -0800 Subject: [PATCH 078/162] update --- docs/source/conf.py | 4 ++++ docs/source/index.rst | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index f3884f5..26f3f02 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -3,6 +3,10 @@ # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html +import os +import sys +sys.path.insert(0, os.path.abspath(os.path.join('..', '..', 'pyavcontrol'))) + # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information diff --git a/docs/source/index.rst b/docs/source/index.rst index 10e4c56..c9f29e9 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -3,7 +3,7 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to pyavcontrol's documentation! +Welcome to pyavcontrol docs! ======================================= .. toctree:: From 24177f655140d81dad63e5c3199c8d6afe722529 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 01:50:13 -0800 Subject: [PATCH 079/162] update --- docs/source/conf.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 26f3f02..27363c1 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -11,20 +11,19 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = 'pyavcontrol' -copyright = '2024, Ryan Snodgrass' +copyright = '2024 Ryan Snodgrass' author = 'Ryan Snodgrass' release = '0.0.1' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -extensions = [] +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon'] templates_path = ['_templates'] exclude_patterns = [] - # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output From 631bbf595064f7521a5fc00c836eea564f52ee7c Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 01:55:52 -0800 Subject: [PATCH 080/162] update --- docs/source/conf.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 27363c1..ce103c2 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -17,7 +17,10 @@ # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration - +# +# NOTE: Use Google Docstring format using the sphinx.ext.napolean +# extension, since Google Docstring is a way more readable format +# than the default Sphinx format. extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon'] templates_path = ['_templates'] @@ -27,6 +30,5 @@ # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -#html_theme = 'alabaster' html_theme = 'sphinx_rtd_theme' html_static_path = ['_static'] From ffd3dfbd5f3f6b7268a8b3eac1c5283418d39782 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 01:57:58 -0800 Subject: [PATCH 081/162] update --- pyavcontrol/library/__init__.py | 8 ++++++-- pyavcontrol/library/model.py | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/pyavcontrol/library/__init__.py b/pyavcontrol/library/__init__.py index c81ef5f..97a8d81 100644 --- a/pyavcontrol/library/__init__.py +++ b/pyavcontrol/library/__init__.py @@ -5,8 +5,12 @@ def filter_models_by_regex(models: set[DeviceModelSummary], regex: str) -> set[DeviceModelSummary]: """ - :return: dict of model summaries where the manufacturer or model name matches the - provided regular expression + Filter the provided set of DeviceModelSummary down into only the ones that + match the given regular expression. + + Returns: + dict of model summaries where the manufacturer or model name matches the + provided regular expression. """ matches = set() rg = re.compile(regex) diff --git a/pyavcontrol/library/model.py b/pyavcontrol/library/model.py index 80db7a6..dace536 100644 --- a/pyavcontrol/library/model.py +++ b/pyavcontrol/library/model.py @@ -41,7 +41,7 @@ def definition(self) -> dict: def validate(self) -> bool: """ Validate the device model data structure using pydantic (allows multiple physical - representations such as YAML/JSON/etc to be read in in the future). + representations such as YAML/JSON/etc to be read in the future). """ if not DeviceModel.validate_model_definition(self._definition): LOG.warning(f'Error in model {self._model_id} definition') @@ -53,6 +53,9 @@ def validate(self) -> bool: def validate_model_definition(model_def: dict) -> bool: """ Validate that the given device model definition is valid + + Args: + model_def (dict) : model definition to validate """ if not model_def: return False From a3f2ad887e22e5e10cf50245f937f72acb36a567 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 02:20:54 -0800 Subject: [PATCH 082/162] update --- docs/source/conf.py | 3 +-- docs/source/index.rst | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index ce103c2..06d68d8 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -5,7 +5,7 @@ import os import sys -sys.path.insert(0, os.path.abspath(os.path.join('..', '..', 'pyavcontrol'))) +sys.path.insert(0, os.path.abspath(os.path.join('..', '..'))) # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information @@ -26,7 +26,6 @@ templates_path = ['_templates'] exclude_patterns = [] - # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output diff --git a/docs/source/index.rst b/docs/source/index.rst index c9f29e9..990dfe1 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -10,7 +10,7 @@ Welcome to pyavcontrol docs! :maxdepth: 2 :caption: Contents: - + modules Indices and tables ================== From 4c6a75903f6404760ebb7fc9e88a7d2cb8fe4292 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 02:20:54 -0800 Subject: [PATCH 083/162] update --- .github/workflows/docs.yml | 1 - pyavcontrol/connection/__init__.py | 2 +- pyavcontrol/library/base.py | 2 +- pyproject.toml | 4 ++-- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e31165a..e126b0f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -25,4 +25,3 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: docs/build/ force_orphan: true - diff --git a/pyavcontrol/connection/__init__.py b/pyavcontrol/connection/__init__.py index 441aeb3..86df9ef 100644 --- a/pyavcontrol/connection/__init__.py +++ b/pyavcontrol/connection/__init__.py @@ -54,7 +54,7 @@ class Connection: @staticmethod def create( url: str, connection_config=None, event_loop=None - ) -> DeviceConnection | None: + ) -> DeviceConnection: # FIXME: | None: """ Create a Connection instance given details about the given device. diff --git a/pyavcontrol/library/base.py b/pyavcontrol/library/base.py index c9f663a..88856df 100644 --- a/pyavcontrol/library/base.py +++ b/pyavcontrol/library/base.py @@ -11,7 +11,7 @@ class DeviceModelSummary: class DeviceModelLibraryBase(ABC): @abstractmethod - def load_model(self, name: str) -> DeviceModel | None: + def load_model(self, name: str) -> DeviceModel: # FIXME | None: """ :param name: model id or a complete path to a file """ diff --git a/pyproject.toml b/pyproject.toml index fb5c9c5..03eb380 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [project] -version = "0.1.3" # see below +version = "0.1.4" # see below requires-python = ">=3.10" [tool.poetry] name = "pyavcontrol" -version = "0.1.3" +version = "0.1.4" description = "Python Control of Audio/Visual Equipment (RS232/IP)" readme = "README.md" license = "LICENSE" From 6e781f98d212cad5d1692cf305c7773daac8cd9b Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 02:31:49 -0800 Subject: [PATCH 084/162] update --- docs/source/conf.py | 5 +- docs/source/index.rst | 3 +- docs/source/pyavcontrol.rst | 19 +++++++ docs/source/supported.md | 109 ++++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 docs/source/pyavcontrol.rst create mode 100644 docs/source/supported.md diff --git a/docs/source/conf.py b/docs/source/conf.py index 06d68d8..c15cccf 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -21,7 +21,10 @@ # NOTE: Use Google Docstring format using the sphinx.ext.napolean # extension, since Google Docstring is a way more readable format # than the default Sphinx format. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon'] +# +# myst_parser = Markdown support (instead of RST) +# see https://myst-parser.readthedocs.io/en/latest/syntax/optional.html +extensions = ['myst_parser', 'sphinx.ext.autodoc', 'sphinx.ext.napoleon'] templates_path = ['_templates'] exclude_patterns = [] diff --git a/docs/source/index.rst b/docs/source/index.rst index 990dfe1..5e869f2 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -10,7 +10,8 @@ Welcome to pyavcontrol docs! :maxdepth: 2 :caption: Contents: - modules + pyavcontrol + supported Indices and tables ================== diff --git a/docs/source/pyavcontrol.rst b/docs/source/pyavcontrol.rst new file mode 100644 index 0000000..c783898 --- /dev/null +++ b/docs/source/pyavcontrol.rst @@ -0,0 +1,19 @@ +pyavcontrol package +=================== + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + +Submodules +---------- + +Module contents +--------------- + +.. automodule:: pyavcontrol + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/supported.md b/docs/source/supported.md new file mode 100644 index 0000000..1dd644a --- /dev/null +++ b/docs/source/supported.md @@ -0,0 +1,109 @@ +## Supported Equipment + +*This is autogenerated from pyavcontrol's DeviceModel library.* + + +### Acurus + +| Model | Protocol | Tested | Notes | +| :---------------- | :----------------: | :-----: | :---------------- | +| M8 | acurus_m8 | model.tested | model.notes | + +### Anthem + +| Model | Protocol | Tested | Notes | +| :---------------- | :----------------: | :-----: | :---------------- | +| Statement D2 | anthem_d2v | model.tested | model.notes | +| Statement D2v | anthem_d2v | model.tested | model.notes | +| Statement D2v 3D | anthem_d2v | model.tested | model.notes | + +### ClassÊ Audio + +| Model | Protocol | Tested | Notes | +| :---------------- | :----------------: | :-----: | :---------------- | +| Omicron | classe_omicron | model.tested | model.notes | +| SSP-300 | classe_ssp600 | model.tested | model.notes | +| SSP-600 | classe_ssp600 | model.tested | model.notes | + +### HDFury + +| Model | Protocol | Tested | Notes | +| :---------------- | :----------------: | :-----: | :---------------- | +| VRROOM | hdfury_vrroom | model.tested | model.notes | + +### JBL Synthesis + +| Model | Protocol | Tested | Notes | +| :---------------- | :----------------: | :-----: | :---------------- | +| SDP-75 | jbl_sdp75 | model.tested | model.notes | + +### Lyngdorf + +| Model | Protocol | Tested | Notes | +| :---------------- | :----------------: | :-----: | :---------------- | +| CD-2 | lyngdorf_cd2 | model.tested | model.notes | +| MP-60 | lyngdorf_mp60 | model.tested | model.notes | + +### Marantz + +| Model | Protocol | Tested | Notes | +| :---------------- | :----------------: | :-----: | :---------------- | +| AV8805 | marantz_av8805 | model.tested | model.notes | + +### McIntosh + +| Model | Protocol | Tested | Notes | +| :---------------- | :----------------: | :-----: | :---------------- | +| MHT100 | mcintosh_legacy | model.tested | model.notes | +| MHT200 | mcintosh_legacy | model.tested | model.notes | +| MX118 | mcintosh_legacy | model.tested | model.notes | +| MX119 | mcintosh_legacy | model.tested | model.notes | +| MX120 | mcintosh_legacy | model.tested | model.notes | +| MX121 | mcintosh_legacy | model.tested | model.notes | +| MX122 | mcintosh_legacy | model.tested | model.notes | +| MX123 | mcintosh_legacy | model.tested | model.notes | +| MX130 | mcintosh_legacy | model.tested | model.notes | +| MX132 | mcintosh_legacy | model.tested | model.notes | +| MX134 | mcintosh_legacy | model.tested | model.notes | +| MX135 | mcintosh_legacy | model.tested | model.notes | +| MX136 | mcintosh_legacy | model.tested | model.notes | +| MX150 | mcintosh_legacy | model.tested | model.notes | +| MX151 | mcintosh_legacy | model.tested | model.notes | +| MX170 | mcintosh_mx170 | model.tested | model.notes | +| MX180 | mcintosh_mx180 | model.tested | model.notes | + +### Teac + +| Model | Protocol | Tested | Notes | +| :---------------- | :----------------: | :-----: | :---------------- | +| Elan Dual Tuner | teac_trd2000 | model.tested | model.notes | +| Speakercraft STT 2.0 | teac_trd2000 | model.tested | model.notes | +| Teac TR-D2000 | teac_trd2000 | model.tested | model.notes | +| Xantech XDT | teac_trd2000 | model.tested | model.notes | + +### Trinnov + +| Model | Protocol | Tested | Notes | +| :---------------- | :----------------: | :-----: | :---------------- | +| Altitude16 | trinnov_altitude16 | model.tested | model.notes | +| Altitude32 | trinnov_altitude32 | model.tested | model.notes | + +### Unknown + +| Model | Protocol | Tested | Notes | +| :---------------- | :----------------: | :-----: | :---------------- | +| MRAUDIO8X8 | xantech_mx88_audio | model.tested | model.notes | +| MRAUDIO8X8m | xantech_mx88_audio | model.tested | model.notes | +| MX160 | mcintosh_mx160 | model.tested | model.notes | +| MX88a | xantech_mx88_audio | model.tested | model.notes | +| MX88ai | xantech_mx88_audio | model.tested | model.notes | + +### Xantech + +| Model | Protocol | Tested | Notes | +| :---------------- | :----------------: | :-----: | :---------------- | +| CM8X8 | xantech_mx88_video | model.tested | model.notes | +| CM8X8DR | xantech_mx88_video | model.tested | model.notes | +| MRC88 | xantech_mx88_video | model.tested | model.notes | +| MRC88m | xantech_mx88_video | model.tested | model.notes | +| MX88 | xantech_mx88_video | model.tested | model.notes | From b5178ce6b470f2beab609350d268f2e6c86fe51e Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 02:31:49 -0800 Subject: [PATCH 085/162] update --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e126b0f..9f10a68 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -14,7 +14,7 @@ jobs: - name: Install dependencies run: | pip install sphinx sphinx_rtd_theme myst_parser - - name: Sphinx build +y - name: Sphinx build run: | sphinx-build docs/source docs/build - name: Deploy to GitHub Pages From a600eb2a468b8697e608154be956cf991d039461 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 02:37:10 -0800 Subject: [PATCH 086/162] update --- build-supported-models-doc | 10 +++++++--- docs/source/supported.md | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/build-supported-models-doc b/build-supported-models-doc index 3f16bb4..2c2658d 100755 --- a/build-supported-models-doc +++ b/build-supported-models-doc @@ -3,14 +3,16 @@ import logging import coloredlogs -from jinja2 import Template import argparse as arg +from jinja2 import Template from pyavcontrol import DeviceModelLibrary LOG = logging.getLogger(__name__) coloredlogs.install(level="DEBUG") +DEFAULT_FILE='docs/source/supported.md' + TEMPLATE = Template(''' ## Supported Equipment @@ -28,7 +30,7 @@ TEMPLATE = Template(''' def parse_args(): p = arg.ArgumentParser(description="Generate SUPPORTED.md using current pyavcontrol model library") p.add_argument( - "--output", default="SUPPORTED-generated.md", help="Markdown output file" + "--output", default=DEFAULT_FILE, help=f"Markdown output file (default={DEFAULT_FILE})" ) p.add_argument( "--library", help="library directories" @@ -51,7 +53,9 @@ def main(): # sort by manufacturer and model name sorted_models = sorted(supported_models, key=lambda k: (k.manufacturer, k.model_name)) - print( TEMPLATE.render(models=sorted_models) ) + LOG.info(f"Saving the output to {args.output}") + with open(args.output , "w") as f: + f.write(TEMPLATE.render(models=sorted_models)) if __name__ == "__main__": main() diff --git a/docs/source/supported.md b/docs/source/supported.md index 1dd644a..ecb2c13 100644 --- a/docs/source/supported.md +++ b/docs/source/supported.md @@ -1,3 +1,4 @@ + ## Supported Equipment *This is autogenerated from pyavcontrol's DeviceModel library.* From 3fa1d704ae2628f555e8836ef6bbfe12162ea35b Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 02:55:15 -0800 Subject: [PATCH 087/162] update --- .../generate-supported-omodels-doc | 0 docs/source/hardware.md | 28 +++++++++++++++++++ docs/source/index.rst | 1 + docs/source/supported.md | 2 +- 4 files changed, 30 insertions(+), 1 deletion(-) rename build-supported-models-doc => docs/generate-supported-omodels-doc (100%) create mode 100644 docs/source/hardware.md diff --git a/build-supported-models-doc b/docs/generate-supported-omodels-doc similarity index 100% rename from build-supported-models-doc rename to docs/generate-supported-omodels-doc diff --git a/docs/source/hardware.md b/docs/source/hardware.md new file mode 100644 index 0000000..7fbfaea --- /dev/null +++ b/docs/source/hardware.md @@ -0,0 +1,28 @@ +# Hardware Specific Details + +## Xantech + +### High-Density RS232 Control Cable (Xantech Part 05913665) + +Some Xantech MX88/MX88ai models use high-density HD15 (or DE15) connectors for rear COM ports, thus requiring Xantech's "DB15 to DB9" adapter cable (PN 05913665). The front DB9 RS232 and USB COM ports cannot be used for device control on these models. Instead, use the rear COM ports which are already wired as a 'null modem' connection, so no use of null modem cable is required as the Transmit and Receive lines have already been interchanged. + +Thanks to @skavan for figuring out the pinouts for the discontinued RS232 Control DB15 cable (PN 05913665) with incorrect pinouts listed in the Xantech manual. The following are the correct pinouts: + +| HDB15 Male | Function | DB9 Female | DB9 Color | Function | Notes | +|:----------:|:--------:|:----------:| --------- | -------- | ----- | +| 13 | Tx | 2 | Brown | Rx | | +| 12 | Rx | 3 | White | Tx | | +| 4 | DSR | 4 | Green | DTR | | +| 6 | DTR | 6 | Red | DSR | | +| 9 | GND | 5 | Yellow | GND | Ground (see also pin 11) | +| 11 | GND | 5 | Yellow | GND | Ground (OPTIONAL) | + +Example parts needed to build a custom Xantech MX88 style cable: + +* [USB to DB9 RS232 Cable](https://amzn.com/dp/B0753HBT12?tag=carreramfi-20&tracking_id=carreramfi-20) or [IP/Ethernet to DB9 Adapter](https://amzn.com/dp/B0B8T95FV1?tag=carreramfi-20&tracking_id=carreramfi-20) or [Virtual IP2SL](https://github.com/rsnodgrass/virtual-ip2sl) + +* [DB9 Female Connector with wires](https://amzn.com/dp/B0BG2BPVXV?tag=carreramfi-20&tracking_id=carreramfi-20) or [DB9 Female Connector](https://amzn.com/dp/B09L7K511Y?tag=carreramfi-20&tracking_id=carreramfi-20) + +* [Xantech Male DB15 Connector](https://amzn.com/dp/B07P6R8DRJ?tag=carreramfi-20&tracking_id=carreramfi-20) + +For help, [ask questions on the support forum](https://community.home-assistant.io/t/xantech-dayton-audio-sonance-multi-zone-amps/450908/80) diff --git a/docs/source/index.rst b/docs/source/index.rst index 5e869f2..7620b8a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,6 +12,7 @@ Welcome to pyavcontrol docs! pyavcontrol supported + hardware Indices and tables ================== diff --git a/docs/source/supported.md b/docs/source/supported.md index ecb2c13..f624bf1 100644 --- a/docs/source/supported.md +++ b/docs/source/supported.md @@ -1,7 +1,7 @@ ## Supported Equipment -*This is autogenerated from pyavcontrol's DeviceModel library.* +*This is autogenerated from pyavcontrol's DeviceModel library (using build-supported-models-doc).* ### Acurus From d832046cb960eede101a5c7d600b1c244b2685b4 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 02:55:15 -0800 Subject: [PATCH 088/162] update --- docs/Makefile | 1 + docs/generate-supported-omodels-doc | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/Makefile b/docs/Makefile index d0c3cbf..678b812 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -17,4 +17,5 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile +# ./generate-supported-models-docs @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/generate-supported-omodels-doc b/docs/generate-supported-omodels-doc index 2c2658d..1d1214a 100755 --- a/docs/generate-supported-omodels-doc +++ b/docs/generate-supported-omodels-doc @@ -1,7 +1,10 @@ #!/usr/bin/env python3 - import logging +import os +import sys +sys.path.insert(0, os.path.abspath(os.path.join('..'))) + import coloredlogs import argparse as arg from jinja2 import Template @@ -11,12 +14,12 @@ from pyavcontrol import DeviceModelLibrary LOG = logging.getLogger(__name__) coloredlogs.install(level="DEBUG") -DEFAULT_FILE='docs/source/supported.md' +DEFAULT_FILE='source/supported.md' TEMPLATE = Template(''' ## Supported Equipment -*This is autogenerated from pyavcontrol's DeviceModel library.* +*This is autogenerated from pyavcontrol's DeviceModel library (using build-supported-models-doc).* {% set manufacturer = namespace(value='') %} {% for model in models %}{% if model.manufacturer != manufacturer.value %}{% set manufacturer.value = model.manufacturer %} ### {{ model.manufacturer }} From 4b472382c9b210bc84420313376466097277b1fa Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 02:56:28 -0800 Subject: [PATCH 089/162] update --- docs/Makefile | 2 +- ...rate-supported-omodels-doc => generate-supported-models-doc} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename docs/{generate-supported-omodels-doc => generate-supported-models-doc} (100%) diff --git a/docs/Makefile b/docs/Makefile index 678b812..c652496 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -17,5 +17,5 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile -# ./generate-supported-models-docs + ./generate-supported-models-doc @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/generate-supported-omodels-doc b/docs/generate-supported-models-doc similarity index 100% rename from docs/generate-supported-omodels-doc rename to docs/generate-supported-models-doc From 5c5627162a8cc4d94a86b43679743ecd0fc77164 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 02:57:27 -0800 Subject: [PATCH 090/162] update --- docs/source/hardware.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/source/hardware.md b/docs/source/hardware.md index 7fbfaea..db0b851 100644 --- a/docs/source/hardware.md +++ b/docs/source/hardware.md @@ -6,7 +6,7 @@ Some Xantech MX88/MX88ai models use high-density HD15 (or DE15) connectors for rear COM ports, thus requiring Xantech's "DB15 to DB9" adapter cable (PN 05913665). The front DB9 RS232 and USB COM ports cannot be used for device control on these models. Instead, use the rear COM ports which are already wired as a 'null modem' connection, so no use of null modem cable is required as the Transmit and Receive lines have already been interchanged. -Thanks to @skavan for figuring out the pinouts for the discontinued RS232 Control DB15 cable (PN 05913665) with incorrect pinouts listed in the Xantech manual. The following are the correct pinouts: +Thanks to [@skavan](https://community.home-assistant.io/t/xantech-dayton-audio-sonance-multi-zone-amps/450908/80) for figuring out the pinouts for the discontinued RS232 Control DB15 cable (PN 05913665) with incorrect pinouts listed in the Xantech manual. The following are the correct pinouts: | HDB15 Male | Function | DB9 Female | DB9 Color | Function | Notes | |:----------:|:--------:|:----------:| --------- | -------- | ----- | @@ -24,5 +24,3 @@ Example parts needed to build a custom Xantech MX88 style cable: * [DB9 Female Connector with wires](https://amzn.com/dp/B0BG2BPVXV?tag=carreramfi-20&tracking_id=carreramfi-20) or [DB9 Female Connector](https://amzn.com/dp/B09L7K511Y?tag=carreramfi-20&tracking_id=carreramfi-20) * [Xantech Male DB15 Connector](https://amzn.com/dp/B07P6R8DRJ?tag=carreramfi-20&tracking_id=carreramfi-20) - -For help, [ask questions on the support forum](https://community.home-assistant.io/t/xantech-dayton-audio-sonance-multi-zone-amps/450908/80) From 39f47fa8e9d0467380f721da4fcb04379e82abde Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 02:59:00 -0800 Subject: [PATCH 091/162] update --- .github/workflows/docs.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 9f10a68..4763861 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -14,9 +14,10 @@ jobs: - name: Install dependencies run: | pip install sphinx sphinx_rtd_theme myst_parser -y - name: Sphinx build + - name: Sphinx build + working-directory: ./docs run: | - sphinx-build docs/source docs/build + make html - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v3 if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} From 23ce153b86311da38eb6d9e50cc7f6867a90e470 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 03:00:37 -0800 Subject: [PATCH 092/162] update --- docs/source/hardware.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/source/hardware.md b/docs/source/hardware.md index db0b851..82c87a3 100644 --- a/docs/source/hardware.md +++ b/docs/source/hardware.md @@ -1,5 +1,11 @@ # Hardware Specific Details +## Recommended RS232 Parts + +* [USB to DB9 RS232 Cable](https://amzn.com/dp/B0753HBT12?tag=carreramfi-20&tracking_id=carreramfi-20) +* [IP/Ethernet to DB9 Adapter](https://amzn.com/dp/B0B8T95FV1?tag=carreramfi-20&tracking_id=carreramfi-20) +* [Virtual IP2SL](https://github.com/rsnodgrass/virtual-ip2sl) + ## Xantech ### High-Density RS232 Control Cable (Xantech Part 05913665) From df5b43dff73aab294557848aa3753ccbd97a39e1 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 03:01:46 -0800 Subject: [PATCH 093/162] update --- docs/source/hardware.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/source/hardware.md b/docs/source/hardware.md index 82c87a3..4c832d7 100644 --- a/docs/source/hardware.md +++ b/docs/source/hardware.md @@ -1,6 +1,8 @@ # Hardware Specific Details -## Recommended RS232 Parts +## Useful RS232 Parts + +The following are various useful parts, hardware, and software for interfacing with various equipment using this library. * [USB to DB9 RS232 Cable](https://amzn.com/dp/B0753HBT12?tag=carreramfi-20&tracking_id=carreramfi-20) * [IP/Ethernet to DB9 Adapter](https://amzn.com/dp/B0B8T95FV1?tag=carreramfi-20&tracking_id=carreramfi-20) From f0c1cf268a622fb02346a4b742cb07f4ced9e3cb Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 12:27:33 -0800 Subject: [PATCH 094/162] update --- LICENSE | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/LICENSE b/LICENSE index 02dd451..58f98c7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,5 @@ -MIT License +THIS IS DUAL LICENSED! -Copyright (c) 2023 Ryan Snodgrass +Commerical uses must pay for license. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +Open Source license forthcoming. From d93eb4b37751d9f7d42b0aed0d37c4375f177f6f Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 12:27:41 -0800 Subject: [PATCH 095/162] update --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 90102ba..d7af796 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ ![beta_badge](https://img.shields.io/badge/maturity-Beta-yellow.png) [![PyPi](https://img.shields.io/pypi/v/pyavcontrol.svg)](https://pypi.python.org/pypi/pyavcontrol) -[![MIT license](http://img.shields.io/badge/license-MIT-brightgreen.svg)](http://opensource.org/licenses/MIT) [![Build Status](https://github.com/rsnodgrass/pyavcontrol/actions/workflows/ci.yml/badge.svg)](https://github.com/rsnodgrass/pyavcontrol/actions/workflows/ci.yml) [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=WREP29UDAMB6G) From d2135bc8797c4eb252a7bd447ec8b0b5eba40693 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 19:33:16 -0800 Subject: [PATCH 096/162] update --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d7af796..23b999e 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,8 @@ Example URL formats supported by pyserial: - Add programmatic override/enhancements to the base protocol where pure YAML configuration would not work fully. Of course, these overrides would have to be implemented in each language, but that surface area should be much smaller. +- Move to a modern schema/config language for the library (Nickel, PKL, etc) +- Split out the library definitions from the library itself (eventually) so other language clients can share ## See Also From 756f2d98d20a6e3597d127443f2b4a294a60d809 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 19:40:40 -0800 Subject: [PATCH 097/162] make sure docs generator installs required dev packages --- docs/Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/Makefile b/docs/Makefile index c652496..e2b82a4 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -17,5 +17,6 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile + pip3 install -r ../requirements-dev.txt ./generate-supported-models-doc @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) From e08b1ff6aaf42db319207e03078cf80f3adaa7ba Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 22:12:17 -0800 Subject: [PATCH 098/162] update --- pyavcontrol/data/future/sunfire_tgiii.yaml | 67 ++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 pyavcontrol/data/future/sunfire_tgiii.yaml diff --git a/pyavcontrol/data/future/sunfire_tgiii.yaml b/pyavcontrol/data/future/sunfire_tgiii.yaml new file mode 100644 index 0000000..df65062 --- /dev/null +++ b/pyavcontrol/data/future/sunfire_tgiii.yaml @@ -0,0 +1,67 @@ +Sunfire Theater Grand III +Sunfire Theater Grand IV + + + +The RS-232 PortThe TGIII has a rear panelRS-232 Serial communication port.This allows the FLASH memory tobe upgraded to the latest software byconnecting to a PC.The TGIII software may be updatedto reīŦ ne operational details and toinclude new features. Downloadableupdates will be posted on our website:www.sunīŦ re.com.CommunicationsSerial RS-232, 9600 Baud, 8-N-1DB-9 WiringPINS 1, 6 and 4 are joined togetherin ter nal lyPINS 7 and 8 are joined togetherin ter nal lyPIN 2- Data from processor to con-troller (processor transmit)PIN 3- Data from controller to pro-cessor (processor receive)PIN 5- Ground/CommonPIN 9- No connectionThe RS-232 connector is female.Serial CableTo connect the TGIII port to acomputer, you will need a "straight-through" serial cable. This has con-nector pins at one end connecteddirectly to the pins of the connector atthe other end. For example, pin 1 atone end connects to pin 1 at the otherend, pin 2 connects to pin 2, pin 3 topin 3 and so on.These common cables are avail-able from most computer stores (orfrom Radio Shack as # 26-117). Itshould be 9-pin male at one end, toīŦ t into the TGIII and normally 9-pinfemale at the other, to īŦ t into yourcomputer's serial port (COM1 orCOM2).User's ManualUpdate Procedure1. The current version level of thesoftware running your TGIIIcan be found by looking at theVersion Level OSD menu. Thisis under the Software OSDmenu (see page 37).2. If the website īŦ le is newer thanyour current version, follow thewebsite directions and down-load the new īŦ le onto yourcomputer's hard drive.3. Record your calibration, presetstations or other settings onpage 57. In most cases, theupgrade will not affect any ofthese settings, but it is good torecord them just in case.4. Turn off your computer andthe TGIII. Position them closeenough so that they can beeasily connected using yourserial cable. If you have a lap-top computer, then it may beeasier to bring that close to theTGIII. Otherwise, you need todisconnect the TGIII and moveit close to your computer.5. Connect the TGIII RS-232 portto the corresponding serial porton your computer.6. Turn on the TGIII and yourcomputer.7. Find the īŦ le you downloaded instep 2, and run the program.8. In AUTO mode, the softwarewill look for an active serialconnection and upload the newīŦ le. The TGIII display will showthe status.9. When the īŦ le transfer is com-plete, press the Power switchon the TGIII front panel. Thiscompletes the upgrade.10. Turn off your computer and theTGIII and disconnect the serialcable.APPENDIXExternal ControlThe RS-232 port also allows theTGIII to be controlled externally byHome Theater controllers and com-put ers.The following information is forprogrammers and developers:Partial Serial command setNote that all stan dard com mandsand ex tend ed data are echoed back tothe sender. When a change is madelocally, the data is broad cast, exceptfor the case of "Toggle" and volumecommands. Here is a list of the mostpopular commands. (Contact SunīŦ reTechnical Support, or our websitewww.sunīŦ re.com for a more extensivelist of commands).COMMANDASCII DATA RECEIVEDPOWER TOGGLE*111POWER ON*112POWER OFF*113CD*114TAPE*115SAT*116DVD*117PHONO*118TUNER*119VID1*11AVCR*11BVID2*11CDSP MODE UP*11DDSP MODE DOWN*13WSTEREO*11EPRO LOGIC*11FPARTY*134NEO:613HSOURCEDIRECT13JJAZZ-CLUB*11KHOLO TOGGLE*11LHOLO ON*11MHOLO OFF*11NMUTE TOGGLE*11PMUTE ON*11QMUTE OFF*11RVOLUME UP*11SVOLUME DOWN*11TVOL ABSOLUTE*11U + 2 EXT*11U00 = zero vol*11U99 = max volZONE2 PWR TOGGLE*13MZONE2 PWR ON*13NZONE2 PWR OFF*13PZONE2 MUTE TGGLE*13QZONE2 MUTE ON*13RZONE2 MUTE OFF*13SZONE2 VOL UP*13TZONE2 VOL DOWN*13UZONE2 CD*138ZONE2 TAPE*139ZONE2 SAT*13AZONE2 DVD*13BZONE2 PHONO*13CZONE2 TUNER*13DZONE2 VID1*13EZONE2 VCR*13FZONE2 VID2*13G + + +https://www.manualslib.com/manual/2624583/Sunfire-Theater-Grand-Processor-Iii.html?page=51#manual + + + + + +IV + +COMMAND ASCII DATA RECEIVED +POWER TOGGLE *111 +POWER ON *112 +POWER OFF *113 +CD *114 +TAPE *115 +SAT *116 +DVD *117 +PHONO *118 +TUNER *119 +VID1 *11A +VCR *11B +VID2 *11C +DSP MODE UP *11D +DSP MODE DOWN *13W +STEREO *11E +PRO LOGIC *11F +PRO LOGIC IIx MUSIC *15P +PRO LOGIC IIx MOVIE *15Q +PARTY *134 +NEO:6 13H +SOURCEDIRECT 13J +JAZZ-CLUB *11K +HOLO TOGGLE *11L +HOLO ON *11M +HOLO OFF *11N +MUTE TOGGLE *11P +MUTE ON *11Q +MUTE OFF *11R +VOLUME UP *11S +VOLUME DOWN *11T +VOL ABSOLUTE *11U + 2 EXT +*11U00 = zero vol +*11U99 = max vol +ZONE2 PWR TOGGLE *13M +ZONE2 PWR ON *13N +ZONE2 PWR OFF *13P +ZONE2 MUTE TGGLE *13Q +ZONE2 MUTE ON *13R +ZONE2 MUTE OFF *13S +ZONE2 VOL UP *13T +ZONE2 VOL DOWN *13U +ZONE2 CD *138 +ZONE2 TAPE *139 +ZONE2 SAT *13A +ZONE2 DVD *13B +ZONE2 PHONO *13C +ZONE2 TUNER *13D +ZONE2 VID1 *13E +ZONE2 VCR *13F +ZONE2 VID2 *13G From 73d4bd07934fc66d9eab7ef81e1aa7e984c3d560 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 22:12:17 -0800 Subject: [PATCH 099/162] update --- .github/workflows/ci.yml.disabled | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml.disabled b/.github/workflows/ci.yml.disabled index ce50025..daee8f7 100644 --- a/.github/workflows/ci.yml.disabled +++ b/.github/workflows/ci.yml.disabled @@ -23,8 +23,6 @@ jobs: - name: Install dependencies run: | pip install -r requirements.txt -# - name: Run unit tests -# run: python -m unittest mypy-test: name: mypy test @@ -43,4 +41,4 @@ jobs: pip install -r requirements.txt pip install mypy # - name: Run mypy test -# run: mypy -p pymcintosh +# run: mypy -p pyavcontrol From 63280051332e7f31508df4a9e6f086cc4138eb04 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 22:22:46 -0800 Subject: [PATCH 100/162] update --- pyavcontrol/data/future/monoprice_6.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyavcontrol/data/future/monoprice_6.yaml b/pyavcontrol/data/future/monoprice_6.yaml index daa63b1..7e20757 100644 --- a/pyavcontrol/data/future/monoprice_6.yaml +++ b/pyavcontrol/data/future/monoprice_6.yaml @@ -1,8 +1,8 @@ --- id: monoprice_6 -manufacturer: - name: Monoprice +info: + manufacturer: Monoprice models: - MPR-6ZHMAUT - Model 10761 From 716035a92237d72c46144655bfc6103c52b000cc Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 22:23:42 -0800 Subject: [PATCH 101/162] update --- pyavcontrol/data/future/{sunfire_tgiii.yaml => sunfire_tgiii.txt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename pyavcontrol/data/future/{sunfire_tgiii.yaml => sunfire_tgiii.txt} (100%) diff --git a/pyavcontrol/data/future/sunfire_tgiii.yaml b/pyavcontrol/data/future/sunfire_tgiii.txt similarity index 100% rename from pyavcontrol/data/future/sunfire_tgiii.yaml rename to pyavcontrol/data/future/sunfire_tgiii.txt From 9d23565628cb99688febc98453289587931f2769 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 22:23:42 -0800 Subject: [PATCH 102/162] update --- docs/source/supported.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/source/supported.md b/docs/source/supported.md index f624bf1..d6a5614 100644 --- a/docs/source/supported.md +++ b/docs/source/supported.md @@ -73,6 +73,13 @@ | MX170 | mcintosh_mx170 | model.tested | model.notes | | MX180 | mcintosh_mx180 | model.tested | model.notes | +### Monoprice + +| Model | Protocol | Tested | Notes | +| :---------------- | :----------------: | :-----: | :---------------- | +| MPR-6ZHMAUT | monoprice_6 | model.tested | model.notes | +| Model 10761 | monoprice_6 | model.tested | model.notes | + ### Teac | Model | Protocol | Tested | Notes | From 071d67f472248ad282bf6edd881f9f395ec9f99d Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 26 Feb 2024 22:24:09 -0800 Subject: [PATCH 103/162] update --- pyavcontrol/data/src/lyngdorf_tdai3400.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyavcontrol/data/src/lyngdorf_tdai3400.yaml b/pyavcontrol/data/src/lyngdorf_tdai3400.yaml index 22209ca..ede0d0b 100644 --- a/pyavcontrol/data/src/lyngdorf_tdai3400.yaml +++ b/pyavcontrol/data/src/lyngdorf_tdai3400.yaml @@ -1,7 +1,7 @@ --- id: lyngdorf_tdai3400 -model: +info: manufacturer: Lyngdorf models: - TDAI-3400 From b1bd7394757c55501e52c3b5cb3e7ff1a14b0d0c Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 27 Feb 2024 02:19:44 -0800 Subject: [PATCH 104/162] update --- .github/workflows/docs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4763861..430a841 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v4 - name: Install dependencies run: | pip install sphinx sphinx_rtd_theme myst_parser @@ -19,7 +19,7 @@ jobs: run: | make html - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v3 + uses: peaceiris/actions-gh-pages@v4 if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} with: publish_branch: gh-pages From 59d027d7a9eafd22bfd17166887685395004f0e3 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 27 Feb 2024 02:19:45 -0800 Subject: [PATCH 105/162] update --- docs/source/supported.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/supported.md b/docs/source/supported.md index d6a5614..0f093d5 100644 --- a/docs/source/supported.md +++ b/docs/source/supported.md @@ -44,6 +44,7 @@ | :---------------- | :----------------: | :-----: | :---------------- | | CD-2 | lyngdorf_cd2 | model.tested | model.notes | | MP-60 | lyngdorf_mp60 | model.tested | model.notes | +| TDAI-3400 | lyngdorf_tdai3400 | model.tested | model.notes | ### Marantz From 1b4fe629f69d5b4a41d16b50704b8aeb55cb93cd Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 27 Feb 2024 08:50:30 -0800 Subject: [PATCH 106/162] test removing the doc generator --- docs/Makefile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/Makefile b/docs/Makefile index e2b82a4..8ee9888 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -17,6 +17,7 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile - pip3 install -r ../requirements-dev.txt - ./generate-supported-models-doc @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +# pip3 install -r ../requirements-dev.txt +# ./generate-supported-models-doc From f6bc4b5e0492e86d3d20ef1e68cc6b6140f2f386 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 27 Feb 2024 08:55:43 -0800 Subject: [PATCH 107/162] update --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 430a841..52a19e6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,7 +19,7 @@ jobs: run: | make html - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v4 + uses: peaceiris/actions-gh-pages@v3 if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} with: publish_branch: gh-pages From 9251f999efe0b65054fb3c64381d8ec71976b69e Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 27 Feb 2024 09:04:27 -0800 Subject: [PATCH 108/162] update --- docs/Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/Makefile b/docs/Makefile index 8ee9888..6533ecd 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -17,7 +17,7 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile + pip3 install -r ../requirements-dev.txt + ./generate-supported-models-doc @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -# pip3 install -r ../requirements-dev.txt -# ./generate-supported-models-doc From 4de7ef340cb4886950e0874292e8754e2113c313 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 27 Feb 2024 09:08:05 -0800 Subject: [PATCH 109/162] update --- docs/source/background.md | 6 ++++++ docs/source/conf.py | 1 + docs/source/index.rst | 1 + 3 files changed, 8 insertions(+) create mode 100644 docs/source/background.md diff --git a/docs/source/background.md b/docs/source/background.md new file mode 100644 index 0000000..e5401e8 --- /dev/null +++ b/docs/source/background.md @@ -0,0 +1,6 @@ +# Background + +## Goals + +## History + diff --git a/docs/source/conf.py b/docs/source/conf.py index c15cccf..45648b5 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -6,6 +6,7 @@ import os import sys sys.path.insert(0, os.path.abspath(os.path.join('..', '..'))) +sys.path.insert(0, os.path.abspath(os.path.join('..', '..', 'pyadtpulse'))) # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information diff --git a/docs/source/index.rst b/docs/source/index.rst index 7620b8a..a2622c1 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -11,6 +11,7 @@ Welcome to pyavcontrol docs! :caption: Contents: pyavcontrol + background supported hardware From b43aca1ec062c287987cc40a84f350dca8a575a9 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 27 Feb 2024 09:10:54 -0800 Subject: [PATCH 110/162] updated supported models layout --- docs/Makefile | 2 +- docs/source/supported.md | 41 ++++++++++++------- ...ted-models-doc => update-supported-models} | 5 ++- 3 files changed, 31 insertions(+), 17 deletions(-) rename docs/{generate-supported-models-doc => update-supported-models} (97%) diff --git a/docs/Makefile b/docs/Makefile index 6533ecd..51f5834 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -18,6 +18,6 @@ help: # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile pip3 install -r ../requirements-dev.txt - ./generate-supported-models-doc + ./update-supported-models @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/source/supported.md b/docs/source/supported.md index 0f093d5..33a60f7 100644 --- a/docs/source/supported.md +++ b/docs/source/supported.md @@ -1,16 +1,18 @@ -## Supported Equipment +# Supported Equipment *This is autogenerated from pyavcontrol's DeviceModel library (using build-supported-models-doc).* -### Acurus + +## Acurus | Model | Protocol | Tested | Notes | | :---------------- | :----------------: | :-----: | :---------------- | | M8 | acurus_m8 | model.tested | model.notes | -### Anthem + +## Anthem | Model | Protocol | Tested | Notes | | :---------------- | :----------------: | :-----: | :---------------- | @@ -18,7 +20,8 @@ | Statement D2v | anthem_d2v | model.tested | model.notes | | Statement D2v 3D | anthem_d2v | model.tested | model.notes | -### ClassÊ Audio + +## ClassÊ Audio | Model | Protocol | Tested | Notes | | :---------------- | :----------------: | :-----: | :---------------- | @@ -26,19 +29,22 @@ | SSP-300 | classe_ssp600 | model.tested | model.notes | | SSP-600 | classe_ssp600 | model.tested | model.notes | -### HDFury + +## HDFury | Model | Protocol | Tested | Notes | | :---------------- | :----------------: | :-----: | :---------------- | | VRROOM | hdfury_vrroom | model.tested | model.notes | -### JBL Synthesis + +## JBL Synthesis | Model | Protocol | Tested | Notes | | :---------------- | :----------------: | :-----: | :---------------- | | SDP-75 | jbl_sdp75 | model.tested | model.notes | -### Lyngdorf + +## Lyngdorf | Model | Protocol | Tested | Notes | | :---------------- | :----------------: | :-----: | :---------------- | @@ -46,13 +52,15 @@ | MP-60 | lyngdorf_mp60 | model.tested | model.notes | | TDAI-3400 | lyngdorf_tdai3400 | model.tested | model.notes | -### Marantz + +## Marantz | Model | Protocol | Tested | Notes | | :---------------- | :----------------: | :-----: | :---------------- | | AV8805 | marantz_av8805 | model.tested | model.notes | -### McIntosh + +## McIntosh | Model | Protocol | Tested | Notes | | :---------------- | :----------------: | :-----: | :---------------- | @@ -74,14 +82,16 @@ | MX170 | mcintosh_mx170 | model.tested | model.notes | | MX180 | mcintosh_mx180 | model.tested | model.notes | -### Monoprice + +## Monoprice | Model | Protocol | Tested | Notes | | :---------------- | :----------------: | :-----: | :---------------- | | MPR-6ZHMAUT | monoprice_6 | model.tested | model.notes | | Model 10761 | monoprice_6 | model.tested | model.notes | -### Teac + +## Teac | Model | Protocol | Tested | Notes | | :---------------- | :----------------: | :-----: | :---------------- | @@ -90,14 +100,16 @@ | Teac TR-D2000 | teac_trd2000 | model.tested | model.notes | | Xantech XDT | teac_trd2000 | model.tested | model.notes | -### Trinnov + +## Trinnov | Model | Protocol | Tested | Notes | | :---------------- | :----------------: | :-----: | :---------------- | | Altitude16 | trinnov_altitude16 | model.tested | model.notes | | Altitude32 | trinnov_altitude32 | model.tested | model.notes | -### Unknown + +## Unknown | Model | Protocol | Tested | Notes | | :---------------- | :----------------: | :-----: | :---------------- | @@ -107,7 +119,8 @@ | MX88a | xantech_mx88_audio | model.tested | model.notes | | MX88ai | xantech_mx88_audio | model.tested | model.notes | -### Xantech + +## Xantech | Model | Protocol | Tested | Notes | | :---------------- | :----------------: | :-----: | :---------------- | diff --git a/docs/generate-supported-models-doc b/docs/update-supported-models similarity index 97% rename from docs/generate-supported-models-doc rename to docs/update-supported-models index 1d1214a..dbd8575 100755 --- a/docs/generate-supported-models-doc +++ b/docs/update-supported-models @@ -17,12 +17,13 @@ coloredlogs.install(level="DEBUG") DEFAULT_FILE='source/supported.md' TEMPLATE = Template(''' -## Supported Equipment +# Supported Equipment *This is autogenerated from pyavcontrol's DeviceModel library (using build-supported-models-doc).* {% set manufacturer = namespace(value='') %} {% for model in models %}{% if model.manufacturer != manufacturer.value %}{% set manufacturer.value = model.manufacturer %} -### {{ model.manufacturer }} + +## {{ model.manufacturer }} | Model | Protocol | Tested | Notes | | :---------------- | :----------------: | :-----: | :---------------- | From 7cc91459ed4ac9a4d7b1729c50c8e4343c4c9c94 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 27 Feb 2024 09:14:25 -0800 Subject: [PATCH 111/162] update --- docs/source/examples.md | 10 ++++++++++ docs/source/index.rst | 1 + 2 files changed, 11 insertions(+) create mode 100644 docs/source/examples.md diff --git a/docs/source/examples.md b/docs/source/examples.md new file mode 100644 index 0000000..8a9ab15 --- /dev/null +++ b/docs/source/examples.md @@ -0,0 +1,10 @@ +# Examples + + +### Simple Client + +```python + +``` + + diff --git a/docs/source/index.rst b/docs/source/index.rst index a2622c1..6b2129d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,6 +12,7 @@ Welcome to pyavcontrol docs! pyavcontrol background + examples supported hardware From 05bafa4d94e6091091ff7c64261687daa3c78036 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 27 Feb 2024 09:30:54 -0800 Subject: [PATCH 112/162] update --- docs/Makefile | 6 ++++-- docs/source/conf.py | 3 +-- docs/source/pyavcontrol.rst | 2 +- docs/update-supported-models | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/Makefile b/docs/Makefile index 51f5834..79d5fbb 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -14,10 +14,12 @@ help: .PHONY: help Makefile +update: + pip3 install -r ../requirements-dev.txt + ./update-supported-models + # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile - pip3 install -r ../requirements-dev.txt - ./update-supported-models @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/source/conf.py b/docs/source/conf.py index 45648b5..223cfff 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -6,12 +6,11 @@ import os import sys sys.path.insert(0, os.path.abspath(os.path.join('..', '..'))) -sys.path.insert(0, os.path.abspath(os.path.join('..', '..', 'pyadtpulse'))) # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -project = 'pyavcontrol' +project = 'PyAVControl' copyright = '2024 Ryan Snodgrass' author = 'Ryan Snodgrass' release = '0.0.1' diff --git a/docs/source/pyavcontrol.rst b/docs/source/pyavcontrol.rst index c783898..8ce471d 100644 --- a/docs/source/pyavcontrol.rst +++ b/docs/source/pyavcontrol.rst @@ -1,4 +1,4 @@ -pyavcontrol package +PyAVControl package =================== Subpackages diff --git a/docs/update-supported-models b/docs/update-supported-models index dbd8575..1b5bc49 100755 --- a/docs/update-supported-models +++ b/docs/update-supported-models @@ -19,7 +19,7 @@ DEFAULT_FILE='source/supported.md' TEMPLATE = Template(''' # Supported Equipment -*This is autogenerated from pyavcontrol's DeviceModel library (using build-supported-models-doc).* +*This is autogenerated from PyAVControl's DeviceModel library (using build-supported-models-doc).* {% set manufacturer = namespace(value='') %} {% for model in models %}{% if model.manufacturer != manufacturer.value %}{% set manufacturer.value = model.manufacturer %} From bf27e1c93aefa18fd2b42c37de2ac6e8b2640d15 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 27 Feb 2024 09:30:54 -0800 Subject: [PATCH 113/162] update --- pyavcontrol/library/__init__.py | 18 ------------------ pyavcontrol/library/base.py | 17 +++++++++++++++++ pyavcontrol/library/yaml_library.py | 4 +--- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/pyavcontrol/library/__init__.py b/pyavcontrol/library/__init__.py index 97a8d81..e08e192 100644 --- a/pyavcontrol/library/__init__.py +++ b/pyavcontrol/library/__init__.py @@ -1,24 +1,6 @@ -import re from pyavcontrol.const import DEFAULT_MODEL_LIBRARIES -from pyavcontrol.library.base import DeviceModelSummary from pyavcontrol.library.model import DeviceModel -def filter_models_by_regex(models: set[DeviceModelSummary], regex: str) -> set[DeviceModelSummary]: - """ - Filter the provided set of DeviceModelSummary down into only the ones that - match the given regular expression. - - Returns: - dict of model summaries where the manufacturer or model name matches the - provided regular expression. - """ - matches = set() - rg = re.compile(regex) - for summary in models: - if rg.match(summary.manufacturer) or rg.match(summary.model_name) or rg.match(summary.model_id): - matches += summary - return matches - class DeviceModelLibrary: @staticmethod def create(library_dirs=DEFAULT_MODEL_LIBRARIES, event_loop=None): diff --git a/pyavcontrol/library/base.py b/pyavcontrol/library/base.py index 88856df..f898ba2 100644 --- a/pyavcontrol/library/base.py +++ b/pyavcontrol/library/base.py @@ -1,3 +1,4 @@ +import re from abc import abstractmethod, ABC from dataclasses import dataclass @@ -9,6 +10,22 @@ class DeviceModelSummary: model_name: str model_id: str +def filter_models_by_regex(models: set[DeviceModelSummary], regex: str) -> set[DeviceModelSummary]: + """ + Filter the provided set of DeviceModelSummary down into only the ones that + match the given regular expression. + + Returns: + dict of model summaries where the manufacturer or model name matches the + provided regular expression. + """ + matches = set() + rg = re.compile(regex) + for summary in models: + if rg.match(summary.manufacturer) or rg.match(summary.model_name) or rg.match(summary.model_id): + matches += summary + return matches + class DeviceModelLibraryBase(ABC): @abstractmethod def load_model(self, name: str) -> DeviceModel: # FIXME | None: diff --git a/pyavcontrol/library/yaml_library.py b/pyavcontrol/library/yaml_library.py index 6306867..95f06f2 100644 --- a/pyavcontrol/library/yaml_library.py +++ b/pyavcontrol/library/yaml_library.py @@ -10,9 +10,7 @@ import yaml -from . import DeviceModelSummary -from .base import DeviceModelLibraryBase -from .. import DeviceModelLibrary +from .base import DeviceModelLibraryBase, DeviceModelSummary from .model import DeviceModel # TODO: investigate CUE (validation) or PKL as replacement/enhancements From e37615fb554e02c0eb112e7a5fadc6f09c17ac7d Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 27 Feb 2024 09:32:25 -0800 Subject: [PATCH 114/162] update --- docs/update-supported-models | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/update-supported-models b/docs/update-supported-models index 1b5bc49..13ae720 100755 --- a/docs/update-supported-models +++ b/docs/update-supported-models @@ -3,6 +3,9 @@ import logging import os import sys + +# force using the pyavcontrol that is relative to this tool, instead of whatever +# pyavcontrol may be installed in the Python environment. sys.path.insert(0, os.path.abspath(os.path.join('..'))) import coloredlogs From b03727b2e0b0fb95d3278f9626cd2b7312ccea74 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 27 Feb 2024 09:38:49 -0800 Subject: [PATCH 115/162] update --- docs/source/conf.py | 2 +- docs/source/index.rst | 4 +- docs/source/modules.rst | 7 ++++ docs/source/pyavcontrol.client.rst | 37 ++++++++++++++++++ docs/source/pyavcontrol.connection.rst | 29 ++++++++++++++ docs/source/pyavcontrol.library.rst | 53 ++++++++++++++++++++++++++ docs/source/pyavcontrol.rst | 36 +++++++++++++++++ docs/source/supported.md | 2 +- 8 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 docs/source/modules.rst create mode 100644 docs/source/pyavcontrol.client.rst create mode 100644 docs/source/pyavcontrol.connection.rst create mode 100644 docs/source/pyavcontrol.library.rst diff --git a/docs/source/conf.py b/docs/source/conf.py index 223cfff..f1aa333 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -10,7 +10,7 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -project = 'PyAVControl' +project = 'PyAVControl' # TBD copyright = '2024 Ryan Snodgrass' author = 'Ryan Snodgrass' release = '0.0.1' diff --git a/docs/source/index.rst b/docs/source/index.rst index 6b2129d..cbcbd44 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -3,9 +3,11 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to pyavcontrol docs! +Welcome to PyAVControl ======================================= +Library created to control a wide variety of A/V equipment which expose text-based control protocols over RS232, USB serial connections, and/or remote IP sockets. + .. toctree:: :maxdepth: 2 :caption: Contents: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..789f0e3 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,7 @@ +pyavcontrol +=========== + +.. toctree:: + :maxdepth: 4 + + pyavcontrol diff --git a/docs/source/pyavcontrol.client.rst b/docs/source/pyavcontrol.client.rst new file mode 100644 index 0000000..422e7f5 --- /dev/null +++ b/docs/source/pyavcontrol.client.rst @@ -0,0 +1,37 @@ +pyavcontrol.client package +========================== + +Submodules +---------- + +pyavcontrol.client.async\_client module +--------------------------------------- + +.. automodule:: pyavcontrol.client.async_client + :members: + :undoc-members: + :show-inheritance: + +pyavcontrol.client.base module +------------------------------ + +.. automodule:: pyavcontrol.client.base + :members: + :undoc-members: + :show-inheritance: + +pyavcontrol.client.sync\_client module +-------------------------------------- + +.. automodule:: pyavcontrol.client.sync_client + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pyavcontrol.client + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/pyavcontrol.connection.rst b/docs/source/pyavcontrol.connection.rst new file mode 100644 index 0000000..ac83a71 --- /dev/null +++ b/docs/source/pyavcontrol.connection.rst @@ -0,0 +1,29 @@ +pyavcontrol.connection package +============================== + +Submodules +---------- + +pyavcontrol.connection.async\_connection module +----------------------------------------------- + +.. automodule:: pyavcontrol.connection.async_connection + :members: + :undoc-members: + :show-inheritance: + +pyavcontrol.connection.sync\_connection module +---------------------------------------------- + +.. automodule:: pyavcontrol.connection.sync_connection + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pyavcontrol.connection + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/pyavcontrol.library.rst b/docs/source/pyavcontrol.library.rst new file mode 100644 index 0000000..be617c5 --- /dev/null +++ b/docs/source/pyavcontrol.library.rst @@ -0,0 +1,53 @@ +pyavcontrol.library package +=========================== + +Submodules +---------- + +pyavcontrol.library.base module +------------------------------- + +.. automodule:: pyavcontrol.library.base + :members: + :undoc-members: + :show-inheritance: + +pyavcontrol.library.docs module +------------------------------- + +.. automodule:: pyavcontrol.library.docs + :members: + :undoc-members: + :show-inheritance: + +pyavcontrol.library.model module +-------------------------------- + +.. automodule:: pyavcontrol.library.model + :members: + :undoc-members: + :show-inheritance: + +pyavcontrol.library.schema module +--------------------------------- + +.. automodule:: pyavcontrol.library.schema + :members: + :undoc-members: + :show-inheritance: + +pyavcontrol.library.yaml\_library module +---------------------------------------- + +.. automodule:: pyavcontrol.library.yaml_library + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pyavcontrol.library + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/pyavcontrol.rst b/docs/source/pyavcontrol.rst index 8ce471d..6a0570c 100644 --- a/docs/source/pyavcontrol.rst +++ b/docs/source/pyavcontrol.rst @@ -7,9 +7,45 @@ Subpackages .. toctree:: :maxdepth: 4 + pyavcontrol.client + pyavcontrol.connection + pyavcontrol.library + Submodules ---------- +pyavcontrol.config module +------------------------- + +.. automodule:: pyavcontrol.config + :members: + :undoc-members: + :show-inheritance: + +pyavcontrol.const module +------------------------ + +.. automodule:: pyavcontrol.const + :members: + :undoc-members: + :show-inheritance: + +pyavcontrol.core module +----------------------- + +.. automodule:: pyavcontrol.core + :members: + :undoc-members: + :show-inheritance: + +pyavcontrol.helper module +------------------------- + +.. automodule:: pyavcontrol.helper + :members: + :undoc-members: + :show-inheritance: + Module contents --------------- diff --git a/docs/source/supported.md b/docs/source/supported.md index 33a60f7..8b203b4 100644 --- a/docs/source/supported.md +++ b/docs/source/supported.md @@ -1,7 +1,7 @@ # Supported Equipment -*This is autogenerated from pyavcontrol's DeviceModel library (using build-supported-models-doc).* +*This is autogenerated from PyAVControl's DeviceModel library (using build-supported-models-doc).* From 8329342e97f1671a23d1c25dd9906a9017255bcf Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 27 Feb 2024 09:47:37 -0800 Subject: [PATCH 116/162] fixed doc generation --- docs/Makefile | 7 ++++--- docs/source/pyavcontrol.library.rst | 8 -------- docs/source/pyavcontrol.rst | 25 +++++-------------------- pyavcontrol/client/base.py | 2 +- pyavcontrol/{core.py => utils.py} | 0 test-dynamic-client.py | 2 +- 6 files changed, 11 insertions(+), 33 deletions(-) rename pyavcontrol/{core.py => utils.py} (100%) diff --git a/docs/Makefile b/docs/Makefile index 79d5fbb..905d0e6 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -14,12 +14,13 @@ help: .PHONY: help Makefile -update: - pip3 install -r ../requirements-dev.txt +# Update all the supported models from the library sources +update_supported_models: + pip3 install --quiet -r ../requirements-dev.txt ./update-supported-models # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile +%: Makefile update_supported_models @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/source/pyavcontrol.library.rst b/docs/source/pyavcontrol.library.rst index be617c5..d25ca02 100644 --- a/docs/source/pyavcontrol.library.rst +++ b/docs/source/pyavcontrol.library.rst @@ -28,14 +28,6 @@ pyavcontrol.library.model module :undoc-members: :show-inheritance: -pyavcontrol.library.schema module ---------------------------------- - -.. automodule:: pyavcontrol.library.schema - :members: - :undoc-members: - :show-inheritance: - pyavcontrol.library.yaml\_library module ---------------------------------------- diff --git a/docs/source/pyavcontrol.rst b/docs/source/pyavcontrol.rst index 6a0570c..202c9e0 100644 --- a/docs/source/pyavcontrol.rst +++ b/docs/source/pyavcontrol.rst @@ -14,38 +14,23 @@ Subpackages Submodules ---------- -pyavcontrol.config module +pyavcontrol.helper module ------------------------- -.. automodule:: pyavcontrol.config +.. automodule:: pyavcontrol.helper :members: :undoc-members: :show-inheritance: -pyavcontrol.const module +pyavcontrol.utils module ------------------------ -.. automodule:: pyavcontrol.const - :members: - :undoc-members: - :show-inheritance: - -pyavcontrol.core module ------------------------ - -.. automodule:: pyavcontrol.core - :members: - :undoc-members: - :show-inheritance: - -pyavcontrol.helper module -------------------------- - -.. automodule:: pyavcontrol.helper +.. automodule:: pyavcontrol.utils :members: :undoc-members: :show-inheritance: + Module contents --------------- diff --git a/pyavcontrol/client/base.py b/pyavcontrol/client/base.py index 9451d40..a87d925 100644 --- a/pyavcontrol/client/base.py +++ b/pyavcontrol/client/base.py @@ -8,7 +8,7 @@ from ..config import CONFIG from ..connection import DeviceConnection -from ..core import ( +from ..utils import ( camel_case, generate_docs_for_action, missing_keys_in_dict, diff --git a/pyavcontrol/core.py b/pyavcontrol/utils.py similarity index 100% rename from pyavcontrol/core.py rename to pyavcontrol/utils.py diff --git a/test-dynamic-client.py b/test-dynamic-client.py index adb5d4b..979f9c4 100755 --- a/test-dynamic-client.py +++ b/test-dynamic-client.py @@ -28,7 +28,7 @@ from pyavcontrol import DeviceClient, DeviceModelLibrary from pyavcontrol.connection import NullConnection -from pyavcontrol.core import ( +from pyavcontrol.utils import ( camel_case, extract_named_regex, get_fstring_vars, From d4531310afe7587c773dc3c91fc39d0249d5ef52 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 4 Mar 2024 01:47:54 -0800 Subject: [PATCH 117/162] * flush out initial (middle of night) thoughts on definitions for devices/APIs --- pyavcontrol/data/README.md | 49 + pyavcontrol/data/rcl/defaults/base.rcl | 7 + pyavcontrol/data/rcl/defaults/rs232.rcl | 12 + .../data/rcl/mcintosh/mcintosh_mx160.rcl | 1470 +++++++++++++++++ pyavcontrol/utils.py | 2 +- requirements.txt | 1 + 6 files changed, 1540 insertions(+), 1 deletion(-) create mode 100644 pyavcontrol/data/README.md create mode 100644 pyavcontrol/data/rcl/defaults/base.rcl create mode 100644 pyavcontrol/data/rcl/defaults/rs232.rcl create mode 100644 pyavcontrol/data/rcl/mcintosh/mcintosh_mx160.rcl diff --git a/pyavcontrol/data/README.md b/pyavcontrol/data/README.md new file mode 100644 index 0000000..29a9dd3 --- /dev/null +++ b/pyavcontrol/data/README.md @@ -0,0 +1,49 @@ + +### Why configuration language? + +Basically PyAVControl is about defining configuration (definitions) for how devices interfaces are defined. Implementing the API definitions directly into a specific language does not achieve the ability for reuse of those definitions across multiple languages/clients. Initially using JSON and YAML was explored as the easiest way to define these interfaces (especially in a way that non-developers could create their own definitions which was often a request from users in example libraries like pyxantech, pymonoprice, pyanthem-serial, etc). + +However, the sheer volume of models and slightly different definitions evolved into needing some sort of import/include/replacement mechanism. While this can be implemented (for the thousandth time) overtop of JSON/YAML, this doesn't make sense since existing configuration language exists that already have implementations in many languages AND this isn't really the point of PyAVControl to define new languages. + +Exploring configuration languages that provided basic support for imports/includes, variables, and a few other features without evolving into a Turing complete language just makes sense. Further, if those languages enable generating a library or repository of these configuration files flattened into raw JSON or YAML, this is a huge bonus since new clients in other languages could use the flattened definitions instead of having to implement the config language if a library didn't already exist. + +This indicates that there should be a build pipeline that converts the definition (config) files into flattened variations as part of the check-in or repository workflow. This provides a nice balance in sufficinet flexibility in defining the interfaces, while keeping the dependencies and simplicity of interacting with common file formats optimized for multiple languages and clients. + +### Requirements/Goals + +* minimize the amount of config required to define interfaces to devices +* enable reuse across device models by sharing large portions of the definitions +* enable non-developers to use an easy to read/understand format for contributing their own equipment definitions +* support access to the intrefaces via JSON by clients (no need to implement complex config parsing for new languages IF it is acceptable to tradeoff "compiling" the definitions down into a large repository of JSON files) +* separate the definition from the runtime dependency +* schema/limited type checking + +#### Config Languages Considered + +* [RCL](https://github.com/ruuda/rcl): see [more](https://ruudvanasseldonk.com/2024/a-reasonable-configuration-language), tooling support might be weak (e.g. VSCode extensions, etc) +* [PKL](https://github.com/apple/pkl): no Python implementation yet (2024-03) +* [Nix])(https://nixos.wiki/wiki/Overview_of_the_Nix_Language): to specialized to package management +* [Nickel](https://github.com/tweag/nickel): evolution of Nix +* [HCL](https://github.com/hashicorp/hcl): primarily targeted towards devops/infrastructure config +* [CUE](https://cuelang.org/) +* Dhall + +And of course raw formats, which was the initial implementation, but quickly abandoned due to the sheer volume of files and duplicate config needed to support minute differences between a vast array of physical device features: + +* JSON: most compatible and frequently used for data interfaces +* YAML: more readable than json, with some limited support for references +* TOML + +Neither JSON or YAML solve the issues of reuse across configuration files, composition, etc. +Decided on RCL as it was most inline with json, could export the equipment definition files to json +files as part of the build process to make integration into other languages easy where RCL +libraries may not be available. + +### Why RCL? + +#### See Also + +* https://news.ycombinator.com/item?id=39250320 +* https://ruudvanasseldonk.com/2024/a-reasonable-configuration-language + + diff --git a/pyavcontrol/data/rcl/defaults/base.rcl b/pyavcontrol/data/rcl/defaults/base.rcl new file mode 100644 index 0000000..697412e --- /dev/null +++ b/pyavcontrol/data/rcl/defaults/base.rcl @@ -0,0 +1,7 @@ +// defaults that all equipment should include as default values +{ + info = { + name = "Unknown", + tested = false + } +} diff --git a/pyavcontrol/data/rcl/defaults/rs232.rcl b/pyavcontrol/data/rcl/defaults/rs232.rcl new file mode 100644 index 0000000..252302c --- /dev/null +++ b/pyavcontrol/data/rcl/defaults/rs232.rcl @@ -0,0 +1,12 @@ +{ + connection = { + rs232 = { + baudrate = 9600, + bytesize = 8, + parity = "N", + stopbits = 1, + timeout = 1.0, + encoding = "ascii", // most typical encoding + response_eol = "\r", // typical EOL + } +} diff --git a/pyavcontrol/data/rcl/mcintosh/mcintosh_mx160.rcl b/pyavcontrol/data/rcl/mcintosh/mcintosh_mx160.rcl new file mode 100644 index 0000000..0512d6a --- /dev/null +++ b/pyavcontrol/data/rcl/mcintosh/mcintosh_mx160.rcl @@ -0,0 +1,1470 @@ +// NOTES: +// - cmd commands are in substitution variable format (e.g. {var}) +// - msg messages/responses are in regex format to decode + +{ + "id": "mcintosh_mx160", + "description": "McIntosh MX160 Protocol [2017-09-18 MX160 Serial Control Manual V7]", + "info": { + "name": "McIntosh", + "models": [ + "MX160" + ], + "type": "processor", + "tested": true, + "urls": [ + "https://www.mcintoshlabs.com/legacy-products/home-theater-processors/MX160", + "http://www.mcintoshcompendium.com/Compendium%20Docs/Home%20Theater%20Controllers/PDFs/MX160.pdf", + "https://www.docdroid.net/OnipkTW/mx160-serial-control-manual-v3-pdf" + ] + }, + "hardware": { + "sources": { + "0": "HDMI 1", + "1": "HDMI 2", + "2": "HDMI 3", + "3": "HDMI 4", + "4": "HDMI 5", + "5": "HDMI 6", + "6": "HDMI 7", + "7": "HDMI 8", + "8": "Audio Return", + "9": "SPDIF 1 (Optical)", + "10": "SPDIF 2 (Optical)", + "11": "SPDIF 3 (Optical)", + "12": "SPDIF 4 (Optical)", + "13": "SPDIF 5 (AES/EBU)", + "14": "SPDIF 6 (Coaxial)", + "15": "SPDIF 7 (Coaxial)", + "16": "SPDIF 8 (Coaxial)", + "17": "USB Audio", + "18": "Analog 1", + "19": "Analog 2", + "20": "Analog 3", + "21": "Analog 4", + "22": "Balanced 1", + "23": "Balanced 2", + "24": "Phono", + "25": "8 Channel Analog" + }, + "baud_rates": { + "9600": "9600", + "115200": "115200 (default)" + } + }, + "connection": { + "rs232": { + "baudrate": 115200, + "bytesize": 8, + "parity": "N", + "stopbits": 1, + "timeout": 2, + "encoding": "ascii", + "response_eol": "\r" + }, + "connection_init": "!VERB(2)" + }, + "protocol": { + "encoding": "ascii", + "command_eol": "\r", + "message_eol": "\r", + "min_time_between_commands": 0.4 + }, + "vars": { + "zone": { + "type": "int", + "pattern": "[1-8]", + "min": 1, + "max": 8 + }, + "power": { + "type": "int", + "pattern": "[01]", + "min": 0, + "max": 1 + }, + "mute": { + "type": "int", + "pattern": "[01]", + "min": 0, + "max": 1 + }, + "volume": { + "type": "int", + "min": 0, + "max": 38 + }, + "treble": { + "type": "int", + "min": 0, + "max": 14 + }, + "bass": { + "type": "int", + "min": 0, + "max": 14 + }, + "balance": { + "type": "int", + "min": 0, + "max": 63 + }, + "source": { + "type": "int", + "min": 1, + "max": 8 + }, + "verbosity_level": { + "type": "int", + "min": 1, + "max": 3, + "values": { + "1": "Minimal", + "2": "Normal", + "3": "All" + } + }, + "dim_level": { + "type": "int", + "min": 0, + "max": 3, + "values": { + "0": "Full (100%)", + "1": "Bright (75%)", + "2": "Low (50%)", + "3": "Dark (25%)" + } + }, + "interface": { + "type": "string", + "values": { + "IP": "IP", + "SERIAL": "Serial" + } + }, + "lipsync": { + "type": "int" + }, + "loudness": { + "type": "int", + "min": 0, + "max": 1, + "pattern": "[01]", + "values": { + "0": "Off", + "1": "On" + } + }, + "roomperfect_position": { + "type": "int", + "min": 1, + "max": 9, + "pattern": "[1-9]", + "values": { + "0": "Bypass", + "1": "Focus 1", + "2": "Focus 2", + "3": "Focus 3", + "4": "Focus 4", + "5": "Focus 5", + "6": "Focus 6", + "7": "Focus 7", + "8": "Focus 8", + "9": "Global" + } + }, + "input": { + "type": "int", + "min": 0, + "max": 25, + "values": { + "0": "HDMI 1", + "1": "HDMI 2", + "2": "HDMI 3", + "3": "HDMI 4", + "4": "HDMI 5", + "5": "HDMI 6", + "6": "HDMI 7", + "7": "HDMI 8", + "8": "Audio Return", + "9": "SPDIF 1 (Optical)", + "10": "SPDIF 2 (Optical)", + "11": "SPDIF 3 (Optical)", + "12": "SPDIF 4 (Optical)", + "13": "SPDIF 5 (AES/EBU)", + "14": "SPDIF 6 (Coaxial)", + "15": "SPDIF 7 (Coaxial)", + "16": "SPDIF 8 (Coaxial)", + "17": "USB Audio", + "18": "Analog 1", + "19": "Analog 2", + "20": "Analog 3", + "21": "Analog 4", + "22": "Balanced 1", + "23": "Balanced 2", + "24": "Phono", + "25": "8 Channel Analog" + } + } + }, + "api": { + "verbosity": { + "actions": { + "set": { + "description": "Set verbosity level of active interface", + "cmd": { + "fstring": "!VERB({verbosity_level})", + "docs": { + "verbosity_level": "0 (min), 1 (normal), or 2 (max)" + } + } + }, + "min": { + "description": "Set verbosity level to minimal", + "cmd": { + "fstring": "!VERB(1)" + } + }, + "normal": { + "description": "Set verbosity level to normal", + "cmd": { + "fstring": "!VERB(2)" + } + }, + "max": { + "description": "Set verbosity level to maximum", + "cmd": { + "fstring": "!VERB(3)" + } + }, + "get": { + "description": "Request verbosity level of active interface", + "cmd": { + "fstring": "!VERB?" + }, + "msg": { + "regex": "!VERB\\((?P[123])\\)", + "tests": { + "!VERB(2)": { + "verbosity_level": 2 + } + } + } + } + } + }, + "audio_mode": { + "description": "Audio processing mode control", + "actions": { + "down": { + "description": "Audio processing mode down button", + "cmd": { + "fstring": "!AUDMODE-" + } + }, + "up": { + "description": "Audio processing mode up button", + "cmd": { + "fstring": "!AUDMODE+" + } + }, + "get": { + "description": "Request audio processing mode", + "cmd": { + "fstring": "!AUDMODE?" + }, + "msg": { + "regex": "!AUDMODE\\((?P\\d+)\\)\\s*\"(?P.+)\"", + "tests": { + "!AUDMODE(1) \"Test\"": { + "type": 1, + "name": "Test" + } + } + } + }, + "modes": { + "description": "Get list of audio processing modes", + "cmd": { + "fstring": "!AUDMODEL?" + }, + "msg": { + "regex": "!AUDMODECOUNT\\((?P\\d+)\\)\\r", + "tests": { + "!AUDMODECOUNT(2)\r!AUDMODE(0)\"Source 1\"\r!AUDMODE(1)\"Source 2\"": { + "count": 2 + } + } + } + } + } + }, + "audio_type": { + "description": "Input audio type", + "actions": { + "get": { + "description": "Return string of the input audio type.", + "cmd": { + "fstring": "!AUDTYPE?" + }, + "msg": { + "regex": "!AUDTYPE\\((?P.+)\\)", + "tests": { + "!AUDTYPE(Unknown)": { + "type": "Unknown" + } + } + } + } + } + }, + "button": { + "description": "Remote button presses", + "actions": { + "back": { + "description": "Back button", + "cmd": { + "fstring": "!BACK" + } + }, + "down": { + "description": "Direction Down button", + "cmd": { + "fstring": "!DIRD" + } + }, + "left": { + "description": "Direction Left button", + "cmd": { + "fstring": "!DIRL" + } + }, + "right": { + "description": "Direction Right button", + "cmd": { + "fstring": "!DIRR" + } + }, + "up": { + "description": "Direction Up button", + "cmd": { + "fstring": "!DIRU" + } + }, + "enter": { + "description": "Enter button", + "cmd": { + "fstring": "!ENTER" + } + }, + "exit": { + "description": "Exit button", + "cmd": { + "fstring": "!EXIT" + } + }, + "info": { + "description": "Info button", + "cmd": { + "fstring": "!INFO" + } + }, + "menu": { + "description": "Menu Button", + "cmd": { + "fstring": "!MENU" + } + }, + "setup": { + "description": "Setup button", + "cmd": { + "fstring": "!SETUP" + } + }, + "source": { + "description": "Source button", + "cmd": { + "fstring": "!SRCBTN" + } + }, + "number": { + "description": "Number button", + "cmd": { + "fstring": "!NUM({num})", + "docs": { + "num": "single digit integer (0-9)" + } + } + }, + "num0": { + "description": "Number button 0", + "cmd": { + "fstring": "!NUM(0)" + } + }, + "num1": { + "description": "Number button 1", + "cmd": { + "fstring": "!NUM(1)" + } + }, + "num2": { + "description": "Number button 2", + "cmd": { + "fstring": "!NUM(2)" + } + }, + "num3": { + "description": "Number button 3", + "cmd": { + "fstring": "!NUM(3)" + } + }, + "num4": { + "description": "Number button 4", + "cmd": { + "fstring": "!NUM(4)" + } + }, + "num5": { + "description": "Number button 5", + "cmd": { + "fstring": "!NUM(5)" + } + }, + "num6": { + "description": "Number button 6", + "cmd": { + "fstring": "!NUM(6)" + } + }, + "num7": { + "description": "Number button 7", + "cmd": { + "fstring": "!NUM(7)" + } + }, + "num8": { + "description": "Number button 8", + "cmd": { + "fstring": "!NUM(8)" + } + }, + "num9": { + "description": "Number button 9", + "cmd": { + "fstring": "!NUM(9)" + } + } + } + }, + "device": { + "actions": { + "name": { + "description": "Returns the name of the device (e.g. MX160)", + "cmd": { + "fstring": "!DEVICE?" + }, + "msg": { + "regex": "!DEVICE\\((?P.+)\\)", + "tests": { + "!DEVICE(MX160)": { + "name": "MX160" + } + } + } + } + } + }, + "display_brightness": { + "description": "VFD display brightness (0 – 3; 0=100%, 1=75%, 2=50%, 3=25%)", + "actions": { + "down": { + "description": "Reduce brightness of the VFD display", + "cmd": { + "fstring": "!DIM-" + } + }, + "up": { + "description": "Increase the brightness of the VFD display", + "cmd": { + "fstring": "!DIM+" + } + }, + "get": { + "description": "Request brightness of the VFD display", + "cmd": { + "fstring": "!DIM?" + }, + "msg": { + "regex": "!DIM\\((?P[0123])\\)", + "tests": { + "!DIM(2)": { + "dim_level": 2 + } + } + } + }, + "set": { + "description": "Set display brightness level", + "cmd": { + "fstring": "!DIM({dim_level})", + "docs": { + "dim_level": "0 (Full 100%), 1 (Bright 75%), 2 (Low 50%), or 3 (Dark 25%)" + } + } + }, + "full": { + "description": "Set display brightness Full (100%)", + "cmd": { + "fstring": "!DIM(0)" + } + }, + "bright": { + "description": "Set display brightness Bright (75%)", + "cmd": { + "fstring": "!DIM(1)" + } + }, + "low": { + "description": "Set display brightness Low (50%)", + "cmd": { + "fstring": "!DIM(2)" + } + }, + "dark": { + "description": "Set display brightness Dark (25%)", + "cmd": { + "fstring": "!DIM(3)" + } + } + } + }, + "interface": { + "description": "Interface type for this session (IP or SERIAL)", + "actions": { + "get": { + "description": "Returns the active interface for this section", + "cmd": { + "fstring": "!INTERFACE?" + }, + "msg": { + "regex": "!INTERFACE\\((?P(IP|SERIAL))\\)", + "tests": { + "!INTERFACE(SERIAL)": { + "interface": "SERIAL" + }, + "!INTERFACE(IP)": { + "interface": "IP" + } + } + } + } + } + }, + "lipsync": { + "description": "Lipsync adjustments", + "actions": { + "set": { + "description": "Set the lipsync value", + "cmd": { + "fstring": "!LIPSYNC({lipsync})", + "regex": "!LIPSYNC\\((?P\\d+)\\)", + "docs": { + "lipsync": "lipsync value" + } + } + }, + "get": { + "description": "Get the lipsync value", + "cmd": { + "fstring": "!LIPSYNC?" + }, + "msg": { + "regex": "!LIPSYNC\\((?P\\d)\\)", + "tests": { + "!LIPSYNC(1)": { + "lipsync": 1 + } + } + } + }, + "range": { + "description": "Get the lipsync value range", + "cmd": { + "fstring": "!LIPSYNCRANGE?" + }, + "msg": { + "regex": "!LIPSYNCRANGE\\((?P\\d+),(?P\\d+)\\)\r", + "tests": { + "!LIPSYNCRANGE(1,3)": { + "min": 1, + "max": 3 + } + } + } + }, + "down": { + "description": "Reduce lipsync value", + "cmd": { + "fstring": "!LIPSYNC-" + } + }, + "up": { + "description": "Increase lipsync value", + "cmd": { + "fstring": "!LIPSYNC+" + } + } + } + }, + "loudness": { + "description": "Loudness", + "actions": { + "on": { + "description": "Turn loudness on", + "cmd": { + "fstring": "!LOUDNESS(1)" + } + }, + "off": { + "description": "Turn loudness off", + "cmd": { + "fstring": "!LOUDNESS(0)" + } + }, + "get": { + "description": "Get the loudness setting (0=off; 1=on)", + "cmd": { + "fstring": "!LOUDNESS?" + }, + "msg": { + "regex": "!LOUDNESS\\((?P[01])\\)", + "tests": { + "!LOUDNESS(0)": { + "loudness": 0 + } + } + } + } + } + }, + "mute": { + "description": "Mute", + "actions": { + "toggle": { + "description": "Mute toggle button", + "cmd": { + "fstring": "!MUTE" + } + }, + "get": { + "description": "get current Mute status", + "cmd": { + "fstring": "!MUTE?" + }, + "msg": { + "regex": "!MUTE\\\\((?P[01])\\)", + "tests": { + "!MUTE(1)": { + "mute": 1 + } + } + } + }, + "off": { + "description": "Mute off", + "cmd": { + "fstring": "!MUTEOFF" + } + }, + "on": { + "description": "Mute on", + "cmd": { + "fstring": "!MUTEON" + } + } + } + }, + "ping": { + "description": "Ping test", + "actions": { + "ping": { + "description": "Ping for a pong (returns PONG)", + "cmd": { + "fstring": "!PING?" + }, + "msg": { + "regex": "!PONG" + } + } + } + }, + "power": { + "description": "Power control for the entire system", + "actions": { + "on": { + "description": "Turn entire system on", + "cmd": { + "fstring": "!PON" + } + }, + "off": { + "description": "Turn entire system off", + "cmd": { + "fstring": "!POFF" + } + }, + "toggle": { + "description": "Toggle system power", + "cmd": { + "fstring": "!PTOGGLE" + } + }, + "get": { + "description": "Get system power status (0=off; 1=on)", + "cmd": { + "fstring": "!POWER?" + }, + "msg": { + "regex": "!POWER\\((?P[01])\\)", + "tests": { + "!POWER(1)": { + "power": 1 + } + } + } + } + } + }, + "power_zone_main": { + "description": "Main zone power", + "actions": { + "on": { + "description": "Turn main zone power on", + "cmd": { + "fstring": "!POWERONMAIN" + } + }, + "off": { + "description": "Turn main zone power off", + "cmd": { + "fstring": "!POWEROFFMAIN" + } + }, + "get": { + "description": "get main zone power status (0=standby; 1=on)", + "cmd": { + "fstring": "!POWERMAIN?" + }, + "msg": { + "regex": "!POWER\\((?P[01])\\)", + "tests": { + "!POWER(1)": { + "power": 1 + } + } + } + } + } + }, + "power_zone_2": { + "description": "Zone 2 power", + "actions": { + "on": { + "description": "Turn zone 2 power on", + "cmd": { + "fstring": "!POWERONZONE2" + } + }, + "off": { + "description": "Turn zone 2 power off", + "cmd": { + "fstring": "!POWEROFFZONE2" + } + }, + "get": { + "description": "Get zone 2 power status (0=off; 1=on)", + "cmd": { + "fstring": "!POWERZONE2?" + }, + "msg": { + "regex": "!POWER\\((?P[01])\\)", + "tests": { + "!POWERZONE2(1)": { + "power": 1 + } + } + } + } + } + }, + "roomperfect_focus": { + "description": "RoomPerfect room correction focus", + "actions": { + "previous": { + "description": "Previous RoomPerfect position button", + "cmd": { + "fstring": "!RPFOC-" + } + }, + "position": { + "description": "Request RoomPerfect position (0=bypass, 1-8=focus1-8, 9=global)", + "cmd": { + "fstring": "!RPFOC?" + } + }, + "set": { + "description": "Set RoomPerfect position", + "cmd": { + "fstring": "!RPFOC({roomperfect_position})", + "docs": { + "roomperfect_position": "RoomPerfect position (0=bypass, 1-8=focus1-8, 9=global)" + } + } + }, + "next": { + "description": "Next Roomperfect position button", + "cmd": { + "fstring": "!RPFOC+" + } + }, + "get": { + "description": "Get available RoomPerfect positions", + "cmd": { + "fstring": "!RPFOCS?" + }, + "msg": { + "regex": "!PPFOCOUNT\\((?P[01])\\)", + "tests": { + "FIXME": { + "positions": 3 + } + } + } + } + } + }, + "roomperfect_voice": { + "description": "RoomPerfect room correction voice", + "actions": { + "previous": { + "description": "Previous voicing button", + "cmd": { + "fstring": "!RPVOI-" + } + }, + "get": { + "description": "Get active voicing", + "cmd": { + "fstring": "!RPVOI?" + }, + "msg": { + "regex": "!RPVOI\\((?P[01])\\)\\s*\"(?P.+)\"", + "tests": { + "!RPVOI(1) \"Test\"": { + "active_voice": 1, + "name": "Test" + } + } + } + }, + "set": { + "description": "Set voicing", + "cmd": { + "fstring": "!RPVOI({roomperfect_voicing})" + }, + "docs": { + "roomperfect_voicing": "RoomPerfect voicing value" + } + }, + "next": { + "description": "Next voicing button", + "cmd": { + "fstring": "!RPVOI+" + } + }, + "list": { + "description": "Request list of available voicings", + "cmd": { + "fstring": "!RPVOIS?" + }, + "msg": { + "regex": "!RPVOICOUNT\\((?P\\d+)\\)\r", + "tests": { + "!RPIVOICOUNT(2)": { + "voice_count": 2 + } + } + } + } + } + }, + "source": { + "description": "Input source selection", + "actions": { + "previous": { + "description": "Previous source button", + "cmd": { + "fstring": "!SRC-" + } + }, + "get": { + "description": "Get info for currently active source", + "cmd": { + "fstring": "!SRC?" + }, + "msg": { + "regex": "!SRC\\((?P\\d+)\\)\\s*\"(?P.+)\"", + "tests": { + "!SRC(1) CD": { + "source": 1, + "name": "CD" + } + } + } + }, + "set": { + "description": "Select source", + "cmd": { + "fstring": "!SRC({source})", + "docs": { + "source": "Source to select (integer)" + } + } + }, + "info": { + "description": "Get info for a specific source", + "cmd": { + "fstring": "!SRC({source})?", + "docs": { + "source": "the integer identifying the source input" + } + }, + "msg": { + "regex": "!SRC\\((?P\\d+)\\)\\s*\"(?P.+)\"", + "tests": { + "!SRC(2) HiFiBerry": { + "source": 2, + "name": "HiFiBerry" + } + } + } + }, + "next": { + "description": "Next source button", + "cmd": { + "fstring": "!SRC+" + } + }, + "list": { + "description": "Get list of available sources", + "cmd": { + "fstring": "!SRCS?" + }, + "msg": { + "regex": "!SRCOUNT\\((?P\\d+)\\)\r", + "tests": { + "FIXME": { + "count": 2 + } + } + } + } + } + }, + "volume_offset": { + "description": "Volume offset", + "actions": { + "down": { + "description": "Decrease Source volume offset", + "cmd": { + "fstring": "!SRCOFF-" + } + }, + "get": { + "description": "Get source volume offset for current source", + "cmd": { + "fstring": "!SRCOFF?" + } + }, + "set": { + "description": "Set source volume offset for current source", + "cmd": { + "fstring": "!SRCOFF(x)" + } + }, + "up": { + "description": "Increase source volume offset", + "cmd": { + "fstring": "!SRCOFF+" + } + } + } + }, + "software": { + "description": "Software/firmware info", + "actions": { + "info": { + "description": "Request SW information (prints a list of version numbers)", + "cmd": { + "fstring": "!SWINFO?" + }, + "msg": { + "regex": "!SWINFO\\((?P.+)\\)", + "tests": { + "!SWINFO(1)": { + "version": "1" + } + } + } + } + } + }, + "trim_bass": { + "description": "Bass trim controls", + "actions": { + "get": { + "description": "Get current bass level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMBASS?" + }, + "msg": { + "regex": "!TRIMBASS\\((?P-?[0-9]{1,3})\\)", + "tests": { + "!TRIMBASS(10)": { + "bass_level": 10 + } + } + } + }, + "set": { + "description": "Sets bass level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMBASS({bass_level})", + "regex": "!TRIMBASS\\((?P-?[0-9]{1,3})\\)" + } + }, + "up": { + "description": "Increases bass level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMBASS+" + } + }, + "down": { + "description": "Decreases bass level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMBASS-" + } + } + } + }, + "trim_center": { + "description": "Center channel trim controls", + "actions": { + "get": { + "description": "get current center channel level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMCENTER?" + }, + "msg": { + "regex": "!TRIMCENTER\\((?P-?[0-9]{1,3})\\)", + "tests": { + "!TRIMCENTER(10)": { + "center_level": 10 + } + } + } + }, + "set": { + "description": "Sets center channel level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMCENTER({center_level})", + "regex": "!TRIMCENTER\\((?P-?[0-9]{1,3})\\)" + } + }, + "up": { + "description": "Increases center channel level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMCENTER+" + } + }, + "down": { + "description": "Decreases center channel level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMCENTER-" + } + } + } + }, + "trim_height": { + "description": "Height channels trim controls", + "actions": { + "get": { + "description": "Gete current height channels level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMHEIGHT?" + }, + "msg": { + "regex": "!TRIMHEIGHT\\((?P-?[0-9]{1,3})\\)", + "tests": { + "!TRIMHEIGHT(9)": { + "height_level": 9 + } + } + } + }, + "set": { + "description": "Sets height channels level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMHEIGHT({height_level})", + "regex": "!TRIMHEIGHT\\((?P-?[0-9]{1,3})\\)" + } + }, + "up": { + "description": "Increases height channels level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMHEIGHT+" + } + }, + "down": { + "description": "Decreases height channels level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMHEIGHT-" + } + } + } + }, + "trim_lfe": { + "description": "LFE channel trim controls", + "actions": { + "get": { + "description": "Get current LFE channel level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMLFE?" + }, + "msg": { + "regex": "!TRIMLFE\\((?P-?[0-9]{1,3})\\)", + "tests": { + "!TRIMLFE(2)": { + "lfe_level": 2 + } + } + } + }, + "set": { + "description": "Sets LFE channel level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMLFE({lfe_level})", + "regex": "!TRIMLFE\\((?P-?[0-9]{1,3})\\)" + } + }, + "up": { + "description": "Increases LFE channel level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMLFE+" + } + }, + "down": { + "description": "Decreases LFE channel level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMLFE-" + } + } + } + }, + "trim_surrounds": { + "description": "Surround channels trim controls", + "actions": { + "down": { + "description": "Decreases surround channels level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMSURRS-" + } + }, + "get": { + "description": "Get current surround channels level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMSURRS?" + }, + "msg": { + "regex": "!TRIMSURRS\\((?P-?[0-9]{1,3})\\)", + "tests": { + "!TRIMSURRS(1)": { + "surround_level": 1 + } + } + } + }, + "set": { + "description": "Sets surround channels level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMSURRS({surround_level})", + "regex": "!TRIMSURRS\\((?P-?[0-9]{1,3})\\)" + } + }, + "up": { + "description": "Increases surround channels level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMSURRS+" + } + } + } + }, + "trim_treble": { + "description": "Treble trim controls", + "actions": { + "get": { + "description": "Get current treble level trim (10 = 1dB; -120=-10 dB to 120=+10 dB)", + "cmd": { + "fstring": "!TRIMTREB?" + }, + "msg": { + "regex": "!TRIMTREB\\(?P-?[0-9]{1,3})\\)", + "tests": { + "!TRIMTREB(100)": { + "trebble_level": 100 + } + } + } + }, + "set": { + "description": "Sets treble level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMTREB({trebble_level})", + "regex": "!TRIMTREB\\((?P-?[0-9]{1,3})\\)" + } + }, + "down": { + "description": "Decreases treble level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMTREB-" + } + }, + "up": { + "description": "Increases treble level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMTREB+" + } + } + } + }, + "volume": { + "description": "Volume controls", + "actions": { + "get": { + "description": "Get current volume", + "cmd": { + "fstring": "!VOL?" + }, + "msg": { + "regex": "!VOL\\((?P[0-9]{1,2})\\)", + "tests": { + "!VOL(1)": { + "volume": 1 + } + } + } + }, + "set": { + "description": "Set volume to x", + "cmd": { + "fstring": "!VOL({volume})", + "regex": "!VOL\\((?P[0-9]{1,2})\\)" + } + }, + "down": { + "description": "Decrease volume", + "cmd": { + "fstring": "!VOL-" + } + }, + "down_by_x": { + "description": "Decrease volume by x", + "cmd": { + "fstring": "!VOL-({volume_amount})", + "regex": "!VOL-\\((?P[0-9]{1,2})\\)" + } + }, + "up": { + "description": "Increase volume", + "cmd": { + "fstring": "!VOL+" + } + }, + "up_by_x": { + "description": "Increase volume by x", + "cmd": { + "fstring": "!VOL+({volume_amount})", + "regex": "!VOL\\+\\((?P[0-9]{1,2})\\)" + } + } + } + }, + "zone_2_mute": { + "description": "Zone 2 mute", + "actions": { + "get": { + "description": "get current Zone B Mute status", + "cmd": { + "fstring": "!ZMUTE?" + }, + "msg": { + "regex": "!ZMUTE\\((?P[01])\\)", + "tests": { + "!ZMUTE(1)": { + "mute": 1 + } + } + } + }, + "on": { + "description": "Zone B Mute on", + "cmd": { + "fstring": "!ZMUTEON" + } + }, + "off": { + "description": "Zone B Mute off", + "cmd": { + "fstring": "!ZMUTEOFF" + } + }, + "toggle": { + "description": "Toggle Zone B Mute", + "cmd": { + "fstring": "!ZMUTE" + } + } + } + }, + "zone_2_power": { + "description": "Zone 2 power", + "actions": { + "on": { + "description": "Zone Power On", + "cmd": { + "fstring": "!ZPON" + } + }, + "off": { + "description": "Zone Power Off", + "cmd": { + "fstring": "!ZPOFF" + } + }, + "toggle": { + "description": "Zone Power Toggle", + "cmd": { + "fstring": "!ZPTOGGLE" + } + } + } + }, + "zone_2_source": { + "description": "Zone 2 input source selection", + "actions": { + "previous": { + "description": "Previous zone B source button", + "cmd": { + "fstring": "!ZSRC-" + } + }, + "get": { + "description": "Get current Zone B source", + "cmd": { + "fstring": "!ZSRC?" + }, + "msg": { + "regex": "!ZSRC\\((?P\\d+)\\s*\"(?P.+)\"\\)", + "tests": { + "!ZSRC(1) \"Source 1\"": { + "source": 1, + "name": "Source 1" + } + } + } + }, + "set": { + "description": "Set Zone B source", + "cmd": { + "fstring": "!ZSRC({source})", + "regex": "!ZSRC\\((?P\\d+)\\)" + } + }, + "next": { + "description": "Next Zone B source button", + "cmd": { + "fstring": "!ZSRC+" + } + }, + "list": { + "description": "Get list of available Zone B sources", + "cmd": { + "fstring": "!ZSRCS?" + }, + "msg": { + "regex": "!ZSRCCOUNT\\\\((?P\\\\d+)\\r!ZSRC\\\\((?P.+)\\\\)\\s*\"(?P.+)\"", + "tests": { + "!ZSRCCOUNT(1)\\r!ZSRC(1) \"Source 1\"": { + "count": 1 + } + } + } + } + } + }, + "zone_2_volume": { + "description": "Zone 2 volume control", + "actions": { + "down": { + "description": "Decrease zone B volume", + "cmd": { + "fstring": "!ZVOL-" + } + }, + "down_by_x": { + "description": "decrease zone B volume by X", + "cmd": { + "fstring": "!ZVOL-({volume_amount})", + "regex": "!ZVOL-\\((?P[0-9]{1,2})\\)" + } + }, + "get": { + "description": "Get current zone B volume", + "cmd": { + "fstring": "!ZVOL?" + }, + "msg": { + "regex": "!ZVOL\\((?P[0-9]{1,2})\\)", + "tests": { + "!ZVOL(2)": { + "volume": 2 + } + } + } + }, + "set": { + "description": "Set zone B volume", + "cmd": { + "fstring": "!ZVOL({volume})", + "regex": "!ZVOL\\((?P[0-9]{1,2})\\)" + } + }, + "up": { + "description": "Increase zone B volume", + "cmd": { + "fstring": "!ZVOL+" + } + }, + "up_by_x": { + "description": "Increase zone B volume by x", + "cmd": { + "fstring": "!ZVOL+({volume_amount})", + "regex": "!ZVOL\\+\\((?P[0-9]{1,2})\\)" + } + } + } + } + } +} \ No newline at end of file diff --git a/pyavcontrol/utils.py b/pyavcontrol/utils.py index 2e242f8..77ac322 100644 --- a/pyavcontrol/utils.py +++ b/pyavcontrol/utils.py @@ -128,4 +128,4 @@ def generate_docs_for_action(action_name: str, action_def: dict): doc += '\n}' # FIXME: may need type info from the overall api variables section - return doc + return doc \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 0a43694..b9de070 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ ratelimit>=2.2.1 syncer>=2.0.3 pytest PyYAML>=6.0.1 +rcl From eb24adb994d309585cb27996cdf23297829329d2 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 5 Mar 2024 09:26:20 -0800 Subject: [PATCH 118/162] updated docs --- README.md | 2 +- docs/source/conf.py | 9 +++++++-- docs/source/index.rst | 10 ++++++++++ pyavcontrol/data/README.md | 3 ++- pyavcontrol/data/rcl/mcintosh/mcintosh_mx170.rcl | 0 5 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 pyavcontrol/data/rcl/mcintosh/mcintosh_mx170.rcl diff --git a/README.md b/README.md index 23b999e..f2d1a57 100644 --- a/README.md +++ b/README.md @@ -138,4 +138,4 @@ Example URL formats supported by pyserial: - [Earlier McIntosh control in Home Assistant](https://community.home-assistant.io/t/need-help-using-rs232-to-control-a-receiver/95210/8) - https://drivers.control4.com/solr/drivers/browse?q=mcintosh - [RS232 to USB cable](https://www.amazon.com/RS232-to-USB/dp/B0759HSLP1?tag=carreramfi-20) - +* [IO Ninja](https://ioninja.com/) diff --git a/docs/source/conf.py b/docs/source/conf.py index f1aa333..33ce576 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -10,8 +10,8 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -project = 'PyAVControl' # TBD -copyright = '2024 Ryan Snodgrass' +project = 'PyAVControl' +copyright = '2024, Ryan Snodgrass' author = 'Ryan Snodgrass' release = '0.0.1' @@ -34,3 +34,8 @@ html_theme = 'sphinx_rtd_theme' html_static_path = ['_static'] + +# -- Generate documentation for Dynamic classes ------------------------------ +# see https://pypi.org/project/sphinx-autorun/#description + +import pyavcontrol.library.docs diff --git a/docs/source/index.rst b/docs/source/index.rst index cbcbd44..fd16cf8 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -8,6 +8,16 @@ Welcome to PyAVControl Library created to control a wide variety of A/V equipment which expose text-based control protocols over RS232, USB serial connections, and/or remote IP sockets. + +.. image:: https://img.shields.io/badge/Donate-PayPal-green.svg + :target: https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=WREP29UDAMB6G + :alt: Done + +.. image:: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg + :target: https://buymeacoffee.com/DYks67r + :alt: Buy Me A Coffee + + .. toctree:: :maxdepth: 2 :caption: Contents: diff --git a/pyavcontrol/data/README.md b/pyavcontrol/data/README.md index 29a9dd3..0345843 100644 --- a/pyavcontrol/data/README.md +++ b/pyavcontrol/data/README.md @@ -17,6 +17,7 @@ This indicates that there should be a build pipeline that converts the definitio * support access to the intrefaces via JSON by clients (no need to implement complex config parsing for new languages IF it is acceptable to tradeoff "compiling" the definitions down into a large repository of JSON files) * separate the definition from the runtime dependency * schema/limited type checking +* ability to add comments #### Config Languages Considered @@ -30,7 +31,7 @@ This indicates that there should be a build pipeline that converts the definitio And of course raw formats, which was the initial implementation, but quickly abandoned due to the sheer volume of files and duplicate config needed to support minute differences between a vast array of physical device features: -* JSON: most compatible and frequently used for data interfaces +* JSON: most compatible and frequently used for data interfaces; no ability to add comments * YAML: more readable than json, with some limited support for references * TOML diff --git a/pyavcontrol/data/rcl/mcintosh/mcintosh_mx170.rcl b/pyavcontrol/data/rcl/mcintosh/mcintosh_mx170.rcl new file mode 100644 index 0000000..e69de29 From b8cb8e425434b906fb61a4f4a42f0b2b53c1e844 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 26 Mar 2024 11:46:51 -0700 Subject: [PATCH 119/162] update --- .github/workflows/python-package.yml | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 9a1746b..04c05d9 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@v4 - + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: @@ -32,12 +32,21 @@ jobs: python -m pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics +# - name: Lint with flake8 +# run: | +# # stop the build if there are Python syntax errors or undefined names +# flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics +# # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide +# flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Lint with Ruff (instead of flake8) + on: [ push, pull_request ] + jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: chartboost/ruff-action@v1 - name: Test with pytest run: | From fb64f2970c944e4e7bb18a5a68b6a990357cd2f8 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 26 Mar 2024 11:51:56 -0700 Subject: [PATCH 120/162] update --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 03eb380..55f8397 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,9 @@ profile = "black" force_to_top = [ "logging" ] balanced_wrapping = true +[tool.ruff] + + [tool.black] line-length = 88 From a5420571fb2922bb834e487dcdec2cf20a6513d2 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 26 Mar 2024 11:51:56 -0700 Subject: [PATCH 121/162] update --- .pre-commit-config.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9e41bae..2fb99ed 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,12 +17,21 @@ repos: - id: brunette args: [--line-length=88, --single-quotes, --target-version py311] + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.4 + hooks: + - id: ruff + args: [ --fix ] # run linter + - id: ruff-format # run formatter + +# pycln - formatter for finding and removing unused import statements #- repo: https://github.com/hadialqattan/pycln # rev: v2.4.0 # hooks: # - id: pycln # args: [--config=pyproject.toml] + # isort - sort import statements - repo: https://github.com/pycqa/isort rev: 5.13.2 hooks: From 6331811637f990d4112f407d57cdaf456378014c Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 26 Mar 2024 12:50:54 -0700 Subject: [PATCH 122/162] update --- .envrc | 5 +++++ .pre-commit-config.yaml | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .envrc diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..93d9b20 --- /dev/null +++ b/.envrc @@ -0,0 +1,5 @@ +# auto-update pre-commit versions (if > 1 week) +if which runonce &> /dev/null; then + DIR=`basename $(pwd)` + runonce -b -n $DIR -d 7 pre-commit autoupdate +fi diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2fb99ed..6781dd8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,11 +40,11 @@ repos: args: [--settings-path=pyproject.toml] # ["--profile", "black" ] - repo: https://github.com/dosisod/refurb - rev: v1.27.0 + rev: v2.0.0 hooks: - id: refurb - repo: https://github.com/asottile/pyupgrade - rev: v3.15.0 + rev: v3.15.2 hooks: - id: pyupgrade From 0f229095323a8b4032711c6269293e857aac7a10 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Thu, 28 Mar 2024 00:08:59 -0700 Subject: [PATCH 123/162] update --- .envrc | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/.envrc b/.envrc index 93d9b20..8e7f16e 100644 --- a/.envrc +++ b/.envrc @@ -1,5 +1,21 @@ -# auto-update pre-commit versions (if > 1 week) -if which runonce &> /dev/null; then +if has runonce; then DIR=`basename $(pwd)` - runonce -b -n $DIR -d 7 pre-commit autoupdate + + # install any missing requirements + if [ -d .venv ]; then + if [ -f requirements.txt ]; then + rononce -b -n $DIR uv pip install -r requirements.txt + fi + + if [ -f requirements-dev.txt ]; then + rononce -b -n $DIR uv pip install -r requirements-dev.txt + fi + fi + + if [ -f .pre-commit-config.yaml ]; then + if has pre-commit; then + # auto-update pre-commit versions (if >= 1 week) + runonce -b -n $DIR -d 7 pre-commit autoupdate + fi + fi fi From c6f1f3d4da0c6a7aa5e6a6973d641cbb08033c73 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sat, 13 Apr 2024 02:49:36 -0400 Subject: [PATCH 124/162] update --- .github/dependabot.yaml | 7 +++++++ .github/dependabot.yml | 14 -------------- 2 files changed, 7 insertions(+), 14 deletions(-) create mode 100644 .github/dependabot.yaml delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..83b26ea --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: weekly + time: "06:00" diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 56bb0da..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,14 +0,0 @@ -version: 2 - -updates: -- package-ecosystem: pip - directory: "/" - schedule: - interval: weekly - time: "13:00" - open-pull-requests-limit: 10 - -- package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" From e96a874dfdfe903bffcbc7a37c6630d9874c49b6 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sat, 13 Apr 2024 02:49:38 -0400 Subject: [PATCH 125/162] update --- .github/dependabot.yaml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 83b26ea..56bb0da 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,7 +1,14 @@ version: 2 + updates: - - package-ecosystem: "github-actions" - directory: "/" +- package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + time: "13:00" + open-pull-requests-limit: 10 + +- package-ecosystem: "github-actions" + directory: "/" schedule: - interval: weekly - time: "06:00" + interval: "weekly" From 7c1a1cc3a26913484df4ef4f6752c43cd711568b Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 21 May 2024 10:22:36 -0400 Subject: [PATCH 126/162] update --- .pre-commit-config.yaml | 8 +------- pyproject.toml | 4 ++++ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6781dd8..5f9eaba 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,14 +11,8 @@ repos: - id: end-of-file-fixer - id: requirements-txt-fixer - - repo: https://github.com/odwyersoftware/brunette - rev: 0.2.8 - hooks: - - id: brunette - args: [--line-length=88, --single-quotes, --target-version py311] - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.4 + rev: v0.4.4 hooks: - id: ruff args: [ --fix ] # run linter diff --git a/pyproject.toml b/pyproject.toml index 55f8397..79c9619 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,11 @@ force_to_top = [ "logging" ] balanced_wrapping = true [tool.ruff] +line-length = 88 +indent-width = 4 +[tool.ruff.format] +quote-style = "single" # Use a single quote instead of double [tool.black] line-length = 88 From effdee80224df1f001ad564f2395d49106f70ed3 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Tue, 21 May 2024 10:24:33 -0400 Subject: [PATCH 127/162] update --- .coveragerc | 15 - .envrc | 21 - .github/dependabot.yaml | 14 - .github/workflows/bandit.yml | 33 - .github/workflows/black.yml.disabled | 36 - .github/workflows/ci.yml.disabled | 44 - .github/workflows/codeql.yml | 84 - .github/workflows/docs.yml | 28 - .github/workflows/pypi-publish.yml | 23 - .github/workflows/pytest.yml | 40 - .github/workflows/python-package.yml | 53 - .gitignore | 13 - .pre-commit-config.yaml | 44 - LICENSE | 5 - README.md | 141 -- SUPPORTED.md | 66 - docs/Makefile | 26 - docs/source/background.md | 6 - docs/source/conf.py | 41 - docs/source/examples.md | 10 - docs/source/hardware.md | 34 - docs/source/index.rst | 36 - docs/source/modules.rst | 7 - docs/source/pyavcontrol.client.rst | 37 - docs/source/pyavcontrol.connection.rst | 29 - docs/source/pyavcontrol.library.rst | 45 - docs/source/pyavcontrol.rst | 40 - docs/source/supported.md | 131 -- docs/update-supported-models | 68 - example-async.py | 71 - example-sync.py | 56 - img/lyngdorf-logo-small.png | Bin 5395 -> 0 bytes img/lyngdorf-logo.png | Bin 47999 -> 0 bytes img/mcintosh-logo-green.png | Bin 13886 -> 0 bytes img/mcintosh-logo-small.png | Bin 13596 -> 0 bytes img/mcintosh-logo.png | Bin 30162 -> 0 bytes img/mcintosh-logo.svg | 15 - pyavcontrol/__init__.py | 6 - pyavcontrol/client/__init__.py | 2 - pyavcontrol/client/async_client.py | 41 - pyavcontrol/client/base.py | 291 ---- pyavcontrol/client/sync_client.py | 36 - pyavcontrol/config.py | 47 - pyavcontrol/connection/__init__.py | 89 - pyavcontrol/connection/async_connection.py | 225 --- pyavcontrol/connection/sync_connection.py | 129 -- pyavcontrol/const.py | 23 - pyavcontrol/data/README.md | 50 - pyavcontrol/data/future/acurus_m8.yaml | 92 -- pyavcontrol/data/future/anthem_d2v.yaml | 236 --- pyavcontrol/data/future/classe_ssp600.yaml | 76 - pyavcontrol/data/future/lyngdorf_mp60.yaml | 50 - pyavcontrol/data/future/marantz_av8805.yaml | 150 -- pyavcontrol/data/future/monoprice_6.yaml | 103 -- pyavcontrol/data/future/sunfire_tgiii.txt | 67 - pyavcontrol/data/rcl/defaults/base.rcl | 7 - pyavcontrol/data/rcl/defaults/rs232.rcl | 12 - .../data/rcl/mcintosh/mcintosh_mx160.rcl | 1470 ----------------- .../data/rcl/mcintosh/mcintosh_mx170.rcl | 0 pyavcontrol/data/src/classe_omicron.yaml | 55 - pyavcontrol/data/src/hdfury_vrroom.yaml | 114 -- pyavcontrol/data/src/jbl_sdp75.yaml | 12 - pyavcontrol/data/src/lyngdorf_cd2.yaml | 245 --- pyavcontrol/data/src/lyngdorf_tdai3400.yaml | 505 ------ pyavcontrol/data/src/mcintosh_legacy.yaml | 397 ----- pyavcontrol/data/src/mcintosh_mx160.yaml | 1054 ------------ pyavcontrol/data/src/mcintosh_mx170.yaml | 100 -- pyavcontrol/data/src/mcintosh_mx180.yaml | 62 - pyavcontrol/data/src/teac_trd2000.yaml | 132 -- pyavcontrol/data/src/trinnov_altitude16.yaml | 11 - pyavcontrol/data/src/trinnov_altitude32.yaml | 168 -- pyavcontrol/data/src/xantech_mx88_audio.yaml | 323 ---- pyavcontrol/data/src/xantech_mx88_video.yaml | 15 - pyavcontrol/helper.py | 70 - pyavcontrol/library/README.md | 15 - pyavcontrol/library/__init__.py | 28 - pyavcontrol/library/base.py | 52 - pyavcontrol/library/docs.py | 38 - pyavcontrol/library/model.py | 71 - pyavcontrol/library/schema.py | 75 - pyavcontrol/library/yaml_library.py | 129 -- pyavcontrol/library_flat/README.md | 8 - pyavcontrol/utils.py | 131 -- pyproject.toml | 71 - requirements-dev.txt | 3 - requirements.txt | 9 - test-dynamic-client.py | 68 - tests/init.py | 0 tests/test_connection.py | 5 - tests/test_device_client.py | 31 - tests/test_device_library.py | 18 - tests/test_device_model.py | 26 - tools/generate-docs | 13 - tools/validate-model-definition | 4 - 94 files changed, 8572 deletions(-) delete mode 100644 .coveragerc delete mode 100644 .envrc delete mode 100644 .github/dependabot.yaml delete mode 100644 .github/workflows/bandit.yml delete mode 100644 .github/workflows/black.yml.disabled delete mode 100644 .github/workflows/ci.yml.disabled delete mode 100644 .github/workflows/codeql.yml delete mode 100644 .github/workflows/docs.yml delete mode 100644 .github/workflows/pypi-publish.yml delete mode 100644 .github/workflows/pytest.yml delete mode 100644 .github/workflows/python-package.yml delete mode 100644 .gitignore delete mode 100644 .pre-commit-config.yaml delete mode 100644 LICENSE delete mode 100644 README.md delete mode 100644 SUPPORTED.md delete mode 100644 docs/Makefile delete mode 100644 docs/source/background.md delete mode 100644 docs/source/conf.py delete mode 100644 docs/source/examples.md delete mode 100644 docs/source/hardware.md delete mode 100644 docs/source/index.rst delete mode 100644 docs/source/modules.rst delete mode 100644 docs/source/pyavcontrol.client.rst delete mode 100644 docs/source/pyavcontrol.connection.rst delete mode 100644 docs/source/pyavcontrol.library.rst delete mode 100644 docs/source/pyavcontrol.rst delete mode 100644 docs/source/supported.md delete mode 100755 docs/update-supported-models delete mode 100755 example-async.py delete mode 100755 example-sync.py delete mode 100644 img/lyngdorf-logo-small.png delete mode 100644 img/lyngdorf-logo.png delete mode 100644 img/mcintosh-logo-green.png delete mode 100644 img/mcintosh-logo-small.png delete mode 100644 img/mcintosh-logo.png delete mode 100644 img/mcintosh-logo.svg delete mode 100644 pyavcontrol/__init__.py delete mode 100644 pyavcontrol/client/__init__.py delete mode 100644 pyavcontrol/client/async_client.py delete mode 100644 pyavcontrol/client/base.py delete mode 100644 pyavcontrol/client/sync_client.py delete mode 100644 pyavcontrol/config.py delete mode 100644 pyavcontrol/connection/__init__.py delete mode 100644 pyavcontrol/connection/async_connection.py delete mode 100644 pyavcontrol/connection/sync_connection.py delete mode 100644 pyavcontrol/const.py delete mode 100644 pyavcontrol/data/README.md delete mode 100644 pyavcontrol/data/future/acurus_m8.yaml delete mode 100644 pyavcontrol/data/future/anthem_d2v.yaml delete mode 100644 pyavcontrol/data/future/classe_ssp600.yaml delete mode 100644 pyavcontrol/data/future/lyngdorf_mp60.yaml delete mode 100644 pyavcontrol/data/future/marantz_av8805.yaml delete mode 100644 pyavcontrol/data/future/monoprice_6.yaml delete mode 100644 pyavcontrol/data/future/sunfire_tgiii.txt delete mode 100644 pyavcontrol/data/rcl/defaults/base.rcl delete mode 100644 pyavcontrol/data/rcl/defaults/rs232.rcl delete mode 100644 pyavcontrol/data/rcl/mcintosh/mcintosh_mx160.rcl delete mode 100644 pyavcontrol/data/rcl/mcintosh/mcintosh_mx170.rcl delete mode 100644 pyavcontrol/data/src/classe_omicron.yaml delete mode 100644 pyavcontrol/data/src/hdfury_vrroom.yaml delete mode 100644 pyavcontrol/data/src/jbl_sdp75.yaml delete mode 100644 pyavcontrol/data/src/lyngdorf_cd2.yaml delete mode 100644 pyavcontrol/data/src/lyngdorf_tdai3400.yaml delete mode 100644 pyavcontrol/data/src/mcintosh_legacy.yaml delete mode 100644 pyavcontrol/data/src/mcintosh_mx160.yaml delete mode 100644 pyavcontrol/data/src/mcintosh_mx170.yaml delete mode 100644 pyavcontrol/data/src/mcintosh_mx180.yaml delete mode 100644 pyavcontrol/data/src/teac_trd2000.yaml delete mode 100644 pyavcontrol/data/src/trinnov_altitude16.yaml delete mode 100644 pyavcontrol/data/src/trinnov_altitude32.yaml delete mode 100644 pyavcontrol/data/src/xantech_mx88_audio.yaml delete mode 100644 pyavcontrol/data/src/xantech_mx88_video.yaml delete mode 100644 pyavcontrol/helper.py delete mode 100644 pyavcontrol/library/README.md delete mode 100644 pyavcontrol/library/__init__.py delete mode 100644 pyavcontrol/library/base.py delete mode 100644 pyavcontrol/library/docs.py delete mode 100644 pyavcontrol/library/model.py delete mode 100644 pyavcontrol/library/schema.py delete mode 100644 pyavcontrol/library/yaml_library.py delete mode 100644 pyavcontrol/library_flat/README.md delete mode 100644 pyavcontrol/utils.py delete mode 100644 pyproject.toml delete mode 100644 requirements-dev.txt delete mode 100644 requirements.txt delete mode 100755 test-dynamic-client.py delete mode 100644 tests/init.py delete mode 100644 tests/test_connection.py delete mode 100644 tests/test_device_client.py delete mode 100644 tests/test_device_library.py delete mode 100644 tests/test_device_model.py delete mode 100755 tools/generate-docs delete mode 100755 tools/validate-model-definition diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 36a61d7..0000000 --- a/.coveragerc +++ /dev/null @@ -1,15 +0,0 @@ -[run] -source = pymcintosh - -[report] -# Regexes for lines to exclude from consideration -exclude_lines = - # Have to re-enable the standard pragma - pragma: no cover - - # Don't complain about missing debug-only code: - def __repr__ - - # Don't complain if tests don't hit defensive assertion code: - raise AssertionError - raise NotImplementedError diff --git a/.envrc b/.envrc deleted file mode 100644 index 8e7f16e..0000000 --- a/.envrc +++ /dev/null @@ -1,21 +0,0 @@ -if has runonce; then - DIR=`basename $(pwd)` - - # install any missing requirements - if [ -d .venv ]; then - if [ -f requirements.txt ]; then - rononce -b -n $DIR uv pip install -r requirements.txt - fi - - if [ -f requirements-dev.txt ]; then - rononce -b -n $DIR uv pip install -r requirements-dev.txt - fi - fi - - if [ -f .pre-commit-config.yaml ]; then - if has pre-commit; then - # auto-update pre-commit versions (if >= 1 week) - runonce -b -n $DIR -d 7 pre-commit autoupdate - fi - fi -fi diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml deleted file mode 100644 index 56bb0da..0000000 --- a/.github/dependabot.yaml +++ /dev/null @@ -1,14 +0,0 @@ -version: 2 - -updates: -- package-ecosystem: pip - directory: "/" - schedule: - interval: weekly - time: "13:00" - open-pull-requests-limit: 10 - -- package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml deleted file mode 100644 index fbbdeee..0000000 --- a/.github/workflows/bandit.yml +++ /dev/null @@ -1,33 +0,0 @@ -# see https://github.com/shundor/python-bandit-scan -# see https://github.com/mdegis/bandit-action - -name: Bandit -on: - push: - branches: [ "main" ] - pull_request: - # branches below must be a subset of the branches above - branches: [ "main" ] - schedule: - - cron: '22 4 * * 6' - -jobs: - bandit: - permissions: - contents: read # for actions/checkout to fetch code - security-events: write # for github/codeql-action/upload-sarif to upload SARIF results - actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status - - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Bandit Scan -# uses: shundor/bandit-action@v1 - uses: mdegis/bandit-action@v1.0.1 - with: - # Github token of the repository (automatically created by Github) - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # needed to get PR info - - # exit with 0, even with results found - exit_zero: true # optional, default is DEFAULT diff --git a/.github/workflows/black.yml.disabled b/.github/workflows/black.yml.disabled deleted file mode 100644 index cf3f15c..0000000 --- a/.github/workflows/black.yml.disabled +++ /dev/null @@ -1,36 +0,0 @@ -name: Lint Python - -on: - pull_request: - push: - branches: - - main - -jobs: - lint: - runs-on: ubuntu-latest - - permissions: - # Give the default GITHUB_TOKEN write permission to commit and push the changed files back to the repository. - contents: write - - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: 3.11 - - - name: Install Flake8 - run: pip install flake8 - - - name: Lint code - run: flake8 . - - # FIXME: see https://github.com/stefanzweifel/git-auto-commit-action - - uses: stefanzweifel/git-auto-commit-action@v5 - with: - commit_message: Apply flake8 changes diff --git a/.github/workflows/ci.yml.disabled b/.github/workflows/ci.yml.disabled deleted file mode 100644 index daee8f7..0000000 --- a/.github/workflows/ci.yml.disabled +++ /dev/null @@ -1,44 +0,0 @@ -name: CI -on: - workflow_dispatch: - push: - pull_request: - -jobs: - unit-test: - name: Python ${{ matrix.python-version}} unit tests - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - -"3.11" - -"3.12" - steps: - - name: Checkout repo - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - pip install -r requirements.txt - - mypy-test: - name: mypy test - runs-on: ubuntu-latest - env: - python-version: "3.12" - steps: - - name: Checkout repo - uses: actions/checkout@v3 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ env.python-version }} - - name: Install dependencies - run: | - pip install -r requirements.txt - pip install mypy -# - name: Run mypy test -# run: mypy -p pyavcontrol diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 7429698..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,84 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - schedule: - - cron: '23 7 * * 3' - -jobs: - analyze: - name: Analyze - # Runner size impacts CodeQL analysis time. To learn more, please see: - # - https://gh.io/recommended-hardware-resources-for-running-codeql - # - https://gh.io/supported-runners-and-hardware-resources - # - https://gh.io/using-larger-runners - # Consider using larger runners for possible analysis time improvements. - runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} - timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} - permissions: - # required for all workflows - security-events: write - - # only required for workflows in private repositories - actions: read - contents: read - - strategy: - fail-fast: false - matrix: - language: [ 'python' ] - # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] - # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both - # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v3 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: "/language:${{matrix.language}}" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 52a19e6..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: documentation - -on: [push, pull_request, workflow_dispatch] - -permissions: - contents: write - -jobs: - docs: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - - name: Install dependencies - run: | - pip install sphinx sphinx_rtd_theme myst_parser - - name: Sphinx build - working-directory: ./docs - run: | - make html - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v3 - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - with: - publish_branch: gh-pages - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: docs/build/ - force_orphan: true diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml deleted file mode 100644 index db512ec..0000000 --- a/.github/workflows/pypi-publish.yml +++ /dev/null @@ -1,23 +0,0 @@ -# see https://github.com/marketplace/actions/publish-python-poetry-package - -name: Upload Release to PyPi - -on: - release: - types: [published] - -permissions: - contents: read - -jobs: - deploy: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Build and publish to pypi - uses: JRubics/poetry-publish@v1.17 - with: - pypi_token: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml deleted file mode 100644 index 75fb92e..0000000 --- a/.github/workflows/pytest.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: pytest - -on: -# push: -# branches: [ main ] - pull_request: - branches: [ main ] - -permissions: - contents: read - -jobs: - test: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python 3.12 - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - uses: actions/cache@v3 - id: cache - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.*') }} - restore-keys: | - ${{ runner.os }}-pip- - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run pytest - run: | - pytest diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml deleted file mode 100644 index 04c05d9..0000000 --- a/.github/workflows/python-package.yml +++ /dev/null @@ -1,53 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Python package - -on: -# push: -# branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - build: - - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: [ "3.11", "3.12" ] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - -# - name: Lint with flake8 -# run: | -# # stop the build if there are Python syntax errors or undefined names -# flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics -# # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide -# flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - - name: Lint with Ruff (instead of flake8) - on: [ push, pull_request ] - jobs: - ruff: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: chartboost/ruff-action@v1 - - - name: Test with pytest - run: | - pytest diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 3719f9c..0000000 --- a/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -.DS_Store -*.pyc -*.log -*.egg-info -.venv -venv - -dist -build - -__pycache__ -.mypy_cache -*.swp diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 5f9eaba..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,44 +0,0 @@ ---- -# pre-commit autoupdate - -fail_fast: true - -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 - hooks: -# - id: trailing-whitespace - - id: end-of-file-fixer - - id: requirements-txt-fixer - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.4 - hooks: - - id: ruff - args: [ --fix ] # run linter - - id: ruff-format # run formatter - -# pycln - formatter for finding and removing unused import statements -#- repo: https://github.com/hadialqattan/pycln -# rev: v2.4.0 -# hooks: -# - id: pycln -# args: [--config=pyproject.toml] - - # isort - sort import statements - - repo: https://github.com/pycqa/isort - rev: 5.13.2 - hooks: - - id: isort - files: \.(py)$ - args: [--settings-path=pyproject.toml] # ["--profile", "black" ] - - - repo: https://github.com/dosisod/refurb - rev: v2.0.0 - hooks: - - id: refurb - - - repo: https://github.com/asottile/pyupgrade - rev: v3.15.2 - hooks: - - id: pyupgrade diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 58f98c7..0000000 --- a/LICENSE +++ /dev/null @@ -1,5 +0,0 @@ -THIS IS DUAL LICENSED! - -Commerical uses must pay for license. - -Open Source license forthcoming. diff --git a/README.md b/README.md deleted file mode 100644 index f2d1a57..0000000 --- a/README.md +++ /dev/null @@ -1,141 +0,0 @@ -# Python Control of A/V Equipment (RS232 / IP) - -![beta_badge](https://img.shields.io/badge/maturity-Beta-yellow.png) -[![PyPi](https://img.shields.io/pypi/v/pyavcontrol.svg)](https://pypi.python.org/pypi/pyavcontrol) -[![Build Status](https://github.com/rsnodgrass/pyavcontrol/actions/workflows/ci.yml/badge.svg)](https://github.com/rsnodgrass/pyavcontrol/actions/workflows/ci.yml) - -[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=WREP29UDAMB6G) -[![Buy Me A Coffee](https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg)](https://buymeacoffee.com/DYks67r) - -Library created to control a wide variety of A/V equipment which expose text-based control -protocols over RS232, USB serial connections, and/or remote IP sockets. - -### Background - -This `pyavcontrol` library evolved from learnings during implementation a half dozen -custom client libraries for controlling specific equipment such as [pyxantech](https://github.com/rsnodgrass/pyxantech) and pyanthem-serial, which -were used to expose integrations for [Home Assistant](https://home-assistant.io). - -From those learnings, it was observed that the control protocols were often fairly similar and typically -simple pattern matching could be used for converting the interfaces into more modern dictionary based APIs. -This couples with dynamic Python class creation based on YAML protocol definition files for the protocols enables -quickly spinning up new interfaces for specific devices even by anyone who has the ability to read technical -documentation on the protocols (and not just those who are software developers). - -Two additional goals: - -1. allow clients in other programming languages to share the same YAML protocol definitions to provide similar dynamic APIs that support a wide variety of devices quickly. -2. Create a basic IP-based RS232 emulator which allows spinning up a basic emulator for each supported -device model based purely on the YAML definition and unit tests against those definitions. This emulator can be used by client libraries in any language for testing. See [avemu]() for more details. - -## Goal - -One of the goals for creating this library is to reduce the amount of otherwise -great equipment being thrown away (especially esoteric equipment that isn't well -supported). Typically these can be modernized easily via wrapping their existing -protocols with modern integrations. - -## Support - -Visit the [community support discussion thread](https://community.home-assistant.io/t/mcintosh/) for issues with this library. - -## Supported Equipment - -See [SUPPORTED.md](SUPPORTED.md) for the complete list of supported equipment. - -## Background - -One annoying thing when developing `pyxantech` was that none of the devices -ever had a protocol definition in a machine-readable format. Manufacturers -would provide a PDF or XLS document (if anything at all) that listed -the various commands that could be sent via RS232. However, there was no -consistency for what were generally very similar callable actions when -controlling preamps/receivers/etc. - -During the development of `pyxantech` it became clear that other manufacturers -had copied the protocol developed by Xantech, with each -manufacturer just making a very small change in the prefixes or suffixes. -From this, a very primitive mechanism was built. YAML was chosen -to be a machine-readable format that was also easily read/updated by humans -who may have limited programming skills. - -This makes it easier and quicker to -add support for new devices without having to build an entirely new library each -time (with varying semantics and degrees of testing/clarity/documentation). -Additionally, these definitions make it possible to create similar libraries in -a variety of languages, all sharing the same protocol definitions. - -The evolution found in this `pyavcontrol` library takes these ideas further by -having a much more cohesive definition of protocols. Additional ideas were -discovered in [onkyo-eiscp](https://github.com/miracle2k/onkyo-eiscp) around -providing a simple CLI to use the library and grouping commands together -logically. These ideas combined with the argument definitions and pattern -matching from `pyxantech` moved these ideas closer to reality. - -If you are trying to implement your own interface to McIntosh in other -languages besides Python, you should consider using the YAML series and -protocol files from this repository as a basis for the interface you provide. -The protocol and series definitions will likely be split out into separate -definition-only package(s) in the future. - -## Using pyavcontrol - -### Documentation - -See [API documentation](https://rsnodgrass.github.io/pyavcontrol/). - -### Asynchronous & Synchronous APIs - -This library provides both an `asyncio` based and synchronous implementations. -By default, the synchronous implementation is returned when instantiating -new objects unless an `event_loop` is passed in when creating -DeviceModelLibrary or DeviceClient objects. - -Async example: - -```python -loop = asyncio.get_event_loop() - -library = DeviceModelLibrary.create(event_loop=loop) -model_definition = library.load_model('mcintosh_mx160') - -client = DeviceClient.create( - model_definition, - url, - connection_config_overrides=config, - event_loop=loop -) - -await client.power.on() -await client.volume.set(50) -``` - -### Connection URL - -This interface uses URLs for specifying the communication transport -to use, as defined in [pyserial](https://pyserial.readthedocs.io/en/latest/url_handlers.html), to allow a wide variety of underlying communication mechanisms. - -Example URL formats supported by pyserial: - -| URL | Notes | -| ------------------------ | --------------------------------------------------------------------------------------------------- | -| `/dev/ttyUSB0` | directly attached serial device (Linux) | -| `COM3` | directly attached serial device (Windows) | -| `socket://:` | remote service exposing RS232 over TCP (natively or using something like [Virtual IP2SL](https://github.com/rsnodgrass/virtual-ip2sl)) | -| `socket://mx160.local:84` | direct connection to MX160's port 84 interface | - -## Future Ideas - -- Add programmatic override/enhancements to the base protocol where pure - YAML configuration would not work fully. Of course, these overrides would have - to be implemented in each language, but that surface area should be much smaller. -- Move to a modern schema/config language for the library (Nickel, PKL, etc) -- Split out the library definitions from the library itself (eventually) so other language clients can share - -## See Also - -- [avemu - A/V Equipment Emulator](https://github.com/rsnodgrass/avemu) (very useful for testing client libraries) -- [Earlier McIntosh control in Home Assistant](https://community.home-assistant.io/t/need-help-using-rs232-to-control-a-receiver/95210/8) -- https://drivers.control4.com/solr/drivers/browse?q=mcintosh -- [RS232 to USB cable](https://www.amazon.com/RS232-to-USB/dp/B0759HSLP1?tag=carreramfi-20) -* [IO Ninja](https://ioninja.com/) diff --git a/SUPPORTED.md b/SUPPORTED.md deleted file mode 100644 index 6785c4c..0000000 --- a/SUPPORTED.md +++ /dev/null @@ -1,66 +0,0 @@ -## Supported Equipment - -*This is autogenerated from the series and protocol yaml definitions.* - -### Lyngdorf - -![Lyngdorf](https://raw.githubusercontent.com/rsnodgrass/pyavcontrol/main/img/lyngdorf-logo-small.png) - -#### Processor - -| Model(s) | Protocol | Supported | Notes | -| -------- | :---------: | :-------: | ----- | -| MP-40 | ? | ? | | -| MP-50 | mcintosh_v3 | ? | | -| MP-60 | mcintosh_v3 | ? | | - -### McIntosh - -![McIntosh](https://raw.githubusercontent.com/rsnodgrass/pyavcontrol/main/img/mcintosh-logo-small.png) - -#### Room Correction - -| Model(s) | Protocol | Supported | Notes | -| -------- | :-------------: | :-------: | ----- | -| MEN220 | mcintosh_rconly | YES | | - -#### Processor - -| Model(s) | Type | Protocol | Supported | Notes | -| -------- | :-------: | :------: | :--------: | --------- | -| MX160 | Processor | V2 | YES | Serial/IP | -| MX170 | Processor | V2 | YES | Serial/IP | -| MX180 | Processor | V2 | *UNTESTED* | Serial/IP | -| MHT300 | Receiver | V2 | *UNTESTED* | -| MX123 | Processor | V2 | *UNTESTED* | -| MX100 | Processor | V2 | *UNTESTED* | -| MX118 | Processor | V1 | NO | -| MX119 | Processor | V1 | NO | -| MX120 | Processor | V1 | NO | -| MX121 | Processor | V1 | NO | Serial/IP | -| MX122 | Processor | V1 | NO | -| MX123 | Processor | V1 | NO | -| MX130 | Processor | V1 | NO | -| MX132 | Processor | V1 | NO | -| MX134 | Processor | V1 | NO | -| MX135 | Processor | V1 | NO | -| MX136 | Processor | V1 | NO | -| MX150 | Processor | V1 | NO | -| MX151 | Processor | V1 | NO | Serial/IP | - -#### Receiver - -| Model(s) | Type | Protocol | Supported | Notes | -| -------- | :------: | :------: | :--------: | ---------------------- | -| MAC7200 | Receiver | V? | 2-ch | -| MHT100 | Receiver | V1 | NO | -| MHT200 | Receiver | V1 | NO | -| MHT300 | Receiver | V2 | *UNTESTED* | -| C48 | Receiver | ? | NO | serial RS232-C (3.5mm) | - -### Trinnov - -| Model(s) | Type | Protocol | Supported | Notes | -| -------- | :------: | :------: | :--------: | ---------------------- | -| Altitude16 | Processor | | | -| Altitude32 | Processor | | | diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 905d0e6..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,26 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = source -BUILDDIR = build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Update all the supported models from the library sources -update_supported_models: - pip3 install --quiet -r ../requirements-dev.txt - ./update-supported-models - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile update_supported_models - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - diff --git a/docs/source/background.md b/docs/source/background.md deleted file mode 100644 index e5401e8..0000000 --- a/docs/source/background.md +++ /dev/null @@ -1,6 +0,0 @@ -# Background - -## Goals - -## History - diff --git a/docs/source/conf.py b/docs/source/conf.py deleted file mode 100644 index 33ce576..0000000 --- a/docs/source/conf.py +++ /dev/null @@ -1,41 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# For the full list of built-in configuration values, see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -import os -import sys -sys.path.insert(0, os.path.abspath(os.path.join('..', '..'))) - -# -- Project information ----------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information - -project = 'PyAVControl' -copyright = '2024, Ryan Snodgrass' -author = 'Ryan Snodgrass' -release = '0.0.1' - -# -- General configuration --------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -# -# NOTE: Use Google Docstring format using the sphinx.ext.napolean -# extension, since Google Docstring is a way more readable format -# than the default Sphinx format. -# -# myst_parser = Markdown support (instead of RST) -# see https://myst-parser.readthedocs.io/en/latest/syntax/optional.html -extensions = ['myst_parser', 'sphinx.ext.autodoc', 'sphinx.ext.napoleon'] - -templates_path = ['_templates'] -exclude_patterns = [] - -# -- Options for HTML output ------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output - -html_theme = 'sphinx_rtd_theme' -html_static_path = ['_static'] - -# -- Generate documentation for Dynamic classes ------------------------------ -# see https://pypi.org/project/sphinx-autorun/#description - -import pyavcontrol.library.docs diff --git a/docs/source/examples.md b/docs/source/examples.md deleted file mode 100644 index 8a9ab15..0000000 --- a/docs/source/examples.md +++ /dev/null @@ -1,10 +0,0 @@ -# Examples - - -### Simple Client - -```python - -``` - - diff --git a/docs/source/hardware.md b/docs/source/hardware.md deleted file mode 100644 index 4c832d7..0000000 --- a/docs/source/hardware.md +++ /dev/null @@ -1,34 +0,0 @@ -# Hardware Specific Details - -## Useful RS232 Parts - -The following are various useful parts, hardware, and software for interfacing with various equipment using this library. - -* [USB to DB9 RS232 Cable](https://amzn.com/dp/B0753HBT12?tag=carreramfi-20&tracking_id=carreramfi-20) -* [IP/Ethernet to DB9 Adapter](https://amzn.com/dp/B0B8T95FV1?tag=carreramfi-20&tracking_id=carreramfi-20) -* [Virtual IP2SL](https://github.com/rsnodgrass/virtual-ip2sl) - -## Xantech - -### High-Density RS232 Control Cable (Xantech Part 05913665) - -Some Xantech MX88/MX88ai models use high-density HD15 (or DE15) connectors for rear COM ports, thus requiring Xantech's "DB15 to DB9" adapter cable (PN 05913665). The front DB9 RS232 and USB COM ports cannot be used for device control on these models. Instead, use the rear COM ports which are already wired as a 'null modem' connection, so no use of null modem cable is required as the Transmit and Receive lines have already been interchanged. - -Thanks to [@skavan](https://community.home-assistant.io/t/xantech-dayton-audio-sonance-multi-zone-amps/450908/80) for figuring out the pinouts for the discontinued RS232 Control DB15 cable (PN 05913665) with incorrect pinouts listed in the Xantech manual. The following are the correct pinouts: - -| HDB15 Male | Function | DB9 Female | DB9 Color | Function | Notes | -|:----------:|:--------:|:----------:| --------- | -------- | ----- | -| 13 | Tx | 2 | Brown | Rx | | -| 12 | Rx | 3 | White | Tx | | -| 4 | DSR | 4 | Green | DTR | | -| 6 | DTR | 6 | Red | DSR | | -| 9 | GND | 5 | Yellow | GND | Ground (see also pin 11) | -| 11 | GND | 5 | Yellow | GND | Ground (OPTIONAL) | - -Example parts needed to build a custom Xantech MX88 style cable: - -* [USB to DB9 RS232 Cable](https://amzn.com/dp/B0753HBT12?tag=carreramfi-20&tracking_id=carreramfi-20) or [IP/Ethernet to DB9 Adapter](https://amzn.com/dp/B0B8T95FV1?tag=carreramfi-20&tracking_id=carreramfi-20) or [Virtual IP2SL](https://github.com/rsnodgrass/virtual-ip2sl) - -* [DB9 Female Connector with wires](https://amzn.com/dp/B0BG2BPVXV?tag=carreramfi-20&tracking_id=carreramfi-20) or [DB9 Female Connector](https://amzn.com/dp/B09L7K511Y?tag=carreramfi-20&tracking_id=carreramfi-20) - -* [Xantech Male DB15 Connector](https://amzn.com/dp/B07P6R8DRJ?tag=carreramfi-20&tracking_id=carreramfi-20) diff --git a/docs/source/index.rst b/docs/source/index.rst deleted file mode 100644 index fd16cf8..0000000 --- a/docs/source/index.rst +++ /dev/null @@ -1,36 +0,0 @@ -.. pyavcontrol documentation master file, created by - sphinx-quickstart on Mon Feb 26 01:18:26 2024. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to PyAVControl -======================================= - -Library created to control a wide variety of A/V equipment which expose text-based control protocols over RS232, USB serial connections, and/or remote IP sockets. - - -.. image:: https://img.shields.io/badge/Donate-PayPal-green.svg - :target: https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=WREP29UDAMB6G - :alt: Done - -.. image:: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg - :target: https://buymeacoffee.com/DYks67r - :alt: Buy Me A Coffee - - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - pyavcontrol - background - examples - supported - hardware - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/docs/source/modules.rst b/docs/source/modules.rst deleted file mode 100644 index 789f0e3..0000000 --- a/docs/source/modules.rst +++ /dev/null @@ -1,7 +0,0 @@ -pyavcontrol -=========== - -.. toctree:: - :maxdepth: 4 - - pyavcontrol diff --git a/docs/source/pyavcontrol.client.rst b/docs/source/pyavcontrol.client.rst deleted file mode 100644 index 422e7f5..0000000 --- a/docs/source/pyavcontrol.client.rst +++ /dev/null @@ -1,37 +0,0 @@ -pyavcontrol.client package -========================== - -Submodules ----------- - -pyavcontrol.client.async\_client module ---------------------------------------- - -.. automodule:: pyavcontrol.client.async_client - :members: - :undoc-members: - :show-inheritance: - -pyavcontrol.client.base module ------------------------------- - -.. automodule:: pyavcontrol.client.base - :members: - :undoc-members: - :show-inheritance: - -pyavcontrol.client.sync\_client module --------------------------------------- - -.. automodule:: pyavcontrol.client.sync_client - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: pyavcontrol.client - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/pyavcontrol.connection.rst b/docs/source/pyavcontrol.connection.rst deleted file mode 100644 index ac83a71..0000000 --- a/docs/source/pyavcontrol.connection.rst +++ /dev/null @@ -1,29 +0,0 @@ -pyavcontrol.connection package -============================== - -Submodules ----------- - -pyavcontrol.connection.async\_connection module ------------------------------------------------ - -.. automodule:: pyavcontrol.connection.async_connection - :members: - :undoc-members: - :show-inheritance: - -pyavcontrol.connection.sync\_connection module ----------------------------------------------- - -.. automodule:: pyavcontrol.connection.sync_connection - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: pyavcontrol.connection - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/pyavcontrol.library.rst b/docs/source/pyavcontrol.library.rst deleted file mode 100644 index d25ca02..0000000 --- a/docs/source/pyavcontrol.library.rst +++ /dev/null @@ -1,45 +0,0 @@ -pyavcontrol.library package -=========================== - -Submodules ----------- - -pyavcontrol.library.base module -------------------------------- - -.. automodule:: pyavcontrol.library.base - :members: - :undoc-members: - :show-inheritance: - -pyavcontrol.library.docs module -------------------------------- - -.. automodule:: pyavcontrol.library.docs - :members: - :undoc-members: - :show-inheritance: - -pyavcontrol.library.model module --------------------------------- - -.. automodule:: pyavcontrol.library.model - :members: - :undoc-members: - :show-inheritance: - -pyavcontrol.library.yaml\_library module ----------------------------------------- - -.. automodule:: pyavcontrol.library.yaml_library - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: pyavcontrol.library - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/pyavcontrol.rst b/docs/source/pyavcontrol.rst deleted file mode 100644 index 202c9e0..0000000 --- a/docs/source/pyavcontrol.rst +++ /dev/null @@ -1,40 +0,0 @@ -PyAVControl package -=================== - -Subpackages ------------ - -.. toctree:: - :maxdepth: 4 - - pyavcontrol.client - pyavcontrol.connection - pyavcontrol.library - -Submodules ----------- - -pyavcontrol.helper module -------------------------- - -.. automodule:: pyavcontrol.helper - :members: - :undoc-members: - :show-inheritance: - -pyavcontrol.utils module ------------------------- - -.. automodule:: pyavcontrol.utils - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: pyavcontrol - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/supported.md b/docs/source/supported.md deleted file mode 100644 index 8b203b4..0000000 --- a/docs/source/supported.md +++ /dev/null @@ -1,131 +0,0 @@ - -# Supported Equipment - -*This is autogenerated from PyAVControl's DeviceModel library (using build-supported-models-doc).* - - - -## Acurus - -| Model | Protocol | Tested | Notes | -| :---------------- | :----------------: | :-----: | :---------------- | -| M8 | acurus_m8 | model.tested | model.notes | - - -## Anthem - -| Model | Protocol | Tested | Notes | -| :---------------- | :----------------: | :-----: | :---------------- | -| Statement D2 | anthem_d2v | model.tested | model.notes | -| Statement D2v | anthem_d2v | model.tested | model.notes | -| Statement D2v 3D | anthem_d2v | model.tested | model.notes | - - -## ClassÊ Audio - -| Model | Protocol | Tested | Notes | -| :---------------- | :----------------: | :-----: | :---------------- | -| Omicron | classe_omicron | model.tested | model.notes | -| SSP-300 | classe_ssp600 | model.tested | model.notes | -| SSP-600 | classe_ssp600 | model.tested | model.notes | - - -## HDFury - -| Model | Protocol | Tested | Notes | -| :---------------- | :----------------: | :-----: | :---------------- | -| VRROOM | hdfury_vrroom | model.tested | model.notes | - - -## JBL Synthesis - -| Model | Protocol | Tested | Notes | -| :---------------- | :----------------: | :-----: | :---------------- | -| SDP-75 | jbl_sdp75 | model.tested | model.notes | - - -## Lyngdorf - -| Model | Protocol | Tested | Notes | -| :---------------- | :----------------: | :-----: | :---------------- | -| CD-2 | lyngdorf_cd2 | model.tested | model.notes | -| MP-60 | lyngdorf_mp60 | model.tested | model.notes | -| TDAI-3400 | lyngdorf_tdai3400 | model.tested | model.notes | - - -## Marantz - -| Model | Protocol | Tested | Notes | -| :---------------- | :----------------: | :-----: | :---------------- | -| AV8805 | marantz_av8805 | model.tested | model.notes | - - -## McIntosh - -| Model | Protocol | Tested | Notes | -| :---------------- | :----------------: | :-----: | :---------------- | -| MHT100 | mcintosh_legacy | model.tested | model.notes | -| MHT200 | mcintosh_legacy | model.tested | model.notes | -| MX118 | mcintosh_legacy | model.tested | model.notes | -| MX119 | mcintosh_legacy | model.tested | model.notes | -| MX120 | mcintosh_legacy | model.tested | model.notes | -| MX121 | mcintosh_legacy | model.tested | model.notes | -| MX122 | mcintosh_legacy | model.tested | model.notes | -| MX123 | mcintosh_legacy | model.tested | model.notes | -| MX130 | mcintosh_legacy | model.tested | model.notes | -| MX132 | mcintosh_legacy | model.tested | model.notes | -| MX134 | mcintosh_legacy | model.tested | model.notes | -| MX135 | mcintosh_legacy | model.tested | model.notes | -| MX136 | mcintosh_legacy | model.tested | model.notes | -| MX150 | mcintosh_legacy | model.tested | model.notes | -| MX151 | mcintosh_legacy | model.tested | model.notes | -| MX170 | mcintosh_mx170 | model.tested | model.notes | -| MX180 | mcintosh_mx180 | model.tested | model.notes | - - -## Monoprice - -| Model | Protocol | Tested | Notes | -| :---------------- | :----------------: | :-----: | :---------------- | -| MPR-6ZHMAUT | monoprice_6 | model.tested | model.notes | -| Model 10761 | monoprice_6 | model.tested | model.notes | - - -## Teac - -| Model | Protocol | Tested | Notes | -| :---------------- | :----------------: | :-----: | :---------------- | -| Elan Dual Tuner | teac_trd2000 | model.tested | model.notes | -| Speakercraft STT 2.0 | teac_trd2000 | model.tested | model.notes | -| Teac TR-D2000 | teac_trd2000 | model.tested | model.notes | -| Xantech XDT | teac_trd2000 | model.tested | model.notes | - - -## Trinnov - -| Model | Protocol | Tested | Notes | -| :---------------- | :----------------: | :-----: | :---------------- | -| Altitude16 | trinnov_altitude16 | model.tested | model.notes | -| Altitude32 | trinnov_altitude32 | model.tested | model.notes | - - -## Unknown - -| Model | Protocol | Tested | Notes | -| :---------------- | :----------------: | :-----: | :---------------- | -| MRAUDIO8X8 | xantech_mx88_audio | model.tested | model.notes | -| MRAUDIO8X8m | xantech_mx88_audio | model.tested | model.notes | -| MX160 | mcintosh_mx160 | model.tested | model.notes | -| MX88a | xantech_mx88_audio | model.tested | model.notes | -| MX88ai | xantech_mx88_audio | model.tested | model.notes | - - -## Xantech - -| Model | Protocol | Tested | Notes | -| :---------------- | :----------------: | :-----: | :---------------- | -| CM8X8 | xantech_mx88_video | model.tested | model.notes | -| CM8X8DR | xantech_mx88_video | model.tested | model.notes | -| MRC88 | xantech_mx88_video | model.tested | model.notes | -| MRC88m | xantech_mx88_video | model.tested | model.notes | -| MX88 | xantech_mx88_video | model.tested | model.notes | diff --git a/docs/update-supported-models b/docs/update-supported-models deleted file mode 100755 index 13ae720..0000000 --- a/docs/update-supported-models +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -import logging - -import os -import sys - -# force using the pyavcontrol that is relative to this tool, instead of whatever -# pyavcontrol may be installed in the Python environment. -sys.path.insert(0, os.path.abspath(os.path.join('..'))) - -import coloredlogs -import argparse as arg -from jinja2 import Template - -from pyavcontrol import DeviceModelLibrary - -LOG = logging.getLogger(__name__) -coloredlogs.install(level="DEBUG") - -DEFAULT_FILE='source/supported.md' - -TEMPLATE = Template(''' -# Supported Equipment - -*This is autogenerated from PyAVControl's DeviceModel library (using build-supported-models-doc).* -{% set manufacturer = namespace(value='') %} -{% for model in models %}{% if model.manufacturer != manufacturer.value %}{% set manufacturer.value = model.manufacturer %} - -## {{ model.manufacturer }} - -| Model | Protocol | Tested | Notes | -| :---------------- | :----------------: | :-----: | :---------------- | -{% endif %}| {{ model.model_name }} | {{ model.model_id }} | model.tested | model.notes | -{% endfor %} -''') - -def parse_args(): - p = arg.ArgumentParser(description="Generate SUPPORTED.md using current pyavcontrol model library") - p.add_argument( - "--output", default=DEFAULT_FILE, help=f"Markdown output file (default={DEFAULT_FILE})" - ) - p.add_argument( - "--library", help="library directories" - ) - p.add_argument("-d", "--debug", action="store_true", help="verbose logging") - return p.parse_args() - -def main(): - args = parse_args() - - # if custom library directories have been specified, only output models found within - if args.library: - dirs = args.library.split(',') - library = DeviceModelLibrary.create(library_dirs=dirs) - else: - library = DeviceModelLibrary.create() - - supported_models = library.supported_models() - - # sort by manufacturer and model name - sorted_models = sorted(supported_models, key=lambda k: (k.manufacturer, k.model_name)) - - LOG.info(f"Saving the output to {args.output}") - with open(args.output , "w") as f: - f.write(TEMPLATE.render(models=sorted_models)) - -if __name__ == "__main__": - main() diff --git a/example-async.py b/example-async.py deleted file mode 100755 index 937a91f..0000000 --- a/example-async.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 -# -# Running: -# ./example-async.py --help -# ./example-async.py --tty /dev/tty.usbserial-A501SGSZ -# ./example-async.py --tty socket:/remote-server:4999/ - -import logging -import argparse as arg -import asyncio - -import coloredlogs - -from pyavcontrol.helper import construct_async_client - -LOG = logging.getLogger(__name__) -coloredlogs.install(level="DEBUG") - -p = arg.ArgumentParser(description="pyavcontrol client example (asynchronous)") -p.add_argument( - "--url", - help="pyserial supported url for communication (e.g. /dev/tty.usbserial-A501SGSZ or socket://host:4999/)", - default="socket://localhost:4999/", -) -p.add_argument( - "--model", default="mcintosh_mx160", help="device model (e.g. mcintosh_mx160)" -) -p.add_argument( - "--baud", - type=int, - default=115200, - help="baud rate if local tty used (default=115200)", -) -p.add_argument("-d", "--debug", action="store_true", help="verbose logging") -args = p.parse_args() - -if args.debug: - logging.getLogger().setLevel(level=logging.DEBUG) - - -async def main(): - try: - loop = asyncio.get_event_loop() - - # FIXME: connection! - - config_overrides = {"baudrate": args.baud} - client = await construct_async_client( - args.model, args.url, loop, connection_config=config_overrides - ) - - # help(client.power) - await client.send_raw(b"!PING?\r") - - await client.ping.ping() - - # help(client.volume) - - result = await client.volume.get() - print(f"Response: {result}") - - await client.volume.set(volume=20) - - await client.power.off() - - except Exception as e: - LOG.error(f"Failed for {args.model}", e) - return - - -asyncio.run(main()) diff --git a/example-sync.py b/example-sync.py deleted file mode 100755 index 2905880..0000000 --- a/example-sync.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -# -# Running: -# ./example-async.py --help -# ./example.py --tty /dev/tty.usbserial-A501SGSZ - -import logging -import argparse as arg - -import coloredlogs - -from pyavcontrol.helper import construct_synchronous_client - -LOG = logging.getLogger(__name__) -coloredlogs.install(level="DEBUG") - -p = arg.ArgumentParser(description="pyavcontrol client example (synchronous)") -p.add_argument( - "--url", - help="pyserial supported url for communication (e.g. /dev/tty.usbserial-A501SGSZ or socket://host:4999/)", - default="socket://localhost:4999/", -) -p.add_argument( - "--model", default="mcintosh_mx160", help="device model (e.g. mcintosh_mx160)" -) -p.add_argument( - "--baud", - type=int, - default=115200, - help="baud rate if local tty used (default=115200)", -) -p.add_argument("-d", "--debug", action="store_true", help="verbose logging") -args = p.parse_args() - -if args.debug: - logging.getLogger().setLevel(level=logging.DEBUG) - - -def main(): - config_overrides = {"baudrate": args.baud} - client = construct_synchronous_client( - args.model, args.url, connection_config=config_overrides - ) - - client.send_raw(b"!PING?") - client.ping.ping() - - result = client.volume.get() - print(f"Response: {result}") - - client.volume.set(volume=15) - - client.power.off() - - -main() diff --git a/img/lyngdorf-logo-small.png b/img/lyngdorf-logo-small.png deleted file mode 100644 index 5d450279e8127c2940f834c9882265eb93eadf9a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5395 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 btRm_SOouN&o+1B zEKkw1>vn}+uY+kqS@O^K2H%)Rna}OYHw-gSb87>S5qLUgCa}%{h;i`EZL_Ak0xlYgqHoAGQL>EAI(ycT4nugLGYG{ zkWC*Q=FGej8nBfD*{T^OL?;WSiY531s1g?N0nI%!}9)G0NsLSQm z&a?`pPc7U~#V0`dV9TWN&aEkJeKx9<-WU2S9Buixv|c^ljl92tvYdVt=LU2Irfi1= zuO&U~iaeFjeMUvcCuQ-;NO(aAJ8#5SP1 zC^$X0hI#owaMz;M2zB;+C81b^WY$VOkKH*%;WL5uH!6F>qFILXu>YYA|2K@d4K5yU zbu_NR#r`y`2!E{SxN~*@|Ee=HnYQ1yy4%4#^1w8tfEgpWns~5F*x6O|*BA$PO}2b* zf7=89+o}oXDaXnV;Jja}W#`7FpVdAtr=dhTJ{;(`s&adKPNbmp{kVa0R9TS7;xFmSqh zMDj_Lcimgi%`MN!V}vM?OSh~(WR0T@J4~DgFavb`0Q8R)i8eWj9y#aW(9w(U7sTCZ zD{39CO_CqV2%5m%(&HhBtPp`W;VBmq1Lda|h9CUY$9 zy~duim%MeZ6{>IVPkEUWO@t2IMvE;|`ZCbUj{YNZXkl7fUQ*<|V#K_naq*Tzbj1_m zS1cb~9EkxXF1HDi{OLjqL53T9D<1 z=lHE$o3u0aX9fKfksv~P8=Sq5EjYMe_1}l#!r@%hDLmsBY$es{%Dz@scUV8s2^?`h zu-}3IPMAEQKPzbAugKSQ7}UB!jVH?7Psiq@&ak^pLX0jx$d${96>q31#?5eLuhQgH zT`)ytZE~@=6!&sT+=*8&Z%Peb#_YUY?9(rFt*8jz0+m>MVFnJJPPTbrb=^xLwD@pf z1qXLD{vGTb!vS+$P;n<`)kA0Z|;}zJge4x0WHc#%pMV4d(jn9Wqv(9 zOm`$PW8N8f8g6%-Bt(gajfL|~RC-DjhB=aZAGJg;NY38cWty^ocKCkIxubU70j505 z%-7U=e;W_RdQymn5vd>{KFLhiS(A_Uq@@H#5Cz zyUj)Q|M!h(C9{{lz$>6@risL-n1u4pq@U%aVt(TRQMrLWwz&vVF_^k@(C9o~E~6iO c{^loPh^c@b=-9z4mvMA-Jc4v|Y`bnlLpv7@>{vf7?@ER>R_6MI8H z{0pW_=B|S1eQ|U;tha?2=l^^^4DON_{^R}5M?3HTyNfFNQ8<)qAo?*K+3&lqpmkXP zhSJ|a90nEs1devipN8w7VoQIA_2l31P44&;ewrBRZz=G_{dFahmj5gR!v9zj|H-5J zzrjZL7v?5&{|$V`zmOb3`7b0#nEfx&e-nZKDBr3qi#c{9?T7C6hWU|H6&>Rx*pT+{ z*B6F5>wjGNUwPTM6S}H^-+9)#jRUnGzs{r|7n z|4xGcyF2RtbiV)HZ2lK0)PMEgzmCQKaP9qrY31>rQr>9~pSBD4i^wCB`QLAz|LH^f zAIbNBfkORPTm9>f^l#h0e}~Zj1=9L=Tkd~j-v0OKzyI&Hft&;Woz(t^Iq-jg;6LfX z=KrI&|EvH0^+ft_oml@n@%lfl|NcME9e)D*cg^#EY^(bpkp4&J?LU8^{;RG21Eg|YA*U+``0|W$ohH)x@NkjD!RASU5Ab@@as?XBo)Va_BtKbqm=jl zMk!t2e|MH7(z8n$qQ8Zs!?L~rcYT5Bh}?g?M+@Hu7 z*>mkX6trfR7WX)*VuCvRl4iV$-F=a|-TA`>UovoP$CsC#>3wNOHyiEyCb;xzfrRRs zB}>P>Ui_LtjBOo?DNjk+>Y4qWa{Um^&GEeyEuN`?OVELAwPGFq<0)p@MSY{yDyiTu zX9XxXNM(JBZcbQBN|@hlz3)zY`|G7HRei*4_Bk_qChFrXMCYv7hFDUv85M_I7G+w{ z-#iu6N(lopOw70Oil&hO<9oHIX3ln|>E{2yRf{1utOfHr6&H@P& zF)rSHj70AHR4V04f)Ggb^j-PgUu=pX^<=@%1D{pb+y#f3H2ZNC6%F2^@SB&7lCAJCE2n%gki*&gUsDBSo?>EK2}=~hJ94Wj-0O9Lp{X&e%XewGF*<*#hn9uCBB z-QpYezs~)pVX82sXJARr^EqKV_fAN@hPtVONGB}d;93_D`WV3Wz?>NY)l+ocpEnbu z%u>PpM5V?y_#ru?e4(SwNF5sQ8Y{f5k#URi2y1-f^2Dy3xa)l{RUorB0U8V?5vP+j zWqvqNETsZOhxLSsXmcp|$P%I7C^v5Mfmu2jcd<)B6;d?!mzd(b)8R;A6?&6fxYhc2 z_jGha`meRsc6%RA?___|*epY#BJlj!grSHQc0KglF&oXw&(Dtvm2d6WjRJx?;qtxd zT1wg|j#MJn--k*bdRoGjwP`wEUQs!8V|~%P*h_x=IQy&X#Ap3BuA{- z&$q8@7@9=5d^Pjts}nriMotsYYBJkXR@`#561MpUd(4?`I#5=G$bG8s+FpR^GWeg} z6HkI(C#|HqJ!2`u7MgI`J*KzQ*U3kE~8jy`W4FBp+NL4$~@NJsNw}dp?RaetYx>h+Y7WKG+W9EU|(r^ zRPy`~(UeJjNG)JfKr*okc)eJB9i%6?`z&^N?}E5JH2ZsKx65XFBheG@?R4fZ_W~q} z%q+gp)G7X+FiiGLrhm+Y<*NW6Rw{o8aR{!qH}JDG!_(`qJXgF0DQbLWbUB(q^o$po zS)N|JIJVF2qyrA?zhs>({4-kaH}efVz1;;}zI-sn+A&POG?%Q5C01(6DasA##5y1F zY`5+}l0Awfd+#-1&|Qmn^vlhqh2t;x^uQ~6%hjYFk-eR;LUO&s`flr%-@CBJ3o6N0 z(TG1v1Cd#i=0*Mfc8Bb-7T8JbcthM&YUgputC07)`CHB>@TPoU-`JHbuTO{pE_v;9 zn@l!}y2fvLy(nc}yS2rc8KkMFzAS$Dp6-d2t!LX9n09Kd9N1}2yJMP<;PU)0k zWqcVB6kW(OI4c|j92?QYhv&DOo(o6@X2xWZ)+I$^$T0t~d^XAArBtYns_2&B_(7o= z{nOaqxqw!Cm7@k9sCm>~2?dWCI}u@OF-4I4+hjo|U#!M-r92fBX#L?g{t!r7$}6Bu z23)YQr`q!l?(@>6nJnUq^uK|m)w}taG(U0k>{^6m_S=@g`5QGhBI*i%*FH{ zhy`J)NWuz$phI(n{-se@;&v`3_{FJfNHY7`UC?i(@aropZs%8)A11#6`|K)c+UGuZ zqqFX4Rd%hB?YUOblu2{?V7w%!Ber)&Aaj@4%J}1~d@20GnNB!e3jZ^d@A+#L(8Gb>BXMGC)1K$@JA_Xg{PhefDapmd4yb7SHe?? z=;`x*&~Fe+l-FFz`I(wf@YCiE7~w#P7)fq4-K2JjRF$HS{M5zz^ZTeKj+;g2Az+zj zdSuvjuMT5-mzT#eMq3K^H>WoIgh3yGTz`Wm!?=FLIXjY3T9|&beQ|=1#SAYrX zq#e-wwSk6kz0YO6 z5hiZ*8YzKp#6`vB=NHJOCqByH7tchknzRzNhZ3>9u2r`vKZymasMfErXf)K7Sd~lc zXvTYJkU%aC{3p4$nJ#mXG`^HypbZ|S5h9!T^4o4jlaSUZ#qVxio2pQKG$rU0kIRQ6 zwoVk=)LgK^fugqdYCPX1Z^SUMs*Tg&GM=Kgfz4ZXY=iyM(UZrnePnFyg~=-BP;@t3 zw^B0_^657x@${N$w5cx)3?zsW(OsX%_Rm%Eo%ihpoR&VW5Iw5^(XB5Ff_P{YHWZqd zTxQ+}gZk)p^r(*pq#jLq?_)6&d=WDUI4$<8LL6UVABh~YiT|yb%Ra)=BTtFwx++p& ziz*a6KH^z>CTatZDY}2!=jx8$U{$USLcbU1?v|k0wQA1T`dgHVqkUEbajrzqaVP7d z4{!4Zi3MwE_7sHbs}8Yq%J?>^&IynEsy{eST!zlSn|^4R%Y$7Pp6VEC+&UMwSO4KF zP>R}_;$JdE92&oXA7GKcyR};dO;!9{vD#hPBgZgZ?{@UQdJ*UFX~o;$mUs0Raaw>M zwh7*n`7R}P-abVv^f!5~mE2Z4a4wMhWJ%BXeu(Kq`?a+AiunVOw_N!_DEbr3)#x1& ze>-_$@DZvOW`R6)cL@OG>r(HGERr5kJ21VY_YE+zry!0YU4Lr6u#kJZx$i>Q;&|@Vw+Cpd>VDtf5SSzWbb0$u3^0tDeQI0_e-cVz$nSjR_j%t(E6Pd2 z;-$R4lN>g7*O~}~xZAfueZhpnw%Pp<{ZS#4#tHO^V#BuFMA?<;QlXKD+i zB!-z{L=p;u7zKdMrHFC4EN&x==Nhk!93;J>SPQF4QaKdPTc>CVMSPWE(3de7M&aMx z$S>H>DIuKYI!+gw?n%FXUkxJyc=cWBNWMY-VOrgKk>Md|19`s?kidh&Rc1!I0%1Ww%)9~MRh>O-*MCgt0W zClA1YLdf54Sl}n$3lv`E+AopZTA1pMCn}wr(#=ZYu0evqyPEBqpVPY&tj3h&^hoMQ z4a|Zfcd5wL!jH064l;g-rLQ}~14TE&b#f1Spx1{=b8E59I0J$>&&LH~oT{v_D!tfSq%F?5bt@?K;M|1?)a;lDFs=^gCdC zar3p;`lyu7E^#I(=i)a#OytJV`k!OdpC=PF`%&}ffVv_u&V}csXTaTNgh(3<-?yZx zzq~89^{40NCQgVuImSj;!|eJ|@ty|2v7*$)Hk|=r+xH+u-v@s#Ha1(Oqb>XBfz9Iz zS#umdsFiov>W=qO5*%Ngzck6}ZM8bu#fv5*YXPB%gN$uYZxbSq(a@rOI0GmG2 z78Cw@|Bzz;kTRy}hT#J+!iJ*tvp>^Uwg_6cvlu*5a+}bh!DV{CBSrI=dT!Pxr`o?JjNC~ zQ$Bg|-2Abp#>8W;B%RHT(h|Fn%p)#!AM{u=%y7Rv)wubcdeLHgFQ!kI^)tNWFy<7q z4ot6miUMSPC6%DoiEDmRW80ST1)Xpt*kD7U6za6;8ozrv>nPDSS$hFv>~u8ylGeM* z+m|237bK`f5w{0QvdUi;SSlWm0A*gqb3;G_%3f!7V&aQOS^GY}Xd?JJD!r)I=sq*6usOG&~&M{A+Q#+>mr|sE`j@##JY=qRJ0Dp zvtEgCW!>ZVnBrhVRISU6H^A5%2lhTLb8?szE82hf8YVJdMQatjqoGgL$B3jCTw~vk z1N|AJe%VI&Ge}T{ZXjL())Y$klVO3ZJ1509g%W?sKS5%N8Sz&OQH`tOg={4>r}9G# zUN=QBu-*p;QUo*%B0)UCv+5q@&ZFbX!}?b~Jp?5lr5BQyU+Q#w44-}g`iXp+6icKx zF?23JD<*-@C2C@1#UWl7A)HQ&^taoBQJ<>daVY@m`eZ3^+Uqu@Nm3m{tUZ&RwDINY zGgHwivahgO#0st5&P_u8?nQ!0KVy`Qlq_}w*`LJ7PYRA}##LcY1K)+G&f-Nbd~>Lr z=(}L6H*yx>fqGH&L8|y6Z%X{$X1tlIB))FFHe_&YPlne?WPn)4UJV}MHVf|zrgq%Y zNan7fbwM%1v`VSU6a50Ka;F(4iho$Pvdf;tFGXmZB--zmBz@!_3JGwYSXdifDm02L z!8uo--?}UqD}A7zi=%BOgl#T&IQrw5O-IMRj4mrmFW=*K2o<%`SOX`@c%Ed{sgvPH zJ!w|6kWabU@&bds)e@bq^uv_82cBso@*gP*gR_p`5 zqjUG)ZK#kZuc+DNn;N>>SyGh9?JqZ?oMN?iml-ipghT4OMMN(1a$MYJDp#MCkxJoIlL$!W2{ls zZtfDMC9m3!6M)ld;_#^hHw#WWtM7$y#O~WQ9#yShl$3@7Y@U~4)+6$`H`ln-b1Xhs zr)4e^t79}}GAfeprx{^nrI8bsZf|K4`aUS^@`Ii`nah3L<7=szppi4M`SEGS9A$19AVt^>!kdpegrz^1!a zI>*obM5k$7M+H-<^4ZY`wj)6=J&%haJtaX=z}KD0>EX|?u=v=vvU;xL2^K!~K(^Nt z``r%vfXy1piTp*)jJul-Z@DBl7sN?A@=6U_NJeit5zTL<%+A1in5M6~tU~hY?Ritd zFW*PqaA8PMn@V&Y9EPHDWNY5Jzr=$vULbV4 z)>u;}bA(pRTUR(FP=_FOUHl5WgVfid#zCL31045Fek*w1I%V+LX7WsV_VOToA%lc-%$C{Vves%`SUh(*eG*=yH{=Pj z#@9iSIDhm|+qaBweZQr~g*+rla?iY%yR%i$?R9<|N7^OuPWn{YYe>U6^|4yql=YIZ zKqMH~Z^=9*W5%-LviXLi5pEe8_PMGzs<$dDJTT~(pV8z7d7hNjZdvCA$( zQPvcKvs6|1L{$S%;iV;Rs0jQ0LKtDsP|rGLg!1VV?4^)V{T2Z}34GY@fM#p^C5B!#PSsf;94TQe?*doriSxoJ7V`2t2#>R!a6jE&LHhJ;9zqHv_w2 znpWa+uhm#zk0+=s(4!r00YN<%y`HtmM?Q}E<~V5P`Spn;o;|i+n-uTq3k;<5Bo7o@ z#Cz6C2q*bP3Cc4CG4(Rl2e>C&;UKg1#37fX0h}U(I`_|!n_!-lX|ni;Fo31);uJQ| zeB?z%gDH-mLj~nM`2&5g9q$r&Urp$k+DP6zv+vd5kyDs_q-P!s$m>x@ruVV5RPNLy z^b@eV`BKC!vP?;wPmsGGuArp4a&dFTaj5itj!Ejo+lOywaRh`ow6P z`9M+m1@G~&3+8x(R?~u7_&hdafbP46pUI;}886cAfHP$n`z`BljOtN!@I`{>{d>4@ zZ;CRIfyzw{&$^LdG%I?;@J1vU^kOkm?5-=Cadoy9Qxdk!8W!xQcHrw6&pF(yU)w9!pauspl+;3Z-61jf?B0*dtAsN%8$-J0eiN7@qoUo zPB`mZJ<}Yt70VzWlY^rGPc+c?j0i@hTogTX_8fq1f*H`UDh1FzNosT931y`0LRq%Q z-mSNGD`SiY^m`fg&ZT@jNiL)9#7ng8<{w3Zb?|rQ5Tl}9vl2`P8{wPdQcvc&<;0#y z^`sI8P6A7JD97jI^RymTpJC7L{y^yPjq2a~uF8t?1|xfN=I0GYM3Hm*98b=(rGWU2}8z@B3dkPV3Oc>{-UKh?TA1P}Y3wGlkOz^CDx z*?N@cdcIt6Eo`P+qFXlCtn~e9>c`H8$1T{?F=gFQ{ zDymW5q~uv0OrBb+@EW^&J7JR{=;Ieo3?OwlBQ!w|;j~n^V4P3{McWuTWNK51*L)-U zK+9tP*CvQ}ON#o%>zS)IaeG56teSs37rM=GmIv`~ajNkfk6{8sYJq7zj0_RWaJzL( z*cMYmOPYxljyZ+Y9^W*y1fD-Wve4WLKVOCf>L(+?1xczP)+*=CYgOp-Vd!~Ru7SiJ z0r`v*%z+b^(_ahK!AlE+4z1er9YmvWGg7mZ!g?)L8DJ_XY!uzbmiOAjJ9EE{$0krau8uGO=lUS~)Zx z28=zg3uwA`lM?wHw|Fq=@YyU7Un2!^K%y$PJ05HOa46Ya$X+i76RQv&DYsVo|pNo&1g zvDt+UrORvh{P@L`ksQ})S$7-BrAbyo0{4u{aCSV{ahlZ8v+6Hnh9I4dN86ztq# zojH?x9gBp_-F+Y5hOhzy40YMG{q<*CG{eUoQxugE0;=CNP;J%8ELdoLx-|8k4Q`6O z#`YsqRjgW5C*d*Ofp^~tEz0_u2pFRLqR)h6PEMl)AVpozQ>6Lnb{;GC^%|r#E@2}q zO%LEV!fSLd#uvOlcuJeC4z|LX_-7uL(3ZnrgvQII_*H#b+44jU3rreL152dI1U5XW zR`oMWvxU1jl_Tpp?HdB8o=5eN9FO}6q!l}zsoR&VS|a6W`jGX z%7;Xlq35L=w2U4Q+~?=6JOh8n5Oaq1V|-Z`_^QizL*BeyoqhR~;OSyT@mBG_VmP9G zA|0%Ox2@*(Mjsn@I&oA>q>7nmIE;1v662@f*~-it?v0o&!tK)q55K>=y>goM>n2^O z`>N*{G@{uIQ!wimB*>z8$)@+45gWR5P32(3{GJNFJu7m}@CEbAl?sll*D5mTS3e3J z&6gLm#i8&^N5$MfGe5mqOEB<$5v$_MTYaC?qs`$*4wei%MN#@{?7Kx=!F%M(@#v$a zafA+xeWPZ@Yop~tWWn<5HSU^)ef|9#!4U*c$MnVXCk>Bi3$2y2?FZOfC$=O`KP4C> zfI877Ttc-j0MF)Qfm_nKVE9C${BYm;F`JjQ95T{3$yq@J$uQy4b<2uq`^18~ zpkXDk^@RKpfs6e^bNB1Z37)R$i^orzE_MX9idGB) zWJtNtJLel6`+!O)$k1E2`uzTs*o`w4V&^`>=lIXaKt#|4TT+ANH}Gk%fz!;%Li>^p z4=j&ne76A@!Io?l{~y#VPtx3<=-vabn>7-UD9G;X&kkfCIhT7EWK8HjU6a4>5T9!0 zsGR#yGpv;ey6mT5y=LFqOLuo)z#oB{q;jt#`@r9a)hb72aDvUS=ny%i-Sio3 znbGk~7E?mg?x~e6)F7qZTMt*8DL)Ox(U0^n>Q%BtHp99+-b8ZP_Ff z!?#^U68N|N*%GYx4koL*==ZtG**~+TR1XF>l#42nU57?!j!nxu z=4>fANn);2Y4jVPSo4stNf~yOud|`e#K7a9 z$+z$ZR+I>Y6vhMO`20uCi3QI<9?px^cZCcB4YlSpfPDl|17#5$t`JXm_{uhmPi8I5 zZEhz{i#9E0J?kNaKbF^|kHmMdg z>NyINF?{Ivtb6YACaZPBqt`cNoTr*$r^3w$b+*@7opd*oNYJ{3mf?5R^h*I&6>bz; zfamBLHMZ8w{bSbeR^`s;A;jj65B3oWx;Kc~Qs!SwiFc+^`{tR30ciAykdrOtR(pmr zI_<8|>55$U>tzH`OCfaZkV$aKNX2al%}DU@cr+Qof7?uNOYcQ;X|mZ_h?OHY?XKN+ zBSp9WrPAnD5uqTvXxsE$jlPq=KjT6XH>8MU3|CROjgGWOeDVg^ISjbtLoTyDUf!Q| z%LWhJow{Y;;%qh9R?((2RykpZyLYdIR-XG0)aHAiwY({V!Y{sFb0M8kMfI6ipVNMH z5QV=Noyn^haVHHLOyq`gkntcqJN-aGb?y)kQS8ZcR-qnNHCaty=F1e6N{A{%s@eDGYT?I zwPx=;tG0bhDOQvLgh$zfR+e6lHEt9Rfc?1pH+~idwpNlO#qvjVV;ljUe(qDjz8U`e z_oeijVe*Ej{?tcg&G$}9Lil9SLwh^Q4`mX;^TxmuDZV!E_J@1aG(v%t-S(9nc#!k* z^cQ;lhoBCvzZw(Y@v854M&w<4wZPw1(#zTLwOVEz2_S0ShhVu}6~@G_p%9A0-3DdK zmT2V}#sXQx7bI_W*RE5VDvoe=ZaK}wYcW>D?)QZzjBHMz1VTa=;`^6?3t7ak~m^lZcQ{+J`kiYO|St6bE42pZ8wmMhENSX?x;EROel z5j#*dB~HzU5(xi4v)e9tCO9WEJ(hW<5e)z+n-J7~cy%?hY z!9Zb^)HKM+7Iz;_tDnRrg;8bUtj;BI{Cw5!03Xh?@=BW>($F`f>AU*9PwsClF5YRj zlK|NLDiPXgULV@dX*%U4REEeSyc-*&=SgOX9$=18FiZDt#q{=iHkDQ{WR3q)$O|4x zc4Gt-Xch5mNx@Rbd9>rfaUhB#w!#zUJTj!_ae-H?Rj&bwBJX&@vfO>`h-A$7JwBjLTzvJnYc9N_oF;~?mw{KlPldAMy{&f}1 z`Ti%3B?C(e0|O9}5(y+F9T-`7tmM`BJRT4=Jeq}XW6j!o{)r^SaW>z#qlKB26wimJ9L*AF`aWK!_Ec=T?^xtP2PI+r%YHKgNou6Uw+;88dT!xspQ zon+xTzFDvLM*QK(69Srga$`?Us_mOxWlL^r3eT(rZ8zr=q+@qbj{0mxg4CfbjPtd< z;LY#&m;kaISD_qh5mzWr0QWPN((>_OuO}I<3M(!C*w+A8+x>dVB7PcruHelt;DQnk z{V3FU3w|!To^JCg0F7{i3_ISdf9}ZH#WrV+utT^oLg22Q)*Y#wLR%hjTMEo%7W44( z`4Qra2xKR~d!E)gvQqOdu2ir0ukXY<8Go6zb&V^3KZo^>Y6XFqF=f8%t6%7w}>b zQ%a+eV8v}It?#vikVP$q$f(7?%3~1ab;9?x1Qm~<*wsyehns~em3q(qHplR7+HaX$ zUVFk`>odTO^w8aGTP4)cdsZb*$NQ8C1aSMyK?@-1KFG!yn_TcX8v@fAGQ{?OSH0&w z!mUV#Yo1kamlvfRLNCRPSacfwNurdo8;sIB?x0Ir&%;f$it)t}_5wtEVcXGrh-e~kel(1?`*4Ix*h1Ok9XVk;4el*yb zZwa?E%|}DtC@OCN(0rJUJNeUjv;3R$9#76ABl5;+!T#mMrrOgsI|U5(Mctr!|2m){ zhU{~QI*t;`BNv4%DTohmzlg{-JW%Xcj^=}Ql0$tZtJ@@}-AjhsLUG?-?q+RGy|iRY z;hU;@_2abA`c+!P#E7)O)gx+r4JV?1I0Fh+tz)nEn^sv(nO+E0MZ2?8)KGXGSV)|j zmU^&kW|%-&4AVY@q8ecMj-CnXES**w7Y$;6$#q{Ris7zZbp2C8L0WW0>UM=BbljHm z+lJP}GYZkkfql5Z_;4D^Wzx}H$%YJ*%pX^VPMD%0@9)z`ND(V+r|yFhC51cK2^OG} z`4W5+3sS&zbMgecR^oi~FG-X%Yyo6^X%j-XiIje|1!swzD!~CFGHwGA}r=KLF!-P8$f1p{9EnenT3&XyXSF) zyl%qKrIK5~jQ`$IDg1M;AU6BB@A00@s8qz8b)B=TY4O%; zeZV=W<=a*BFHE!!VN1y|%oFkv54fQOH3drX3&}^+EVB<;qlDGELY|ybBtNJ~m7&!; z5j?CArPlUx^aBzfLq?p6YnWHYVr{QWIrwx zxI0`Y6K?|~#eh$<*Hp>F+iuw}Or>|a(n|KB?He6BDReJ1#7Uow7A$K#TA)q`?ZkMH z%f@ptAgVlgO|#;&JEnwo_L1^|ek%NIIYvhGu1*=hdCJ0-Y|Y9l2}z2cIMm{i###7z zhYqnQd6rY*fiy?enKePLFPNszi;_@G?DG3}@ah9alyKFeIjzTuj!Eo|jd=`3TT)QV zm@B3H;v+qs+^rbRN>?ub{Xlu7hTIh#t?{F%K?qw`!HxOD^L|M90p0Lb4zA;&3zou| zb-wh6wv;zzIxZpxE8_dsb|{0>ZW8#>I5Wm1Q=5>6bfBgx6_(b^Db0_U!pX27+r;U| z59IgO;@mOow9T@by^gOn-lic+qUZQc%4bY(d`BnqbTfR~;qJx8s};T(cEURx&@>+T z=rmfL!(shSp{o>_SxybNOVb%|IE8HG-dkMqY00a+Quzn6Fppy35$)yVs(8o89I-_3 z2>+!uSQ?5scc}Q&+V2y&CA*Wekz~mVKuihlUtw#0p<}T#)q_$Kau;Lg9ckb!gsrA+ zxvg;pZPy%nwv$&I8|L`{t93WZrJnOYI|F0q6FV&xA0l_j(e^rry{y|@#nJ}ux?GLk zIT0IV+@k-@w0VL0lGVWS1-GDl!_j@*LpM?QZ@s$Ga_vYOsGFGe-LIb$JXHX8>F-an z1rgPH7``b}kBV4iK!1n2F~(oU$*|1WvNHCMG4`fTX!YfW!5;$r6w=ENepYyrXRFO0Ar*S|8c0DmC* zS?|k<>#K#99S`cS%@J{|mugU*trrL&zAHK}CM4fS%qZv7C2qB+Rk!&p=w2UW>QH8S z@pxN$>E*<+jMaf=h^fr~G(kps-;2p^!}a>JKph-!;Q0w1tGJ#bDh6@P$3VBir6beg zq!-9dHyNVeenx^4!oac9+FC+sA#x;>TO40>jk)E0Fp*$@*osdZ{TCTLwz?ihel{;E zH2bb_G1MC;gXrnpNE;?hW~qfm^FmGWp6`Fn8q$jkBj)tEi&W-V3ZQj`8n@7~t2VI%h|M}UDDIZ!_piIPpC=$LX)$7qIyHfuMro;+|5s}XbT289b7ma3t`fO;^3kv%hFhuI3#`2Y-v|?1Coe`sIKbN&9Gz zS2F97y>>e`qt2RKr|RAk@Vz4vv}H z%o^L^U;kWgD`km8ypuBz=I#7d8ZNbgvEQic4xZwItSAIbiCQ9y?aJKLF`6#fx~pO( zoPN*+!)T>Xj%a)-`VC9+hhSrFNOQ0zAa0E82zZr>J)$KP%fERe6rRnn3=+To($$l!!xz}MNs36FVo?T0x}f@63Juhf8i zH7(Fdjw82Oy_41DNLgSM{}20OWh9D^h=zdFkgJaG_6OIK#sl6Uf>xBS!tAjalXRNW zu6vZ%Ju#Cm6xEQSoOpvLTET06Ew&U^ek71c&?J`<^X=!C7x%J2r%mn|du{BzJVGC* z)(loCdcHkPidjApoI`YS6n1L?s))u;xIQkiWM+^!d@<|fKAemkG)nZo0K=8kvoZdEky zx1{X)FlI}kvc_A+q%ach(BKn2v7PmMS4HSHz&ks+5nKN4Qw+$Jk@{PUjCt4~CBfEf>e+fh6{9n^Svd}QtM z(pjotHL9m|z#2!^4Ees@$by|LOx!6SvP^)#LS=EMI}8?95+8#o%zEoKe?+VIM|jAk z+h;4~=(mYkhv-8z`@I{{TI(J<&%ANwkTLT6IHMIf->;P3`Pf^E4GKnvX`oOlb_>m(_Ko>n z1u)IN*rhvC3|r1ptUDeg={F|mWF~d=z!Y<`iwkAM2Mrt> zfH`TpkhbE9c0ThAFXSanwL;@xUp~i@9Ca)Tl)IW4&*<{iI`>l7cydSx{f?%fVxjKG z`k8MoCUkt&?R_;{AVbX)7srz$TRGJcZDFrytYzQ#)TGTTrrWQ$PaDi-zi~jGW{L4? zS^LxEbUf+%NoyLp`f@uu27J03cTWuD^y?ZQke`sJuYm{ZpI7M7`WoU`(z^qOgHQWC zb;ry4jM70lQQEQzNosaJh&W|?iA{8;L~OtEX_}bvZF3a$Ic33y?biYzD;2XBqQw+IGNVr3L(iu0Ur5$w@56%Z7 z*VSkIER2qe=t0kXCThUnhBQyQU#L}>xd8F&jQOp4LY$U5bF z?#Oz%xHzc*bV={@#`Na2XiS3$N&@2oPTK>qEl*29FB(BAx-C|D_=u)?Otq23 zCgM$(s2_&tI#f%t-AU)`d)Wa$Kdp*^g%A_+zx~_600IPBpPg`@HTePYBav%L@gUcWVs7Ukfzmc;aU`6O=2aO&7NTt zml3`bNBK@Grq#c3_Qff+S4rq^ap5aEm27u&su$){f)OJO(VaF+N$HlPiAy?86g^D_ z!0=enY+d-f3j6PuZi7)`7ZrHo4j|Dn#4kqEs78wuF!V^~awvq<;|&T^3r_jAJfI5) zpS8COzFEGiHy~ytPVHt!Jiz~a>m^oUBB&b?$!md|GFm7-XMmU#LQ#>1`8`sfpKC|k z#Prp3hqApXd!X~`koN;ML_`Ara(~u2b6O^mp<(ZEhF$EGgFoF8igZgAuQorOe)kyg zj4vJ?>`2wSkPoW9VnH-r(vJ}r_Q~Vowxp14Dd(T<%ox7NIN_lmA7R226QXOf?M>?9 z$>$~efkNq6X<;Rv>h#qhdW>u|xY99pBMVLaqYpj>ol-sqUQM2kuD>r%T6(>Pb>C0R z311IB9Q1JC;j4(sgL$6xE^R(5oP#P=%jM*Ev4e=`XT?&@+p_RqW!7io4HN(bu&7rj zNAlAUL2F!}jY^zTe?OdiHx&GgS#QQRfY?co4pajA^p2$QTco7wr8FM)ut|`{jTVsA zN#q^&MG!9`%BLbosf&g_IVOsc>=LBH)J1y<9>!{q24qiklPnZLPL#n0=Ctf2N#yx` zGfDT_JrblD$y6im^jo_D~SQg@5O+jt)#rz21vF&!LnOqy~eNDjc3K1W8g0ogFs zYb;0T5#rMkO_P2e;XTPwn>+J=Nt6vi34TjUg3Q^}1$Qm%Y70;p#FnbzhJ?=zsplvm z0Hs5;_K=6=FJW8i1=BJ1PG=NqV{(Uekad%aT^&n49JU!b5m7YPp7kuI8V#2YI^8}2 z8CMdwd9^C;DEXb+OQRpv6Hs{F6NXflndj2KJVDz`;>7V>;HN1J1W65mU0{HjIe3jtswN=Q78(93`nYpJ^RJYM%otg z)I($rP_`D3K27?TVSo{S>L|pHCf!iQyFiKunGgvC)lB5)QKeA=d|5(K4DR2@fhtW%wu^07vvGnriCq`*&ihzVsk;XC-HbsaJr?0rQY*YOe5pSGY)G+#6;ywe z7HKuxiihFW=2aa_2CP4dxOgU+l}0H1@D{=q^r|aTJ2R-rBbsV`pa9lBsF8ZW_!6R> z!;;ecTtIbhQg@&|ez8=~{4yfQUT;`c^WkA~yfXAkDt1|Vr-U)DF1&lad<`(Ak!s5d zJ$&&eVEpM6*1s2HY#Q+^jJCBQJpw}xpbj9AD;X{yT0)K?d}aF?0F8nqbO)S3hCA6a z5S!X|6eNul%kv2|r_lCCG!6AnP-xpt{v<}EeIu=_&%)YE$C~mcc5g{;C=$ggWvPP0 z$VrG@inalAQ$&2aD=&d3fw{Hp($B$_F(<|6pbQdlgpSd+eOj+Jp93!Y9%W;=2Qjge zWPbwMR)a>na;^b8jlv{!7tEOeKGc#&K1S*Ryb&sdyvqB4R6VIlUSqD#RLV&e`)a8( za0f<GL+C(QYp5TX-3rHBR6Cjt^YOJ1U~C#HEeF^g@ocIqCckUIp9sPptTiq|AQZj!N>)-tJVlyj(X$jKkc@E=_V;V{={y&p*5M5opVv^;i}!xw-u zsfg<|zP$jmM9mP-ttc0<_6ME}Ip4UeqxqIryh^7=XxpVjK#&DR+pFlB1|orR*LNtB)OQwN9clmcA#5Isi@{T@TtL_SFIs*`efI4YCgDh&GfZ zTDzQo`Jq*;Uz^XF56lMXOj-JU5a8QIKg`s*cZomd1ka`9?BBH&#cs=dIN=O;_*a^r! z;%b2df)t^OjAx&X5UH=TdD;#Z6t6VuS*ewNe-C0{x%LR-2eeCsG<8W$To^jZ(KL;x`;iGQpl=z1XI!mhO|+- zO)#bjcFV2xHO}Qh&BF?bs$^|&HzJ%}nco}oRcY-4{vK#|_zZHzhVuS- z$W0u;-b_&bg}4Pced1t2?+EneTp3W2pt+f79*=(Qlxs~X`jFechd&G?c$OXrxcReT ztW@bE9A)nAkI~(xm~_sliBt3AJ@e$m0qjMKNQa|H7}uNpZ=6aP**yv)yRVxs(B<=G zG@ZyFbvu$7dHl9X@_8MSR32gzU$B(D7DvoLpr-kn!2>zZ9(9w7K>~Bom^~kCo*&n< z7m^;Fxw`fcBrL9B)?=QB4bh+qc#62xv^2Y!=)3GjISNU0#PWKbrfEU`jsd+D-#jgG zfa&nK{g6mGHsm65IKFE9ib)eeh{g?UFT>T(2&&=&IdzC3qmf! zB-!aN!O@^e<(L6Z_|E{{yi(@6)zn72_nmi32}iIK%wC^qN?mIJAxy8Vt}zArqe=*# zHG|yig#idH%AZQYUk;M1i)Wzo0$VPep26>8d7)?z;@J7E=TQbS&J~=y*DUP@H z6TU9C19lDk+C~my+CE%;2%*)$k&CSlLO8PBhIS?0q;!Q+*l1W@EfxZ>=GMSKN1b(R z7vdvo?rjcBrA@z`9unUhF+sNaoeH?VU5(&Sus>}30L`Pe#h1Pw*4IajZxRaJUKD(@ z@Vf<>RY9~Arr2d1nJ7PX_B6uQNztx=4Y7-&dAKat)U?2A2h5cTNqiSqn$*>G>qO-L zu`nfak{p{xhlH>w{FE~O(lU*ZJ85iN^`?9&{{&-*{*S%b#n23uy{A9J**uvqt|FKV z{7Hq`{)n~-MMN`cCfI>w`VEJ;v;<A~{rEVp>aq9Bds_8!k zI2}8qz-hW0@%W$wb>;RSoY~knD*5R+LeR}QxBEI1g7x46rg!yB=7}I$`p~n32w5D@kQr#9C za))LvQN8<1kdkL88AZLOV@=~(fyB&;;7KG?2KjpiA0XBnzxF<9d-wm7RRTQGq++Ak;UaZ;8M07ix zUMZDAqr}IU`T5$O7TE1W%u{j1NKdhGag~$OvO-82$Z0+B`;R6NmjENkx{6hX{<{M}*ZT%JQImc;hTo{lYZr{10DbGD@r z$~-M8tED=CQ}F-Q-kXO*`ThUnLZt;2Z3;!kmMw!2DoMi(g(yozma$d_+4E{oQbv}s zy~>U3(I9&fLdb2au~o8+z3lt%+_UKYy{_N&`(EGQ=lA_ypFe)DYpz~q?sGrSb9p?M zbIyIv?P4|WcMuR;Y-krbim4G0#VJs~vn3%aMzqYI*?XU}X)lm%(xX?hCbUq=KLm62 zg=P%%SpYM)z==L%AUv)6kcY-$_nT^fHIn!KqkYL=Eu{2ZG4^*!1nO0hq=WdQd}XS&YR9qWS%QEH0qB*S3?Mi!!;vBxM= zD*(jGCznR&6=wV$qA_Oa4I&=m_Z6vBEm{1HjbAT*1{8`5#vm8OH7vJeo$B`>=t3kU zWEVc5F~hehN(l&d8}TN2MM190rEixNbn1C_-Yj3uHn++*xTT3 zAw;{FX^#?LE2Nu{Kpnsp-wmwKfl(X8aq5y124{6T4gsm$8p^pzM;WFf6@+dp-&_x1 z5Z1S0ZOA*-@mr$d_FFukX=2qu@&d%<(NhQ`Hy)lFe^HHT!KqBHTe9&DHdQSGc#j#` zuW1R6yEvDZ)-UeuCF&*05H{|mnZ0@u2+^6CWZ${CsWRid(Pr_UvqzN~44Cq*eLV@s zN&yp^8f?$%&i>$?xpBBz+|+XRIau2Ts=bTy*QGqk2JO|%G_3=H?`76bxeqShw8${^ zr1EAq*#j6LQHkI|boIWO038JElFkJ7`YVr=q1ACI`@H_8HK=sW1MGAE`ML5yYjrZPmfW( zK$~c$2B!=BHV!DcXq8VR1yjIDO!oHyY`;42Mz>={y=vnpCF+eZ z4hxOr-%ecPaaAUkar($Gtwg=?^&CtVeGWR(>iX#Q>pkaA*h$ zC(H!^-ZZ3ozUb?5{5^~||9WV|oGY5_u#6^9=ZA~zGto2!rEbm~XC~14R;Vo3iEd=^$X)jl33S4!wh$7c; z=^sTh8}cC)NUNSBE!2V*#nm~MuVj?;Os(OL>fxy}habQ{0s8$-iK_vl4?k~0aBaN9 zwR<%cvnQnj%Cx*$BxQOF$dph6X#;hyJ=By2rJfHay%4AFG(AoxS zawMF)lL$2hF|cE4mq@+bS9V#?!PaVE!s7{~{GVzo;g9&J7jXf!rEdHy~= z93TA|vMHk{4$$OI-(1a=FgsBWpx?pyA){Soo61*Ws~{V$aD-9(*WP*v@9*nC^xT18 ze2$DL29JB1=Fe1Pg0YL;m+t84?h&9lC2W5T&;5L05EQ!j!zJSu#%m=%;aHsjCyLp2 zrnx1!5-iS;0^b1zQdAeWhf5#VGra~M-pr^@qtQbTr#lk$&0Des0hNjQAp`Ec2Sufh z;YZA-1Q{L0q-~J!c)7lWoB8qD;Z3>wf7|KT##{@a$hY>U=9J z3nP8a8mnUO0zh}&eTkX|nSgU!II{XQ!6Ijpo?WDE;PTbo2?+`1r)5oNHiA$E4?`+r zJyaek{}-5YKHX;T4;8@P0BojsnEKP`#tP7jF4f~(>Fl7w-eh~_eb?y`B^rH*3#;;* zh!m$kUtRC)OwZzx!oI-t@;Np8GilU8i3P@>!RqfE)roTLKP}TJ{z+Xuggp4DxbnIn zM(+xql=qcV)-$z(H>|6f@b&d@256Nx>(hJLxG^}ZKGiX1@N+(bDg0L?_;>HOoQlfow|g$5-n zb?MxCzKL`tK?}qP8r+{Fh(BEB+4eD8tE03Lk_qu0Rsn0B zj8q%@u6-O0bn@`{cxvf`V}+ScNSQu$a-DiY5Td*OygC0R?j{92U?rQrWFNuT35fLYn^aE`7DOhyRGO ztMCPu%(V6mt)7Cn^F3I;jkBFQ+Pj8*8}l%_3VZ6vZSL>=MXLX>K7X`XwwB_)l^&@; zx(ZPft^_(?YRXvqd=KA=DJD&ioi~+BMFy|1H*T^1_D%4o%Xxo|fy`tJ9I1^yss|bo z|2^_B|YtLB9VRzWRc{Mh7xj$8JH2#kJg<4jc( zO`~caLQ&H$W=6XPj5q;NCP$7Pn7cX8ym7`J=g z9{S=)_CP)=EAEIJmB6w$RPpx2h=E0gB&2U?2YBQhwF;`N7Kpe68s@z^ba~E1Z%P0h zo1k9)ejB6E;Fk=*6$gUr8{9`0bRN4^hMcn`p7mc3pZth1H*FnVJ$)+L_l(5DVzN(< z=DOAwk#<3+$JD1wbp7;WP@da z-GQ-}_9D0@JgJ#A8(0Am3KwPW>wVv?kMo>85#_HC=t8vH{iTT~WhbHst{qJp1`|l! zWdAT;HS(Hk99efib;BIHB19E}d01b=Dn>2-Q^;67Mekj3N6EP4J~ zX_Z6c0y3si=rblC>p=-ltO5WJw1bHu8VP$e9zt~BbT){6HY~~K^Gs?vM)!cu++9Yf zuY+`7gO=!|O}+MRfIPMH<{njz-F7%7WQu4`G(=*Z2!CT<-tbiEuAx*Oc>b^tp?$Kxb<7uc0WeaL0TCSaW zd4>fHI0=id?qcMkyTr%6Hi8~U zcVSJ|H1>I3TXdd zvJc@b$)f@@&AjK{mZ&yA1aKhp5E7ftm|Q~*9fFhXuK4t0M!`vKbAXMFtUT;qDmIiQ{J z1bB9)K!Ks#Tz>Uqa{rN)yeY6?K#OkpzM-k#KqC0{=@TAXIOXsa>(E6j7fsh9VKX%J zN6R&7A_`5b*AOQrECWlb*v3ZZbAw^J29tXQ;_Og3(wAUC4)K}_F`_5bsdpWvV~Lf5 zf9#N}k)~cTm~>0+q_=p6E;&P+2IfM6i2`E3o-fMI6>>Q>XER}jRJ;gM36E0dfcTt0 zOJY_o*=kY3#+k8M0L%#ahRlyG&5@*CSEmsxpX$;p{?;iW2}ck#^0OFvAlxDv`*E#~ zO_T?$T}e4X4w$1gjqNzEGF<^4!iUL$gi=-pH!Fd^y>>?`>YV_3*>uBHllo;0QbvvS zu4f(lwZ-y)5pzMnp7z62EjGr)*)>Pnyf?5zyA`!|CCRL1sRq=bLav&2HDr`jz-7Y7 zWu`dOBg#I*SMi-GL?D!K!`qyWq=PhNHEm;n1P;4!+%O4eh^VBt45x|@$MlZm0#noU zq3RhQ_Zm(f0duK%)3_+GF7ydX^1W-{w65!WH?COX>$@bQi+eTiU^YK==hG?3*?hPL zs^UzVd%BK|;Jzkx5$lYkmnr^HWXt7)uEmN47`HetHozD1F;!C6Q;~3q@&jL7ZuUL- zc#BJ&;}oj2)q^(m2!!=?Q&UYzCm+5JRMEG!Dq{>xs*bI!`$(ohAgAZSk3bP?)fva? zPNAgiXsIMW_8uTPxc$@qrilW(NKmbllv?a8UW8#Og)bS=31jx;)0WMr$J952XiFN* z!E}OEbJHU2TMXz@ucOqGQpuN*`L%(I;87%Sd9m3G3AQxM@P;eWhK-5ya(c!YR`5?X zRz^a9>HznNxy()i2%xX0;flmWqRCV1xF_rj&#yyDtdq|gzUh&`sYhxR9-|D^7G+?# z6Q{vcT#;*P)Y~|8Jv3qQfHp;zevt*-BXnS+S_zmnaM`VWK&M8qPmCB*_G|iJ@#Hdn zI>w>{($?70Cbj{vxcOVM!m{JUF4S1(IC(RZ+klT##8vPkC(58g2#onv z&M1EVqDQiW;hN)3&)36qfRnK}kg%oij}PPe&wx3X@9SvZ9N(kSaubavFQ1|ej(xNUf{DnvRB;1;(iXMM3d*c7S&zXtNQ}BY~``5%0Q2l zc`Mf}Pxu5eBHqlck1;$oKuI+ew`0J3kV?hs=m$vQ)pXaQd)K-YNhaMsmqCWW6{>+c z0!#!?clihwAeG`%TUfX}5{MUAo!)!L=UP89RIXo8wCsK&Pc3kr3u5?vjS4^IEushV z%!e+cF~}P=*0GKpwhXQ81CN2Q$v$!xs$h{daJFbC;>1X({(6e4CZP>6nZ-bTi}@r?ZRU z%3Pl6MhOU8zhmBBrhw-bOeyD!IB8QQ(S(7N^H@`NXqBHEa8~=OXw|igBTRfd2E>;hayTl!6xdPFRoM}HU9L8uaV2t zOsr{_E(igLV((!Jew}gJ@kj=7$xV?@3K1SC)O2JeVH;^0nW{Kn@+YE2ZHNT4?0d)| zJ|_f?$6xkg2C;dRf4@`;MH0~uEJZ4|J4+&yBdrDBBDMhm_89dg+pA49@~HX@E&Z9F zC@B{)|EGVGN&h2&NEJMYlvK*warm#_ zq_55hnuBicr+hdgA`dQyx~V@nig?9H)N~wxr0#F~g;NL{&NncW7jjhXP=DI8jIpjf zP)K&wHtq>Y{e9rip;vN-s`%HKUY}OTf#}NfS%DTf@fi$iuh;Wpgjl}p6g4Jj-PlnJPZy*@!(ZUIxZp7ZonZ`?Vb=?r_J zGJTKGz>sFU-AbCPL^ zZ`=I+9@}KahWX}&8iL7`|J$KL!)Ir<9^u;MpcZhu#+B4I%dHIZEuebVi?{SaFwhY7 zhX;si{3jwC_S(`-KDpC_u3)Un9ZX_hIUZR^10PR~^$|kL+f=V>bj=qFUI0;-CUa%x zC;A_$(k`LvWw=V0yi)@|o%DFhK#C%DdpCUod9iq_tC&9w7Hq(xj@)N~4jQ3`c7I@m z`+eoq5V{Xl>o=_JKgt||6jjcH1PxM~i@EL>DB*(;qLfw^V1x#h)tJ@8^c4Vj`~Whd z03Yswi^FH@RafcX4w%fhE-zi$$t=|Ps{>m!r0Eu0+zk#|<(FiorQuv zL>pNG2MnkJTs+ym0kHOR)BEHd@qpfe0FmZg+7LV(Q|^;MVopi0sWY5spv?;zKI7Gh z(m*lFlC#1o5$O!0UVrCW&RpBV8Q9bHV}uG>{X_-Rku;9LfPD7FnflLkHJr_MouUsZ zTohS=?#%*3=uwPPR20A7x3F|wU_5~qA2THvg;Pe(?YtC*HAl$f{yeixJs>slvvpdJ zZo4(NiBW!J{@(}XFN;rm@EIC;N1uQsjhMw|-eJz0wHOuj?xJ%szKZegJA+sN_GJ6_ z5+#~E!Z1^Qc62MnGkvU+3L?Itk zDZpgo8aXzF4* zQXImb8s&wpI;zC+^YnTSFHJnF6u!Eh2VjOKMEAxM`YN`zY6Ws7KeF3IYk?ohA&bw`L9_m^ zZwX22t#}@$m*-jtMmGk?gQZN?DjY;sYrrR6`bNDoeZ-xl9AHD^vH|3R(Aahz zP_>X_F-_vQ#Z^*z6v0KRJK!I>qZchb?7kU?P@y!+fd5H#Fnq4^z>wPvy->D=f8c!R z)N$aERtI?Q5X0xX``BhOP--ccBI$tWND8RjHY^Y%4^(@+SCg)AfRG3!s!1y}CnvNc zx*!`dnoZ2~HT@(BiZD}3hiS5}^2lZ>T40f-jyPpm z!vek9j7w(6J7n-Q_Fci=7nBQ$kAOiHAVX?^ei%+oXr5taM4%IJi0?Ic@;*GWgQhX~oAyznr;jHCI(@zl*}rKw zkBvuQd^sPbs`-^TYQ@a@|LCRr>@U zT*dN%<8Y8>=s1Lvwe35CI>oqYBeLj=Pp^eAp9P@P0*@U8m?=4f_P{(9P~rWf#H@f@ zW2-_mOCO`&-)1rQlGa$)B2JY)GnR#jx^W;upWQ=wZGWiIv#RXPxg zB*G6fDG8V+&>E*v_l2I691_dhk$t1W8M}OuLDC)(ED(Ryj^M<^ka1F(ANG zL=@?9cfW>Awl5Cc(uVZ`v+jj^vTtK73l9LXX;M_`QY2&%KB!M)t}6P8g3&WyDDJH% z9nqXK%>qsiYaGmla$wQ4D1t0e{U6}*q3>Zsc0f304=-v=wlIoN*-aN{prloy57c`g zCrOK8Dc>U*B@))>OkNLtjKR4#TsM_0kDtrAftzFnEt9gqMvz2L+~(=f_ewy9xLFX@ zyT9oJ`ryO1J>}vQm`>2>vu83MZ6rtC4jw=e^S4Lz1wytPe1H~zz@K*TWa`4)F9fn` z`4RC-zLGCy+1&i3iRyHnfd`f)9bu=8kf)H7_?7l1l_}8&Gj6_Ox(PLEKWw7Ce zK~e%0R{@3oi|MTu20mx%F#19PKM_L4>TE_hq?A?Aa{*d+KrjhzgjCz;6OAMoajEP= z;p?CnBQ(B*xRF1^;ID8r;IyyTBdumqALA z!oxl3ibQ&Cf-0t5_eJ=^Yoer)zPcaKXN&M1TiwJ;2cIJ75z7W6J0?s!aC6srekoGf zQ*GeW$OZD;uC`G-x+;{)lZU{g^{7lcSVL{*k3}mq*drf-q0(Z2Z|g2JRn?0fDHyOS z3r`qX0v5;dsVl6-tFT7utX_p*;uZm18q^r`)Kk*0nw@~SQg*OB5wziA!yi}iMt zcVPlMf`Fx{hfv*R@v%BdahPI_GP@KCJv_Ju>VJ>}lQiH2J9;3e<$*ET-a9gqg_!J0 z008ZFIB%rfxDBdZhw)l^1&AE-+TTGDVp!8(iq3MA?;DmS)}rbXS6e5y@d z9h;*;3IVbNF~9RXsSE0x18yTuTy-i9Qvweh(QKYo`u)-a{vAOeAWXkEjY4-A+QCvo z_}Pwfy577Tg1(y4C`k!h5@x`&xPrC}aMh#QH`mg1qUJh0meV7TPgwJ}GoWW%_AxSk z7-#@l{!#(+E#{?&KhyM^iirdOdxQW?<x}gSR+OuL{;s0`` zVmJ!yn3?|{aQ+EuHk?}f74ja$uIcx~%RI%}N#yLh6?t!F@}c^C(#ncK0A?mFO6L#R zhKEl#oCjIjNHyI|1!X#?k z^yv?IpJXr^g}xjI6baVn(*x*HsC#fE?)_^df5Q<4ixx06I4;aYsA0hG9S$486;sqo zqZ9lH1YyK}sjUspuoRuLJ^#!ali&0Qv#VI0L(q_6@V67+BfWlqk z#LQmG{ec?kPanRw`B>8g&0rUYy9*dr08_00zHUfGM&x;*MuEX*ydHpnu8pL{i$J*(>q8^5F{ z!|su^z?C`#MdVZq9TM_rEMjLk8b`UNN!_{g;fYmI;5Zj#t|d&qFfXIS4JlCXH zY%v~SsA8fhu=ar_txRZGD;#h0uWPh{SDlzqFgb3zykve9#joxKP8EZ6U+P=YO=MtF z4FNX-99@?bK7``GHBeftAA<*z#soHoeR0bb0R?HmViCuvQg1OS(YcIUl}eGC-u>)% zx#1~$SO>K-?MAtba$alUj0)#Mw2l7V!ChXAj8VV?7;ASX4WsQr!UQ5oc-+G-a{GFL zr}%T(uaZTsp49lnqGrXyz3=vsAGs?1u0vMaI&8s8m)aSvHZ^pYTKp2fz?Qk@Kuy+dHr03Y_E zhNJ?9U1%^+u89wYQ(!;&5i0g`Vj;d_1H|O0jeYyj)Pmd3&U^>k(D2|0>)oT4Y^d%r z_Iwhur2r5i)UVDa{&UU^F7|GhO&!B^lLH%O;d0iNIw^kuNQ4U58q?7x-;#ZV71!h&#i=&ePQmSDN~KW0K3mQz{7-?eL-Ls6)dssR@j98Le%tsbnYrUxBBmq zKQYrk;}n4W^|-g%bghVZh+tJV;e2%WA=aba+xrweCKCRaan~P(6X`gfAkDb2YN##e z$Yq7;Xe&8t@YEl$Z5Hgu&>0>k5jwL629>A?^ zovZt|vJv-J98a{igPlRC11|(4TYwqGQMOhFPDFu!XnVV=Ah%qZd*Gy@UwtM_*>(#r zhqZ`Uu-i>Y-o~gs7&1|RU_F&_ca?elt?FQL+72x%F~gRqR`R4)py#kvfSNVTFHGC5 zQU!SjOTK$aXzWb_oprAnJJde_ug0LP-eMxd&MmWdcWe*_*~#jaFlhu9$zv>;vUdwB zcAb}ad@@dOZxs~J(-R?Rn>KrAE^Os3vwsnr%Itw)q4R?^T5_ov6UVRA^k}2pgBIk=H^dDm~V@5i(Up{_+iS<+zy6u@i z5&zexTA?4=1HEsxtN_B%?%nAqia>z5QLcZ!3{ik4UsP)i)EpV>+K4JWZqEtG-+6pL z4`te5zlj-q_zr%EjMU#9^it=7jY0i_s~M3?L@zu4s*M5RHex)5sM+!p+b^=$EkENy z%{b`b4yNf3cE2Nvt5x#I(6l?m!6fz=JPOB_fn$v)nD(MCf-G@~HhT3ov zW--}q6`SaY6!A#Hs<7jVP{AjEsd0>$JO}UQc)9ebiHNQApln5HZH@}BWh3%^zHF&; zdJlqHo{rDeww7Sl9U?QkR>@fKySP7>BR2RNH_X!KA-KAgdVQOiCvDJED?dQpBJ7524_QRTfH*i*I z(HnT1{@RkMz`UV1me`MbP#$0l07|MXU~WxrTDA}U3E6!f6&{2s1}Ec*q?l!K95h+U z05AHWs{8*s{|OQbWD=M;{;(P&bPsL!sgU zhQDJ1bv4KMSgO`b5hZF~_6SG^z=-}~dlaZ)lC1VX^7PN1&r?{vroPQ&{NSY%v4$a$ z**^OdMI28o%-OMy@@H*hWO5B@JHioF;b^LIr{RnoD*1e`ODJsYV&WC!ceJ;mPCG%+Go+l)j0Az2fv7Nii>L8s-AD`B41OgX7_N>pKoNs~A~l z_~yQZJkzV}?YpRz&?f#=|1KfXTjDK7j0i~y#uVJDt^EiQT|nmRz4y-1scnfZDN#&^ z>ERZXQG53sSymfSlMO`E?PJEzB{Y)Km!!b1X)YQOMcrKqXjLBKV!HyWL9TV%i*$kg z)X~5hvGo9}u}||0h79u4qgX-I*QjU{1wwQdFx~Fh6Rh zIMC_Bl+B#|adpvdwXL)*`bZ|%M;t1dqji2CFRG#U+3fKOzT%X7`Wj4!T|`rA$ntWj zZp*T%-5w6R@?W0Y8*{Pd5vCd0_8ZkY$f}-o!j`>#{P0~&%k~-7vkIPR9D#4PGAe~s z&yup2tyR$8j*P1Y#X5!Qg9q)2@$_I3-r9zyih~zbVu8!>Dx&fv7qmjG2SZQQD*m!! z#skwAw~il>6qZeoyN#)jA{Z+@Z!xvbRjVBeVRA6ERsE=%ExVA}sIz5B4MbgPRu{3f zo+DpZ-p6F3`T2=a&pM8SK)K!cArtYYZmf#gMyjiUL#^8@dhBeyarx(uA7aAif7+an zLRWrKGYD`@YA*PuqQ~=Fj@Oo*wuEqI9L4&o^=zB z2nA%QyRdIP%74`$+sDo8mhx)wiTMQcb(rFb#&a+CItcIZ#&w{BqFSBeoxDa2t>(zN z084b2C1v9LjtjZ4b2;gouo4$7?|5c+n|xKv{1|wdn6*V31<(p{5!G9U)jxltaPVGI zserER&V?TSLmj7Hco@iZJ-d+an({let;Wi5HU{Z8-M*a?x;I?tYkIjc4!k3Y9)G~w zL{Aa39tl1Bl`4T6`v$#ajlPL3h?sI2%Bi7kwYRl`SFwxmeS;h9)jgePR9+D~PE#?% zqCqt~$zh7^BNGn!D!=Ep-aF3vGAXfE&MC@BJ-+Abt0OTsq6g@p3~${H_Ol+)soFV& zu(QCSrtMI>o0>RA;CZgUUPZf*zs{SiJQ99Zy)B9i5-NV&H}~jQ)ThN+nH%f5Nj0`S z6vH1x<0j0Y^JXL|bLBWDct6vU7BchcI;}g0Noxcic=_ei_lHCG$>JkXYJ-I%ThzMBW)9A560cw=hh$wc+2-fLuHx1L z&H&NV-*bOCFsDyW%OrcEfAe{tjS}LxA;~aSHn=Ohtc~2 z6aQI2Oad61IIf(r)o|CU(8-gV~Cw+Ia^B@|=h<*4enW4hJlt4#_eZM}HUH^05$ZoWlm$FQB} z0UfziEz90??`xX5LW3?`+nFjS?NmUxIz8e46}$)^S$LuAe$_BAuj|)#FWrb9*ZuQ> zITGs$6=yrw6lDAGWPW6#99|H`-|ojUI}ja0~xw? z?rz-toT=*~99>HkdBTj=Jc@@^klF!6#n-l56dV3g{=R8cOty_#@A${; zUi)zG49AhhtAo5C%$4UWc+|=DY;rb;8g@X%(S5mf|H;cT;W~HUAF1064l9CxHO-9k z24`VbyfN9TwA8i<1-5jx3i4@WA3OZaGal~&&b{J~YJF2y3W?$&56Bd}N{5kW!`#d3 zHA8lsGjG*9n{yx2mQp%40;TeI`Igjr>aN?o&30}Et8n_z?~7m4FM#?aSP@_O%;#+9 z?aU+L>$&Nc%Y2a%AT`Rb%->^Ukk_B$MH0Odvls9^F0pt_Pfp zUY?YrT@^xJTWnPnpPH9LG7pp??-w^*q_!$fgu#xJcitF7L!`Dzd64`wcrf1 zI5l1HUe!7Ce8(kG%i{nmNv$28-8%+`-8XW*QBg*LZte5&yF%q# zH@^59Ue@`Sr#&k#L$Dk}t5`l_Q8c;~N6DWEFe-cZOYRXQ%`7*}SXLw&-=5?6XqqCI zdZ8~%*!I3_Y2@@4$U|E+%+5pMK+hIt+F)@JdcNaa+uhpiSXq4i^BLiCBH_o2_^B69*Ak-% zw6l+@pm9{g!o3IQIF2N+y1r7|0oKKqRh?biRl=Q~jKp1i#hm7HX>nNC-c zGKxMIclX%-cME2kukSOXt4Enaj^!H4GPEl312lHFXVBaC?uBQU0(VltF)#FyaY)Wl z-Mc4m26tHFKR{vb-Gv($4&N<|;90$nT?cfgiY#!Ao}X&_!4Y_A+e!mYUViX!W}0)d zQLdRDoWKEe!-{ox-~als1bnoG_@&)TP)5MbxnoeDX zjX&3`6i)2cY+}iY=Nc)tU6-oiprj=+xxivO%un2!aok7!(T))r10vIL&?l!(qu60( zmoFf8yHxwyo%gDnnC^R0?Czix<;*nG)kQ7V!_Pk8SNv+AS8By&#iZJ^O(H2>@_+fg z_1YBG@SPlx2zcN3%`L;+pY+5rOsA>6*#1@O12nz*tA3{}N3=69@&j+k+hb9qtd#7< zm1lDEr{zcR010DKYh#DKC9MshBte|EyKv`-xCd`1%M&M9XA9$?t&rfiQ5bh=f1W|S z73_Ip*q@M)=V3(hE?g02BJ^!5j}r9(<@5s#C${L&cl|vCTUypY95jZS~Zm!7vVkO}j&kS>|C)Gj;|Vh4kR zRgftI;i%~cIT(4ai7JzQ*fd=@#e$Uf*Lu6wB9@}BtWjIFP)Rb0n_Vv$DPj!(s&-7UUY(;D~sCe}s!e0y-Z zt!_=+lR$Y%hJY-9&8PH&LIsFn%%6s0Q;XBmD`vRBIx6F>tp;*VdN8 z>CY2x-A{O!L5~`iqo{}2$*mXliK6#OYd0Wmm ziCPeo#ed_kFFP-r(6#mX*$bw%SAM@kmC`5HQ)jO$swSN)VqxLy#hf{1gr7BG;}yK9 z^Sc6SI3rOJ)+Hb0nle|orkws^BXiKHfYtCI4y>IXIWl*!Q6DQIW)MwQCp|Zv_o-Y@ zJg2aWKb+ywV3Vad!=DqCa(Kz-btQhzo~TJ_zrsG!baVN3aH^tC>s8o5S$Hq2q_5+$ zV;D-&PmVfKV{3^Hzu5jre2Gg>?_yF_@g}A}Y1RC4hBqdd>|TsB^s(e7M!=;_J^c=Z zZHBXkw;XnJ(&|f)z;HFA&hSi&VAB4zuaz?XSQ_-=FP2!LGRbPR zQ)R7T=}=>7_6UM^AW0EaPQ7=g&tLWOXsKCN73^6l1#fH#yZ9!Z)S0rB~>+7_F%3@|EVPfPnS`rokUuSd-E(`*X`?fn5h{ z>L>+4?wZ#%kY}HR3(Rd6E-8M6VD!8{=-}Zfl zJcMY=EZ{^qicGhr)k43LUn+spG^WdjYS^!uotL}z{JrDsrOAruX~yJTU(|F}C$zCg z?UtHCQLBF>IMB|+($!gI;bJIzL>T$<24)SDriyYSM%Pm+mut`4+DfA zPifAm)}r|Uue{N{0~^&vny*)HR>a3$LDRe6bCFnxNh$o|5owu~BC*E8Lo^oPT+{G- zK-J(ado5JnkQbf{9Z-`PA(VUW)G)Mt{hf%WT2W2EkPFxKatb70YGL|R-SVPg$^EP zBeow{#G4DlY_;f=m;-N^9GT zwBHOfs=C?BLs#MFd`VG+F5*(%^Rn)MQq`4md{o($_@2hyYXh5z`9;EvqRcb#)nVE5 zprCl_d($%K0>w%2UtbjM*gs@)RDkfCq8$ZJ_%HLm=9F>Htqa9Zfl_*YAj5$R4>0BO ztWkv6vtS*xIY$`w#rm9JuA`tMFrgxvk+VBd9tTAuokMu3nVpbk4534n59m~6SeEKl zt#TWaGI)rMSp8*Z2rN*FXG$70ZluV579Va64{h{{J?B=wa~((OLQ&-9@^>G=6JFEf z;h9&Tvb(}s;Ejd7mhnpdYsl$=XZTTL?+f>JU%{%t+s*u<(N_`)!WL9!@ko)m$(3>m zsGKjw?b%s;s0{xTXIPS6g558^*u7(4pYQo7^879L`i^}eFqaEB7si5iUauTpmKfn& z@-Tu2;G*>R@3jS$%~7+`6O&J!VF@LM#`?9l6S;n?iDCIZoISnyfM%&Iv+y85mG9#V zeuKkv@e<%&e_KX+#Y)qgI)y36JSIxPmTOw>f@569c0?uo*o=}=`5bUlaPY!t>o#^e zMyz4=M61~|h1|%qwff2XeXrfCgz%Wl{i?{a1}M0|guWb=WvyWgF=8}~g34O(=}1@O zZ-RQc+385;&mMyp&Q>IVLEsYGHBU>6GPZk+vWP(5{d9FF>K-&&wtAzB6D1XkNhsaR z1@3#Kf~B1B9}>K3aE@XX8|_UeRR!(DnU1%_cTXIdr>cSb*2;WxOG2#SyLJAj>HH8c zU~dMQmPMg+G2gUj`S{DrnVq#-@v8nA50uP7DvtVh4ob%Ntd(wKyy7~LjEi*W=O5fU z1u`U#F9^y?nez&Tt*l`|e-m+f@0oIs`$J7eSinr7LCabXuh5s`#+5Iq?tkesIQ3#} z=G)t*tDwUvZBv0=YYo@^aJDuE{Q^I>D$#XGd15el`=#q9;#oA+DOSki8lE|H9gXjc&N@E=-UVJu%qw(KG~;RMRAwXe@rOxzJz}nmT6&q6~5M zLr?c1g~xX{?0c%Q`O+6QvEMAlQEW%Qm99k_7u50DSLgoGY_SYnIkERV2mVS?kb?n^ z^3(`Nnv+W6mK=Y;6@#vA!9{X54ZJRPg##r714u?hm0>jg`H!M|nui9>bRymD7tmCh zYG(|k-9@72zGnog4FZiEVV`k#1$^CLY@@yej^x!{Rw!?|f*HPx^I2S2xHOaPWPlrf z-2&}?T*+=wcsY3+mpqPitGg^Ov_CaT?iA6{0GEbQz^h5B?csH}b5o0Yv-wB@YDKxW zM0Z$QSn%ZSy#hy^;g*g9bSlyz`S8dQ^M5&Y6j0{3L?P75@q`m&uw$R%3mY>V^D>mh z)@2WbUMh-Ht$P6CETVnZ(RP}bzsNw=@RE` zOmWl^mtUiM`|NtMV#puAW*|h`pd>r9KMMHAN~fhdPDo{8)*2Sx`8%bQ@6)_ifhx^Z zQ3TQu0B2g~UyX86(aBEuIZ}d&T&|x@zj6Vk4?n^AR7;uA+fbfe zEYtk0nEp-7!lt=!1U2^hoTfHDHd0_35YjWGH{!D*I1TS`kk_fX$d&dqhMX<0`1$=1V9Dobs3UNL>q-AJHvbF zroerJyznvPkOa%4vQ-_HM9+5)g2#1iL{d=~HzO>Av0}0d{7(P&vdVB~=}^~Y;?p>_ z4&D`6!A}0~Bs|=r|0OV;jizVaD}NO6(*=*$GzM_#ZUlXt%DAQ130=61G<}5*C3i(C zZUEKIZyiW|u*==A6ipR6*QZ6*eCJQh!MM>ugtjO4B)h`hF5hExwsmj%rbXm3Y?^y^ zzjSu%EkUhboT(KNO;xg)c!q(teJ)-gM4z2?&sS~WZSc$cnt=Z@vvgDcvAr9t6&kQJ zX=hex#KXPA`(vh!iPg>qw~Ys$<@?Brx&x9q;cn))HwM{!3Psrvd+-Lht!N3#)LgyM z^sl&FOyt#AJLSdh3s-zkSv@JVJO<7U(~vg(US&?={Vwr5Stq!>SnMK^%S}q9p?z5Y z!rUAou`Eo|mZ%PNzBTGh(c-D4Kkoicid1kOuzF|du-WT(0p%rUyTu0&62{E0nRG#w z`+bdwtbWR2?d82$lO+E~sH!BB%8}WBF+tDbeK~+%!s!*NM@Da<#ozBAmiq3t7b3Nu zDqA>0P49k++UW4F&I74CMqu-AU$Ks6SvW6Rd`HjKPKnIlOOd437m>EUU!dFavng9( z3H_%|1~v0$SVye(z)uzjebL4F#XI9Kxuv%&bhLoWI~ zcdnB`oe6dR+U7*e25UR&l1#4l*Q2;P?H3WIT>BcHBMlqjt4ZIQkBj1Qbxmv~?_2ch zt7Vkfa9au1Wv_H2uaX9=>>Y4*gAx|rxrFIiQMCBUTAn#{Y~C}Ck{XB48?m@LH|;q` zrPL@PEh4M)RdsArB$zgOV7uDaS>3i=eKf5@I*J?z5UXX8bP(j?XaJ+|l zL*`sF#NuFJT==yJ0$u%yQX}!Q;Of#We2%MOBUvUoH{!Pbi@^+!mtxDkGu-x9<2C9p zpv758;hdkR%;Ey_-A2aoUmz9|?@1!-znBeZ@!P14;JbqX3vwg)PQOI^PQUcOu_*}n z^iF9#(K=!zoZ9>+F#{v{oii<{{{4|<(Sp5vVi~YENAkF-?d2BH=V(x ztK-3`aKl7ne>jcyeG$5tK&F4EU7~%bU;6LSbP+sY{Fh%1gnb6j0SplkK3D#Vp})wG z5@|O5S5kHA`kg@fzsmUkumkmVUFw_u<diatzb8!o zLL{954+P@>?Rot_0LuTg-hT!4Mf(3YkbYxUa|UeL+EI!AzGaQH((wjhhZ(nsNVUkp7RR zw+#L(z5X9Xy^+BGYe<)z!0%<*qQCVGfPd`ozb>G^^*>bczofPQXGfDAYY*BE>Y4Y4 z0!MuPw4Q?~1F`6BrTuf8$a-9y_7C$H@H68V@biDuhO)fEH+pWaEKMdObs9!X?@ZQd HtAGA4S>TJO diff --git a/img/mcintosh-logo-green.png b/img/mcintosh-logo-green.png deleted file mode 100644 index 08c1609d7d4c604935e572ef5a2f78918ab166e7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13886 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 diff --git a/img/mcintosh-logo.svg b/img/mcintosh-logo.svg deleted file mode 100644 index dc68f1c..0000000 --- a/img/mcintosh-logo.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/pyavcontrol/__init__.py b/pyavcontrol/__init__.py deleted file mode 100644 index 0fb071f..0000000 --- a/pyavcontrol/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -__version__ = "2024.02.24" - -# easily expose key classes and APIs that clients typically use -from .client import DeviceClient -from .library import DeviceModelLibrary -from .helper import construct_async_client, construct_synchronous_client diff --git a/pyavcontrol/client/__init__.py b/pyavcontrol/client/__init__.py deleted file mode 100644 index 6c08f4a..0000000 --- a/pyavcontrol/client/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# expose DeviceClient through just importing the package itself -from .base import DeviceClient diff --git a/pyavcontrol/client/async_client.py b/pyavcontrol/client/async_client.py deleted file mode 100644 index c6fab85..0000000 --- a/pyavcontrol/client/async_client.py +++ /dev/null @@ -1,41 +0,0 @@ -import logging -from collections.abc import Callable - -from ..connection import DeviceConnection -from ..connection.async_connection import locked_coro -from ..library.model import DeviceModel -from .base import DeviceClient - -LOG = logging.getLogger(__name__) - -class DeviceClientAsync(DeviceClient): - """Asynchronous client for communicating with devices via the provided connection""" - - def __init__(self, model: DeviceModel, connection: DeviceConnection, loop): - super().__init__(model, connection) - self._loop = loop - self._callback = None - - if not connection.is_async(): - raise RuntimeError(f"Provided DeviceConnection is not asynchronous!") - - @property - def is_async(self): - """:return: true since this client is asynchronous""" - return True - - @locked_coro - async def send_raw(self, data: bytes, wait_for_response=False): - #if LOG.isEnabledFor(logging.DEBUG): - # LOG.debug(f'Sending {self._connection!r}: {data}') - return await self._connection.send(data, wait_for_response=wait_for_response) - - @locked_coro - def register_callback(self, callback: Callable[[str], None]) -> None: - if not callable(callback): - raise ValueError('Callback is not Callable') - self._callback = callback - - @locked_coro - async def received_message(self): - await self._loop.call_soon(self._callback) diff --git a/pyavcontrol/client/base.py b/pyavcontrol/client/base.py deleted file mode 100644 index a87d925..0000000 --- a/pyavcontrol/client/base.py +++ /dev/null @@ -1,291 +0,0 @@ -# postpone eval of annotations (for DeviceClient type annotation) -from __future__ import annotations - -import logging -import re -from abc import ABC, abstractmethod -from dataclasses import dataclass - -from ..config import CONFIG -from ..connection import DeviceConnection -from ..utils import ( - camel_case, - generate_docs_for_action, - missing_keys_in_dict, - substitute_fstring_vars, get_args_for_command, -) -from ..library.model import DeviceModel - -LOG = logging.getLogger(__name__) - - -class DynamicActions: - """ - Dynamically created class representing a group of actions that can be called - on a connection to the device. - """ - def __init__(self, model_name, group_actions_def): - self._model_name = model_name - self._group_actions = group_actions_def - - -def _create_activity_group_class( - client: DeviceClient, - model: DeviceModel, - group_name: str, - group_actions: dict -): - """ - Create dynamic class that represents a group of activities for a specific - DeviceClient. These are injected into the DeviceClient as properties that - can be accessed by the caller. - """ - cls_props = {} - cls_bases = (DynamicActions,) - - # CamelCase the model+group to represent this dynamic class of action methods - cls_name = camel_case(f'{model.id} {group_name}') - if client.is_async: - cls_name += 'Async' - - # dynamically add methods (and associated documentation) for each action - for action_name, action_def in group_actions.items(): - # handle yamlfmt/yamlfix rewriting of "on" and "off" as YAML keys into bools - if type(action_name) is bool: - action_name = 'on' if action_name else 'off' - - action = ActionDef(group_name, action_name, action_def) - action.required_args = get_args_for_command(action.definition) - - # if a response msg is defined, then wait for a response - action.response_expected = 'msg' in action_def - - # ClientAPIAction(group=group, name=action_name, definition=action_def) - method = _create_action_method(client, cls_name, action) - - # FIXME: danger Will Robinson...potential exploits (need to explore how to filter out) - method.__name__ = action_name - method.__doc__ = generate_docs_for_action(action_name, action_def) - - cls_props[action_name] = method - - # return the new dynamic class that contains the above actions - cls = type(cls_name, cls_bases, cls_props) - return cls(model.id, group_actions) - - -@dataclass -class ActionDef: - group: str - name: str - definition: dict - required_args: list[str] = () - response_expected: bool = False - -def _inject_client_api(client: DeviceClient, model: DeviceModel): - """ - Add a property at the top level of a DeviceClient class that exposes a - group of actions that can be called. If none are specified in the - model definition, the client is returned unchanged. - """ - api = model.definition.get(CONFIG.api, {}) - for group_name, group_def in api.items(): - if hasattr(type(client), group_name): - raise RuntimeError(f'Injecting "{group_name}" failed as it already exists in {type(client)}') - - group_actions = group_def['actions'] - group_class = _create_activity_group_class(client, model, group_name, group_actions) - setattr(type(client), group_name, group_class) - - return client - - -def _encode_request(client, action_name, action_def: dict, values: dict, kwargs): - # FIXME: explain the intent...and kwargs - - if cmd := action_def.get('cmd'): - if fstring := cmd.get('fstring'): - request = substitute_fstring_vars(fstring, dict) - return request.encode(client.encoding()) - - LOG.error(f"Invalid action_def for {action_name} - cannot form a request: {action_def}") - return None - -def _create_action_method(client: DeviceClient, cls_name: str, action: ActionDef): - """ - Creates a dynamic method that makes calls against the provided client using - the command format for the given action definition. - - This returns an asynchronous method if an event_loop is provided, otherwise - a synchronous method is returned by default. Calling code knows whether they - instantiated a synchronous or asynchronous client. - """ - # noinspection PyShadowingNames - LOG = logging.getLogger(cls_name) - - # FIXME: need to also convert response back into dictionary! - - def _prepare_request(**kwargs): - if missing_keys := missing_keys_in_dict(action.required_args, kwargs): - err_msg = f'Call to {action.group}.{action.name} missing required keys {missing_keys}, skipping!' - LOG.error(err_msg) - raise ValueError(err_msg) - - # substitute any templated fstrings in the command with provided kwargs - if cmd := action.definition.get('cmd'): - if fstring := cmd.get('fstring'): - request = substitute_fstring_vars(fstring, kwargs) - return request.encode(client.encoding()) - - return None - - def _extract_vars_in_response(response: bytes) -> dict: - """Given a response, extract all the known values using the response - message regex defined for this action.""" - response_text = response.decode(client.encoding()) - - if msg := action.definition.get('msg'): - if regex := msg.get('regex'): - return re.match(regex, response_text).groupdict() - - return {} - - # noinspection PyUnusedLocal - def _activity_call_sync(self, **kwargs): - """Synchronous version of making a client call""" - if request := _prepare_request(**kwargs): - if response := client.send_raw(request, wait_for_response=action.response_expected): - return _extract_vars_in_response(response) - return - LOG.warning(f'Failed to make request for {action.group}.{action.name}') - - # noinspection PyUnusedLocal - async def _activity_call_async(self, **kwargs): - """ - Asynchronous version of making a client call is used when an event_loop - is provided. Calling code knows whether they instantiated a synchronous - or asynchronous client. - """ - if request := _prepare_request(**kwargs): - # noinspection PyUnresolvedReferences - if response := await client.send_raw(request, wait_for_response=action.response_expected): - return _extract_vars_in_response(response) - return - LOG.warning(f'Failed to make request for {action.group}.{action.name}') - - # return the async or sync version of the request method - if client.is_async: - return _activity_call_async - return _activity_call_sync - - -class DeviceClient(ABC): - """ - DeviceClientBase base class that defines operations allowed - to control a device. - """ - def __init__(self, model: DeviceModel, connection: DeviceConnection): - super().__init__() - self._model = model - self._connection = connection - - def encoding(self) -> str: - """ - :return: the bytes encoding format for requests/responses - """ - return self._model.encoding - - @property - def is_async(self) -> bool: - """ - :return: True if this client implementation is asynchronous (asyncio) versus synchronous. - """ - return False - - @property - def client(self) -> DeviceConnection: - """ - :return: DeviceConnection ref to the connection this client is using - """ - return self._connection - - @property - def is_connected(self) -> bool: - """ - :return: True if client is connected to device - """ - return True - - - @abstractmethod - def send_raw(self, data: bytes, wait_for_response: bool=False, return_raw=False): - """ - Allows sending a raw data to the device. Generally this should not - be used except for testing, since all commands should be defined in - the yaml protocol configuration. No response messages are supported. - - :return: (optional) if response, return dict of decoded values (and raw response if return_raw set) - """ - raise NotImplementedError() - - @property - def model(self) -> DeviceModel: - """ - :return: the model this client uses for communication and commands with the device - """ - return self._model - - - - @classmethod - def create( - cls, - model: DeviceModel, - connection: DeviceConnection, - event_loop=None, - ) -> DeviceClient: - """ - Creates a DeviceClient instance using the standard pyserial connection - types supported by this library when given details about the model - and connection url. - - NOTE: The model definition could be passed in from any source, though - it is recommended to only use those from the DeviceClient library. That - said, it MAY make sense to split the entire connection stuff into a more - generalized library for serial/IP communication to legacy devices and - have libraries in separate package that are domain specific. - - If an event_loop argument is passed in this will return the - asynchronous implementation. By default, the synchronous interface - is returned. - - :param model: DeviceModel representing the API and protocol for the device - :param connection: connection to the device - :param event_loop: (optional) pass in event loop to get an asynchronous interface - - :return: an instance of DeviceControllerBase - """ - class_name = camel_case(f'{model.id} Client') - LOG.debug(f'Connecting to {model.id} at {connection!r} (class={class_name})') - - # if event_loop provided, return an asynchronous client; otherwise synchronous - if event_loop: - # lazy import the async client to avoid loading both sync/async - from .async_client import DeviceClientAsync - - # dynamically create subclass - dynamic_class = type(class_name, (DeviceClientAsync,), {}) - client = dynamic_class(model, connection, event_loop) - else: - from .sync_client import DeviceClientSync - - dynamic_class = type(class_name, (DeviceClientSync,), {}) - client = dynamic_class(model, connection) - - client.__module__ = f'pyavcontrol.client.{model.id}' - client.__qualname__ = f'{client.__module__}.{class_name}' - - # inject all the methods into the new dynamic class - client = _inject_client_api(client, model) - - return client diff --git a/pyavcontrol/client/sync_client.py b/pyavcontrol/client/sync_client.py deleted file mode 100644 index d740e3b..0000000 --- a/pyavcontrol/client/sync_client.py +++ /dev/null @@ -1,36 +0,0 @@ -import logging -from collections.abc import Callable - -from ..connection import DeviceConnection -from ..connection.sync_connection import synchronized -from ..library.model import DeviceModel -from .base import DeviceClient - -LOG = logging.getLogger(__name__) - - -class DeviceClientSync(DeviceClient): - """Synchronous client for communicating with devices via the provided connection""" - - def __init__(self, model: DeviceModel, connection: DeviceConnection): - super().__init__(model, connection) - self._callback = None - - @synchronized - def send_raw(self, data: bytes, wait_for_response: bool=False): - #if LOG.isEnabledFor(logging.DEBUG): - # LOG.debug(f'Sending {self._connection!r}: {data}') - return self._connection.send(data, wait_for_response=wait_for_response) - - - @synchronized - def register_callback(self, callback: Callable[[str], None]) -> None: - if not callable(callback): - raise ValueError('Callback is not Callable') - self._callback = callback - - @synchronized - def received_message(self): - if self._callback: - LOG.error(f'Callback not implemented!! {self._callback}') # FIXME - # self._loop.call_soon(cb) diff --git a/pyavcontrol/config.py b/pyavcontrol/config.py deleted file mode 100644 index 8ce8356..0000000 --- a/pyavcontrol/config.py +++ /dev/null @@ -1,47 +0,0 @@ -from dataclasses import dataclass - -@dataclass(frozen=True) -class _ConfigKeys: - api = 'api' - baudrate = 'baudrate' - clear_before_new_commands = 'clear_before_new_commands' - command_eol = 'command_eol' - command_separator = 'command_separator' - description = 'description' - encoding = 'encoding' - id = 'id' - message_eol = 'message_eol' - min_time_between_commands = 'min_time_between_commands' - model = 'model' - name = 'name' - protocol = 'protocol' - serial_config = 'serial_config' - timeout = 'timeout' - urls = 'urls' - - -CONFIG = _ConfigKeys() - -# FIXME: see schema! - -# FIXME: other explorations below -# https://dev.to/eblocha/using-dataclasses-for-configuration-in-python-4o53 -# -# raw_config = {...} -# config = Order.from_dict(raw_config) -# config.customer.first_name - - - -# FIXME: if we want completely dynamic config we can use below -# https://alexandra-zaharia.github.io/posts/python-configuration-and-dataclasses/ -# config = DynamicConfig({'host': 'example.com', 'port': 80, 'timeout': 0.5}) -# print(f'host: {config.host}, port: {config.port}, timeout: {config.timeout}') -class DynamicConfig: - def __init__(self, conf): - if not isinstance(conf, dict): - raise TypeError(f'dict expected, found {type(conf).__name__}') - - self._raw = conf - for key, value in self._raw.items(): - setattr(self, key, value) diff --git a/pyavcontrol/connection/__init__.py b/pyavcontrol/connection/__init__.py deleted file mode 100644 index 86df9ef..0000000 --- a/pyavcontrol/connection/__init__.py +++ /dev/null @@ -1,89 +0,0 @@ -import logging - -from pyavcontrol.const import DEFAULT_ENCODING - -LOG = logging.getLogger(__name__) - - -class DeviceConnection: - """ - Connection base class that defines communication APIs. - """ - - def __init__(self): - LOG.error(f'Use factory method create(url, config_overrides') - raise NotImplementedError() - - def is_connected(self) -> bool: - """ - :return: True if the connection is established - """ - raise NotImplementedError() - - def send(self, data: bytes, callback=None, wait_for_response: bool=False): - """ - Send data to the remote device. - - Optional callback can be provided for responses, otherwise any response is returned. - """ - raise NotImplementedError() - - def is_async(self) -> bool: - """ - :return: True if this connection implementation is asynchronous (asyncio) versus synchronous. - """ - return False - - def __repr__(self) -> str: - return self.__class__.__name__ - - -class NullConnection(DeviceConnection): - """NullConnection that sends all data to /dev/null; useful for testing""" - def __init__(self): - pass - - def is_connected(self) -> bool: - return True - - def send(self, data: bytes, callback=None, wait_for_response: bool=False) -> None: - pass - - -class Connection: - @staticmethod - def create( - url: str, connection_config=None, event_loop=None - ) -> DeviceConnection: # FIXME: | None: - """ - Create a Connection instance given details about the given device. - - If an event_loop argument is passed in this will return the - asynchronous implementation. By default, the synchronous interface - is returned. - - :param url: pyserial supported url for communication (e.g. '/dev/ttyUSB0' or 'socket://remote-host:7000/') - :param connection_config: pyserial connection configuration (optional) - :param event_loop: pass in an event loop to get an interface that can be used asynchronously (optional) - - :return an instance of DeviceConnection - """ - if not connection_config: - connection_config = {} - - # FIXME: Types of config needed: - # - connection (pyserial style)...must be passed in since it is determined based on connection type (ip, rs232, etc) - # - # - timeouts/etc (from ???) - # - encoding (from protocol def) - - LOG.debug(f'Connecting to {url}: %s', connection_config) - - if event_loop: - from pyavcontrol.connection.async_connection import AsyncDeviceConnection - - return AsyncDeviceConnection(url, connection_config, event_loop) - else: - from pyavcontrol.connection.sync_connection import SyncDeviceConnection - - return SyncDeviceConnection(url, connection_config) diff --git a/pyavcontrol/connection/async_connection.py b/pyavcontrol/connection/async_connection.py deleted file mode 100644 index b9effb4..0000000 --- a/pyavcontrol/connection/async_connection.py +++ /dev/null @@ -1,225 +0,0 @@ -import logging -import asyncio -import functools -import time -from abc import ABC -from functools import wraps - -from ratelimit import limits -from serial_asyncio import create_serial_connection - -from pyavcontrol.connection import DeviceConnection - -from ..config import CONFIG -from ..const import * # noqa: F403 - -LOG = logging.getLogger(__name__) - -ONE_MINUTE = 60 - -# FIXME: for a specific instance we do not want communication to happen -# simultaneously...for now just lock ALL accesses to ANY device. -async_lock = asyncio.Lock() - - -def locked_coro(coro): - @wraps(coro) - async def wrapper(*args, **kwargs): - async with async_lock: - return await coro(*args, **kwargs) - - return wrapper - - - -class AsyncDeviceConnection(DeviceConnection, ABC): - def __init__(self, url: str, connection_config: dict, loop): - """ - :param url: pyserial compatible url - :param connection_config: pyserial connection config (plus additional attributes timeout/encoding) - """ - self._url = url - self._connection_config = connection_config - self._legacy_connection = None - self._event_loop = loop - - # FIXME: I think encoding should be moved up a level - self._encoding = connection_config.get(CONFIG.encoding, DEFAULT_ENCODING) - - # schedule connecting after returning (since construction of this class is executed - # in a synchronous context) - asyncio.create_task(self._connect()) - - def __repr__(self) -> str: - return f'{self.__class__.__name__} / {self._url}' - - async def _connect(self) -> None: - # FIXME: hacky...merge this old code into this class eventually... - if not self._legacy_connection: - try: - self._legacy_connection = await async_get_rs232_connection( - self._url, - self._connection_config, # self._config, - self._connection_config, - self._event_loop, - ) - except Exception as e: - LOG.error(f"Failed connecting to {self._url}", e) - - - def is_async(self) -> bool: - """ - :return: always True since this connection implementation is asynchronous - """ - return True - - async def is_connected(self) -> bool: - return (self._legacy_connection is not None) - - # check if connected, and abort calling provided method if no connection before timeout - @staticmethod - def ensure_connected(method): - @wraps(method) - async def wrapper(self, *method_args, **method_kwargs): - try: - await self._connect() - return await method(self, *method_args, **method_kwargs) - except Exception as e: - LOG.warning(f'Cannot connect to {self._url}!', e) - raise e - return wrapper - - - @ensure_connected - async def send(self, data: bytes, callback=None, wait_for_response: bool=False): - if not self._legacy_connection: - LOG.error(f"Missing legacy connection!!!") - return - return await self._legacy_connection.send(data, wait_for_response=wait_for_response) - - -async def async_get_rs232_connection( - serial_port: str, config: dict, connection_config: dict, loop -): - # ensure only a single, ordered command is sent to RS232 at a time (non-reentrant lock) - def locked_method(method): - @wraps(method) - async def wrapper(self, *method_args, **method_kwargs): - async with self._lock: - return await method(self, *method_args, **method_kwargs) - - return wrapper - - # check if connected, and abort calling provided method if no connection before timeout - def ensure_connected_legacy(method): - @wraps(method) - async def wrapper(self, *method_args, **method_kwargs): - try: - await asyncio.wait_for(self._connected.wait(), self._timeout) - except Exception as e: - LOG.debug(f'Timeout sending data to {self._url}, no connection!', e) - raise e - return await method(self, *method_args, **method_kwargs) - - return wrapper - - class RS232ControlProtocol(asyncio.Protocol): - # noinspection PyShadowingNames - def __init__( - self, serial_port, config, connection_config, loop - ): - super().__init__() - - self._url = serial_port - self._config = config - self._connection_config = connection_config - self._loop = loop - - # FIXME: this should actually be on the client layer and not connection itself - self._encoding = self._connection_config.get( - CONFIG.encoding, DEFAULT_ENCODING - ) - - self._min_time_between_commands = self._config.get( - CONFIG.min_time_between_commands, 0 - ) - - self._last_send = time.time() - 1 - self._timeout = self._connection_config.get(CONFIG.timeout, DEFAULT_TIMEOUT) - - self._transport = None - self._connected = asyncio.Event() - self._q = asyncio.Queue() - - # ensure only a single, ordered command is sent to RS232 at a time (non-reentrant lock) - self._lock = asyncio.Lock() - - def connection_made(self, transport): - self._transport = transport - LOG.debug(f'Port {self._url} opened {self._transport}') - self._connected.set() - - def data_received(self, data): - # LOG.debug(f"Received {self._url}: %s", data) - asyncio.ensure_future(self._q.put(data)) # , loop=self._loop) - - def connection_lost(self, exc): - LOG.debug(f'Port {self._url} closed') - - async def _reset_buffers(self): - """Reset all input and output buffers""" - self._transport.serial.reset_output_buffer() - self._transport.serial.reset_input_buffer() - while not self._q.empty(): - self._q.get_nowait() - - @locked_method - @ensure_connected_legacy - async def send(self, data: bytes, callback=None, wait_for_response=False): - - @limits(calls=1, period=self._min_time_between_commands) - async def write_rate_limited(data_bytes: bytes): - LOG.debug(f'>> {self._url}: %s', data_bytes) - self._transport.serial.write(data_bytes) - - # clear all buffers of any data waiting to be read before sending the request - await self._reset_buffers() - - await write_rate_limited(data) - - # FIXME: move away from this with callbacks instead - if callback or wait_for_response: - result = await self.receive_response(data) - LOG.debug(f'<< {self._url}: %s', result) - if callback: - await callback(result) - return result - - async def receive_response(self, request): - data = bytearray() - try: - data += await asyncio.wait_for(self._q.get(), self._timeout) - return data - - except asyncio.TimeoutError: - # log up to two times within a time period to avoid saturating the logs - @limits(calls=2, period=ONE_MINUTE) - def log_timeout(): - LOG.info( - f"Timeout @ {self._timeout}s for {self._url} request '%s'; received '%s'", - request, - data, - ) - - log_timeout() - raise - - factory = functools.partial( - RS232ControlProtocol, serial_port, config, connection_config, loop - ) - - LOG.info(f'Connecting to {serial_port}: {connection_config}') - _, protocol = await create_serial_connection( - loop, factory, serial_port, **connection_config - ) - return protocol diff --git a/pyavcontrol/connection/sync_connection.py b/pyavcontrol/connection/sync_connection.py deleted file mode 100644 index ec5d570..0000000 --- a/pyavcontrol/connection/sync_connection.py +++ /dev/null @@ -1,129 +0,0 @@ -import logging -from abc import ABC -from functools import wraps -from threading import RLock - -import serial -from ratelimit import limits - -from pyavcontrol.connection import DeviceConnection - -from ..config import CONFIG -from ..const import DEFAULT_ENCODING, DEFAULT_EOL - -LOG = logging.getLogger(__name__) - -sync_lock = RLock() - - -def synchronized(func): - @wraps(func) - def wrapper(*args, **kwargs): - with sync_lock: - return func(*args, **kwargs) - - return wrapper - - -class SyncDeviceConnection(DeviceConnection, ABC): - """ - Synchronous device connection implementation (NOT YET IMPLEMENTED) - """ - - def __init__(self, url: str, connection_config: dict): - """ - :param url: pyserial compatible url - """ - self._url = url - self._connection_config = connection_config - - self._encoding = connection_config.get(CONFIG.encoding, DEFAULT_ENCODING) - - # FIXME: remove the following - config = connection_config # FIXME: remove - self._eol = config.get(CONFIG.message_eol, DEFAULT_EOL).encode(self._encoding) - - # FIXME: all min time between commands should probably be at the client level and - # not at the raw connection... move up! - self._min_time_between_commands = config.get( - CONFIG.min_time_between_commands, 0 - ) - - # FIXME: contemplate on this more, do we really want to reset/clear - self._clear_before_new_commands = connection_config.get( - CONFIG.clear_before_new_commands, True - ) - - self._port = serial.serial_for_url(self._url, **self._connection_config) - - def __repr__(self) -> str: -# return f'{self.__class__.__name__}->{self._url}' - return f'{self._url}' - - def encoding(self) -> str: - return self._encoding - - def _reset_buffers(self): - self._port.reset_output_buffer() - self._port.reset_input_buffer() - - def send(self, data: bytes, callback=None, wait_for_response: bool=False): - """ - :param data: data bytes sent to the device - :param callback: (optional) - :param wait_for_response: (optional) - :return: string returned by device - """ - - @limits(calls=1, period=self._min_time_between_commands) - def write_rate_limited(data_bytes: bytes): - LOG.debug(f'>> {self._url}: %s', data_bytes) - # send data and force flush to send immediately - self._port.write(data_bytes) - self._port.flush() - - # clear any pending transactions if a response is expected - if response_expected := (callback or wait_for_response): - if self._clear_before_new_commands: - self._reset_buffers() - - write_rate_limited(data) - - # if the caller has requested to receive the result, send it to any - # provided callback and return the result - if response_expected: - LOG.debug(f"Waiting for response (EOL={self._eol})...") - - result = self.handle_receive() - LOG.debug(f'<< {self._url}: %s', result) - - if callback: - callback(result) - return result - - def handle_receive(self) -> bytes: - skip = 0 - - len_eol = len(self._eol) - - # FIXME: implement a much better receive mechanism, without timeouts. - - # receive - result = bytearray() - while True: - c = self._port.read(1) - if not c: - ret = bytes(result) - LOG.info(ret) - raise serial.SerialTimeoutException( - 'Connection timed out! Last received bytes {}'.format( - [hex(a) for a in result] - ) - ) - result += c - if len(result) > skip and result[-len_eol:] == self._eol: - break - - ret = bytes(result) - LOG.debug(f'Received {self._url} "%s"', ret) - return ret diff --git a/pyavcontrol/const.py b/pyavcontrol/const.py deleted file mode 100644 index 9e7a0eb..0000000 --- a/pyavcontrol/const.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Python client library for controlling A/V processors and receivers""" - -import os - -DEFAULT_ENCODING = "ascii" -DEFAULT_EOL = "\r" # "\r\n" -DEFAULT_TCP_IP_PORT = 4999 # IP2SL / Virtual IP2SL uses this port -DEFAULT_TIMEOUT = 1.0 - -PACKAGE_PATH = os.path.dirname(__file__) - -PROCESSOR_TYPE = 'processor' -RECEIVER_TYPE = 'receiver' -MATRIX_TYPE = 'matrix' -ALL_DEVICE_TYPES = [ PROCESSOR_TYPE, RECEIVER_TYPE, MATRIX_TYPE ] - -DEFAULT_MODEL_LIBRARIES = ( - f"{PACKAGE_PATH}/data/flattened", - f"{PACKAGE_PATH}/data/src", - f"{PACKAGE_PATH}/data/future", -) # FIXME: remove this later - -BAUD_RATES = [ 2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000 ] diff --git a/pyavcontrol/data/README.md b/pyavcontrol/data/README.md deleted file mode 100644 index 0345843..0000000 --- a/pyavcontrol/data/README.md +++ /dev/null @@ -1,50 +0,0 @@ - -### Why configuration language? - -Basically PyAVControl is about defining configuration (definitions) for how devices interfaces are defined. Implementing the API definitions directly into a specific language does not achieve the ability for reuse of those definitions across multiple languages/clients. Initially using JSON and YAML was explored as the easiest way to define these interfaces (especially in a way that non-developers could create their own definitions which was often a request from users in example libraries like pyxantech, pymonoprice, pyanthem-serial, etc). - -However, the sheer volume of models and slightly different definitions evolved into needing some sort of import/include/replacement mechanism. While this can be implemented (for the thousandth time) overtop of JSON/YAML, this doesn't make sense since existing configuration language exists that already have implementations in many languages AND this isn't really the point of PyAVControl to define new languages. - -Exploring configuration languages that provided basic support for imports/includes, variables, and a few other features without evolving into a Turing complete language just makes sense. Further, if those languages enable generating a library or repository of these configuration files flattened into raw JSON or YAML, this is a huge bonus since new clients in other languages could use the flattened definitions instead of having to implement the config language if a library didn't already exist. - -This indicates that there should be a build pipeline that converts the definition (config) files into flattened variations as part of the check-in or repository workflow. This provides a nice balance in sufficinet flexibility in defining the interfaces, while keeping the dependencies and simplicity of interacting with common file formats optimized for multiple languages and clients. - -### Requirements/Goals - -* minimize the amount of config required to define interfaces to devices -* enable reuse across device models by sharing large portions of the definitions -* enable non-developers to use an easy to read/understand format for contributing their own equipment definitions -* support access to the intrefaces via JSON by clients (no need to implement complex config parsing for new languages IF it is acceptable to tradeoff "compiling" the definitions down into a large repository of JSON files) -* separate the definition from the runtime dependency -* schema/limited type checking -* ability to add comments - -#### Config Languages Considered - -* [RCL](https://github.com/ruuda/rcl): see [more](https://ruudvanasseldonk.com/2024/a-reasonable-configuration-language), tooling support might be weak (e.g. VSCode extensions, etc) -* [PKL](https://github.com/apple/pkl): no Python implementation yet (2024-03) -* [Nix])(https://nixos.wiki/wiki/Overview_of_the_Nix_Language): to specialized to package management -* [Nickel](https://github.com/tweag/nickel): evolution of Nix -* [HCL](https://github.com/hashicorp/hcl): primarily targeted towards devops/infrastructure config -* [CUE](https://cuelang.org/) -* Dhall - -And of course raw formats, which was the initial implementation, but quickly abandoned due to the sheer volume of files and duplicate config needed to support minute differences between a vast array of physical device features: - -* JSON: most compatible and frequently used for data interfaces; no ability to add comments -* YAML: more readable than json, with some limited support for references -* TOML - -Neither JSON or YAML solve the issues of reuse across configuration files, composition, etc. -Decided on RCL as it was most inline with json, could export the equipment definition files to json -files as part of the build process to make integration into other languages easy where RCL -libraries may not be available. - -### Why RCL? - -#### See Also - -* https://news.ycombinator.com/item?id=39250320 -* https://ruudvanasseldonk.com/2024/a-reasonable-configuration-language - - diff --git a/pyavcontrol/data/future/acurus_m8.yaml b/pyavcontrol/data/future/acurus_m8.yaml deleted file mode 100644 index 77c42b7..0000000 --- a/pyavcontrol/data/future/acurus_m8.yaml +++ /dev/null @@ -1,92 +0,0 @@ ---- -id: acurus_m8 -description: Acurus Amplifier Control Protocol 1.0 - -info: - manufacturer: Acurus - models: - - M8 - tested: false - urls: - - https://app.box.com/s/bp7h228vihe92nmjlo6o8w6e66tvj58o - -connection: - rs232: - baudrate: 9600 - bytesize: 8 - parity: N - stopbits: 1 - timeout: 1.0 - -protocol: - command_eol: "\r" # CR Carriage Return - command_format: '{cmd}{eol}' - - message_format: '{msg}{eol}' - message_eol: "\r\n" - -api: - power: - actions: - status: - cmd: - fstring: STSPOW - toggle: - cmd: - fstring: PWRTGL - msg: - regex: '!OK POWER O(?P[NF])[F]*' - on: - cmd: - fstring: PWRONN - off: - cmd: - fstring: PWROFF - - mute: - description: Mute - actions: - toggle: - description: Mute toggle button - cmd: - fstring: MUTTGL - msg: - regex: '!OK MUTE O(?P[NF])[F]*' - get: - description: get current Mute status - cmd: - fstring: STSMUT - msg: - regex: '!OK MUTE O(?P[NF])[F]*' - off: - description: Mute off - cmd: - fstring: MUTOFF - on: - description: Mute on - cmd: - fstring: MUTONN - channel_on: - cmd: - fstring: MONCH{zone} - regex: MONCH\d - channel_off: - cmd: - fstring: MOFCH{zone} - regex: MOFCH\d - channel_toggle: - cmd: - fstring: MOTCH{zone} - regex: MOTCH\d - - volume: - description: Volume controls - actions: - down: - description: Decrease volume - cmd: - fstring: VOLDWN - up: - description: Increase volume - cmd: - fstring: VOLUPP diff --git a/pyavcontrol/data/future/anthem_d2v.yaml b/pyavcontrol/data/future/anthem_d2v.yaml deleted file mode 100644 index 139877c..0000000 --- a/pyavcontrol/data/future/anthem_d2v.yaml +++ /dev/null @@ -1,236 +0,0 @@ ---- -id: anthem_d2v - -info: - manufacturer: Anthem - models: - - Statement D2 - - Statement D2v - - Statement D2v 3D - tested: false - urls: - - https://www.anthemav.com/downloads/d2v_manual.pdf - -protocol: - min_time_between_commands: 0.25 - command_eol: "\n" - message_eol: "\n" - -connection: - rs232: - baudrate: 9600 - bytesize: 8 - parity: N - stopbits: 1 - timeout: 1.0 - -vars: - zone: - 1: Main - 2: Zone 2 - 3: Zone 3 - power: - 0: Off - 1: On - -api: - power: - description: Power control for the entire system - actions: - 'on': - description: Turn entire system on - cmd: - fstring: 'Z{zone}POW1' - 'off': - description: Turn entire system off - cmd: - fstring: 'Z{zone}POW0' - get: - description: Get system power status (0=off; 1=on) - cmd: - fstring: 'Z{zone}POW?' - msg: - regex: 'Z(?P[0-3])POW(?P[01])' - tests: - 'Z11': - zone: 1 - power: 1 - 'PWSTANDBY': - power: STANDBY - - mute: - description: Mute - actions: - get: - description: Get current Mute status - cmd: - fstring: 'Z{zone}MU?' - msg: - regex: 'Z(?P[0-3])MUT(?P[01])' - tests: - 'Z1MUT0': - zone: 1 - mute: 0 - 'Z2MUT1': - zone: 2 - mute: 1 - 'off': - description: Mute off - cmd: - fstring: 'Z{zone}MU0' - 'on': - description: Mute on - cmd: - fstring: 'Z{zone}MU1' - toggle: - description: Mute toggle - cmd: - fstring: 'Z{zone}MUt' - - volume: - description: Volume controls - actions: - get: - description: Get current volume - cmd: - fstring: 'Z{zone}VOL?' - msg: - regex: 'Z(?P[0-3])VOL(?P[0-9]{1,3})' - tests: - 'Z1VOL80': - zone: 1 - volume: 80 - set: - description: Set volume to x - cmd: - fstring: 'Z{zone}VOL{volume}' - regex: 'Z(?P[0-3])VOL(?P[0-9]{1,3})' - down: - description: Decrease volume - cmd: - fstring: 'Z{zone}VDN' - up: - description: Increase volume - cmd: - fstring: 'Z{zone}VUP' - - source: - description: Input source selection - actions: - get: - description: Get info for currently active source - cmd: - fstring: 'SI?' - msg: - regex: 'SI(?P.+)' - tests: - '!SRC(1) CD': - source: 1 - name: CD - set: - description: Select source - cmd: - fstring: 'SI{source}' - docs: - source: Source to select (integer) - - arc: - description: Anthem Room Correction (ARC) controls - actions: - 'off': - description: ARC off - cmd: - fstring: 'Z1ARC0' - 'on': - description: ARC on - cmd: - fstring: 'Z1ARC1' - - trigger: - description: Set triggers on or off - actions: - 'off': - description: Trigger off - cmd: - fstring: 'R{trigger}SET0' - regex: 'R(?P[12])SET0' - 'on': - description: ARC on - cmd: - fstring: 'R{trigger}SET1' - regex: 'R(?P[12])SET1' - - button: - description: Remote button presses - actions: - back: - description: Back button - cmd: - fstring: '!BACK' - down: - description: Direction Down button - cmd: - fstring: 'Z1SIM0019' - left: - description: Direction Left button - cmd: - fstring: 'Z1SIM0020' - right: - description: Direction Right button - cmd: - fstring: 'Z1SIM0022' # FIXME - up: - description: Direction Up button - cmd: - fstring: 'Z1SIM0021' # FIXME - guide: - description: Guide button - cmd: - fstring: 'Z1SIM0017' - number: - description: Number button - cmd: - fstring: 'Z1SIM000{num}' - regex: 'Z1SIM000(?P[0-9])' - docs: - num: single digit integer (0-9) - num0: - description: Number button 0 - cmd: - fstring: 'Z1SIM0000' - num1: - description: Number button 1 - cmd: - fstring: 'Z1SIM0001' - num2: - description: Number button 2 - cmd: - fstring: 'Z1SIM0002' - num3: - description: Number button 3 - cmd: - fstring: 'Z1SIM0003' - num4: - description: Number button 4 - cmd: - fstring: 'Z1SIM0004' - num5: - description: Number button 5 - cmd: - fstring: 'Z1SIM0005' - num6: - description: Number button 6 - cmd: - fstring: 'Z1SIM0006' - num7: - description: Number button 7 - cmd: - fstring: 'Z1SIM0007' - num8: - description: Number button 8 - cmd: - fstring: 'Z1SIM0008' - num9: - description: Number button 9 - cmd: - fstring: 'Z1SIM0009' \ No newline at end of file diff --git a/pyavcontrol/data/future/classe_ssp600.yaml b/pyavcontrol/data/future/classe_ssp600.yaml deleted file mode 100644 index 213e0b1..0000000 --- a/pyavcontrol/data/future/classe_ssp600.yaml +++ /dev/null @@ -1,76 +0,0 @@ ---- -id: classe_ssp600 - -info: - manufacturer: ClassÊ Audio - models: - - SSP-300 - - SSP-600 - tested: false - urls: - - https://support.classeaudio.com/files/documents/automation_and_control/rs232/CLASSE_SSP-300-600_RS232_Protocol.pdf - -connection: - rs232: - baudrate: 9600 - bytesize: 8 - parity: N - stopbits: 1 - timeout: 0.15 - -protocol: - command_eol: "\r" - command_prefix: "S600" # FIXME: S300 for SSP0-300 - message_eol: "\r\n" - message_prefix: "!" - -api: - mute: - description: Mute - actions: - get: - description: Get current Mute status - cmd: - fstring: 'STAT MAIN' - msg: - regex: 'SY VOLA \d+\s*(?P[muted]+)' - tests: - 'SY VOLA 1 muted': - mute: 'muted' - 'SY VOLA 1': - 'on': - description: Mute on - cmd: - fstring: 'MUTE' - 'off': - description: Mute off - cmd: - fstring: 'UNMT' - - volume: - description: Volume controls - actions: - get: - description: Get current volume - cmd: - fstring: 'STAT MAIN' - msg: - regex: 'SY VOLA (?P\d+)\s*.+' - tests: - 'SY VOLA 50 muted': - volume: 50 - 'SY VOLA 1': - volume: 1 - set: - description: Set volume to x - cmd: - fstring: 'VOLA {volume}' - regex: 'VOLA (?P[0-9]{1,2})' - down: - description: Decrease volume - cmd: - fstring: 'MVOL-' - up: - description: Increase volume - cmd: - fstring: 'MVOL+' diff --git a/pyavcontrol/data/future/lyngdorf_mp60.yaml b/pyavcontrol/data/future/lyngdorf_mp60.yaml deleted file mode 100644 index f32ad5a..0000000 --- a/pyavcontrol/data/future/lyngdorf_mp60.yaml +++ /dev/null @@ -1,50 +0,0 @@ ---- -id: lyngdorf_mp60 - -info: - manufacturer: Lyngdorf - models: - - MP-60 - tested: false - -connection: - ip: - port: 84 - rs232: - baudrate: 115200 - bytesize: 8 - parity: N - stopbits: 1 - timeout: 2.0 - -protocol: - command_eol: "\r\n" # CR/LF - -api: - device: - actions: - name: - description: Returns the name of the device - cmd: - fstring: '!DEVICE?' - msg: - regex: '!DEVICE\((?P.+)\)' - tests: - '!DEVICE(MP-60)': - name: MP-60 - - power: - description: Power controls - actions: - on: - description: Turn CD on - cmd: - fstring: '!ON' - off: - description: Turn CD off - cmd: - fstring: '!OFF' - toggle: - description: Toggle power - cmd: - fstring: '!PWR' diff --git a/pyavcontrol/data/future/marantz_av8805.yaml b/pyavcontrol/data/future/marantz_av8805.yaml deleted file mode 100644 index e0e0158..0000000 --- a/pyavcontrol/data/future/marantz_av8805.yaml +++ /dev/null @@ -1,150 +0,0 @@ ---- -id: marantz_av8805 - -info: - manufacturer: Marantz - models: - - AV8805 - tested: false - urls: - - https://www.marantz.com/-/media/files/documentmaster/marantzna/us/marantz_fy20_sr_nr_protocol_v03_20190827182350130.xls - -connection: - ip: - port: 23 - rs232: - baudrate: 9600 - bytesize: 8 - parity: N - stopbits: 1 - timeout: 1.0 - -protocol: - command_eol: "\r" - message_eol: "\r" - -vars: - source: - PHONE: Phono - CD: CD - BD: BD - TV: TV - SAT/CBL: SAT/CBL - MPLAY: MPLAY - GAME: Game - TUNER: Tuner - HDRADIO: HD Radio - AUX1: AUX1 - AUX2: AUX2 - AUX3: AUX3 - AUX4: AUX4 - AUX5: AUX5 - AUX6: AUX6 - AUX7: AUX7 - NET: NET - BT: BT - power: - ON: On - OFF: Off - mute: - ON: On - OFF: Off - -api: - power: - description: Power control for the entire system - actions: - 'on': - description: Turn entire system on - cmd: - fstring: 'PWON' - 'off': - description: Turn entire system off - cmd: - fstring: 'PWSTANDBY' - toggle: - description: Toggle system power - cmd: - fstring: '@PWR:0' - msg: - regex: 'PWR:(?P[12])' - get: - description: Get system power status (0=off; 1=on) - cmd: - fstring: 'PW?' - msg: - regex: 'PW(?P.+)' - tests: - 'PWON': - power: ON - 'PWSTANDBY': - power: STANDBY - - mute: - description: Mute - actions: - get: - description: Get current Mute status - cmd: - fstring: 'MU?' - msg: - regex: 'MU(?P.+)' - tests: - 'MUOFF': - mute: 'OFF' - 'MUON': - mute: 'ON' - 'off': - description: Mute off - cmd: - fstring: 'MUOFF' - 'on': - description: Mute on - cmd: - fstring: 'MUON' - - volume: - description: Volume controls - actions: - get: - description: Get current volume - cmd: - fstring: 'MV?' - msg: - regex: 'MV(?P[0-9]{1,3})' - tests: - 'MV80': - volume: 80 - set: - description: Set volume to x - cmd: - fstring: 'MV{volume}' - regex: 'MV(?P[0-9]{1,3})' - down: - description: Decrease volume - cmd: - fstring: 'MVDOWN' - up: - description: Increase volume - cmd: - fstring: 'MVUP' - - source: - description: Input source selection - actions: - get: - description: Get info for currently active source - cmd: - fstring: 'SI?' - msg: - regex: 'SI(?P.+)' - tests: - '!SRC(1) CD': - source: 1 - name: CD - set: - description: Select source - cmd: - fstring: 'SI{source}' - docs: - source: Source to select (integer) diff --git a/pyavcontrol/data/future/monoprice_6.yaml b/pyavcontrol/data/future/monoprice_6.yaml deleted file mode 100644 index 7e20757..0000000 --- a/pyavcontrol/data/future/monoprice_6.yaml +++ /dev/null @@ -1,103 +0,0 @@ ---- -id: monoprice_6 - -info: - manufacturer: Monoprice - models: - - MPR-6ZHMAUT - - Model 10761 - tested: false - urls: - - https://app.box.com/s/bp7h228vihe92nmjlo6o8w6e66tvj58o - -connection: - rs232: - baudrate: 9600 - bytesize: 8 - parity: N - stopbits: 1 - timeout: 2.0 - -protocol: - command_format: '<{cmd}{eol}' - command_eol: "\r" # CR Carriage Return - command_separator: '#' - message_format: '>{msg}{eol}' - message_eol: "\r" - -api: - zone: - actions: - status: - cmd: - fstring: '?{zone}' - regex: '\?(?P[1-3][1-6])' - msg: - regex: '#>(?P\d{2})(?P\d{2})(?P[01]{2})(?P[01]{2})(?P[01]{2})(?P\d{2})(?P\d{2})(?P\d{2})(?P\d{2})(?P\d{2})(?P\d{2})' - tests: - '#>1100010000130707100600': - zone: 11 - pa: 0 - power: 1 - mute: 0 - do_not_disturb: 0 - volume: 13 - treble: 7 - bass: 7 - balance: 10 - source: 6 - keypad: 0 - - all_status: - description: Special status request that returns statue for all zones for a specific hardware unit - cmd: - fstring: '?{zone_group}0' - regex: '\?(?P(?P\d{2})(?P\d{2})(?P[01]{2})(?P[01]{2})(?P[01]{2})(?P\d{2})(?P\d{2})(?P\d{2})(?P\d{2})(?P\d{2})(?P\d{2})' - - - power: - actions: - set: - cmd: - fstring: '<{zone}PR0{power}' - regex: '\?(?P\d+)PR0(?P[01])' - tests: - '?1PR01': - power: 1 - 'on': - cmd: - fstring: <{zone}PR01 - 'off': - cmd: - fstring: <{zone}PR00 - - mute: - description: Mute - actions: - 'on': - description: Mute on - cmd: - fstring: <{zone}MU01 - 'off': - description: Mute off - cmd: - fstring: <{zone}MU00 - - volume: - description: Volume controls - actions: - set: - description: Set volume - cmd: - fstring: '<{zone}VO{volume:02}' - regex: '<(?P\d+)VO(?P\d+)' diff --git a/pyavcontrol/data/future/sunfire_tgiii.txt b/pyavcontrol/data/future/sunfire_tgiii.txt deleted file mode 100644 index df65062..0000000 --- a/pyavcontrol/data/future/sunfire_tgiii.txt +++ /dev/null @@ -1,67 +0,0 @@ -Sunfire Theater Grand III -Sunfire Theater Grand IV - - - -The RS-232 PortThe TGIII has a rear panelRS-232 Serial communication port.This allows the FLASH memory tobe upgraded to the latest software byconnecting to a PC.The TGIII software may be updatedto reīŦ ne operational details and toinclude new features. Downloadableupdates will be posted on our website:www.sunīŦ re.com.CommunicationsSerial RS-232, 9600 Baud, 8-N-1DB-9 WiringPINS 1, 6 and 4 are joined togetherin ter nal lyPINS 7 and 8 are joined togetherin ter nal lyPIN 2- Data from processor to con-troller (processor transmit)PIN 3- Data from controller to pro-cessor (processor receive)PIN 5- Ground/CommonPIN 9- No connectionThe RS-232 connector is female.Serial CableTo connect the TGIII port to acomputer, you will need a "straight-through" serial cable. This has con-nector pins at one end connecteddirectly to the pins of the connector atthe other end. For example, pin 1 atone end connects to pin 1 at the otherend, pin 2 connects to pin 2, pin 3 topin 3 and so on.These common cables are avail-able from most computer stores (orfrom Radio Shack as # 26-117). Itshould be 9-pin male at one end, toīŦ t into the TGIII and normally 9-pinfemale at the other, to īŦ t into yourcomputer's serial port (COM1 orCOM2).User's ManualUpdate Procedure1. The current version level of thesoftware running your TGIIIcan be found by looking at theVersion Level OSD menu. Thisis under the Software OSDmenu (see page 37).2. If the website īŦ le is newer thanyour current version, follow thewebsite directions and down-load the new īŦ le onto yourcomputer's hard drive.3. Record your calibration, presetstations or other settings onpage 57. In most cases, theupgrade will not affect any ofthese settings, but it is good torecord them just in case.4. Turn off your computer andthe TGIII. Position them closeenough so that they can beeasily connected using yourserial cable. If you have a lap-top computer, then it may beeasier to bring that close to theTGIII. Otherwise, you need todisconnect the TGIII and moveit close to your computer.5. Connect the TGIII RS-232 portto the corresponding serial porton your computer.6. Turn on the TGIII and yourcomputer.7. Find the īŦ le you downloaded instep 2, and run the program.8. In AUTO mode, the softwarewill look for an active serialconnection and upload the newīŦ le. The TGIII display will showthe status.9. When the īŦ le transfer is com-plete, press the Power switchon the TGIII front panel. Thiscompletes the upgrade.10. Turn off your computer and theTGIII and disconnect the serialcable.APPENDIXExternal ControlThe RS-232 port also allows theTGIII to be controlled externally byHome Theater controllers and com-put ers.The following information is forprogrammers and developers:Partial Serial command setNote that all stan dard com mandsand ex tend ed data are echoed back tothe sender. When a change is madelocally, the data is broad cast, exceptfor the case of "Toggle" and volumecommands. Here is a list of the mostpopular commands. (Contact SunīŦ reTechnical Support, or our websitewww.sunīŦ re.com for a more extensivelist of commands).COMMANDASCII DATA RECEIVEDPOWER TOGGLE*111POWER ON*112POWER OFF*113CD*114TAPE*115SAT*116DVD*117PHONO*118TUNER*119VID1*11AVCR*11BVID2*11CDSP MODE UP*11DDSP MODE DOWN*13WSTEREO*11EPRO LOGIC*11FPARTY*134NEO:613HSOURCEDIRECT13JJAZZ-CLUB*11KHOLO TOGGLE*11LHOLO ON*11MHOLO OFF*11NMUTE TOGGLE*11PMUTE ON*11QMUTE OFF*11RVOLUME UP*11SVOLUME DOWN*11TVOL ABSOLUTE*11U + 2 EXT*11U00 = zero vol*11U99 = max volZONE2 PWR TOGGLE*13MZONE2 PWR ON*13NZONE2 PWR OFF*13PZONE2 MUTE TGGLE*13QZONE2 MUTE ON*13RZONE2 MUTE OFF*13SZONE2 VOL UP*13TZONE2 VOL DOWN*13UZONE2 CD*138ZONE2 TAPE*139ZONE2 SAT*13AZONE2 DVD*13BZONE2 PHONO*13CZONE2 TUNER*13DZONE2 VID1*13EZONE2 VCR*13FZONE2 VID2*13G - - -https://www.manualslib.com/manual/2624583/Sunfire-Theater-Grand-Processor-Iii.html?page=51#manual - - - - - -IV - -COMMAND ASCII DATA RECEIVED -POWER TOGGLE *111 -POWER ON *112 -POWER OFF *113 -CD *114 -TAPE *115 -SAT *116 -DVD *117 -PHONO *118 -TUNER *119 -VID1 *11A -VCR *11B -VID2 *11C -DSP MODE UP *11D -DSP MODE DOWN *13W -STEREO *11E -PRO LOGIC *11F -PRO LOGIC IIx MUSIC *15P -PRO LOGIC IIx MOVIE *15Q -PARTY *134 -NEO:6 13H -SOURCEDIRECT 13J -JAZZ-CLUB *11K -HOLO TOGGLE *11L -HOLO ON *11M -HOLO OFF *11N -MUTE TOGGLE *11P -MUTE ON *11Q -MUTE OFF *11R -VOLUME UP *11S -VOLUME DOWN *11T -VOL ABSOLUTE *11U + 2 EXT -*11U00 = zero vol -*11U99 = max vol -ZONE2 PWR TOGGLE *13M -ZONE2 PWR ON *13N -ZONE2 PWR OFF *13P -ZONE2 MUTE TGGLE *13Q -ZONE2 MUTE ON *13R -ZONE2 MUTE OFF *13S -ZONE2 VOL UP *13T -ZONE2 VOL DOWN *13U -ZONE2 CD *138 -ZONE2 TAPE *139 -ZONE2 SAT *13A -ZONE2 DVD *13B -ZONE2 PHONO *13C -ZONE2 TUNER *13D -ZONE2 VID1 *13E -ZONE2 VCR *13F -ZONE2 VID2 *13G diff --git a/pyavcontrol/data/rcl/defaults/base.rcl b/pyavcontrol/data/rcl/defaults/base.rcl deleted file mode 100644 index 697412e..0000000 --- a/pyavcontrol/data/rcl/defaults/base.rcl +++ /dev/null @@ -1,7 +0,0 @@ -// defaults that all equipment should include as default values -{ - info = { - name = "Unknown", - tested = false - } -} diff --git a/pyavcontrol/data/rcl/defaults/rs232.rcl b/pyavcontrol/data/rcl/defaults/rs232.rcl deleted file mode 100644 index 252302c..0000000 --- a/pyavcontrol/data/rcl/defaults/rs232.rcl +++ /dev/null @@ -1,12 +0,0 @@ -{ - connection = { - rs232 = { - baudrate = 9600, - bytesize = 8, - parity = "N", - stopbits = 1, - timeout = 1.0, - encoding = "ascii", // most typical encoding - response_eol = "\r", // typical EOL - } -} diff --git a/pyavcontrol/data/rcl/mcintosh/mcintosh_mx160.rcl b/pyavcontrol/data/rcl/mcintosh/mcintosh_mx160.rcl deleted file mode 100644 index 0512d6a..0000000 --- a/pyavcontrol/data/rcl/mcintosh/mcintosh_mx160.rcl +++ /dev/null @@ -1,1470 +0,0 @@ -// NOTES: -// - cmd commands are in substitution variable format (e.g. {var}) -// - msg messages/responses are in regex format to decode - -{ - "id": "mcintosh_mx160", - "description": "McIntosh MX160 Protocol [2017-09-18 MX160 Serial Control Manual V7]", - "info": { - "name": "McIntosh", - "models": [ - "MX160" - ], - "type": "processor", - "tested": true, - "urls": [ - "https://www.mcintoshlabs.com/legacy-products/home-theater-processors/MX160", - "http://www.mcintoshcompendium.com/Compendium%20Docs/Home%20Theater%20Controllers/PDFs/MX160.pdf", - "https://www.docdroid.net/OnipkTW/mx160-serial-control-manual-v3-pdf" - ] - }, - "hardware": { - "sources": { - "0": "HDMI 1", - "1": "HDMI 2", - "2": "HDMI 3", - "3": "HDMI 4", - "4": "HDMI 5", - "5": "HDMI 6", - "6": "HDMI 7", - "7": "HDMI 8", - "8": "Audio Return", - "9": "SPDIF 1 (Optical)", - "10": "SPDIF 2 (Optical)", - "11": "SPDIF 3 (Optical)", - "12": "SPDIF 4 (Optical)", - "13": "SPDIF 5 (AES/EBU)", - "14": "SPDIF 6 (Coaxial)", - "15": "SPDIF 7 (Coaxial)", - "16": "SPDIF 8 (Coaxial)", - "17": "USB Audio", - "18": "Analog 1", - "19": "Analog 2", - "20": "Analog 3", - "21": "Analog 4", - "22": "Balanced 1", - "23": "Balanced 2", - "24": "Phono", - "25": "8 Channel Analog" - }, - "baud_rates": { - "9600": "9600", - "115200": "115200 (default)" - } - }, - "connection": { - "rs232": { - "baudrate": 115200, - "bytesize": 8, - "parity": "N", - "stopbits": 1, - "timeout": 2, - "encoding": "ascii", - "response_eol": "\r" - }, - "connection_init": "!VERB(2)" - }, - "protocol": { - "encoding": "ascii", - "command_eol": "\r", - "message_eol": "\r", - "min_time_between_commands": 0.4 - }, - "vars": { - "zone": { - "type": "int", - "pattern": "[1-8]", - "min": 1, - "max": 8 - }, - "power": { - "type": "int", - "pattern": "[01]", - "min": 0, - "max": 1 - }, - "mute": { - "type": "int", - "pattern": "[01]", - "min": 0, - "max": 1 - }, - "volume": { - "type": "int", - "min": 0, - "max": 38 - }, - "treble": { - "type": "int", - "min": 0, - "max": 14 - }, - "bass": { - "type": "int", - "min": 0, - "max": 14 - }, - "balance": { - "type": "int", - "min": 0, - "max": 63 - }, - "source": { - "type": "int", - "min": 1, - "max": 8 - }, - "verbosity_level": { - "type": "int", - "min": 1, - "max": 3, - "values": { - "1": "Minimal", - "2": "Normal", - "3": "All" - } - }, - "dim_level": { - "type": "int", - "min": 0, - "max": 3, - "values": { - "0": "Full (100%)", - "1": "Bright (75%)", - "2": "Low (50%)", - "3": "Dark (25%)" - } - }, - "interface": { - "type": "string", - "values": { - "IP": "IP", - "SERIAL": "Serial" - } - }, - "lipsync": { - "type": "int" - }, - "loudness": { - "type": "int", - "min": 0, - "max": 1, - "pattern": "[01]", - "values": { - "0": "Off", - "1": "On" - } - }, - "roomperfect_position": { - "type": "int", - "min": 1, - "max": 9, - "pattern": "[1-9]", - "values": { - "0": "Bypass", - "1": "Focus 1", - "2": "Focus 2", - "3": "Focus 3", - "4": "Focus 4", - "5": "Focus 5", - "6": "Focus 6", - "7": "Focus 7", - "8": "Focus 8", - "9": "Global" - } - }, - "input": { - "type": "int", - "min": 0, - "max": 25, - "values": { - "0": "HDMI 1", - "1": "HDMI 2", - "2": "HDMI 3", - "3": "HDMI 4", - "4": "HDMI 5", - "5": "HDMI 6", - "6": "HDMI 7", - "7": "HDMI 8", - "8": "Audio Return", - "9": "SPDIF 1 (Optical)", - "10": "SPDIF 2 (Optical)", - "11": "SPDIF 3 (Optical)", - "12": "SPDIF 4 (Optical)", - "13": "SPDIF 5 (AES/EBU)", - "14": "SPDIF 6 (Coaxial)", - "15": "SPDIF 7 (Coaxial)", - "16": "SPDIF 8 (Coaxial)", - "17": "USB Audio", - "18": "Analog 1", - "19": "Analog 2", - "20": "Analog 3", - "21": "Analog 4", - "22": "Balanced 1", - "23": "Balanced 2", - "24": "Phono", - "25": "8 Channel Analog" - } - } - }, - "api": { - "verbosity": { - "actions": { - "set": { - "description": "Set verbosity level of active interface", - "cmd": { - "fstring": "!VERB({verbosity_level})", - "docs": { - "verbosity_level": "0 (min), 1 (normal), or 2 (max)" - } - } - }, - "min": { - "description": "Set verbosity level to minimal", - "cmd": { - "fstring": "!VERB(1)" - } - }, - "normal": { - "description": "Set verbosity level to normal", - "cmd": { - "fstring": "!VERB(2)" - } - }, - "max": { - "description": "Set verbosity level to maximum", - "cmd": { - "fstring": "!VERB(3)" - } - }, - "get": { - "description": "Request verbosity level of active interface", - "cmd": { - "fstring": "!VERB?" - }, - "msg": { - "regex": "!VERB\\((?P[123])\\)", - "tests": { - "!VERB(2)": { - "verbosity_level": 2 - } - } - } - } - } - }, - "audio_mode": { - "description": "Audio processing mode control", - "actions": { - "down": { - "description": "Audio processing mode down button", - "cmd": { - "fstring": "!AUDMODE-" - } - }, - "up": { - "description": "Audio processing mode up button", - "cmd": { - "fstring": "!AUDMODE+" - } - }, - "get": { - "description": "Request audio processing mode", - "cmd": { - "fstring": "!AUDMODE?" - }, - "msg": { - "regex": "!AUDMODE\\((?P\\d+)\\)\\s*\"(?P.+)\"", - "tests": { - "!AUDMODE(1) \"Test\"": { - "type": 1, - "name": "Test" - } - } - } - }, - "modes": { - "description": "Get list of audio processing modes", - "cmd": { - "fstring": "!AUDMODEL?" - }, - "msg": { - "regex": "!AUDMODECOUNT\\((?P\\d+)\\)\\r", - "tests": { - "!AUDMODECOUNT(2)\r!AUDMODE(0)\"Source 1\"\r!AUDMODE(1)\"Source 2\"": { - "count": 2 - } - } - } - } - } - }, - "audio_type": { - "description": "Input audio type", - "actions": { - "get": { - "description": "Return string of the input audio type.", - "cmd": { - "fstring": "!AUDTYPE?" - }, - "msg": { - "regex": "!AUDTYPE\\((?P.+)\\)", - "tests": { - "!AUDTYPE(Unknown)": { - "type": "Unknown" - } - } - } - } - } - }, - "button": { - "description": "Remote button presses", - "actions": { - "back": { - "description": "Back button", - "cmd": { - "fstring": "!BACK" - } - }, - "down": { - "description": "Direction Down button", - "cmd": { - "fstring": "!DIRD" - } - }, - "left": { - "description": "Direction Left button", - "cmd": { - "fstring": "!DIRL" - } - }, - "right": { - "description": "Direction Right button", - "cmd": { - "fstring": "!DIRR" - } - }, - "up": { - "description": "Direction Up button", - "cmd": { - "fstring": "!DIRU" - } - }, - "enter": { - "description": "Enter button", - "cmd": { - "fstring": "!ENTER" - } - }, - "exit": { - "description": "Exit button", - "cmd": { - "fstring": "!EXIT" - } - }, - "info": { - "description": "Info button", - "cmd": { - "fstring": "!INFO" - } - }, - "menu": { - "description": "Menu Button", - "cmd": { - "fstring": "!MENU" - } - }, - "setup": { - "description": "Setup button", - "cmd": { - "fstring": "!SETUP" - } - }, - "source": { - "description": "Source button", - "cmd": { - "fstring": "!SRCBTN" - } - }, - "number": { - "description": "Number button", - "cmd": { - "fstring": "!NUM({num})", - "docs": { - "num": "single digit integer (0-9)" - } - } - }, - "num0": { - "description": "Number button 0", - "cmd": { - "fstring": "!NUM(0)" - } - }, - "num1": { - "description": "Number button 1", - "cmd": { - "fstring": "!NUM(1)" - } - }, - "num2": { - "description": "Number button 2", - "cmd": { - "fstring": "!NUM(2)" - } - }, - "num3": { - "description": "Number button 3", - "cmd": { - "fstring": "!NUM(3)" - } - }, - "num4": { - "description": "Number button 4", - "cmd": { - "fstring": "!NUM(4)" - } - }, - "num5": { - "description": "Number button 5", - "cmd": { - "fstring": "!NUM(5)" - } - }, - "num6": { - "description": "Number button 6", - "cmd": { - "fstring": "!NUM(6)" - } - }, - "num7": { - "description": "Number button 7", - "cmd": { - "fstring": "!NUM(7)" - } - }, - "num8": { - "description": "Number button 8", - "cmd": { - "fstring": "!NUM(8)" - } - }, - "num9": { - "description": "Number button 9", - "cmd": { - "fstring": "!NUM(9)" - } - } - } - }, - "device": { - "actions": { - "name": { - "description": "Returns the name of the device (e.g. MX160)", - "cmd": { - "fstring": "!DEVICE?" - }, - "msg": { - "regex": "!DEVICE\\((?P.+)\\)", - "tests": { - "!DEVICE(MX160)": { - "name": "MX160" - } - } - } - } - } - }, - "display_brightness": { - "description": "VFD display brightness (0 – 3; 0=100%, 1=75%, 2=50%, 3=25%)", - "actions": { - "down": { - "description": "Reduce brightness of the VFD display", - "cmd": { - "fstring": "!DIM-" - } - }, - "up": { - "description": "Increase the brightness of the VFD display", - "cmd": { - "fstring": "!DIM+" - } - }, - "get": { - "description": "Request brightness of the VFD display", - "cmd": { - "fstring": "!DIM?" - }, - "msg": { - "regex": "!DIM\\((?P[0123])\\)", - "tests": { - "!DIM(2)": { - "dim_level": 2 - } - } - } - }, - "set": { - "description": "Set display brightness level", - "cmd": { - "fstring": "!DIM({dim_level})", - "docs": { - "dim_level": "0 (Full 100%), 1 (Bright 75%), 2 (Low 50%), or 3 (Dark 25%)" - } - } - }, - "full": { - "description": "Set display brightness Full (100%)", - "cmd": { - "fstring": "!DIM(0)" - } - }, - "bright": { - "description": "Set display brightness Bright (75%)", - "cmd": { - "fstring": "!DIM(1)" - } - }, - "low": { - "description": "Set display brightness Low (50%)", - "cmd": { - "fstring": "!DIM(2)" - } - }, - "dark": { - "description": "Set display brightness Dark (25%)", - "cmd": { - "fstring": "!DIM(3)" - } - } - } - }, - "interface": { - "description": "Interface type for this session (IP or SERIAL)", - "actions": { - "get": { - "description": "Returns the active interface for this section", - "cmd": { - "fstring": "!INTERFACE?" - }, - "msg": { - "regex": "!INTERFACE\\((?P(IP|SERIAL))\\)", - "tests": { - "!INTERFACE(SERIAL)": { - "interface": "SERIAL" - }, - "!INTERFACE(IP)": { - "interface": "IP" - } - } - } - } - } - }, - "lipsync": { - "description": "Lipsync adjustments", - "actions": { - "set": { - "description": "Set the lipsync value", - "cmd": { - "fstring": "!LIPSYNC({lipsync})", - "regex": "!LIPSYNC\\((?P\\d+)\\)", - "docs": { - "lipsync": "lipsync value" - } - } - }, - "get": { - "description": "Get the lipsync value", - "cmd": { - "fstring": "!LIPSYNC?" - }, - "msg": { - "regex": "!LIPSYNC\\((?P\\d)\\)", - "tests": { - "!LIPSYNC(1)": { - "lipsync": 1 - } - } - } - }, - "range": { - "description": "Get the lipsync value range", - "cmd": { - "fstring": "!LIPSYNCRANGE?" - }, - "msg": { - "regex": "!LIPSYNCRANGE\\((?P\\d+),(?P\\d+)\\)\r", - "tests": { - "!LIPSYNCRANGE(1,3)": { - "min": 1, - "max": 3 - } - } - } - }, - "down": { - "description": "Reduce lipsync value", - "cmd": { - "fstring": "!LIPSYNC-" - } - }, - "up": { - "description": "Increase lipsync value", - "cmd": { - "fstring": "!LIPSYNC+" - } - } - } - }, - "loudness": { - "description": "Loudness", - "actions": { - "on": { - "description": "Turn loudness on", - "cmd": { - "fstring": "!LOUDNESS(1)" - } - }, - "off": { - "description": "Turn loudness off", - "cmd": { - "fstring": "!LOUDNESS(0)" - } - }, - "get": { - "description": "Get the loudness setting (0=off; 1=on)", - "cmd": { - "fstring": "!LOUDNESS?" - }, - "msg": { - "regex": "!LOUDNESS\\((?P[01])\\)", - "tests": { - "!LOUDNESS(0)": { - "loudness": 0 - } - } - } - } - } - }, - "mute": { - "description": "Mute", - "actions": { - "toggle": { - "description": "Mute toggle button", - "cmd": { - "fstring": "!MUTE" - } - }, - "get": { - "description": "get current Mute status", - "cmd": { - "fstring": "!MUTE?" - }, - "msg": { - "regex": "!MUTE\\\\((?P[01])\\)", - "tests": { - "!MUTE(1)": { - "mute": 1 - } - } - } - }, - "off": { - "description": "Mute off", - "cmd": { - "fstring": "!MUTEOFF" - } - }, - "on": { - "description": "Mute on", - "cmd": { - "fstring": "!MUTEON" - } - } - } - }, - "ping": { - "description": "Ping test", - "actions": { - "ping": { - "description": "Ping for a pong (returns PONG)", - "cmd": { - "fstring": "!PING?" - }, - "msg": { - "regex": "!PONG" - } - } - } - }, - "power": { - "description": "Power control for the entire system", - "actions": { - "on": { - "description": "Turn entire system on", - "cmd": { - "fstring": "!PON" - } - }, - "off": { - "description": "Turn entire system off", - "cmd": { - "fstring": "!POFF" - } - }, - "toggle": { - "description": "Toggle system power", - "cmd": { - "fstring": "!PTOGGLE" - } - }, - "get": { - "description": "Get system power status (0=off; 1=on)", - "cmd": { - "fstring": "!POWER?" - }, - "msg": { - "regex": "!POWER\\((?P[01])\\)", - "tests": { - "!POWER(1)": { - "power": 1 - } - } - } - } - } - }, - "power_zone_main": { - "description": "Main zone power", - "actions": { - "on": { - "description": "Turn main zone power on", - "cmd": { - "fstring": "!POWERONMAIN" - } - }, - "off": { - "description": "Turn main zone power off", - "cmd": { - "fstring": "!POWEROFFMAIN" - } - }, - "get": { - "description": "get main zone power status (0=standby; 1=on)", - "cmd": { - "fstring": "!POWERMAIN?" - }, - "msg": { - "regex": "!POWER\\((?P[01])\\)", - "tests": { - "!POWER(1)": { - "power": 1 - } - } - } - } - } - }, - "power_zone_2": { - "description": "Zone 2 power", - "actions": { - "on": { - "description": "Turn zone 2 power on", - "cmd": { - "fstring": "!POWERONZONE2" - } - }, - "off": { - "description": "Turn zone 2 power off", - "cmd": { - "fstring": "!POWEROFFZONE2" - } - }, - "get": { - "description": "Get zone 2 power status (0=off; 1=on)", - "cmd": { - "fstring": "!POWERZONE2?" - }, - "msg": { - "regex": "!POWER\\((?P[01])\\)", - "tests": { - "!POWERZONE2(1)": { - "power": 1 - } - } - } - } - } - }, - "roomperfect_focus": { - "description": "RoomPerfect room correction focus", - "actions": { - "previous": { - "description": "Previous RoomPerfect position button", - "cmd": { - "fstring": "!RPFOC-" - } - }, - "position": { - "description": "Request RoomPerfect position (0=bypass, 1-8=focus1-8, 9=global)", - "cmd": { - "fstring": "!RPFOC?" - } - }, - "set": { - "description": "Set RoomPerfect position", - "cmd": { - "fstring": "!RPFOC({roomperfect_position})", - "docs": { - "roomperfect_position": "RoomPerfect position (0=bypass, 1-8=focus1-8, 9=global)" - } - } - }, - "next": { - "description": "Next Roomperfect position button", - "cmd": { - "fstring": "!RPFOC+" - } - }, - "get": { - "description": "Get available RoomPerfect positions", - "cmd": { - "fstring": "!RPFOCS?" - }, - "msg": { - "regex": "!PPFOCOUNT\\((?P[01])\\)", - "tests": { - "FIXME": { - "positions": 3 - } - } - } - } - } - }, - "roomperfect_voice": { - "description": "RoomPerfect room correction voice", - "actions": { - "previous": { - "description": "Previous voicing button", - "cmd": { - "fstring": "!RPVOI-" - } - }, - "get": { - "description": "Get active voicing", - "cmd": { - "fstring": "!RPVOI?" - }, - "msg": { - "regex": "!RPVOI\\((?P[01])\\)\\s*\"(?P.+)\"", - "tests": { - "!RPVOI(1) \"Test\"": { - "active_voice": 1, - "name": "Test" - } - } - } - }, - "set": { - "description": "Set voicing", - "cmd": { - "fstring": "!RPVOI({roomperfect_voicing})" - }, - "docs": { - "roomperfect_voicing": "RoomPerfect voicing value" - } - }, - "next": { - "description": "Next voicing button", - "cmd": { - "fstring": "!RPVOI+" - } - }, - "list": { - "description": "Request list of available voicings", - "cmd": { - "fstring": "!RPVOIS?" - }, - "msg": { - "regex": "!RPVOICOUNT\\((?P\\d+)\\)\r", - "tests": { - "!RPIVOICOUNT(2)": { - "voice_count": 2 - } - } - } - } - } - }, - "source": { - "description": "Input source selection", - "actions": { - "previous": { - "description": "Previous source button", - "cmd": { - "fstring": "!SRC-" - } - }, - "get": { - "description": "Get info for currently active source", - "cmd": { - "fstring": "!SRC?" - }, - "msg": { - "regex": "!SRC\\((?P\\d+)\\)\\s*\"(?P.+)\"", - "tests": { - "!SRC(1) CD": { - "source": 1, - "name": "CD" - } - } - } - }, - "set": { - "description": "Select source", - "cmd": { - "fstring": "!SRC({source})", - "docs": { - "source": "Source to select (integer)" - } - } - }, - "info": { - "description": "Get info for a specific source", - "cmd": { - "fstring": "!SRC({source})?", - "docs": { - "source": "the integer identifying the source input" - } - }, - "msg": { - "regex": "!SRC\\((?P\\d+)\\)\\s*\"(?P.+)\"", - "tests": { - "!SRC(2) HiFiBerry": { - "source": 2, - "name": "HiFiBerry" - } - } - } - }, - "next": { - "description": "Next source button", - "cmd": { - "fstring": "!SRC+" - } - }, - "list": { - "description": "Get list of available sources", - "cmd": { - "fstring": "!SRCS?" - }, - "msg": { - "regex": "!SRCOUNT\\((?P\\d+)\\)\r", - "tests": { - "FIXME": { - "count": 2 - } - } - } - } - } - }, - "volume_offset": { - "description": "Volume offset", - "actions": { - "down": { - "description": "Decrease Source volume offset", - "cmd": { - "fstring": "!SRCOFF-" - } - }, - "get": { - "description": "Get source volume offset for current source", - "cmd": { - "fstring": "!SRCOFF?" - } - }, - "set": { - "description": "Set source volume offset for current source", - "cmd": { - "fstring": "!SRCOFF(x)" - } - }, - "up": { - "description": "Increase source volume offset", - "cmd": { - "fstring": "!SRCOFF+" - } - } - } - }, - "software": { - "description": "Software/firmware info", - "actions": { - "info": { - "description": "Request SW information (prints a list of version numbers)", - "cmd": { - "fstring": "!SWINFO?" - }, - "msg": { - "regex": "!SWINFO\\((?P.+)\\)", - "tests": { - "!SWINFO(1)": { - "version": "1" - } - } - } - } - } - }, - "trim_bass": { - "description": "Bass trim controls", - "actions": { - "get": { - "description": "Get current bass level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMBASS?" - }, - "msg": { - "regex": "!TRIMBASS\\((?P-?[0-9]{1,3})\\)", - "tests": { - "!TRIMBASS(10)": { - "bass_level": 10 - } - } - } - }, - "set": { - "description": "Sets bass level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMBASS({bass_level})", - "regex": "!TRIMBASS\\((?P-?[0-9]{1,3})\\)" - } - }, - "up": { - "description": "Increases bass level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMBASS+" - } - }, - "down": { - "description": "Decreases bass level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMBASS-" - } - } - } - }, - "trim_center": { - "description": "Center channel trim controls", - "actions": { - "get": { - "description": "get current center channel level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMCENTER?" - }, - "msg": { - "regex": "!TRIMCENTER\\((?P-?[0-9]{1,3})\\)", - "tests": { - "!TRIMCENTER(10)": { - "center_level": 10 - } - } - } - }, - "set": { - "description": "Sets center channel level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMCENTER({center_level})", - "regex": "!TRIMCENTER\\((?P-?[0-9]{1,3})\\)" - } - }, - "up": { - "description": "Increases center channel level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMCENTER+" - } - }, - "down": { - "description": "Decreases center channel level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMCENTER-" - } - } - } - }, - "trim_height": { - "description": "Height channels trim controls", - "actions": { - "get": { - "description": "Gete current height channels level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMHEIGHT?" - }, - "msg": { - "regex": "!TRIMHEIGHT\\((?P-?[0-9]{1,3})\\)", - "tests": { - "!TRIMHEIGHT(9)": { - "height_level": 9 - } - } - } - }, - "set": { - "description": "Sets height channels level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMHEIGHT({height_level})", - "regex": "!TRIMHEIGHT\\((?P-?[0-9]{1,3})\\)" - } - }, - "up": { - "description": "Increases height channels level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMHEIGHT+" - } - }, - "down": { - "description": "Decreases height channels level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMHEIGHT-" - } - } - } - }, - "trim_lfe": { - "description": "LFE channel trim controls", - "actions": { - "get": { - "description": "Get current LFE channel level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMLFE?" - }, - "msg": { - "regex": "!TRIMLFE\\((?P-?[0-9]{1,3})\\)", - "tests": { - "!TRIMLFE(2)": { - "lfe_level": 2 - } - } - } - }, - "set": { - "description": "Sets LFE channel level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMLFE({lfe_level})", - "regex": "!TRIMLFE\\((?P-?[0-9]{1,3})\\)" - } - }, - "up": { - "description": "Increases LFE channel level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMLFE+" - } - }, - "down": { - "description": "Decreases LFE channel level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMLFE-" - } - } - } - }, - "trim_surrounds": { - "description": "Surround channels trim controls", - "actions": { - "down": { - "description": "Decreases surround channels level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMSURRS-" - } - }, - "get": { - "description": "Get current surround channels level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMSURRS?" - }, - "msg": { - "regex": "!TRIMSURRS\\((?P-?[0-9]{1,3})\\)", - "tests": { - "!TRIMSURRS(1)": { - "surround_level": 1 - } - } - } - }, - "set": { - "description": "Sets surround channels level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMSURRS({surround_level})", - "regex": "!TRIMSURRS\\((?P-?[0-9]{1,3})\\)" - } - }, - "up": { - "description": "Increases surround channels level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMSURRS+" - } - } - } - }, - "trim_treble": { - "description": "Treble trim controls", - "actions": { - "get": { - "description": "Get current treble level trim (10 = 1dB; -120=-10 dB to 120=+10 dB)", - "cmd": { - "fstring": "!TRIMTREB?" - }, - "msg": { - "regex": "!TRIMTREB\\(?P-?[0-9]{1,3})\\)", - "tests": { - "!TRIMTREB(100)": { - "trebble_level": 100 - } - } - } - }, - "set": { - "description": "Sets treble level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMTREB({trebble_level})", - "regex": "!TRIMTREB\\((?P-?[0-9]{1,3})\\)" - } - }, - "down": { - "description": "Decreases treble level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMTREB-" - } - }, - "up": { - "description": "Increases treble level trim (10 = 1dB)", - "cmd": { - "fstring": "!TRIMTREB+" - } - } - } - }, - "volume": { - "description": "Volume controls", - "actions": { - "get": { - "description": "Get current volume", - "cmd": { - "fstring": "!VOL?" - }, - "msg": { - "regex": "!VOL\\((?P[0-9]{1,2})\\)", - "tests": { - "!VOL(1)": { - "volume": 1 - } - } - } - }, - "set": { - "description": "Set volume to x", - "cmd": { - "fstring": "!VOL({volume})", - "regex": "!VOL\\((?P[0-9]{1,2})\\)" - } - }, - "down": { - "description": "Decrease volume", - "cmd": { - "fstring": "!VOL-" - } - }, - "down_by_x": { - "description": "Decrease volume by x", - "cmd": { - "fstring": "!VOL-({volume_amount})", - "regex": "!VOL-\\((?P[0-9]{1,2})\\)" - } - }, - "up": { - "description": "Increase volume", - "cmd": { - "fstring": "!VOL+" - } - }, - "up_by_x": { - "description": "Increase volume by x", - "cmd": { - "fstring": "!VOL+({volume_amount})", - "regex": "!VOL\\+\\((?P[0-9]{1,2})\\)" - } - } - } - }, - "zone_2_mute": { - "description": "Zone 2 mute", - "actions": { - "get": { - "description": "get current Zone B Mute status", - "cmd": { - "fstring": "!ZMUTE?" - }, - "msg": { - "regex": "!ZMUTE\\((?P[01])\\)", - "tests": { - "!ZMUTE(1)": { - "mute": 1 - } - } - } - }, - "on": { - "description": "Zone B Mute on", - "cmd": { - "fstring": "!ZMUTEON" - } - }, - "off": { - "description": "Zone B Mute off", - "cmd": { - "fstring": "!ZMUTEOFF" - } - }, - "toggle": { - "description": "Toggle Zone B Mute", - "cmd": { - "fstring": "!ZMUTE" - } - } - } - }, - "zone_2_power": { - "description": "Zone 2 power", - "actions": { - "on": { - "description": "Zone Power On", - "cmd": { - "fstring": "!ZPON" - } - }, - "off": { - "description": "Zone Power Off", - "cmd": { - "fstring": "!ZPOFF" - } - }, - "toggle": { - "description": "Zone Power Toggle", - "cmd": { - "fstring": "!ZPTOGGLE" - } - } - } - }, - "zone_2_source": { - "description": "Zone 2 input source selection", - "actions": { - "previous": { - "description": "Previous zone B source button", - "cmd": { - "fstring": "!ZSRC-" - } - }, - "get": { - "description": "Get current Zone B source", - "cmd": { - "fstring": "!ZSRC?" - }, - "msg": { - "regex": "!ZSRC\\((?P\\d+)\\s*\"(?P.+)\"\\)", - "tests": { - "!ZSRC(1) \"Source 1\"": { - "source": 1, - "name": "Source 1" - } - } - } - }, - "set": { - "description": "Set Zone B source", - "cmd": { - "fstring": "!ZSRC({source})", - "regex": "!ZSRC\\((?P\\d+)\\)" - } - }, - "next": { - "description": "Next Zone B source button", - "cmd": { - "fstring": "!ZSRC+" - } - }, - "list": { - "description": "Get list of available Zone B sources", - "cmd": { - "fstring": "!ZSRCS?" - }, - "msg": { - "regex": "!ZSRCCOUNT\\\\((?P\\\\d+)\\r!ZSRC\\\\((?P.+)\\\\)\\s*\"(?P.+)\"", - "tests": { - "!ZSRCCOUNT(1)\\r!ZSRC(1) \"Source 1\"": { - "count": 1 - } - } - } - } - } - }, - "zone_2_volume": { - "description": "Zone 2 volume control", - "actions": { - "down": { - "description": "Decrease zone B volume", - "cmd": { - "fstring": "!ZVOL-" - } - }, - "down_by_x": { - "description": "decrease zone B volume by X", - "cmd": { - "fstring": "!ZVOL-({volume_amount})", - "regex": "!ZVOL-\\((?P[0-9]{1,2})\\)" - } - }, - "get": { - "description": "Get current zone B volume", - "cmd": { - "fstring": "!ZVOL?" - }, - "msg": { - "regex": "!ZVOL\\((?P[0-9]{1,2})\\)", - "tests": { - "!ZVOL(2)": { - "volume": 2 - } - } - } - }, - "set": { - "description": "Set zone B volume", - "cmd": { - "fstring": "!ZVOL({volume})", - "regex": "!ZVOL\\((?P[0-9]{1,2})\\)" - } - }, - "up": { - "description": "Increase zone B volume", - "cmd": { - "fstring": "!ZVOL+" - } - }, - "up_by_x": { - "description": "Increase zone B volume by x", - "cmd": { - "fstring": "!ZVOL+({volume_amount})", - "regex": "!ZVOL\\+\\((?P[0-9]{1,2})\\)" - } - } - } - } - } -} \ No newline at end of file diff --git a/pyavcontrol/data/rcl/mcintosh/mcintosh_mx170.rcl b/pyavcontrol/data/rcl/mcintosh/mcintosh_mx170.rcl deleted file mode 100644 index e69de29..0000000 diff --git a/pyavcontrol/data/src/classe_omicron.yaml b/pyavcontrol/data/src/classe_omicron.yaml deleted file mode 100644 index a5563aa..0000000 --- a/pyavcontrol/data/src/classe_omicron.yaml +++ /dev/null @@ -1,55 +0,0 @@ ---- -id: classe_omicron - -info: - manufacturer: ClassÊ Audio - models: - - Omicron - type: amp - tested: false - urls: - - https://support.classeaudio.com/files/documents/automation_and_control/rs232/CLASSE_OMICRON_Mono_RS232_Protocol.pdf - -connection: - rs232: - baudrate: 9600 - bytesize: 8 - parity: N - stopbits: 1 - timeout: 0.15 - -protocol: - command_eol: "\r" - -api: - power: - description: Power control for the entire system - actions: - 'on': - description: Turn on - cmd: - fstring: 'PW1' - 'off': - description: Turn off - cmd: - fstring: 'PW0' - toggle: - description: Toggle power - cmd: - fstring: 'PWR' - - mute: - description: Mute - actions: - 'on': - description: Mute on - cmd: - fstring: 'MUT1' - 'off': - description: Mute off - cmd: - fstring: 'MUT0' - toggle: - description: Mute toggle - cmd: - fstring: 'MUT' diff --git a/pyavcontrol/data/src/hdfury_vrroom.yaml b/pyavcontrol/data/src/hdfury_vrroom.yaml deleted file mode 100644 index 7113924..0000000 --- a/pyavcontrol/data/src/hdfury_vrroom.yaml +++ /dev/null @@ -1,114 +0,0 @@ ---- -id: hdfury_vrroom -description: HDFury VRROOM Automation Protocol over RS232 and IP (FW 0.61, 2023-05-24) - -info: - manufacturer: HDFury - models: - - VRROOM - tested: false - urls: - - https://www.hdfury.com/docs/HDfuryVRRoom.pdf - -connection: - ip: - port: 2222 - rs232: - baudrate: 19200 - bytesize: 8 - parity: N - stopbits: 1 - timeout: 1.0 - min_time_between_commands: 0.4 - -protocol: - encoding: ascii - command_eol: "\n" - message_eol: "\r\n" - min_time_between_commands: 0.4 - -vars: - opmode: - 0: SPLITTER TX0/TX1 FRL5 VRR - 1: SPLITTER TX0/TX1 UPSCALE FRL5 - 2: MATRIX TMDS - 3: MATRIX TMDS DOWNSCALE - 4: MATRIX RX0:FRL5 + RX1-3:TMDS - edidpcmsrmode: - 0: 48kHz - 1: 96kHz - 2: 192kHz - edidpcmbwmode: - 0: 16bit - 1: 20bit - 2: 24bit - edidtruehdsrmode: - 0: 48kHz - 1: 96kHz - 2: 192kHz - 3: copy sink - ediddtshdmode: - 0: DTS:X IMAX - 1: DTS:X - 2: NO DTS:X - 3: remove all - edidddplussrmode: - 0: 48kHz - 1: 96kHz - 2: 192kHz - 3: copy sink - output: - tx0: TX0 - tx1: TX1 - on_off: - on: true - off: false - -api: - power: - actions: - reboot: - description: Reboot - cmd: - fstring: vrroom set reboot - - volume: - actions: - mute: - cmd: - fstring: vrroom set mute{output}audio {on_off} - regex: vrroom set mute\(?Ptx[01]\)audio \(?Po[nf][f]*\) - - opmode: - description: Opmode - actions: - get: - description: Get opmode - cmd: - fstring: vrroom get opmode - msg: - regex: (?P[0-4)\) - set: - description: Set opmode - cmd: - fstring: vrroom set opmode {opmode} - regex: vrroom set opmode \(?P[0-4]\) - - ddplus: - description: Dolby Digital Plus - actions: - mode: - description: Sets the features for Dolby Digital Plus - cmd: - fstring: vrroom set edidddplusmode {ddplusmode} - regex: vrroom set edidddplusmode \(?P[0-2]\) - automix: - description: Sets the automix EDID Dolby Digital Plus option - cmd: - fstring: vrroom set edidddplusflag {on_off} - regex: vrroom set edidddplusflag \(?Po[nf][f]*\) - sample_rate: - description: Sets the sample rate capability for Dolby Digital Plus - cmd: - fstring: vrroom set edidddplussrmode {ddplussrmode} - regex: vrroom set edidddplussrmode \(?P[0-3]\) diff --git a/pyavcontrol/data/src/jbl_sdp75.yaml b/pyavcontrol/data/src/jbl_sdp75.yaml deleted file mode 100644 index ce02767..0000000 --- a/pyavcontrol/data/src/jbl_sdp75.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -id: jbl_sdp75 - -import_models: - - trinnov_altitude32 - -info: - manufacturer: JBL Synthesis - models: - - SDP-75 - tested: false - diff --git a/pyavcontrol/data/src/lyngdorf_cd2.yaml b/pyavcontrol/data/src/lyngdorf_cd2.yaml deleted file mode 100644 index 974029f..0000000 --- a/pyavcontrol/data/src/lyngdorf_cd2.yaml +++ /dev/null @@ -1,245 +0,0 @@ ---- -id: lyngdorf_cd2 - -# NOTE: None of the state subscriptions have been defined (SUBSCRIBETRACK. etc) - -info: - manufacturer: Lyngdorf - models: - - CD-2 - tested: false - urls: - - https://site.currants.info/wp-content/uploads/2021/07/TDAI-3400-External-Control-Manual-March-2021.pdf - -connection: - ip: - port: 84 - rs232: - baudrate: 115200 - bytesize: 8 - parity: N - stopbits: 1 - timeout: 2.0 - -protocol: - command_eol: "\r\n" # CR/LF - -api: - device: - actions: - name: - description: Returns the name of the device - cmd: - fstring: '!DEVICE?' - msg: - regex: '!DEVICE\((?P.+)\)' - tests: - '!DEVICE(CD2)': - name: CD2 - - power: - description: Power controls - actions: - on: - description: Turn CD on - cmd: - fstring: '!ON' - off: - description: Turn CD off - cmd: - fstring: '!OFF' - toggle: - description: Toggle power - cmd: - fstring: '!PWR' - - playback: - actions: - next: - description: Next track - cmd: - fstring: '!NEXT' - play: - description: Play - cmd: - fstring: '!PLAY' - pause: - description: Pause - cmd: - fstring: '!STOP' - stop: - description: Stop - cmd: - fstring: '!STOP' - previous: - description: Previous track - cmd: - fstring: '!PREV' - eject: - description: Eject - cmd: - fstring: '!EJECT' - rewind: - description: Start scanning backwards - cmd: - fstring: '!REWIND' - wind: - description: Start scanning forwards - cmd: - fstring: '!WIND' - stopwind: - description: Stop winding - cmd: - fstring: '!STOPWIND' - state: - description: Current state of playback - cmd: - fstring: '!STATE?' - msg: - regex: '!STATE\((?P(OFF|OPENING|OPEN|CLOSING|NODISC|DISCERROR|READING|PLAY|STOP|PAUSE|WIND|REWIND))\)' - tests: - '!STATE(PLAY)': - state: PLAY - '!STATE(NODISC)': - state: NODISC - - buttons: - actions: - num1: - description: Button number 1 - cmd: - fstring: '!DIGIT(1)' - num2: - description: Button number 2 - cmd: - fstring: '!DIGIT(2)' - - play_status: - actions: - track: - description: Get current track - cmd: - fstring: '!TRACK?' - msg: - regex: '!TRACK\((?P([-]|\d+))\)' - tests: - '!TRACK(-)': - track: '-' - '!TRACK(14)': - track: 14 - total_tracks: - description: Total number of tracks being played - cmd: - fstring: '!NOFTRACKS?' - msg: - regex: '!NOFTRACKS\((?P([-]|\d+))\)' - tests: - '!NOFTRACKS(-)': - track: '-' - '!NOFTRACKS(20)': - track: 20 - time: - description: Requests the elapsed time of the playing track. - cmd: - fstring: '!TIME?' - msg: - regex: '!TIME\((?PnGVE@3W#Q6W~7IkVKpm`}ers5zPfMIZCZ6d!N5IuK`{eur{`;#g2y zdZgNtfj%5iG}UG1>O8tpH{$^h-wg3N=@BqM-N1pXI-=osBKP^;iJB?KMz^|zj5ZOw zmn;{xRmS6QIjFLhq$tYf_K8wukK+Ba&$2wDXH)wTIwkXe(caY~#tO5Pm8RdaG5w2? zM4NW~aVd2-&Ks?Wd5uuXclm;sIYr#_3!)cTua~ibav2%dv>~sJy5DmYF~<&K{8Ti4*MMcD$j#mQ<~j z25(NYdLUV@Y=~-z(oIS{=8X4M>R@&DuL){zA%b~a8L#?f8fv)i<| z8Sj~7ab2TIa{&7t<(%;9urS38$4yWFhq+9?CIiMxic{|uWFH2G$u<4zuN zAEc>h3-IaV{3#;G-kHKm#Gapdl^w^~Fe1ZX@E;uSnA`8SX70{?hXUsUq@t!DCy#*J zahR9a5smUgbxPl6A>W9Ymq;a0?aCn@8+VZVBm1wFf?j7GsV0F3qjI8Mo_fE#i7!U* zgoi7w1;fEd-*^B3 literal 0 HcmV?d00001 diff --git a/img/mcintosh-logo.svg b/img/mcintosh-logo.svg new file mode 100644 index 0000000..dc68f1c --- /dev/null +++ b/img/mcintosh-logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/pyavcontrol/__init__.py b/pyavcontrol/__init__.py new file mode 100644 index 0000000..4434628 --- /dev/null +++ b/pyavcontrol/__init__.py @@ -0,0 +1,6 @@ +__version__ = '2024.02.24' + +# easily expose key classes and APIs that clients typically use +from .client import DeviceClient +from .helper import construct_async_client, construct_synchronous_client +from .library import DeviceModelLibrary diff --git a/pyavcontrol/client/__init__.py b/pyavcontrol/client/__init__.py new file mode 100644 index 0000000..6c08f4a --- /dev/null +++ b/pyavcontrol/client/__init__.py @@ -0,0 +1,2 @@ +# expose DeviceClient through just importing the package itself +from .base import DeviceClient diff --git a/pyavcontrol/client/async_client.py b/pyavcontrol/client/async_client.py new file mode 100644 index 0000000..b2d80ba --- /dev/null +++ b/pyavcontrol/client/async_client.py @@ -0,0 +1,42 @@ +import logging +from collections.abc import Callable + +from ..connection import DeviceConnection +from ..connection.async_connection import locked_coro +from ..library.model import DeviceModel +from .base import DeviceClient + +LOG = logging.getLogger(__name__) + + +class DeviceClientAsync(DeviceClient): + """Asynchronous client for communicating with devices via the provided connection""" + + def __init__(self, model: DeviceModel, connection: DeviceConnection, loop): + super().__init__(model, connection) + self._loop = loop + self._callback = None + + if not connection.is_async(): + raise RuntimeError('Provided DeviceConnection is not asynchronous!') + + @property + def is_async(self): + """:return: true since this client is asynchronous""" + return True + + @locked_coro + async def send_raw(self, data: bytes, wait_for_response=False): + # if LOG.isEnabledFor(logging.DEBUG): + # LOG.debug(f'Sending {self._connection!r}: {data}') + return await self._connection.send(data, wait_for_response=wait_for_response) + + @locked_coro + def register_callback(self, callback: Callable[[str], None]) -> None: + if not callable(callback): + raise ValueError('Callback is not Callable') + self._callback = callback + + @locked_coro + async def received_message(self): + await self._loop.call_soon(self._callback) diff --git a/pyavcontrol/client/base.py b/pyavcontrol/client/base.py new file mode 100644 index 0000000..4aa89a4 --- /dev/null +++ b/pyavcontrol/client/base.py @@ -0,0 +1,300 @@ +# postpone eval of annotations (for DeviceClient type annotation) +from __future__ import annotations + +import logging +import re +from abc import ABC, abstractmethod +from dataclasses import dataclass + +from ..config import CONFIG +from ..connection import DeviceConnection +from ..library.model import DeviceModel +from ..utils import ( + camel_case, + generate_docs_for_action, + get_args_for_command, + missing_keys_in_dict, + substitute_fstring_vars, +) + +LOG = logging.getLogger(__name__) + + +class DynamicActions: + """ + Dynamically created class representing a group of actions that can be called + on a connection to the device. + """ + + def __init__(self, model_name, group_actions_def): + self._model_name = model_name + self._group_actions = group_actions_def + + +def _create_activity_group_class( + client: DeviceClient, model: DeviceModel, group_name: str, group_actions: dict +): + """ + Create dynamic class that represents a group of activities for a specific + DeviceClient. These are injected into the DeviceClient as properties that + can be accessed by the caller. + """ + cls_props = {} + cls_bases = (DynamicActions,) + + # CamelCase the model+group to represent this dynamic class of action methods + cls_name = camel_case(f'{model.id} {group_name}') + if client.is_async: + cls_name += 'Async' + + # dynamically add methods (and associated documentation) for each action + for action_name, action_def in group_actions.items(): + # handle yamlfmt/yamlfix rewriting of "on" and "off" as YAML keys into bools + if type(action_name) is bool: # noqa: E721 + action_name = 'on' if action_name else 'off' + + action = ActionDef(group_name, action_name, action_def) + action.required_args = get_args_for_command(action.definition) + + # if a response msg is defined, then wait for a response + action.response_expected = 'msg' in action_def + + # ClientAPIAction(group=group, name=action_name, definition=action_def) + method = _create_action_method(client, cls_name, action) + + # FIXME: danger Will Robinson...potential exploits (need to explore how to filter out) + method.__name__ = action_name + method.__doc__ = generate_docs_for_action(action_name, action_def) + + cls_props[action_name] = method + + # return the new dynamic class that contains the above actions + cls = type(cls_name, cls_bases, cls_props) + return cls(model.id, group_actions) + + +@dataclass +class ActionDef: + group: str + name: str + definition: dict + required_args: list[str] = () + response_expected: bool = False + + +def _inject_client_api(client: DeviceClient, model: DeviceModel): + """ + Add a property at the top level of a DeviceClient class that exposes a + group of actions that can be called. If none are specified in the + model definition, the client is returned unchanged. + """ + api = model.definition.get(CONFIG.api, {}) + for group_name, group_def in api.items(): + if hasattr(type(client), group_name): + raise RuntimeError( + f'Injecting "{group_name}" failed as it already exists in {type(client)}' + ) + + group_actions = group_def['actions'] + group_class = _create_activity_group_class( + client, model, group_name, group_actions + ) + setattr(type(client), group_name, group_class) + + return client + + +def _encode_request(client, action_name, action_def: dict, values: dict, kwargs): + # FIXME: explain the intent...and kwargs + + if cmd := action_def.get('cmd'): + if fstring := cmd.get('fstring'): + request = substitute_fstring_vars(fstring, dict) + return request.encode(client.encoding()) + + LOG.error( + f'Invalid action_def for {action_name} - cannot form a request: {action_def}' + ) + return None + + +def _create_action_method(client: DeviceClient, cls_name: str, action: ActionDef): + """ + Creates a dynamic method that makes calls against the provided client using + the command format for the given action definition. + + This returns an asynchronous method if an event_loop is provided, otherwise + a synchronous method is returned by default. Calling code knows whether they + instantiated a synchronous or asynchronous client. + """ + # noinspection PyShadowingNames + LOG = logging.getLogger(cls_name) + + # FIXME: need to also convert response back into dictionary! + + def _prepare_request(**kwargs): + if missing_keys := missing_keys_in_dict(action.required_args, kwargs): + err_msg = f'Call to {action.group}.{action.name} missing required keys {missing_keys}, skipping!' + LOG.error(err_msg) + raise ValueError(err_msg) + + # substitute any templated fstrings in the command with provided kwargs + if cmd := action.definition.get('cmd'): + if fstring := cmd.get('fstring'): + request = substitute_fstring_vars(fstring, kwargs) + return request.encode(client.encoding()) + + return None + + def _extract_vars_in_response(response: bytes) -> dict: + """Given a response, extract all the known values using the response + message regex defined for this action.""" + response_text = response.decode(client.encoding()) + + if msg := action.definition.get('msg'): + if regex := msg.get('regex'): + return re.match(regex, response_text).groupdict() + + return {} + + # noinspection PyUnusedLocal + def _activity_call_sync(self, **kwargs): + """Synchronous version of making a client call""" + if request := _prepare_request(**kwargs): + if response := client.send_raw( + request, wait_for_response=action.response_expected + ): + return _extract_vars_in_response(response) + return + LOG.warning(f'Failed to make request for {action.group}.{action.name}') + + # noinspection PyUnusedLocal + async def _activity_call_async(self, **kwargs): + """ + Asynchronous version of making a client call is used when an event_loop + is provided. Calling code knows whether they instantiated a synchronous + or asynchronous client. + """ + if request := _prepare_request(**kwargs): + # noinspection PyUnresolvedReferences + if response := await client.send_raw( + request, wait_for_response=action.response_expected + ): + return _extract_vars_in_response(response) + return + LOG.warning(f'Failed to make request for {action.group}.{action.name}') + + # return the async or sync version of the request method + if client.is_async: + return _activity_call_async + return _activity_call_sync + + +class DeviceClient(ABC): + """ + DeviceClientBase base class that defines operations allowed + to control a device. + """ + + def __init__(self, model: DeviceModel, connection: DeviceConnection): + super().__init__() + self._model = model + self._connection = connection + + def encoding(self) -> str: + """ + :return: the bytes encoding format for requests/responses + """ + return self._model.encoding + + @property + def is_async(self) -> bool: + """ + :return: True if this client implementation is asynchronous (asyncio) versus synchronous. + """ + return False + + @property + def client(self) -> DeviceConnection: + """ + :return: DeviceConnection ref to the connection this client is using + """ + return self._connection + + @property + def is_connected(self) -> bool: + """ + :return: True if client is connected to device + """ + return True + + @abstractmethod + def send_raw(self, data: bytes, wait_for_response: bool = False, return_raw=False): + """ + Allows sending a raw data to the device. Generally this should not + be used except for testing, since all commands should be defined in + the yaml protocol configuration. No response messages are supported. + + :return: (optional) if response, return dict of decoded values (and raw response if return_raw set) + """ + raise NotImplementedError() + + @property + def model(self) -> DeviceModel: + """ + :return: the model this client uses for communication and commands with the device + """ + return self._model + + @classmethod + def create( + cls, + model: DeviceModel, + connection: DeviceConnection, + event_loop=None, + ) -> DeviceClient: + """ + Creates a DeviceClient instance using the standard pyserial connection + types supported by this library when given details about the model + and connection url. + + NOTE: The model definition could be passed in from any source, though + it is recommended to only use those from the DeviceClient library. That + said, it MAY make sense to split the entire connection stuff into a more + generalized library for serial/IP communication to legacy devices and + have libraries in separate package that are domain specific. + + If an event_loop argument is passed in this will return the + asynchronous implementation. By default, the synchronous interface + is returned. + + :param model: DeviceModel representing the API and protocol for the device + :param connection: connection to the device + :param event_loop: (optional) pass in event loop to get an asynchronous interface + + :return: an instance of DeviceControllerBase + """ + class_name = camel_case(f'{model.id} Client') + LOG.debug(f'Connecting to {model.id} at {connection!r} (class={class_name})') + + # if event_loop provided, return an asynchronous client; otherwise synchronous + if event_loop: + # lazy import the async client to avoid loading both sync/async + from .async_client import DeviceClientAsync + + # dynamically create subclass + dynamic_class = type(class_name, (DeviceClientAsync,), {}) + client = dynamic_class(model, connection, event_loop) + else: + from .sync_client import DeviceClientSync + + dynamic_class = type(class_name, (DeviceClientSync,), {}) + client = dynamic_class(model, connection) + + client.__module__ = f'pyavcontrol.client.{model.id}' + client.__qualname__ = f'{client.__module__}.{class_name}' + + # inject all the methods into the new dynamic class + client = _inject_client_api(client, model) + + return client diff --git a/pyavcontrol/client/sync_client.py b/pyavcontrol/client/sync_client.py new file mode 100644 index 0000000..bcc51e1 --- /dev/null +++ b/pyavcontrol/client/sync_client.py @@ -0,0 +1,35 @@ +import logging +from collections.abc import Callable + +from ..connection import DeviceConnection +from ..connection.sync_connection import synchronized +from ..library.model import DeviceModel +from .base import DeviceClient + +LOG = logging.getLogger(__name__) + + +class DeviceClientSync(DeviceClient): + """Synchronous client for communicating with devices via the provided connection""" + + def __init__(self, model: DeviceModel, connection: DeviceConnection): + super().__init__(model, connection) + self._callback = None + + @synchronized + def send_raw(self, data: bytes, wait_for_response: bool = False): + # if LOG.isEnabledFor(logging.DEBUG): + # LOG.debug(f'Sending {self._connection!r}: {data}') + return self._connection.send(data, wait_for_response=wait_for_response) + + @synchronized + def register_callback(self, callback: Callable[[str], None]) -> None: + if not callable(callback): + raise ValueError('Callback is not Callable') + self._callback = callback + + @synchronized + def received_message(self): + if self._callback: + LOG.error(f'Callback not implemented!! {self._callback}') # FIXME + # self._loop.call_soon(cb) diff --git a/pyavcontrol/config.py b/pyavcontrol/config.py new file mode 100644 index 0000000..71e7c16 --- /dev/null +++ b/pyavcontrol/config.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class _ConfigKeys: + api = 'api' + baudrate = 'baudrate' + clear_before_new_commands = 'clear_before_new_commands' + command_eol = 'command_eol' + command_separator = 'command_separator' + description = 'description' + encoding = 'encoding' + id = 'id' + message_eol = 'message_eol' + min_time_between_commands = 'min_time_between_commands' + model = 'model' + name = 'name' + protocol = 'protocol' + serial_config = 'serial_config' + timeout = 'timeout' + urls = 'urls' + + +CONFIG = _ConfigKeys() + +# FIXME: see schema! + +# FIXME: other explorations below +# https://dev.to/eblocha/using-dataclasses-for-configuration-in-python-4o53 +# +# raw_config = {...} +# config = Order.from_dict(raw_config) +# config.customer.first_name + + +# FIXME: if we want completely dynamic config we can use below +# https://alexandra-zaharia.github.io/posts/python-configuration-and-dataclasses/ +# config = DynamicConfig({'host': 'example.com', 'port': 80, 'timeout': 0.5}) +# print(f'host: {config.host}, port: {config.port}, timeout: {config.timeout}') +class DynamicConfig: + def __init__(self, conf): + if not isinstance(conf, dict): + raise TypeError(f'dict expected, found {type(conf).__name__}') + + self._raw = conf + for key, value in self._raw.items(): + setattr(self, key, value) diff --git a/pyavcontrol/connection/__init__.py b/pyavcontrol/connection/__init__.py new file mode 100644 index 0000000..653c1ee --- /dev/null +++ b/pyavcontrol/connection/__init__.py @@ -0,0 +1,90 @@ +import logging + +from pyavcontrol.const import DEFAULT_ENCODING + +LOG = logging.getLogger(__name__) + + +class DeviceConnection: + """ + Connection base class that defines communication APIs. + """ + + def __init__(self): + LOG.error('Use factory method create(url, config_overrides') + raise NotImplementedError() + + def is_connected(self) -> bool: + """ + :return: True if the connection is established + """ + raise NotImplementedError() + + def send(self, data: bytes, callback=None, wait_for_response: bool = False): + """ + Send data to the remote device. + + Optional callback can be provided for responses, otherwise any response is returned. + """ + raise NotImplementedError() + + def is_async(self) -> bool: + """ + :return: True if this connection implementation is asynchronous (asyncio) versus synchronous. + """ + return False + + def __repr__(self) -> str: + return self.__class__.__name__ + + +class NullConnection(DeviceConnection): + """NullConnection that sends all data to /dev/null; useful for testing""" + + def __init__(self): + pass + + def is_connected(self) -> bool: + return True + + def send(self, data: bytes, callback=None, wait_for_response: bool = False) -> None: + pass + + +class Connection: + @staticmethod + def create( + url: str, connection_config=None, event_loop=None + ) -> DeviceConnection: # FIXME: | None: + """ + Create a Connection instance given details about the given device. + + If an event_loop argument is passed in this will return the + asynchronous implementation. By default, the synchronous interface + is returned. + + :param url: pyserial supported url for communication (e.g. '/dev/ttyUSB0' or 'socket://remote-host:7000/') + :param connection_config: pyserial connection configuration (optional) + :param event_loop: pass in an event loop to get an interface that can be used asynchronously (optional) + + :return an instance of DeviceConnection + """ + if not connection_config: + connection_config = {} + + # FIXME: Types of config needed: + # - connection (pyserial style)...must be passed in since it is determined based on connection type (ip, rs232, etc) + # + # - timeouts/etc (from ???) + # - encoding (from protocol def) + + LOG.debug(f'Connecting to {url}: %s', connection_config) + + if event_loop: + from pyavcontrol.connection.async_connection import AsyncDeviceConnection + + return AsyncDeviceConnection(url, connection_config, event_loop) + else: + from pyavcontrol.connection.sync_connection import SyncDeviceConnection + + return SyncDeviceConnection(url, connection_config) diff --git a/pyavcontrol/connection/async_connection.py b/pyavcontrol/connection/async_connection.py new file mode 100644 index 0000000..a57baaf --- /dev/null +++ b/pyavcontrol/connection/async_connection.py @@ -0,0 +1,222 @@ +import logging +import asyncio +import functools +import time +from abc import ABC +from functools import wraps + +from ratelimit import limits +from serial_asyncio import create_serial_connection + +from pyavcontrol.connection import DeviceConnection + +from ..config import CONFIG +from ..const import * # noqa: F403 + +LOG = logging.getLogger(__name__) + +ONE_MINUTE = 60 + +# FIXME: for a specific instance we do not want communication to happen +# simultaneously...for now just lock ALL accesses to ANY device. +async_lock = asyncio.Lock() + + +def locked_coro(coro): + @wraps(coro) + async def wrapper(*args, **kwargs): + async with async_lock: + return await coro(*args, **kwargs) + + return wrapper + + +class AsyncDeviceConnection(DeviceConnection, ABC): + def __init__(self, url: str, connection_config: dict, loop): + """ + :param url: pyserial compatible url + :param connection_config: pyserial connection config (plus additional attributes timeout/encoding) + """ + self._url = url + self._connection_config = connection_config + self._legacy_connection = None + self._event_loop = loop + + # FIXME: I think encoding should be moved up a level + self._encoding = connection_config.get(CONFIG.encoding, DEFAULT_ENCODING) + + # schedule connecting after returning (since construction of this class is executed + # in a synchronous context) + asyncio.create_task(self._connect()) + + def __repr__(self) -> str: + return f'{self.__class__.__name__} / {self._url}' + + async def _connect(self) -> None: + # FIXME: hacky...merge this old code into this class eventually... + if not self._legacy_connection: + try: + self._legacy_connection = await async_get_rs232_connection( + self._url, + self._connection_config, # self._config, + self._connection_config, + self._event_loop, + ) + except Exception as e: + LOG.error(f'Failed connecting to {self._url}', e) + + def is_async(self) -> bool: + """ + :return: always True since this connection implementation is asynchronous + """ + return True + + async def is_connected(self) -> bool: + return self._legacy_connection is not None + + # check if connected, and abort calling provided method if no connection before timeout + @staticmethod + def ensure_connected(method): + @wraps(method) + async def wrapper(self, *method_args, **method_kwargs): + try: + await self._connect() + return await method(self, *method_args, **method_kwargs) + except Exception as e: + LOG.warning(f'Cannot connect to {self._url}!', e) + raise e + + return wrapper + + @ensure_connected + async def send(self, data: bytes, callback=None, wait_for_response: bool = False): + if not self._legacy_connection: + LOG.error('Missing legacy connection!!!') + return + return await self._legacy_connection.send( + data, wait_for_response=wait_for_response + ) + + +async def async_get_rs232_connection( + serial_port: str, config: dict, connection_config: dict, loop +): + # ensure only a single, ordered command is sent to RS232 at a time (non-reentrant lock) + def locked_method(method): + @wraps(method) + async def wrapper(self, *method_args, **method_kwargs): + async with self._lock: + return await method(self, *method_args, **method_kwargs) + + return wrapper + + # check if connected, and abort calling provided method if no connection before timeout + def ensure_connected_legacy(method): + @wraps(method) + async def wrapper(self, *method_args, **method_kwargs): + try: + await asyncio.wait_for(self._connected.wait(), self._timeout) + except Exception as e: + LOG.debug(f'Timeout sending data to {self._url}, no connection!', e) + raise e + return await method(self, *method_args, **method_kwargs) + + return wrapper + + class RS232ControlProtocol(asyncio.Protocol): + # noinspection PyShadowingNames + def __init__(self, serial_port, config, connection_config, loop): + super().__init__() + + self._url = serial_port + self._config = config + self._connection_config = connection_config + self._loop = loop + + # FIXME: this should actually be on the client layer and not connection itself + self._encoding = self._connection_config.get( + CONFIG.encoding, DEFAULT_ENCODING + ) + + self._min_time_between_commands = self._config.get( + CONFIG.min_time_between_commands, 0 + ) + + self._last_send = time.time() - 1 + self._timeout = self._connection_config.get(CONFIG.timeout, DEFAULT_TIMEOUT) + + self._transport = None + self._connected = asyncio.Event() + self._q = asyncio.Queue() + + # ensure only a single, ordered command is sent to RS232 at a time (non-reentrant lock) + self._lock = asyncio.Lock() + + def connection_made(self, transport): + self._transport = transport + LOG.debug(f'Port {self._url} opened {self._transport}') + self._connected.set() + + def data_received(self, data): + # LOG.debug(f"Received {self._url}: %s", data) + asyncio.ensure_future(self._q.put(data)) # , loop=self._loop) + + def connection_lost(self, exc): + LOG.debug(f'Port {self._url} closed') + + async def _reset_buffers(self): + """Reset all input and output buffers""" + self._transport.serial.reset_output_buffer() + self._transport.serial.reset_input_buffer() + while not self._q.empty(): + self._q.get_nowait() + + @locked_method + @ensure_connected_legacy + async def send(self, data: bytes, callback=None, wait_for_response=False): + @limits(calls=1, period=self._min_time_between_commands) + async def write_rate_limited(data_bytes: bytes): + LOG.debug(f'>> {self._url}: %s', data_bytes) + self._transport.serial.write(data_bytes) + + # clear all buffers of any data waiting to be read before sending the request + await self._reset_buffers() + + await write_rate_limited(data) + + # FIXME: move away from this with callbacks instead + if callback or wait_for_response: + result = await self.receive_response(data) + LOG.debug(f'<< {self._url}: %s', result) + if callback: + await callback(result) + return result + + async def receive_response(self, request): + data = bytearray() + try: + data += await asyncio.wait_for(self._q.get(), self._timeout) + return data + + except TimeoutError: + # log up to two times within a time period to avoid saturating the logs + @limits(calls=2, period=ONE_MINUTE) + def log_timeout(): + LOG.info( + f"Timeout @ {self._timeout}s for {self._url} request '%s'; received '%s'", + request, + data, + ) + + log_timeout() + raise + + factory = functools.partial( + RS232ControlProtocol, serial_port, config, connection_config, loop + ) + + LOG.info(f'Connecting to {serial_port}: {connection_config}') + _, protocol = await create_serial_connection( + loop, factory, serial_port, **connection_config + ) + return protocol diff --git a/pyavcontrol/connection/sync_connection.py b/pyavcontrol/connection/sync_connection.py new file mode 100644 index 0000000..0f13189 --- /dev/null +++ b/pyavcontrol/connection/sync_connection.py @@ -0,0 +1,129 @@ +import logging +from abc import ABC +from functools import wraps +from threading import RLock + +import serial +from ratelimit import limits + +from pyavcontrol.connection import DeviceConnection + +from ..config import CONFIG +from ..const import DEFAULT_ENCODING, DEFAULT_EOL + +LOG = logging.getLogger(__name__) + +sync_lock = RLock() + + +def synchronized(func): + @wraps(func) + def wrapper(*args, **kwargs): + with sync_lock: + return func(*args, **kwargs) + + return wrapper + + +class SyncDeviceConnection(DeviceConnection, ABC): + """ + Synchronous device connection implementation (NOT YET IMPLEMENTED) + """ + + def __init__(self, url: str, connection_config: dict): + """ + :param url: pyserial compatible url + """ + self._url = url + self._connection_config = connection_config + + self._encoding = connection_config.get(CONFIG.encoding, DEFAULT_ENCODING) + + # FIXME: remove the following + config = connection_config # FIXME: remove + self._eol = config.get(CONFIG.message_eol, DEFAULT_EOL).encode(self._encoding) + + # FIXME: all min time between commands should probably be at the client level and + # not at the raw connection... move up! + self._min_time_between_commands = config.get( + CONFIG.min_time_between_commands, 0 + ) + + # FIXME: contemplate on this more, do we really want to reset/clear + self._clear_before_new_commands = connection_config.get( + CONFIG.clear_before_new_commands, True + ) + + self._port = serial.serial_for_url(self._url, **self._connection_config) + + def __repr__(self) -> str: + # return f'{self.__class__.__name__}->{self._url}' + return str(self._url) + + def encoding(self) -> str: + return self._encoding + + def _reset_buffers(self): + self._port.reset_output_buffer() + self._port.reset_input_buffer() + + def send(self, data: bytes, callback=None, wait_for_response: bool = False): + """ + :param data: data bytes sent to the device + :param callback: (optional) + :param wait_for_response: (optional) + :return: string returned by device + """ + + @limits(calls=1, period=self._min_time_between_commands) + def write_rate_limited(data_bytes: bytes): + LOG.debug(f'>> {self._url}: %s', data_bytes) + # send data and force flush to send immediately + self._port.write(data_bytes) + self._port.flush() + + # clear any pending transactions if a response is expected + if response_expected := (callback or wait_for_response): + if self._clear_before_new_commands: + self._reset_buffers() + + write_rate_limited(data) + + # if the caller has requested to receive the result, send it to any + # provided callback and return the result + if response_expected: + LOG.debug(f'Waiting for response (EOL={self._eol})...') + + result = self.handle_receive() + LOG.debug(f'<< {self._url}: %s', result) + + if callback: + callback(result) + return result + + def handle_receive(self) -> bytes: + skip = 0 + + len_eol = len(self._eol) + + # FIXME: implement a much better receive mechanism, without timeouts. + + # receive + result = bytearray() + while True: + c = self._port.read(1) + if not c: + ret = bytes(result) + LOG.info(ret) + raise serial.SerialTimeoutException( + 'Connection timed out! Last received bytes {}'.format( + [hex(a) for a in result] + ) + ) + result += c + if len(result) > skip and result[-len_eol:] == self._eol: + break + + ret = bytes(result) + LOG.debug(f'Received {self._url} "%s"', ret) + return ret diff --git a/pyavcontrol/const.py b/pyavcontrol/const.py new file mode 100644 index 0000000..05f0205 --- /dev/null +++ b/pyavcontrol/const.py @@ -0,0 +1,23 @@ +"""Python client library for controlling A/V processors and receivers""" + +import os + +DEFAULT_ENCODING = 'ascii' +DEFAULT_EOL = '\r' # "\r\n" +DEFAULT_TCP_IP_PORT = 4999 # IP2SL / Virtual IP2SL uses this port +DEFAULT_TIMEOUT = 1.0 + +PACKAGE_PATH = os.path.dirname(__file__) + +PROCESSOR_TYPE = 'processor' +RECEIVER_TYPE = 'receiver' +MATRIX_TYPE = 'matrix' +ALL_DEVICE_TYPES = [PROCESSOR_TYPE, RECEIVER_TYPE, MATRIX_TYPE] + +DEFAULT_MODEL_LIBRARIES = ( + f'{PACKAGE_PATH}/data/flattened', + f'{PACKAGE_PATH}/data/src', + f'{PACKAGE_PATH}/data/future', +) # FIXME: remove this later + +BAUD_RATES = [2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000] diff --git a/pyavcontrol/data/README.md b/pyavcontrol/data/README.md new file mode 100644 index 0000000..750108f --- /dev/null +++ b/pyavcontrol/data/README.md @@ -0,0 +1,48 @@ + +### Why configuration language? + +Basically PyAVControl is about defining configuration (definitions) for how devices interfaces are defined. Implementing the API definitions directly into a specific language does not achieve the ability for reuse of those definitions across multiple languages/clients. Initially using JSON and YAML was explored as the easiest way to define these interfaces (especially in a way that non-developers could create their own definitions which was often a request from users in example libraries like pyxantech, pymonoprice, pyanthem-serial, etc). + +However, the sheer volume of models and slightly different definitions evolved into needing some sort of import/include/replacement mechanism. While this can be implemented (for the thousandth time) overtop of JSON/YAML, this doesn't make sense since existing configuration language exists that already have implementations in many languages AND this isn't really the point of PyAVControl to define new languages. + +Exploring configuration languages that provided basic support for imports/includes, variables, and a few other features without evolving into a Turing complete language just makes sense. Further, if those languages enable generating a library or repository of these configuration files flattened into raw JSON or YAML, this is a huge bonus since new clients in other languages could use the flattened definitions instead of having to implement the config language if a library didn't already exist. + +This indicates that there should be a build pipeline that converts the definition (config) files into flattened variations as part of the check-in or repository workflow. This provides a nice balance in sufficinet flexibility in defining the interfaces, while keeping the dependencies and simplicity of interacting with common file formats optimized for multiple languages and clients. + +### Requirements/Goals + +* minimize the amount of config required to define interfaces to devices +* enable reuse across device models by sharing large portions of the definitions +* enable non-developers to use an easy to read/understand format for contributing their own equipment definitions +* support access to the intrefaces via JSON by clients (no need to implement complex config parsing for new languages IF it is acceptable to tradeoff "compiling" the definitions down into a large repository of JSON files) +* separate the definition from the runtime dependency +* schema/limited type checking +* ability to add comments + +#### Config Languages Considered + +* [RCL](https://github.com/ruuda/rcl): see [more](https://ruudvanasseldonk.com/2024/a-reasonable-configuration-language), tooling support might be weak (e.g. VSCode extensions, etc) +* [PKL](https://github.com/apple/pkl): no Python implementation yet (2024-03) +* [Nix])(https://nixos.wiki/wiki/Overview_of_the_Nix_Language): to specialized to package management +* [Nickel](https://github.com/tweag/nickel): evolution of Nix +* [HCL](https://github.com/hashicorp/hcl): primarily targeted towards devops/infrastructure config +* [CUE](https://cuelang.org/) +* Dhall + +And of course raw formats, which was the initial implementation, but quickly abandoned due to the sheer volume of files and duplicate config needed to support minute differences between a vast array of physical device features: + +* JSON: most compatible and frequently used for data interfaces; no ability to add comments +* YAML: more readable than json, with some limited support for references +* TOML + +Neither JSON or YAML solve the issues of reuse across configuration files, composition, etc. +Decided on RCL as it was most inline with json, could export the equipment definition files to json +files as part of the build process to make integration into other languages easy where RCL +libraries may not be available. + +### Why RCL? + +#### See Also + +* https://news.ycombinator.com/item?id=39250320 +* https://ruudvanasseldonk.com/2024/a-reasonable-configuration-language diff --git a/pyavcontrol/data/future/acurus_m8.yaml b/pyavcontrol/data/future/acurus_m8.yaml new file mode 100644 index 0000000..77c42b7 --- /dev/null +++ b/pyavcontrol/data/future/acurus_m8.yaml @@ -0,0 +1,92 @@ +--- +id: acurus_m8 +description: Acurus Amplifier Control Protocol 1.0 + +info: + manufacturer: Acurus + models: + - M8 + tested: false + urls: + - https://app.box.com/s/bp7h228vihe92nmjlo6o8w6e66tvj58o + +connection: + rs232: + baudrate: 9600 + bytesize: 8 + parity: N + stopbits: 1 + timeout: 1.0 + +protocol: + command_eol: "\r" # CR Carriage Return + command_format: '{cmd}{eol}' + + message_format: '{msg}{eol}' + message_eol: "\r\n" + +api: + power: + actions: + status: + cmd: + fstring: STSPOW + toggle: + cmd: + fstring: PWRTGL + msg: + regex: '!OK POWER O(?P[NF])[F]*' + on: + cmd: + fstring: PWRONN + off: + cmd: + fstring: PWROFF + + mute: + description: Mute + actions: + toggle: + description: Mute toggle button + cmd: + fstring: MUTTGL + msg: + regex: '!OK MUTE O(?P[NF])[F]*' + get: + description: get current Mute status + cmd: + fstring: STSMUT + msg: + regex: '!OK MUTE O(?P[NF])[F]*' + off: + description: Mute off + cmd: + fstring: MUTOFF + on: + description: Mute on + cmd: + fstring: MUTONN + channel_on: + cmd: + fstring: MONCH{zone} + regex: MONCH\d + channel_off: + cmd: + fstring: MOFCH{zone} + regex: MOFCH\d + channel_toggle: + cmd: + fstring: MOTCH{zone} + regex: MOTCH\d + + volume: + description: Volume controls + actions: + down: + description: Decrease volume + cmd: + fstring: VOLDWN + up: + description: Increase volume + cmd: + fstring: VOLUPP diff --git a/pyavcontrol/data/future/anthem_d2v.yaml b/pyavcontrol/data/future/anthem_d2v.yaml new file mode 100644 index 0000000..ba65b95 --- /dev/null +++ b/pyavcontrol/data/future/anthem_d2v.yaml @@ -0,0 +1,236 @@ +--- +id: anthem_d2v + +info: + manufacturer: Anthem + models: + - Statement D2 + - Statement D2v + - Statement D2v 3D + tested: false + urls: + - https://www.anthemav.com/downloads/d2v_manual.pdf + +protocol: + min_time_between_commands: 0.25 + command_eol: "\n" + message_eol: "\n" + +connection: + rs232: + baudrate: 9600 + bytesize: 8 + parity: N + stopbits: 1 + timeout: 1.0 + +vars: + zone: + 1: Main + 2: Zone 2 + 3: Zone 3 + power: + 0: Off + 1: On + +api: + power: + description: Power control for the entire system + actions: + 'on': + description: Turn entire system on + cmd: + fstring: 'Z{zone}POW1' + 'off': + description: Turn entire system off + cmd: + fstring: 'Z{zone}POW0' + get: + description: Get system power status (0=off; 1=on) + cmd: + fstring: 'Z{zone}POW?' + msg: + regex: 'Z(?P[0-3])POW(?P[01])' + tests: + 'Z11': + zone: 1 + power: 1 + 'PWSTANDBY': + power: STANDBY + + mute: + description: Mute + actions: + get: + description: Get current Mute status + cmd: + fstring: 'Z{zone}MU?' + msg: + regex: 'Z(?P[0-3])MUT(?P[01])' + tests: + 'Z1MUT0': + zone: 1 + mute: 0 + 'Z2MUT1': + zone: 2 + mute: 1 + 'off': + description: Mute off + cmd: + fstring: 'Z{zone}MU0' + 'on': + description: Mute on + cmd: + fstring: 'Z{zone}MU1' + toggle: + description: Mute toggle + cmd: + fstring: 'Z{zone}MUt' + + volume: + description: Volume controls + actions: + get: + description: Get current volume + cmd: + fstring: 'Z{zone}VOL?' + msg: + regex: 'Z(?P[0-3])VOL(?P[0-9]{1,3})' + tests: + 'Z1VOL80': + zone: 1 + volume: 80 + set: + description: Set volume to x + cmd: + fstring: 'Z{zone}VOL{volume}' + regex: 'Z(?P[0-3])VOL(?P[0-9]{1,3})' + down: + description: Decrease volume + cmd: + fstring: 'Z{zone}VDN' + up: + description: Increase volume + cmd: + fstring: 'Z{zone}VUP' + + source: + description: Input source selection + actions: + get: + description: Get info for currently active source + cmd: + fstring: 'SI?' + msg: + regex: 'SI(?P.+)' + tests: + '!SRC(1) CD': + source: 1 + name: CD + set: + description: Select source + cmd: + fstring: 'SI{source}' + docs: + source: Source to select (integer) + + arc: + description: Anthem Room Correction (ARC) controls + actions: + 'off': + description: ARC off + cmd: + fstring: 'Z1ARC0' + 'on': + description: ARC on + cmd: + fstring: 'Z1ARC1' + + trigger: + description: Set triggers on or off + actions: + 'off': + description: Trigger off + cmd: + fstring: 'R{trigger}SET0' + regex: 'R(?P[12])SET0' + 'on': + description: ARC on + cmd: + fstring: 'R{trigger}SET1' + regex: 'R(?P[12])SET1' + + button: + description: Remote button presses + actions: + back: + description: Back button + cmd: + fstring: '!BACK' + down: + description: Direction Down button + cmd: + fstring: 'Z1SIM0019' + left: + description: Direction Left button + cmd: + fstring: 'Z1SIM0020' + right: + description: Direction Right button + cmd: + fstring: 'Z1SIM0022' # FIXME + up: + description: Direction Up button + cmd: + fstring: 'Z1SIM0021' # FIXME + guide: + description: Guide button + cmd: + fstring: 'Z1SIM0017' + number: + description: Number button + cmd: + fstring: 'Z1SIM000{num}' + regex: 'Z1SIM000(?P[0-9])' + docs: + num: single digit integer (0-9) + num0: + description: Number button 0 + cmd: + fstring: 'Z1SIM0000' + num1: + description: Number button 1 + cmd: + fstring: 'Z1SIM0001' + num2: + description: Number button 2 + cmd: + fstring: 'Z1SIM0002' + num3: + description: Number button 3 + cmd: + fstring: 'Z1SIM0003' + num4: + description: Number button 4 + cmd: + fstring: 'Z1SIM0004' + num5: + description: Number button 5 + cmd: + fstring: 'Z1SIM0005' + num6: + description: Number button 6 + cmd: + fstring: 'Z1SIM0006' + num7: + description: Number button 7 + cmd: + fstring: 'Z1SIM0007' + num8: + description: Number button 8 + cmd: + fstring: 'Z1SIM0008' + num9: + description: Number button 9 + cmd: + fstring: 'Z1SIM0009' diff --git a/pyavcontrol/data/future/classe_ssp600.yaml b/pyavcontrol/data/future/classe_ssp600.yaml new file mode 100644 index 0000000..213e0b1 --- /dev/null +++ b/pyavcontrol/data/future/classe_ssp600.yaml @@ -0,0 +1,76 @@ +--- +id: classe_ssp600 + +info: + manufacturer: ClassÊ Audio + models: + - SSP-300 + - SSP-600 + tested: false + urls: + - https://support.classeaudio.com/files/documents/automation_and_control/rs232/CLASSE_SSP-300-600_RS232_Protocol.pdf + +connection: + rs232: + baudrate: 9600 + bytesize: 8 + parity: N + stopbits: 1 + timeout: 0.15 + +protocol: + command_eol: "\r" + command_prefix: "S600" # FIXME: S300 for SSP0-300 + message_eol: "\r\n" + message_prefix: "!" + +api: + mute: + description: Mute + actions: + get: + description: Get current Mute status + cmd: + fstring: 'STAT MAIN' + msg: + regex: 'SY VOLA \d+\s*(?P[muted]+)' + tests: + 'SY VOLA 1 muted': + mute: 'muted' + 'SY VOLA 1': + 'on': + description: Mute on + cmd: + fstring: 'MUTE' + 'off': + description: Mute off + cmd: + fstring: 'UNMT' + + volume: + description: Volume controls + actions: + get: + description: Get current volume + cmd: + fstring: 'STAT MAIN' + msg: + regex: 'SY VOLA (?P\d+)\s*.+' + tests: + 'SY VOLA 50 muted': + volume: 50 + 'SY VOLA 1': + volume: 1 + set: + description: Set volume to x + cmd: + fstring: 'VOLA {volume}' + regex: 'VOLA (?P[0-9]{1,2})' + down: + description: Decrease volume + cmd: + fstring: 'MVOL-' + up: + description: Increase volume + cmd: + fstring: 'MVOL+' diff --git a/pyavcontrol/data/future/lyngdorf_mp60.yaml b/pyavcontrol/data/future/lyngdorf_mp60.yaml new file mode 100644 index 0000000..f32ad5a --- /dev/null +++ b/pyavcontrol/data/future/lyngdorf_mp60.yaml @@ -0,0 +1,50 @@ +--- +id: lyngdorf_mp60 + +info: + manufacturer: Lyngdorf + models: + - MP-60 + tested: false + +connection: + ip: + port: 84 + rs232: + baudrate: 115200 + bytesize: 8 + parity: N + stopbits: 1 + timeout: 2.0 + +protocol: + command_eol: "\r\n" # CR/LF + +api: + device: + actions: + name: + description: Returns the name of the device + cmd: + fstring: '!DEVICE?' + msg: + regex: '!DEVICE\((?P.+)\)' + tests: + '!DEVICE(MP-60)': + name: MP-60 + + power: + description: Power controls + actions: + on: + description: Turn CD on + cmd: + fstring: '!ON' + off: + description: Turn CD off + cmd: + fstring: '!OFF' + toggle: + description: Toggle power + cmd: + fstring: '!PWR' diff --git a/pyavcontrol/data/future/marantz_av8805.yaml b/pyavcontrol/data/future/marantz_av8805.yaml new file mode 100644 index 0000000..e0e0158 --- /dev/null +++ b/pyavcontrol/data/future/marantz_av8805.yaml @@ -0,0 +1,150 @@ +--- +id: marantz_av8805 + +info: + manufacturer: Marantz + models: + - AV8805 + tested: false + urls: + - https://www.marantz.com/-/media/files/documentmaster/marantzna/us/marantz_fy20_sr_nr_protocol_v03_20190827182350130.xls + +connection: + ip: + port: 23 + rs232: + baudrate: 9600 + bytesize: 8 + parity: N + stopbits: 1 + timeout: 1.0 + +protocol: + command_eol: "\r" + message_eol: "\r" + +vars: + source: + PHONE: Phono + CD: CD + BD: BD + TV: TV + SAT/CBL: SAT/CBL + MPLAY: MPLAY + GAME: Game + TUNER: Tuner + HDRADIO: HD Radio + AUX1: AUX1 + AUX2: AUX2 + AUX3: AUX3 + AUX4: AUX4 + AUX5: AUX5 + AUX6: AUX6 + AUX7: AUX7 + NET: NET + BT: BT + power: + ON: On + OFF: Off + mute: + ON: On + OFF: Off + +api: + power: + description: Power control for the entire system + actions: + 'on': + description: Turn entire system on + cmd: + fstring: 'PWON' + 'off': + description: Turn entire system off + cmd: + fstring: 'PWSTANDBY' + toggle: + description: Toggle system power + cmd: + fstring: '@PWR:0' + msg: + regex: 'PWR:(?P[12])' + get: + description: Get system power status (0=off; 1=on) + cmd: + fstring: 'PW?' + msg: + regex: 'PW(?P.+)' + tests: + 'PWON': + power: ON + 'PWSTANDBY': + power: STANDBY + + mute: + description: Mute + actions: + get: + description: Get current Mute status + cmd: + fstring: 'MU?' + msg: + regex: 'MU(?P.+)' + tests: + 'MUOFF': + mute: 'OFF' + 'MUON': + mute: 'ON' + 'off': + description: Mute off + cmd: + fstring: 'MUOFF' + 'on': + description: Mute on + cmd: + fstring: 'MUON' + + volume: + description: Volume controls + actions: + get: + description: Get current volume + cmd: + fstring: 'MV?' + msg: + regex: 'MV(?P[0-9]{1,3})' + tests: + 'MV80': + volume: 80 + set: + description: Set volume to x + cmd: + fstring: 'MV{volume}' + regex: 'MV(?P[0-9]{1,3})' + down: + description: Decrease volume + cmd: + fstring: 'MVDOWN' + up: + description: Increase volume + cmd: + fstring: 'MVUP' + + source: + description: Input source selection + actions: + get: + description: Get info for currently active source + cmd: + fstring: 'SI?' + msg: + regex: 'SI(?P.+)' + tests: + '!SRC(1) CD': + source: 1 + name: CD + set: + description: Select source + cmd: + fstring: 'SI{source}' + docs: + source: Source to select (integer) diff --git a/pyavcontrol/data/future/monoprice_6.yaml b/pyavcontrol/data/future/monoprice_6.yaml new file mode 100644 index 0000000..7e20757 --- /dev/null +++ b/pyavcontrol/data/future/monoprice_6.yaml @@ -0,0 +1,103 @@ +--- +id: monoprice_6 + +info: + manufacturer: Monoprice + models: + - MPR-6ZHMAUT + - Model 10761 + tested: false + urls: + - https://app.box.com/s/bp7h228vihe92nmjlo6o8w6e66tvj58o + +connection: + rs232: + baudrate: 9600 + bytesize: 8 + parity: N + stopbits: 1 + timeout: 2.0 + +protocol: + command_format: '<{cmd}{eol}' + command_eol: "\r" # CR Carriage Return + command_separator: '#' + message_format: '>{msg}{eol}' + message_eol: "\r" + +api: + zone: + actions: + status: + cmd: + fstring: '?{zone}' + regex: '\?(?P[1-3][1-6])' + msg: + regex: '#>(?P\d{2})(?P\d{2})(?P[01]{2})(?P[01]{2})(?P[01]{2})(?P\d{2})(?P\d{2})(?P\d{2})(?P\d{2})(?P\d{2})(?P\d{2})' + tests: + '#>1100010000130707100600': + zone: 11 + pa: 0 + power: 1 + mute: 0 + do_not_disturb: 0 + volume: 13 + treble: 7 + bass: 7 + balance: 10 + source: 6 + keypad: 0 + + all_status: + description: Special status request that returns statue for all zones for a specific hardware unit + cmd: + fstring: '?{zone_group}0' + regex: '\?(?P(?P\d{2})(?P\d{2})(?P[01]{2})(?P[01]{2})(?P[01]{2})(?P\d{2})(?P\d{2})(?P\d{2})(?P\d{2})(?P\d{2})(?P\d{2})' + + + power: + actions: + set: + cmd: + fstring: '<{zone}PR0{power}' + regex: '\?(?P\d+)PR0(?P[01])' + tests: + '?1PR01': + power: 1 + 'on': + cmd: + fstring: <{zone}PR01 + 'off': + cmd: + fstring: <{zone}PR00 + + mute: + description: Mute + actions: + 'on': + description: Mute on + cmd: + fstring: <{zone}MU01 + 'off': + description: Mute off + cmd: + fstring: <{zone}MU00 + + volume: + description: Volume controls + actions: + set: + description: Set volume + cmd: + fstring: '<{zone}VO{volume:02}' + regex: '<(?P\d+)VO(?P\d+)' diff --git a/pyavcontrol/data/future/sunfire_tgiii.txt b/pyavcontrol/data/future/sunfire_tgiii.txt new file mode 100644 index 0000000..df65062 --- /dev/null +++ b/pyavcontrol/data/future/sunfire_tgiii.txt @@ -0,0 +1,67 @@ +Sunfire Theater Grand III +Sunfire Theater Grand IV + + + +The RS-232 PortThe TGIII has a rear panelRS-232 Serial communication port.This allows the FLASH memory tobe upgraded to the latest software byconnecting to a PC.The TGIII software may be updatedto reīŦ ne operational details and toinclude new features. Downloadableupdates will be posted on our website:www.sunīŦ re.com.CommunicationsSerial RS-232, 9600 Baud, 8-N-1DB-9 WiringPINS 1, 6 and 4 are joined togetherin ter nal lyPINS 7 and 8 are joined togetherin ter nal lyPIN 2- Data from processor to con-troller (processor transmit)PIN 3- Data from controller to pro-cessor (processor receive)PIN 5- Ground/CommonPIN 9- No connectionThe RS-232 connector is female.Serial CableTo connect the TGIII port to acomputer, you will need a "straight-through" serial cable. This has con-nector pins at one end connecteddirectly to the pins of the connector atthe other end. For example, pin 1 atone end connects to pin 1 at the otherend, pin 2 connects to pin 2, pin 3 topin 3 and so on.These common cables are avail-able from most computer stores (orfrom Radio Shack as # 26-117). Itshould be 9-pin male at one end, toīŦ t into the TGIII and normally 9-pinfemale at the other, to īŦ t into yourcomputer's serial port (COM1 orCOM2).User's ManualUpdate Procedure1. The current version level of thesoftware running your TGIIIcan be found by looking at theVersion Level OSD menu. Thisis under the Software OSDmenu (see page 37).2. If the website īŦ le is newer thanyour current version, follow thewebsite directions and down-load the new īŦ le onto yourcomputer's hard drive.3. Record your calibration, presetstations or other settings onpage 57. In most cases, theupgrade will not affect any ofthese settings, but it is good torecord them just in case.4. Turn off your computer andthe TGIII. Position them closeenough so that they can beeasily connected using yourserial cable. If you have a lap-top computer, then it may beeasier to bring that close to theTGIII. Otherwise, you need todisconnect the TGIII and moveit close to your computer.5. Connect the TGIII RS-232 portto the corresponding serial porton your computer.6. Turn on the TGIII and yourcomputer.7. Find the īŦ le you downloaded instep 2, and run the program.8. In AUTO mode, the softwarewill look for an active serialconnection and upload the newīŦ le. The TGIII display will showthe status.9. When the īŦ le transfer is com-plete, press the Power switchon the TGIII front panel. Thiscompletes the upgrade.10. Turn off your computer and theTGIII and disconnect the serialcable.APPENDIXExternal ControlThe RS-232 port also allows theTGIII to be controlled externally byHome Theater controllers and com-put ers.The following information is forprogrammers and developers:Partial Serial command setNote that all stan dard com mandsand ex tend ed data are echoed back tothe sender. When a change is madelocally, the data is broad cast, exceptfor the case of "Toggle" and volumecommands. Here is a list of the mostpopular commands. (Contact SunīŦ reTechnical Support, or our websitewww.sunīŦ re.com for a more extensivelist of commands).COMMANDASCII DATA RECEIVEDPOWER TOGGLE*111POWER ON*112POWER OFF*113CD*114TAPE*115SAT*116DVD*117PHONO*118TUNER*119VID1*11AVCR*11BVID2*11CDSP MODE UP*11DDSP MODE DOWN*13WSTEREO*11EPRO LOGIC*11FPARTY*134NEO:613HSOURCEDIRECT13JJAZZ-CLUB*11KHOLO TOGGLE*11LHOLO ON*11MHOLO OFF*11NMUTE TOGGLE*11PMUTE ON*11QMUTE OFF*11RVOLUME UP*11SVOLUME DOWN*11TVOL ABSOLUTE*11U + 2 EXT*11U00 = zero vol*11U99 = max volZONE2 PWR TOGGLE*13MZONE2 PWR ON*13NZONE2 PWR OFF*13PZONE2 MUTE TGGLE*13QZONE2 MUTE ON*13RZONE2 MUTE OFF*13SZONE2 VOL UP*13TZONE2 VOL DOWN*13UZONE2 CD*138ZONE2 TAPE*139ZONE2 SAT*13AZONE2 DVD*13BZONE2 PHONO*13CZONE2 TUNER*13DZONE2 VID1*13EZONE2 VCR*13FZONE2 VID2*13G + + +https://www.manualslib.com/manual/2624583/Sunfire-Theater-Grand-Processor-Iii.html?page=51#manual + + + + + +IV + +COMMAND ASCII DATA RECEIVED +POWER TOGGLE *111 +POWER ON *112 +POWER OFF *113 +CD *114 +TAPE *115 +SAT *116 +DVD *117 +PHONO *118 +TUNER *119 +VID1 *11A +VCR *11B +VID2 *11C +DSP MODE UP *11D +DSP MODE DOWN *13W +STEREO *11E +PRO LOGIC *11F +PRO LOGIC IIx MUSIC *15P +PRO LOGIC IIx MOVIE *15Q +PARTY *134 +NEO:6 13H +SOURCEDIRECT 13J +JAZZ-CLUB *11K +HOLO TOGGLE *11L +HOLO ON *11M +HOLO OFF *11N +MUTE TOGGLE *11P +MUTE ON *11Q +MUTE OFF *11R +VOLUME UP *11S +VOLUME DOWN *11T +VOL ABSOLUTE *11U + 2 EXT +*11U00 = zero vol +*11U99 = max vol +ZONE2 PWR TOGGLE *13M +ZONE2 PWR ON *13N +ZONE2 PWR OFF *13P +ZONE2 MUTE TGGLE *13Q +ZONE2 MUTE ON *13R +ZONE2 MUTE OFF *13S +ZONE2 VOL UP *13T +ZONE2 VOL DOWN *13U +ZONE2 CD *138 +ZONE2 TAPE *139 +ZONE2 SAT *13A +ZONE2 DVD *13B +ZONE2 PHONO *13C +ZONE2 TUNER *13D +ZONE2 VID1 *13E +ZONE2 VCR *13F +ZONE2 VID2 *13G diff --git a/pyavcontrol/data/rcl/defaults/base.rcl b/pyavcontrol/data/rcl/defaults/base.rcl new file mode 100644 index 0000000..697412e --- /dev/null +++ b/pyavcontrol/data/rcl/defaults/base.rcl @@ -0,0 +1,7 @@ +// defaults that all equipment should include as default values +{ + info = { + name = "Unknown", + tested = false + } +} diff --git a/pyavcontrol/data/rcl/defaults/rs232.rcl b/pyavcontrol/data/rcl/defaults/rs232.rcl new file mode 100644 index 0000000..252302c --- /dev/null +++ b/pyavcontrol/data/rcl/defaults/rs232.rcl @@ -0,0 +1,12 @@ +{ + connection = { + rs232 = { + baudrate = 9600, + bytesize = 8, + parity = "N", + stopbits = 1, + timeout = 1.0, + encoding = "ascii", // most typical encoding + response_eol = "\r", // typical EOL + } +} diff --git a/pyavcontrol/data/rcl/mcintosh/mcintosh_mx160.rcl b/pyavcontrol/data/rcl/mcintosh/mcintosh_mx160.rcl new file mode 100644 index 0000000..38031d1 --- /dev/null +++ b/pyavcontrol/data/rcl/mcintosh/mcintosh_mx160.rcl @@ -0,0 +1,1470 @@ +// NOTES: +// - cmd commands are in substitution variable format (e.g. {var}) +// - msg messages/responses are in regex format to decode + +{ + "id": "mcintosh_mx160", + "description": "McIntosh MX160 Protocol [2017-09-18 MX160 Serial Control Manual V7]", + "info": { + "name": "McIntosh", + "models": [ + "MX160" + ], + "type": "processor", + "tested": true, + "urls": [ + "https://www.mcintoshlabs.com/legacy-products/home-theater-processors/MX160", + "http://www.mcintoshcompendium.com/Compendium%20Docs/Home%20Theater%20Controllers/PDFs/MX160.pdf", + "https://www.docdroid.net/OnipkTW/mx160-serial-control-manual-v3-pdf" + ] + }, + "hardware": { + "sources": { + "0": "HDMI 1", + "1": "HDMI 2", + "2": "HDMI 3", + "3": "HDMI 4", + "4": "HDMI 5", + "5": "HDMI 6", + "6": "HDMI 7", + "7": "HDMI 8", + "8": "Audio Return", + "9": "SPDIF 1 (Optical)", + "10": "SPDIF 2 (Optical)", + "11": "SPDIF 3 (Optical)", + "12": "SPDIF 4 (Optical)", + "13": "SPDIF 5 (AES/EBU)", + "14": "SPDIF 6 (Coaxial)", + "15": "SPDIF 7 (Coaxial)", + "16": "SPDIF 8 (Coaxial)", + "17": "USB Audio", + "18": "Analog 1", + "19": "Analog 2", + "20": "Analog 3", + "21": "Analog 4", + "22": "Balanced 1", + "23": "Balanced 2", + "24": "Phono", + "25": "8 Channel Analog" + }, + "baud_rates": { + "9600": "9600", + "115200": "115200 (default)" + } + }, + "connection": { + "rs232": { + "baudrate": 115200, + "bytesize": 8, + "parity": "N", + "stopbits": 1, + "timeout": 2, + "encoding": "ascii", + "response_eol": "\r" + }, + "connection_init": "!VERB(2)" + }, + "protocol": { + "encoding": "ascii", + "command_eol": "\r", + "message_eol": "\r", + "min_time_between_commands": 0.4 + }, + "vars": { + "zone": { + "type": "int", + "pattern": "[1-8]", + "min": 1, + "max": 8 + }, + "power": { + "type": "int", + "pattern": "[01]", + "min": 0, + "max": 1 + }, + "mute": { + "type": "int", + "pattern": "[01]", + "min": 0, + "max": 1 + }, + "volume": { + "type": "int", + "min": 0, + "max": 38 + }, + "treble": { + "type": "int", + "min": 0, + "max": 14 + }, + "bass": { + "type": "int", + "min": 0, + "max": 14 + }, + "balance": { + "type": "int", + "min": 0, + "max": 63 + }, + "source": { + "type": "int", + "min": 1, + "max": 8 + }, + "verbosity_level": { + "type": "int", + "min": 1, + "max": 3, + "values": { + "1": "Minimal", + "2": "Normal", + "3": "All" + } + }, + "dim_level": { + "type": "int", + "min": 0, + "max": 3, + "values": { + "0": "Full (100%)", + "1": "Bright (75%)", + "2": "Low (50%)", + "3": "Dark (25%)" + } + }, + "interface": { + "type": "string", + "values": { + "IP": "IP", + "SERIAL": "Serial" + } + }, + "lipsync": { + "type": "int" + }, + "loudness": { + "type": "int", + "min": 0, + "max": 1, + "pattern": "[01]", + "values": { + "0": "Off", + "1": "On" + } + }, + "roomperfect_position": { + "type": "int", + "min": 1, + "max": 9, + "pattern": "[1-9]", + "values": { + "0": "Bypass", + "1": "Focus 1", + "2": "Focus 2", + "3": "Focus 3", + "4": "Focus 4", + "5": "Focus 5", + "6": "Focus 6", + "7": "Focus 7", + "8": "Focus 8", + "9": "Global" + } + }, + "input": { + "type": "int", + "min": 0, + "max": 25, + "values": { + "0": "HDMI 1", + "1": "HDMI 2", + "2": "HDMI 3", + "3": "HDMI 4", + "4": "HDMI 5", + "5": "HDMI 6", + "6": "HDMI 7", + "7": "HDMI 8", + "8": "Audio Return", + "9": "SPDIF 1 (Optical)", + "10": "SPDIF 2 (Optical)", + "11": "SPDIF 3 (Optical)", + "12": "SPDIF 4 (Optical)", + "13": "SPDIF 5 (AES/EBU)", + "14": "SPDIF 6 (Coaxial)", + "15": "SPDIF 7 (Coaxial)", + "16": "SPDIF 8 (Coaxial)", + "17": "USB Audio", + "18": "Analog 1", + "19": "Analog 2", + "20": "Analog 3", + "21": "Analog 4", + "22": "Balanced 1", + "23": "Balanced 2", + "24": "Phono", + "25": "8 Channel Analog" + } + } + }, + "api": { + "verbosity": { + "actions": { + "set": { + "description": "Set verbosity level of active interface", + "cmd": { + "fstring": "!VERB({verbosity_level})", + "docs": { + "verbosity_level": "0 (min), 1 (normal), or 2 (max)" + } + } + }, + "min": { + "description": "Set verbosity level to minimal", + "cmd": { + "fstring": "!VERB(1)" + } + }, + "normal": { + "description": "Set verbosity level to normal", + "cmd": { + "fstring": "!VERB(2)" + } + }, + "max": { + "description": "Set verbosity level to maximum", + "cmd": { + "fstring": "!VERB(3)" + } + }, + "get": { + "description": "Request verbosity level of active interface", + "cmd": { + "fstring": "!VERB?" + }, + "msg": { + "regex": "!VERB\\((?P[123])\\)", + "tests": { + "!VERB(2)": { + "verbosity_level": 2 + } + } + } + } + } + }, + "audio_mode": { + "description": "Audio processing mode control", + "actions": { + "down": { + "description": "Audio processing mode down button", + "cmd": { + "fstring": "!AUDMODE-" + } + }, + "up": { + "description": "Audio processing mode up button", + "cmd": { + "fstring": "!AUDMODE+" + } + }, + "get": { + "description": "Request audio processing mode", + "cmd": { + "fstring": "!AUDMODE?" + }, + "msg": { + "regex": "!AUDMODE\\((?P\\d+)\\)\\s*\"(?P.+)\"", + "tests": { + "!AUDMODE(1) \"Test\"": { + "type": 1, + "name": "Test" + } + } + } + }, + "modes": { + "description": "Get list of audio processing modes", + "cmd": { + "fstring": "!AUDMODEL?" + }, + "msg": { + "regex": "!AUDMODECOUNT\\((?P\\d+)\\)\\r", + "tests": { + "!AUDMODECOUNT(2)\r!AUDMODE(0)\"Source 1\"\r!AUDMODE(1)\"Source 2\"": { + "count": 2 + } + } + } + } + } + }, + "audio_type": { + "description": "Input audio type", + "actions": { + "get": { + "description": "Return string of the input audio type.", + "cmd": { + "fstring": "!AUDTYPE?" + }, + "msg": { + "regex": "!AUDTYPE\\((?P.+)\\)", + "tests": { + "!AUDTYPE(Unknown)": { + "type": "Unknown" + } + } + } + } + } + }, + "button": { + "description": "Remote button presses", + "actions": { + "back": { + "description": "Back button", + "cmd": { + "fstring": "!BACK" + } + }, + "down": { + "description": "Direction Down button", + "cmd": { + "fstring": "!DIRD" + } + }, + "left": { + "description": "Direction Left button", + "cmd": { + "fstring": "!DIRL" + } + }, + "right": { + "description": "Direction Right button", + "cmd": { + "fstring": "!DIRR" + } + }, + "up": { + "description": "Direction Up button", + "cmd": { + "fstring": "!DIRU" + } + }, + "enter": { + "description": "Enter button", + "cmd": { + "fstring": "!ENTER" + } + }, + "exit": { + "description": "Exit button", + "cmd": { + "fstring": "!EXIT" + } + }, + "info": { + "description": "Info button", + "cmd": { + "fstring": "!INFO" + } + }, + "menu": { + "description": "Menu Button", + "cmd": { + "fstring": "!MENU" + } + }, + "setup": { + "description": "Setup button", + "cmd": { + "fstring": "!SETUP" + } + }, + "source": { + "description": "Source button", + "cmd": { + "fstring": "!SRCBTN" + } + }, + "number": { + "description": "Number button", + "cmd": { + "fstring": "!NUM({num})", + "docs": { + "num": "single digit integer (0-9)" + } + } + }, + "num0": { + "description": "Number button 0", + "cmd": { + "fstring": "!NUM(0)" + } + }, + "num1": { + "description": "Number button 1", + "cmd": { + "fstring": "!NUM(1)" + } + }, + "num2": { + "description": "Number button 2", + "cmd": { + "fstring": "!NUM(2)" + } + }, + "num3": { + "description": "Number button 3", + "cmd": { + "fstring": "!NUM(3)" + } + }, + "num4": { + "description": "Number button 4", + "cmd": { + "fstring": "!NUM(4)" + } + }, + "num5": { + "description": "Number button 5", + "cmd": { + "fstring": "!NUM(5)" + } + }, + "num6": { + "description": "Number button 6", + "cmd": { + "fstring": "!NUM(6)" + } + }, + "num7": { + "description": "Number button 7", + "cmd": { + "fstring": "!NUM(7)" + } + }, + "num8": { + "description": "Number button 8", + "cmd": { + "fstring": "!NUM(8)" + } + }, + "num9": { + "description": "Number button 9", + "cmd": { + "fstring": "!NUM(9)" + } + } + } + }, + "device": { + "actions": { + "name": { + "description": "Returns the name of the device (e.g. MX160)", + "cmd": { + "fstring": "!DEVICE?" + }, + "msg": { + "regex": "!DEVICE\\((?P.+)\\)", + "tests": { + "!DEVICE(MX160)": { + "name": "MX160" + } + } + } + } + } + }, + "display_brightness": { + "description": "VFD display brightness (0 – 3; 0=100%, 1=75%, 2=50%, 3=25%)", + "actions": { + "down": { + "description": "Reduce brightness of the VFD display", + "cmd": { + "fstring": "!DIM-" + } + }, + "up": { + "description": "Increase the brightness of the VFD display", + "cmd": { + "fstring": "!DIM+" + } + }, + "get": { + "description": "Request brightness of the VFD display", + "cmd": { + "fstring": "!DIM?" + }, + "msg": { + "regex": "!DIM\\((?P[0123])\\)", + "tests": { + "!DIM(2)": { + "dim_level": 2 + } + } + } + }, + "set": { + "description": "Set display brightness level", + "cmd": { + "fstring": "!DIM({dim_level})", + "docs": { + "dim_level": "0 (Full 100%), 1 (Bright 75%), 2 (Low 50%), or 3 (Dark 25%)" + } + } + }, + "full": { + "description": "Set display brightness Full (100%)", + "cmd": { + "fstring": "!DIM(0)" + } + }, + "bright": { + "description": "Set display brightness Bright (75%)", + "cmd": { + "fstring": "!DIM(1)" + } + }, + "low": { + "description": "Set display brightness Low (50%)", + "cmd": { + "fstring": "!DIM(2)" + } + }, + "dark": { + "description": "Set display brightness Dark (25%)", + "cmd": { + "fstring": "!DIM(3)" + } + } + } + }, + "interface": { + "description": "Interface type for this session (IP or SERIAL)", + "actions": { + "get": { + "description": "Returns the active interface for this section", + "cmd": { + "fstring": "!INTERFACE?" + }, + "msg": { + "regex": "!INTERFACE\\((?P(IP|SERIAL))\\)", + "tests": { + "!INTERFACE(SERIAL)": { + "interface": "SERIAL" + }, + "!INTERFACE(IP)": { + "interface": "IP" + } + } + } + } + } + }, + "lipsync": { + "description": "Lipsync adjustments", + "actions": { + "set": { + "description": "Set the lipsync value", + "cmd": { + "fstring": "!LIPSYNC({lipsync})", + "regex": "!LIPSYNC\\((?P\\d+)\\)", + "docs": { + "lipsync": "lipsync value" + } + } + }, + "get": { + "description": "Get the lipsync value", + "cmd": { + "fstring": "!LIPSYNC?" + }, + "msg": { + "regex": "!LIPSYNC\\((?P\\d)\\)", + "tests": { + "!LIPSYNC(1)": { + "lipsync": 1 + } + } + } + }, + "range": { + "description": "Get the lipsync value range", + "cmd": { + "fstring": "!LIPSYNCRANGE?" + }, + "msg": { + "regex": "!LIPSYNCRANGE\\((?P\\d+),(?P\\d+)\\)\r", + "tests": { + "!LIPSYNCRANGE(1,3)": { + "min": 1, + "max": 3 + } + } + } + }, + "down": { + "description": "Reduce lipsync value", + "cmd": { + "fstring": "!LIPSYNC-" + } + }, + "up": { + "description": "Increase lipsync value", + "cmd": { + "fstring": "!LIPSYNC+" + } + } + } + }, + "loudness": { + "description": "Loudness", + "actions": { + "on": { + "description": "Turn loudness on", + "cmd": { + "fstring": "!LOUDNESS(1)" + } + }, + "off": { + "description": "Turn loudness off", + "cmd": { + "fstring": "!LOUDNESS(0)" + } + }, + "get": { + "description": "Get the loudness setting (0=off; 1=on)", + "cmd": { + "fstring": "!LOUDNESS?" + }, + "msg": { + "regex": "!LOUDNESS\\((?P[01])\\)", + "tests": { + "!LOUDNESS(0)": { + "loudness": 0 + } + } + } + } + } + }, + "mute": { + "description": "Mute", + "actions": { + "toggle": { + "description": "Mute toggle button", + "cmd": { + "fstring": "!MUTE" + } + }, + "get": { + "description": "get current Mute status", + "cmd": { + "fstring": "!MUTE?" + }, + "msg": { + "regex": "!MUTE\\\\((?P[01])\\)", + "tests": { + "!MUTE(1)": { + "mute": 1 + } + } + } + }, + "off": { + "description": "Mute off", + "cmd": { + "fstring": "!MUTEOFF" + } + }, + "on": { + "description": "Mute on", + "cmd": { + "fstring": "!MUTEON" + } + } + } + }, + "ping": { + "description": "Ping test", + "actions": { + "ping": { + "description": "Ping for a pong (returns PONG)", + "cmd": { + "fstring": "!PING?" + }, + "msg": { + "regex": "!PONG" + } + } + } + }, + "power": { + "description": "Power control for the entire system", + "actions": { + "on": { + "description": "Turn entire system on", + "cmd": { + "fstring": "!PON" + } + }, + "off": { + "description": "Turn entire system off", + "cmd": { + "fstring": "!POFF" + } + }, + "toggle": { + "description": "Toggle system power", + "cmd": { + "fstring": "!PTOGGLE" + } + }, + "get": { + "description": "Get system power status (0=off; 1=on)", + "cmd": { + "fstring": "!POWER?" + }, + "msg": { + "regex": "!POWER\\((?P[01])\\)", + "tests": { + "!POWER(1)": { + "power": 1 + } + } + } + } + } + }, + "power_zone_main": { + "description": "Main zone power", + "actions": { + "on": { + "description": "Turn main zone power on", + "cmd": { + "fstring": "!POWERONMAIN" + } + }, + "off": { + "description": "Turn main zone power off", + "cmd": { + "fstring": "!POWEROFFMAIN" + } + }, + "get": { + "description": "get main zone power status (0=standby; 1=on)", + "cmd": { + "fstring": "!POWERMAIN?" + }, + "msg": { + "regex": "!POWER\\((?P[01])\\)", + "tests": { + "!POWER(1)": { + "power": 1 + } + } + } + } + } + }, + "power_zone_2": { + "description": "Zone 2 power", + "actions": { + "on": { + "description": "Turn zone 2 power on", + "cmd": { + "fstring": "!POWERONZONE2" + } + }, + "off": { + "description": "Turn zone 2 power off", + "cmd": { + "fstring": "!POWEROFFZONE2" + } + }, + "get": { + "description": "Get zone 2 power status (0=off; 1=on)", + "cmd": { + "fstring": "!POWERZONE2?" + }, + "msg": { + "regex": "!POWER\\((?P[01])\\)", + "tests": { + "!POWERZONE2(1)": { + "power": 1 + } + } + } + } + } + }, + "roomperfect_focus": { + "description": "RoomPerfect room correction focus", + "actions": { + "previous": { + "description": "Previous RoomPerfect position button", + "cmd": { + "fstring": "!RPFOC-" + } + }, + "position": { + "description": "Request RoomPerfect position (0=bypass, 1-8=focus1-8, 9=global)", + "cmd": { + "fstring": "!RPFOC?" + } + }, + "set": { + "description": "Set RoomPerfect position", + "cmd": { + "fstring": "!RPFOC({roomperfect_position})", + "docs": { + "roomperfect_position": "RoomPerfect position (0=bypass, 1-8=focus1-8, 9=global)" + } + } + }, + "next": { + "description": "Next Roomperfect position button", + "cmd": { + "fstring": "!RPFOC+" + } + }, + "get": { + "description": "Get available RoomPerfect positions", + "cmd": { + "fstring": "!RPFOCS?" + }, + "msg": { + "regex": "!PPFOCOUNT\\((?P[01])\\)", + "tests": { + "FIXME": { + "positions": 3 + } + } + } + } + } + }, + "roomperfect_voice": { + "description": "RoomPerfect room correction voice", + "actions": { + "previous": { + "description": "Previous voicing button", + "cmd": { + "fstring": "!RPVOI-" + } + }, + "get": { + "description": "Get active voicing", + "cmd": { + "fstring": "!RPVOI?" + }, + "msg": { + "regex": "!RPVOI\\((?P[01])\\)\\s*\"(?P.+)\"", + "tests": { + "!RPVOI(1) \"Test\"": { + "active_voice": 1, + "name": "Test" + } + } + } + }, + "set": { + "description": "Set voicing", + "cmd": { + "fstring": "!RPVOI({roomperfect_voicing})" + }, + "docs": { + "roomperfect_voicing": "RoomPerfect voicing value" + } + }, + "next": { + "description": "Next voicing button", + "cmd": { + "fstring": "!RPVOI+" + } + }, + "list": { + "description": "Request list of available voicings", + "cmd": { + "fstring": "!RPVOIS?" + }, + "msg": { + "regex": "!RPVOICOUNT\\((?P\\d+)\\)\r", + "tests": { + "!RPIVOICOUNT(2)": { + "voice_count": 2 + } + } + } + } + } + }, + "source": { + "description": "Input source selection", + "actions": { + "previous": { + "description": "Previous source button", + "cmd": { + "fstring": "!SRC-" + } + }, + "get": { + "description": "Get info for currently active source", + "cmd": { + "fstring": "!SRC?" + }, + "msg": { + "regex": "!SRC\\((?P\\d+)\\)\\s*\"(?P.+)\"", + "tests": { + "!SRC(1) CD": { + "source": 1, + "name": "CD" + } + } + } + }, + "set": { + "description": "Select source", + "cmd": { + "fstring": "!SRC({source})", + "docs": { + "source": "Source to select (integer)" + } + } + }, + "info": { + "description": "Get info for a specific source", + "cmd": { + "fstring": "!SRC({source})?", + "docs": { + "source": "the integer identifying the source input" + } + }, + "msg": { + "regex": "!SRC\\((?P\\d+)\\)\\s*\"(?P.+)\"", + "tests": { + "!SRC(2) HiFiBerry": { + "source": 2, + "name": "HiFiBerry" + } + } + } + }, + "next": { + "description": "Next source button", + "cmd": { + "fstring": "!SRC+" + } + }, + "list": { + "description": "Get list of available sources", + "cmd": { + "fstring": "!SRCS?" + }, + "msg": { + "regex": "!SRCOUNT\\((?P\\d+)\\)\r", + "tests": { + "FIXME": { + "count": 2 + } + } + } + } + } + }, + "volume_offset": { + "description": "Volume offset", + "actions": { + "down": { + "description": "Decrease Source volume offset", + "cmd": { + "fstring": "!SRCOFF-" + } + }, + "get": { + "description": "Get source volume offset for current source", + "cmd": { + "fstring": "!SRCOFF?" + } + }, + "set": { + "description": "Set source volume offset for current source", + "cmd": { + "fstring": "!SRCOFF(x)" + } + }, + "up": { + "description": "Increase source volume offset", + "cmd": { + "fstring": "!SRCOFF+" + } + } + } + }, + "software": { + "description": "Software/firmware info", + "actions": { + "info": { + "description": "Request SW information (prints a list of version numbers)", + "cmd": { + "fstring": "!SWINFO?" + }, + "msg": { + "regex": "!SWINFO\\((?P.+)\\)", + "tests": { + "!SWINFO(1)": { + "version": "1" + } + } + } + } + } + }, + "trim_bass": { + "description": "Bass trim controls", + "actions": { + "get": { + "description": "Get current bass level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMBASS?" + }, + "msg": { + "regex": "!TRIMBASS\\((?P-?[0-9]{1,3})\\)", + "tests": { + "!TRIMBASS(10)": { + "bass_level": 10 + } + } + } + }, + "set": { + "description": "Sets bass level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMBASS({bass_level})", + "regex": "!TRIMBASS\\((?P-?[0-9]{1,3})\\)" + } + }, + "up": { + "description": "Increases bass level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMBASS+" + } + }, + "down": { + "description": "Decreases bass level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMBASS-" + } + } + } + }, + "trim_center": { + "description": "Center channel trim controls", + "actions": { + "get": { + "description": "get current center channel level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMCENTER?" + }, + "msg": { + "regex": "!TRIMCENTER\\((?P-?[0-9]{1,3})\\)", + "tests": { + "!TRIMCENTER(10)": { + "center_level": 10 + } + } + } + }, + "set": { + "description": "Sets center channel level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMCENTER({center_level})", + "regex": "!TRIMCENTER\\((?P-?[0-9]{1,3})\\)" + } + }, + "up": { + "description": "Increases center channel level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMCENTER+" + } + }, + "down": { + "description": "Decreases center channel level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMCENTER-" + } + } + } + }, + "trim_height": { + "description": "Height channels trim controls", + "actions": { + "get": { + "description": "Gete current height channels level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMHEIGHT?" + }, + "msg": { + "regex": "!TRIMHEIGHT\\((?P-?[0-9]{1,3})\\)", + "tests": { + "!TRIMHEIGHT(9)": { + "height_level": 9 + } + } + } + }, + "set": { + "description": "Sets height channels level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMHEIGHT({height_level})", + "regex": "!TRIMHEIGHT\\((?P-?[0-9]{1,3})\\)" + } + }, + "up": { + "description": "Increases height channels level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMHEIGHT+" + } + }, + "down": { + "description": "Decreases height channels level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMHEIGHT-" + } + } + } + }, + "trim_lfe": { + "description": "LFE channel trim controls", + "actions": { + "get": { + "description": "Get current LFE channel level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMLFE?" + }, + "msg": { + "regex": "!TRIMLFE\\((?P-?[0-9]{1,3})\\)", + "tests": { + "!TRIMLFE(2)": { + "lfe_level": 2 + } + } + } + }, + "set": { + "description": "Sets LFE channel level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMLFE({lfe_level})", + "regex": "!TRIMLFE\\((?P-?[0-9]{1,3})\\)" + } + }, + "up": { + "description": "Increases LFE channel level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMLFE+" + } + }, + "down": { + "description": "Decreases LFE channel level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMLFE-" + } + } + } + }, + "trim_surrounds": { + "description": "Surround channels trim controls", + "actions": { + "down": { + "description": "Decreases surround channels level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMSURRS-" + } + }, + "get": { + "description": "Get current surround channels level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMSURRS?" + }, + "msg": { + "regex": "!TRIMSURRS\\((?P-?[0-9]{1,3})\\)", + "tests": { + "!TRIMSURRS(1)": { + "surround_level": 1 + } + } + } + }, + "set": { + "description": "Sets surround channels level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMSURRS({surround_level})", + "regex": "!TRIMSURRS\\((?P-?[0-9]{1,3})\\)" + } + }, + "up": { + "description": "Increases surround channels level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMSURRS+" + } + } + } + }, + "trim_treble": { + "description": "Treble trim controls", + "actions": { + "get": { + "description": "Get current treble level trim (10 = 1dB; -120=-10 dB to 120=+10 dB)", + "cmd": { + "fstring": "!TRIMTREB?" + }, + "msg": { + "regex": "!TRIMTREB\\(?P-?[0-9]{1,3})\\)", + "tests": { + "!TRIMTREB(100)": { + "trebble_level": 100 + } + } + } + }, + "set": { + "description": "Sets treble level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMTREB({trebble_level})", + "regex": "!TRIMTREB\\((?P-?[0-9]{1,3})\\)" + } + }, + "down": { + "description": "Decreases treble level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMTREB-" + } + }, + "up": { + "description": "Increases treble level trim (10 = 1dB)", + "cmd": { + "fstring": "!TRIMTREB+" + } + } + } + }, + "volume": { + "description": "Volume controls", + "actions": { + "get": { + "description": "Get current volume", + "cmd": { + "fstring": "!VOL?" + }, + "msg": { + "regex": "!VOL\\((?P[0-9]{1,2})\\)", + "tests": { + "!VOL(1)": { + "volume": 1 + } + } + } + }, + "set": { + "description": "Set volume to x", + "cmd": { + "fstring": "!VOL({volume})", + "regex": "!VOL\\((?P[0-9]{1,2})\\)" + } + }, + "down": { + "description": "Decrease volume", + "cmd": { + "fstring": "!VOL-" + } + }, + "down_by_x": { + "description": "Decrease volume by x", + "cmd": { + "fstring": "!VOL-({volume_amount})", + "regex": "!VOL-\\((?P[0-9]{1,2})\\)" + } + }, + "up": { + "description": "Increase volume", + "cmd": { + "fstring": "!VOL+" + } + }, + "up_by_x": { + "description": "Increase volume by x", + "cmd": { + "fstring": "!VOL+({volume_amount})", + "regex": "!VOL\\+\\((?P[0-9]{1,2})\\)" + } + } + } + }, + "zone_2_mute": { + "description": "Zone 2 mute", + "actions": { + "get": { + "description": "get current Zone B Mute status", + "cmd": { + "fstring": "!ZMUTE?" + }, + "msg": { + "regex": "!ZMUTE\\((?P[01])\\)", + "tests": { + "!ZMUTE(1)": { + "mute": 1 + } + } + } + }, + "on": { + "description": "Zone B Mute on", + "cmd": { + "fstring": "!ZMUTEON" + } + }, + "off": { + "description": "Zone B Mute off", + "cmd": { + "fstring": "!ZMUTEOFF" + } + }, + "toggle": { + "description": "Toggle Zone B Mute", + "cmd": { + "fstring": "!ZMUTE" + } + } + } + }, + "zone_2_power": { + "description": "Zone 2 power", + "actions": { + "on": { + "description": "Zone Power On", + "cmd": { + "fstring": "!ZPON" + } + }, + "off": { + "description": "Zone Power Off", + "cmd": { + "fstring": "!ZPOFF" + } + }, + "toggle": { + "description": "Zone Power Toggle", + "cmd": { + "fstring": "!ZPTOGGLE" + } + } + } + }, + "zone_2_source": { + "description": "Zone 2 input source selection", + "actions": { + "previous": { + "description": "Previous zone B source button", + "cmd": { + "fstring": "!ZSRC-" + } + }, + "get": { + "description": "Get current Zone B source", + "cmd": { + "fstring": "!ZSRC?" + }, + "msg": { + "regex": "!ZSRC\\((?P\\d+)\\s*\"(?P.+)\"\\)", + "tests": { + "!ZSRC(1) \"Source 1\"": { + "source": 1, + "name": "Source 1" + } + } + } + }, + "set": { + "description": "Set Zone B source", + "cmd": { + "fstring": "!ZSRC({source})", + "regex": "!ZSRC\\((?P\\d+)\\)" + } + }, + "next": { + "description": "Next Zone B source button", + "cmd": { + "fstring": "!ZSRC+" + } + }, + "list": { + "description": "Get list of available Zone B sources", + "cmd": { + "fstring": "!ZSRCS?" + }, + "msg": { + "regex": "!ZSRCCOUNT\\\\((?P\\\\d+)\\r!ZSRC\\\\((?P.+)\\\\)\\s*\"(?P.+)\"", + "tests": { + "!ZSRCCOUNT(1)\\r!ZSRC(1) \"Source 1\"": { + "count": 1 + } + } + } + } + } + }, + "zone_2_volume": { + "description": "Zone 2 volume control", + "actions": { + "down": { + "description": "Decrease zone B volume", + "cmd": { + "fstring": "!ZVOL-" + } + }, + "down_by_x": { + "description": "decrease zone B volume by X", + "cmd": { + "fstring": "!ZVOL-({volume_amount})", + "regex": "!ZVOL-\\((?P[0-9]{1,2})\\)" + } + }, + "get": { + "description": "Get current zone B volume", + "cmd": { + "fstring": "!ZVOL?" + }, + "msg": { + "regex": "!ZVOL\\((?P[0-9]{1,2})\\)", + "tests": { + "!ZVOL(2)": { + "volume": 2 + } + } + } + }, + "set": { + "description": "Set zone B volume", + "cmd": { + "fstring": "!ZVOL({volume})", + "regex": "!ZVOL\\((?P[0-9]{1,2})\\)" + } + }, + "up": { + "description": "Increase zone B volume", + "cmd": { + "fstring": "!ZVOL+" + } + }, + "up_by_x": { + "description": "Increase zone B volume by x", + "cmd": { + "fstring": "!ZVOL+({volume_amount})", + "regex": "!ZVOL\\+\\((?P[0-9]{1,2})\\)" + } + } + } + } + } +} diff --git a/pyavcontrol/data/rcl/mcintosh/mcintosh_mx170.rcl b/pyavcontrol/data/rcl/mcintosh/mcintosh_mx170.rcl new file mode 100644 index 0000000..e69de29 diff --git a/pyavcontrol/data/src/classe_omicron.yaml b/pyavcontrol/data/src/classe_omicron.yaml new file mode 100644 index 0000000..a5563aa --- /dev/null +++ b/pyavcontrol/data/src/classe_omicron.yaml @@ -0,0 +1,55 @@ +--- +id: classe_omicron + +info: + manufacturer: ClassÊ Audio + models: + - Omicron + type: amp + tested: false + urls: + - https://support.classeaudio.com/files/documents/automation_and_control/rs232/CLASSE_OMICRON_Mono_RS232_Protocol.pdf + +connection: + rs232: + baudrate: 9600 + bytesize: 8 + parity: N + stopbits: 1 + timeout: 0.15 + +protocol: + command_eol: "\r" + +api: + power: + description: Power control for the entire system + actions: + 'on': + description: Turn on + cmd: + fstring: 'PW1' + 'off': + description: Turn off + cmd: + fstring: 'PW0' + toggle: + description: Toggle power + cmd: + fstring: 'PWR' + + mute: + description: Mute + actions: + 'on': + description: Mute on + cmd: + fstring: 'MUT1' + 'off': + description: Mute off + cmd: + fstring: 'MUT0' + toggle: + description: Mute toggle + cmd: + fstring: 'MUT' diff --git a/pyavcontrol/data/src/hdfury_vrroom.yaml b/pyavcontrol/data/src/hdfury_vrroom.yaml new file mode 100644 index 0000000..7113924 --- /dev/null +++ b/pyavcontrol/data/src/hdfury_vrroom.yaml @@ -0,0 +1,114 @@ +--- +id: hdfury_vrroom +description: HDFury VRROOM Automation Protocol over RS232 and IP (FW 0.61, 2023-05-24) + +info: + manufacturer: HDFury + models: + - VRROOM + tested: false + urls: + - https://www.hdfury.com/docs/HDfuryVRRoom.pdf + +connection: + ip: + port: 2222 + rs232: + baudrate: 19200 + bytesize: 8 + parity: N + stopbits: 1 + timeout: 1.0 + min_time_between_commands: 0.4 + +protocol: + encoding: ascii + command_eol: "\n" + message_eol: "\r\n" + min_time_between_commands: 0.4 + +vars: + opmode: + 0: SPLITTER TX0/TX1 FRL5 VRR + 1: SPLITTER TX0/TX1 UPSCALE FRL5 + 2: MATRIX TMDS + 3: MATRIX TMDS DOWNSCALE + 4: MATRIX RX0:FRL5 + RX1-3:TMDS + edidpcmsrmode: + 0: 48kHz + 1: 96kHz + 2: 192kHz + edidpcmbwmode: + 0: 16bit + 1: 20bit + 2: 24bit + edidtruehdsrmode: + 0: 48kHz + 1: 96kHz + 2: 192kHz + 3: copy sink + ediddtshdmode: + 0: DTS:X IMAX + 1: DTS:X + 2: NO DTS:X + 3: remove all + edidddplussrmode: + 0: 48kHz + 1: 96kHz + 2: 192kHz + 3: copy sink + output: + tx0: TX0 + tx1: TX1 + on_off: + on: true + off: false + +api: + power: + actions: + reboot: + description: Reboot + cmd: + fstring: vrroom set reboot + + volume: + actions: + mute: + cmd: + fstring: vrroom set mute{output}audio {on_off} + regex: vrroom set mute\(?Ptx[01]\)audio \(?Po[nf][f]*\) + + opmode: + description: Opmode + actions: + get: + description: Get opmode + cmd: + fstring: vrroom get opmode + msg: + regex: (?P[0-4)\) + set: + description: Set opmode + cmd: + fstring: vrroom set opmode {opmode} + regex: vrroom set opmode \(?P[0-4]\) + + ddplus: + description: Dolby Digital Plus + actions: + mode: + description: Sets the features for Dolby Digital Plus + cmd: + fstring: vrroom set edidddplusmode {ddplusmode} + regex: vrroom set edidddplusmode \(?P[0-2]\) + automix: + description: Sets the automix EDID Dolby Digital Plus option + cmd: + fstring: vrroom set edidddplusflag {on_off} + regex: vrroom set edidddplusflag \(?Po[nf][f]*\) + sample_rate: + description: Sets the sample rate capability for Dolby Digital Plus + cmd: + fstring: vrroom set edidddplussrmode {ddplussrmode} + regex: vrroom set edidddplussrmode \(?P[0-3]\) diff --git a/pyavcontrol/data/src/jbl_sdp75.yaml b/pyavcontrol/data/src/jbl_sdp75.yaml new file mode 100644 index 0000000..27771b1 --- /dev/null +++ b/pyavcontrol/data/src/jbl_sdp75.yaml @@ -0,0 +1,11 @@ +--- +id: jbl_sdp75 + +import_models: + - trinnov_altitude32 + +info: + manufacturer: JBL Synthesis + models: + - SDP-75 + tested: false diff --git a/pyavcontrol/data/src/lyngdorf_cd2.yaml b/pyavcontrol/data/src/lyngdorf_cd2.yaml new file mode 100644 index 0000000..974029f --- /dev/null +++ b/pyavcontrol/data/src/lyngdorf_cd2.yaml @@ -0,0 +1,245 @@ +--- +id: lyngdorf_cd2 + +# NOTE: None of the state subscriptions have been defined (SUBSCRIBETRACK. etc) + +info: + manufacturer: Lyngdorf + models: + - CD-2 + tested: false + urls: + - https://site.currants.info/wp-content/uploads/2021/07/TDAI-3400-External-Control-Manual-March-2021.pdf + +connection: + ip: + port: 84 + rs232: + baudrate: 115200 + bytesize: 8 + parity: N + stopbits: 1 + timeout: 2.0 + +protocol: + command_eol: "\r\n" # CR/LF + +api: + device: + actions: + name: + description: Returns the name of the device + cmd: + fstring: '!DEVICE?' + msg: + regex: '!DEVICE\((?P.+)\)' + tests: + '!DEVICE(CD2)': + name: CD2 + + power: + description: Power controls + actions: + on: + description: Turn CD on + cmd: + fstring: '!ON' + off: + description: Turn CD off + cmd: + fstring: '!OFF' + toggle: + description: Toggle power + cmd: + fstring: '!PWR' + + playback: + actions: + next: + description: Next track + cmd: + fstring: '!NEXT' + play: + description: Play + cmd: + fstring: '!PLAY' + pause: + description: Pause + cmd: + fstring: '!STOP' + stop: + description: Stop + cmd: + fstring: '!STOP' + previous: + description: Previous track + cmd: + fstring: '!PREV' + eject: + description: Eject + cmd: + fstring: '!EJECT' + rewind: + description: Start scanning backwards + cmd: + fstring: '!REWIND' + wind: + description: Start scanning forwards + cmd: + fstring: '!WIND' + stopwind: + description: Stop winding + cmd: + fstring: '!STOPWIND' + state: + description: Current state of playback + cmd: + fstring: '!STATE?' + msg: + regex: '!STATE\((?P(OFF|OPENING|OPEN|CLOSING|NODISC|DISCERROR|READING|PLAY|STOP|PAUSE|WIND|REWIND))\)' + tests: + '!STATE(PLAY)': + state: PLAY + '!STATE(NODISC)': + state: NODISC + + buttons: + actions: + num1: + description: Button number 1 + cmd: + fstring: '!DIGIT(1)' + num2: + description: Button number 2 + cmd: + fstring: '!DIGIT(2)' + + play_status: + actions: + track: + description: Get current track + cmd: + fstring: '!TRACK?' + msg: + regex: '!TRACK\((?P([-]|\d+))\)' + tests: + '!TRACK(-)': + track: '-' + '!TRACK(14)': + track: 14 + total_tracks: + description: Total number of tracks being played + cmd: + fstring: '!NOFTRACKS?' + msg: + regex: '!NOFTRACKS\((?P([-]|\d+))\)' + tests: + '!NOFTRACKS(-)': + track: '-' + '!NOFTRACKS(20)': + track: 20 + time: + description: Requests the elapsed time of the playing track. + cmd: + fstring: '!TIME?' + msg: + regex: '!TIME\((?P