diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..a645d7f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +github: rsnodgrass +patreon: rsnodgrass +custom: ['https://buymeacoffee.com/DYks67r','https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=WREP29UDAMB6G'] diff --git a/.github/dependabot.yml b/.github/dependabot.yaml similarity index 61% rename from .github/dependabot.yml rename to .github/dependabot.yaml index 522d0f2..093deb0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yaml @@ -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" diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml new file mode 100644 index 0000000..4578cd2 --- /dev/null +++ b/.github/workflows/bandit.yml @@ -0,0 +1,33 @@ +# 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.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 index b04fb15..cf3f15c 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@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 index 8a5d878..daee8f7 100644 --- a/.github/workflows/ci.yml.disabled +++ b/.github/workflows/ci.yml.disabled @@ -15,16 +15,15 @@ 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 run: | pip install -r requirements.txt -# - name: Run unit tests -# run: python -m unittest + mypy-test: name: mypy test runs-on: ubuntu-latest @@ -34,7 +33,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 @@ -42,4 +41,4 @@ jobs: pip install -r requirements.txt pip install mypy # - name: Run mypy test -# run: mypy -p pymcintosh +# run: mypy -p pyavcontrol diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..4723eff --- /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@v4 + 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@v4 + + # ℹ️ 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@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..c3e0e9a --- /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@v5 + - 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@v4 + 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/ha-config-check.yml b/.github/workflows/ha-config-check.yml new file mode 100644 index 0000000..c49950a --- /dev/null +++ b/.github/workflows/ha-config-check.yml @@ -0,0 +1,19 @@ +name: Home Assistant CI + +jobs: + home-assistant: + name: "Home Assistant Core ${{ matrix.version }} Configuration Check" + needs: [yamllint] + runs-on: ubuntu-latest + strategy: + matrix: + version: ["beta"] + steps: + - name: ⤵️ Check out configuration from GitHub + uses: actions/checkout@v4 + - name: 🚀 Run Home Assistant Configuration Check + uses: frenck/action-home-assistant@v1.4 + with: + path: "." + secrets: ./secrets.fake.yaml + version: "${{ matrix.version }}" diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml new file mode 100644 index 0000000..8e636bc --- /dev/null +++ b/.github/workflows/pypi-publish.yml @@ -0,0 +1,23 @@ +# 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@v2.0 + with: + pypi_token: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 076d0b5..e2c4a8a 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -15,22 +15,26 @@ 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 + uses: actions/setup-python@v5 with: python-version: "3.12" - - uses: actions/cache@v3 + + - uses: actions/cache@v4 id: cache with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.*') }} - restore-keys: | + 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: | + run: | pytest diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 0000000..f020b15 --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,41 @@ +# 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: Test Multiple Python Versions + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + 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 + if [ -f test-requirements.txt ]; then pip install -r test-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 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/* diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 0000000..b268138 --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,8 @@ +name: Ruff +on: [push, pull_request] +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: chartboost/ruff-action@v1 diff --git a/.gitignore b/.gitignore index 3719f9c..ce172c3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ build __pycache__ .mypy_cache *.swp +pypi.key +poetry.lock +.idea diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7c20d94..1d00c19 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,42 +1,40 @@ --- # pre-commit autoupdate +# pre-commit run --all-files fail_fast: true repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: requirements-txt-fixer + - id: check-yaml - - repo: https://github.com/odwyersoftware/brunette - rev: 0.2.8 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.10 hooks: - - id: brunette - args: [--line-length=88, --single-quotes] + - id: ruff + args: [ --fix ] # run linter + - id: ruff-format # run formatter - -#- repo: https://github.com/hadialqattan/pycln -# rev: v2.4.0 + # check python type issues +# - repo: https://github.com/RobertCraigie/pyright-python +# rev: v1.1.400 # hooks: -# - id: pycln -# args: [--config=pyproject.toml] +# - id: pyright + + #### OPTIONAL: for keeping syntax more current - - repo: https://github.com/pycqa/isort - rev: 5.13.2 + - repo: https://github.com/asottile/pyupgrade + rev: v3.19.1 hooks: - - id: isort - files: \.(py)$ - args: [--settings-path=pyproject.toml] # ["--profile", "black" ] + - id: pyupgrade + args: [--py312-plus] # keep 2 versions behind current - repo: https://github.com/dosisod/refurb - rev: v1.27.0 + rev: v2.1.0 hooks: - id: refurb - - - repo: https://github.com/asottile/pyupgrade - rev: v3.15.0 - hooks: - - id: pyupgrade 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. diff --git a/README.md b/README.md index c3d7b70..f2d1a57 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) @@ -29,24 +28,17 @@ 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 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. @@ -88,6 +80,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. @@ -98,10 +94,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, @@ -117,29 +113,29 @@ 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 | 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 [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. - ## 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/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..bcb0e8b --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,25 @@ +# 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/README.md b/docs/README.md new file mode 100644 index 0000000..85eb2a3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,5 @@ +To build: + +``` +make html +``` diff --git a/docs/source/background.md b/docs/source/background.md new file mode 100644 index 0000000..380e0fa --- /dev/null +++ b/docs/source/background.md @@ -0,0 +1,5 @@ +# Background + +## Goals + +## History diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..78bed58 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,40 @@ +# 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 diff --git a/docs/source/examples.md b/docs/source/examples.md new file mode 100644 index 0000000..29c30cc --- /dev/null +++ b/docs/source/examples.md @@ -0,0 +1,8 @@ +# Examples + + +### Simple Client + +```python + +``` diff --git a/docs/source/hardware.md b/docs/source/hardware.md new file mode 100644 index 0000000..4c832d7 --- /dev/null +++ b/docs/source/hardware.md @@ -0,0 +1,34 @@ +# 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 new file mode 100644 index 0000000..fd16cf8 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,36 @@ +.. 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 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..d25ca02 --- /dev/null +++ b/docs/source/pyavcontrol.library.rst @@ -0,0 +1,45 @@ +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 new file mode 100644 index 0000000..709a992 --- /dev/null +++ b/docs/source/pyavcontrol.rst @@ -0,0 +1,40 @@ +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 new file mode 100644 index 0000000..8b203b4 --- /dev/null +++ b/docs/source/supported.md @@ -0,0 +1,131 @@ + +# 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 new file mode 100755 index 0000000..80d173d --- /dev/null +++ b/docs/update-supported-models @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +import logging + +import os +import sys +from pathlib import Path + +# 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}') + Path(args.output).write_text(TEMPLATE.render(models=sorted_models)) + + +if __name__ == '__main__': + main() diff --git a/example-async.py b/example-async.py index b12b642..a705a1b 100755 --- a/example-async.py +++ b/example-async.py @@ -11,7 +11,7 @@ import coloredlogs -from pyavcontrol import DeviceClient, DeviceModelLibrary +from pyavcontrol.helper import construct_async_client LOG = logging.getLogger(__name__) coloredlogs.install(level='DEBUG') @@ -41,20 +41,26 @@ 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 = 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) + + # 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: diff --git a/example-sync.py b/example-sync.py index 9730c9a..e1fa011 100755 --- a/example-sync.py +++ b/example-sync.py @@ -9,7 +9,7 @@ import coloredlogs -from pyavcontrol import DeviceClient, DeviceModelLibrary +from pyavcontrol.helper import construct_synchronous_client LOG = logging.getLogger(__name__) coloredlogs.install(level='DEBUG') @@ -37,16 +37,20 @@ 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()) - 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/__init__.py b/pyavcontrol/__init__.py index f71433c..4434628 100644 --- a/pyavcontrol/__init__.py +++ b/pyavcontrol/__init__.py @@ -1,4 +1,6 @@ -__version__ = "2024.01.06" +__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/async_client.py b/pyavcontrol/client/async_client.py index ec8600c..b2d80ba 100644 --- a/pyavcontrol/client/async_client.py +++ b/pyavcontrol/client/async_client.py @@ -8,44 +8,28 @@ 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""" 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) + if not connection.is_async(): + raise RuntimeError('Provided DeviceConnection is not asynchronous!') @property def is_async(self): - """ - :return: True if this client implementation is asynchronous (asyncio) versus synchronous. - """ + """:return: true since this client is asynchronous""" return True @locked_coro - async def send_raw(self, data: bytes): - 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!') + 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: diff --git a/pyavcontrol/client/base.py b/pyavcontrol/client/base.py index fc9126f..5ec8f45 100644 --- a/pyavcontrol/client/base.py +++ b/pyavcontrol/client/base.py @@ -1,21 +1,21 @@ -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 +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 ( +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, ) -from ..library.model import DeviceModel LOG = logging.getLogger(__name__) @@ -23,45 +23,44 @@ 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, + 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' - 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: + 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, group_name, action_name, 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 @@ -71,7 +70,16 @@ 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 +class ActionDef: + group: str + name: str + definition: dict + required_args: list[str] = () + response_expected: bool = False def _inject_client_api(client: DeviceClient, model: DeviceModel): @@ -81,8 +89,13 @@ 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(): - # LOG.debug(f'Adding property for group {group_name}') + 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 ) @@ -91,13 +104,21 @@ 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, -): +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. @@ -106,30 +127,49 @@ 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 + # 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'): + # 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 _activity_call_sync(self, **kwargs) -> 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): - return client.send_raw(request) - LOG.warning(f'Failed to make request for {group_name}.{action_name}') - - async def _activity_call_async(self, **kwargs) -> None: + 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 @@ -137,8 +177,12 @@ async def _activity_call_async(self, **kwargs) -> None: """ if request := _prepare_request(**kwargs): # noinspection PyUnresolvedReferences - return await client.send_raw(request) - LOG.warning(f'Failed to make request for {group_name}.{action_name}') + 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: @@ -152,73 +196,56 @@ class DeviceClient(ABC): to control a device. """ - def _new__(cls, *args, **kwargs): - return super().__new__(cls, *args, **kwargs) - # return - 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): + def is_async(self) -> bool: """ :return: True if this client implementation is asynchronous (asyncio) versus synchronous. """ return False - @abstractmethod - def send_command(self, group: str, action: str, **kwargs) -> None: + @property + def client(self) -> DeviceConnection: + """ + :return: DeviceConnection ref to the connection this client is using """ - Call a command by the group/action and args as defined in the - device's protocol yaml. E.g. + return self._connection - client.send_command(group, action, arg1=one, my_arg=my_arg) + @property + def is_connected(self) -> bool: """ - raise NotImplementedError() + :return: True if client is connected to device + """ + return True @abstractmethod - def send_raw(self, data: bytes) -> 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() - def _command(self, model_id: str, format_code: str, args=None): + @property + def model(self) -> DeviceModel: """ - Convert group/action/args into the full command string that should be sent - - FIXME: is this still even used/referenced? + :return: the model this client uses for communication and commands with the device """ - 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 - - # FIXME: should take: - # 1. a model - # 2. a connection - # 3. whether asynchronous + return self._model + @classmethod def create( cls, @@ -241,35 +268,27 @@ def create( asynchronous implementation. By default, the synchronous interface 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 event_loop: optionally to get an interface that can be used asynchronously, pass in an event loop + :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 + :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/client/sync_client.py b/pyavcontrol/client/sync_client.py index 4cd59da..bcc51e1 100644 --- a/pyavcontrol/client/sync_client.py +++ b/pyavcontrol/client/sync_client.py @@ -13,28 +13,14 @@ 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: - if LOG.isEnabledFor(logging.DEBUG): - LOG.debug(f'Sending {self._connection!r}: {data}') - self._connection.sent(data) @synchronized - def send_command(self, group: str, action: str, **kwargs) -> None: - # self.send_raw(data.bytes()) - LOG.error(f'Not implemented!') # FIXME + 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: diff --git a/pyavcontrol/config.py b/pyavcontrol/config.py index e804783..71e7c16 100644 --- a/pyavcontrol/config.py +++ b/pyavcontrol/config.py @@ -1,23 +1,29 @@ -import typing as t from dataclasses import dataclass -# FIXME: also consider pydantic - @dataclass(frozen=True) -class _Config: +class _ConfigKeys: api = 'api' + baudrate = 'baudrate' + clear_before_new_commands = 'clear_before_new_commands' command_eol = 'command_eol' command_separator = 'command_separator' - response_eol = 'response_eol' - serial_config = 'serial_config' + description = 'description' encoding = 'encoding' - timeout = 'timeout' + id = 'id' + message_eol = 'message_eol' min_time_between_commands = 'min_time_between_commands' - format = 'format' + model = 'model' + name = 'name' + protocol = 'protocol' + serial_config = 'serial_config' + timeout = 'timeout' + urls = 'urls' -CONFIG = _Config() +CONFIG = _ConfigKeys() + +# FIXME: see schema! # FIXME: other explorations below # https://dev.to/eblocha/using-dataclasses-for-configuration-in-python-4o53 @@ -27,42 +33,6 @@ class _Config: # config.customer.first_name -@dataclass -class ManufacturerInfo: - name: str - model: str - - def __init__(self, conf): - self.name = conf['name'] - self.model = conf['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['id'] - self.description = conf['description'] - self.urls = conf['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/ # config = DynamicConfig({'host': 'example.com', 'port': 80, 'timeout': 0.5}) diff --git a/pyavcontrol/connection/__init__.py b/pyavcontrol/connection/__init__.py index 6ef09ff..653c1ee 100644 --- a/pyavcontrol/connection/__init__.py +++ b/pyavcontrol/connection/__init__.py @@ -11,7 +11,7 @@ class DeviceConnection: """ def __init__(self): - LOG.error(f'Use factory method create(url, config_overrides') + LOG.error('Use factory method create(url, config_overrides') raise NotImplementedError() def is_connected(self) -> bool: @@ -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. @@ -28,18 +28,26 @@ 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__ 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 @@ -47,7 +55,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. @@ -77,6 +85,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 b626928..a57baaf 100644 --- a/pyavcontrol/connection/async_connection.py +++ b/pyavcontrol/connection/async_connection.py @@ -37,10 +37,9 @@ 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._legacy_connection = None self._event_loop = loop # FIXME: I think encoding should be moved up a level @@ -51,28 +50,56 @@ 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... - self._legacy_connection = await async_get_rs232_connection( - self._url, - self._connection_config, # self._config, - self._connection_config, - self._connection_config.get(CONFIG.format, {}), # self._protocol_def, - 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: + """ + :return: always True since this connection implementation is asynchronous + """ + 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 + 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 - 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) + 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, 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): @@ -84,22 +111,21 @@ 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 class RS232ControlProtocol(asyncio.Protocol): - def __init__( - self, serial_port, config, connection_config, protocol_config, loop - ): + # noinspection PyShadowingNames + def __init__(self, serial_port, config, connection_config, loop): super().__init__() self._url = serial_port @@ -146,12 +172,12 @@ 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): - 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() @@ -159,7 +185,7 @@ async def write_rate_limited(data: 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: @@ -172,7 +198,7 @@ async def receive_response(self, request): data += await asyncio.wait_for(self._q.get(), self._timeout) return data - except asyncio.TimeoutError: + 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(): @@ -186,7 +212,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..63f2263 100644 --- a/pyavcontrol/connection/sync_connection.py +++ b/pyavcontrol/connection/sync_connection.py @@ -30,72 +30,78 @@ 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__() - self._url = url - self._config = config self._connection_config = connection_config self._encoding = connection_config.get(CONFIG.encoding, DEFAULT_ENCODING) - self._eol = config.get(CONFIG.response_eol, DEFAULT_EOL).encode(self._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 = self._config.get( + 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( - 'clear_before_new_commands', False + CONFIG.clear_before_new_commands, True ) 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 self._url 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=False): + def send(self, data: bytes, callback=None, wait_for_response: bool = False): """ - :param data: request that is sent to the device - :param skip: number of bytes to skip for end of transmission decoding - :return: string returned by device + :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 - 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 - def handle_receive(self) -> str: + def handle_receive(self) -> bytes: skip = 0 len_eol = len(self._eol) @@ -106,7 +112,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) @@ -121,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 diff --git a/pyavcontrol/const.py b/pyavcontrol/const.py index 2bb1b92..05f0205 100644 --- a/pyavcontrol/const.py +++ b/pyavcontrol/const.py @@ -2,14 +2,22 @@ import os -DEFAULT_ENCODING = "ascii" -DEFAULT_EOL = "\r\n" +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/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/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 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 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 index 52c4400..f32ad5a 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 +info: + manufacturer: Lyngdorf + models: + - MP-60 + tested: false connection: ip: @@ -19,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: 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/meridian_541.yaml b/pyavcontrol/data/future/meridian_541.yaml new file mode 100644 index 0000000..80d62ae --- /dev/null +++ b/pyavcontrol/data/future/meridian_541.yaml @@ -0,0 +1,73 @@ +--- +id: meridian_g98 + +# NOTE: None of the state subscriptions have been defined (SUBSCRIBETRACK. etc) + +info: + manufacturer: Meridian + models: + - 541 + tested: false + urls: + - https://www.meridian-audio.com/download/AppNotes/232_541.pdf + +connection: + rs232: + baudrate: 9600 + bytesize: 8 + parity: N + stopbits: 1 + timeout: 1.0 + +protocol: + command_eol: "\r\n" # CR/LF + +api: + power: + description: Power controls + actions: + on: + description: Turn on + cmd: + fstring: 'DI' # hack: show display + off: + description: Turn off (standby) + cmd: + fstring: 'SB' + + playback: + actions: + next: + description: Next track + cmd: + fstring: 'NE' + play: + description: Play + cmd: + fstring: 'PP' + pause: + description: Pause + cmd: + fstring: 'PS' + stop: + description: Stop + cmd: + fstring: 'ST' + previous: + description: Previous track + cmd: + fstring: 'PR' + rewind: + description: Start scanning backwards + cmd: + fstring: 'FB' + wind: + description: Start scanning forwards + cmd: + fstring: 'FF' + state: + description: Current state of playback + cmd: + fstring: '' + msg: + regex: '123456789012' diff --git a/pyavcontrol/data/future/meridian_g98.yaml b/pyavcontrol/data/future/meridian_g98.yaml new file mode 100644 index 0000000..bb3bc06 --- /dev/null +++ b/pyavcontrol/data/future/meridian_g98.yaml @@ -0,0 +1,243 @@ +--- +id: meridian_g98 + +# NOTE: None of the state subscriptions have been defined (SUBSCRIBETRACK. etc) + +info: + manufacturer: Meridian + models: + - G98 + tested: false + urls: + - + +connection: + rs232: + baudrate: 9600 + bytesize: 8 + parity: N + stopbits: 1 + timeout: 1.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