pax_global_header00006660000000000000000000000064147500546620014523gustar00rootroot0000000000000052 comment=3636fa8d7e6977c4e918abf7d68ca25c2ed91920 pypck-0.8.5/000077500000000000000000000000001475005466200126635ustar00rootroot00000000000000pypck-0.8.5/.github/000077500000000000000000000000001475005466200142235ustar00rootroot00000000000000pypck-0.8.5/.github/workflows/000077500000000000000000000000001475005466200162605ustar00rootroot00000000000000pypck-0.8.5/.github/workflows/ReleaseActions.yaml000066400000000000000000000014601475005466200220460ustar00rootroot00000000000000name: "Release actions" on: release: types: ["published"] env: PYTHON_VERSION: "3.x" jobs: deploy: runs-on: ubuntu-latest name: Deploy to PyPi steps: - uses: actions/checkout@v4 with: submodules: recursive - name: Set up Python uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.PYTHON_VERSION }} - name: "Set version number from tag" run: | echo -n '${{ github.ref_name }}' > ./VERSION cat ./VERSION - name: Install dependencies run: python -m pip install build twine - name: Publish to PyPi env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} run: | python -m build twine upload dist/* pypck-0.8.5/.github/workflows/ci.yaml000066400000000000000000000204261475005466200175430ustar00rootroot00000000000000name: CI on: push: branches: - dev - master pull_request: ~ env: CACHE_VERSION: 1 DEFAULT_PYTHON: "3.13" PRE_COMMIT_HOME: ~/.cache/pre-commit jobs: prepare-tests: name: Prepare tests for Python ${{ matrix.python-version }} runs-on: ubuntu-latest strategy: matrix: python-version: ['3.11', '3.12', '3.13'] steps: - name: Check out code from GitHub uses: actions/checkout@v4.1.7 - name: Set up Python ${{ matrix.python-version }} virtual environment uses: actions/setup-python@v5.1.1 id: python with: python-version: ${{ matrix.python-version }} - name: Restore Python ${{ matrix.python-version }} virtual environment id: cache-venv uses: actions/cache@v4.0.2 with: path: venv key: >- ${{ env.CACHE_VERSION}}-${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements_test*.txt') }} restore-keys: | ${{ env.CACHE_VERSION}}-${{ runner.os }}-venv-${{ matrix.python-version }}-${{ hashFiles('requirements_test*.txt') }} - name: Create Python ${{ matrix.python-version }} virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | python -m venv venv . venv/bin/activate pip install -U pip setuptools pip install -r requirements_test.txt - name: Restore pre-commit environment from cache id: cache-precommit uses: actions/cache@v4.0.2 with: path: ${{ env.PRE_COMMIT_HOME }} key: | ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} restore-keys: | ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit - name: Install pre-commit dependencies if: steps.cache-precommit.outputs.cache-hit != 'true' run: | . venv/bin/activate pre-commit install-hooks lint-ruff-format: name: Check ruff-format runs-on: ubuntu-latest needs: prepare-tests steps: - name: Check out code from GitHub uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv uses: actions/cache@v4.0.2 with: path: venv key: >- ${{ env.CACHE_VERSION }}-${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements_test*.txt') }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | echo "Failed to restore Python virtual environment from cache" exit 1 - name: Run ruff run: | . venv/bin/activate pre-commit run --hook-stage manual ruff --all-files --show-diff-on-failure env: RUFF_OUTPUT_FORMAT: github mypy: name: Check mypy runs-on: ubuntu-latest needs: prepare-tests steps: - name: Check out code from GitHub uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv uses: actions/cache@v4.0.2 with: path: venv key: >- ${{ env.CACHE_VERSION }}-${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements_test*.txt') }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | echo "Failed to restore Python virtual environment from cache" exit 1 - name: Run mypy run: | . venv/bin/activate mypy --strict pypck lint-codespell: name: Check codespell runs-on: ubuntu-latest needs: prepare-tests steps: - name: Check out code from GitHub uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv uses: actions/cache@v4.0.2 with: path: venv key: >- ${{ env.CACHE_VERSION }}-${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements_test*.txt') }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | echo "Failed to restore Python virtual environment from cache" exit 1 - name: Restore pre-commit environment from cache id: cache-precommit uses: actions/cache@v4.0.2 with: path: ${{ env.PRE_COMMIT_HOME }} key: | ${{ env.CACHE_VERSION }}-${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} - name: Fail job if cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | echo "Failed to restore Python virtual environment from cache" exit 1 - name: Run codespell run: | . venv/bin/activate pre-commit run codespell --all-files --show-diff-on-failure pytest: runs-on: ubuntu-latest needs: prepare-tests strategy: matrix: python-version: ['3.11', '3.12', '3.13'] name: Run tests Python ${{ matrix.python-version }} steps: - name: Check out code from GitHub uses: actions/checkout@v4.1.7 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5.1.1 id: python with: python-version: ${{ matrix.python-version }} - name: Restore Python virtual environment id: cache-venv uses: actions/cache@v4.0.2 with: path: venv key: >- ${{ env.CACHE_VERSION }}-${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements_test*.txt') }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | echo "Failed to restore Python virtual environment from cache" exit 1 - name: Run pytest run: | . venv/bin/activate pytest \ --timeout=9 \ --durations=10 \ --cov pypck \ tests - name: Upload coverage artifacts uses: actions/upload-artifact@v4.3.6 with: name: coverage-${{ matrix.python-version }} path: .coverage coverage: name: Process test coverage runs-on: ubuntu-latest needs: pytest steps: - name: Check out code from GitHub uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv uses: actions/cache@v4.0.2 with: path: venv key: >- ${{ env.CACHE_VERSION }}-${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements_test*.txt') }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | echo "Failed to restore Python virtual environment from cache" exit 1 - name: Download coverage artifacts uses: actions/download-artifact@v4.1.8 - name: Combine coverage results run: | . venv/bin/activate coverage combine coverage*/.coverage* coverage report --fail-under=70 coverage xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v4.5.0 pypck-0.8.5/.gitignore000066400000000000000000000006211475005466200146520ustar00rootroot00000000000000# Packages *.egg *.egg-info dist build eggs .eggsparts bin var sdist develop-ags .installed.cfg lib lib64 # Python specific *.pyc /__pycache__ /venv # pytest .pytest_cache .cache # Development .settings .project .pydevproject # Visual Studio Code .vscode # Testing .tox .coverage # Build docs docs/build # Windows Explorer desktop.ini # Patches *.patch # mypy /.mypy_cache/* # other sandbox pypck-0.8.5/.pre-commit-config.yaml000066400000000000000000000017711475005466200171520ustar00rootroot00000000000000repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.8.3 hooks: - id: ruff args: - --fix - id: ruff-format files: ^((pypck|tests)/.+)?[^/]+\.(py|pyi)$ - repo: https://github.com/codespell-project/codespell rev: v2.3.0 hooks: - id: codespell args: - --ignore-words-list=authentification,SHS - --quiet-level=2 - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.13.0 hooks: - id: mypy name: mypy pypck args: - --strict files: ^(pypck)/.+\.py$ - id: mypy name: mypy tests args: - --strict - --allow-untyped-defs files: ^(tests)/.+\.py$ # Need to enumerate the deps with type hints since requirements.txt is # not read by pre-commit additional_dependencies: - pytest==8.3.4 - pytest-cov==6.0.0 - pytest-timeout==2.3.1 - pytest-asyncio==0.25.0 pypck-0.8.5/LICENSE000066400000000000000000000017771475005466200137040ustar00rootroot00000000000000Permission 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. pypck-0.8.5/README.md000066400000000000000000000113751475005466200141510ustar00rootroot00000000000000# pypck - Asynchronous LCN-PCK library written in Python ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/alengwenus/pypck?color=success) ![GitHub Workflow Status (dev branch)](https://github.com/alengwenus/pypck/actions/workflows/ci.yaml/badge.svg?branch=dev) ![Codecov branch](https://img.shields.io/codecov/c/github/alengwenus/pypck/dev) [![PyPI - Downloads](https://img.shields.io/pypi/dm/pypck)](https://pypi.org/project/pypck/) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) Buy Me A Coffee ## Overview **pypck** is an open source library written in Python which allows the connection to the [LCN (local control network) system](https://www.lcn.eu). It uses the vendor protocol LCN-PCK. To get started an unused license of the coupling software LCN-PCHK and a hardware coupler is necessary. **pypck** is used by the LCN integration of the [Home Assistant](https://home-assistant.io/) project. ## Example ```python """Example for switching an output port of module 10 on and off.""" import asyncio from pypck.connection import PchkConnectionManager from pypck.lcn_addr import LcnAddr async def main(): """Connect to PCK host, get module object and switch output port on and off.""" async with PchkConnectionManager( "192.168.2.41", 4114, username="lcn", password="lcn", settings={"SK_NUM_TRIES": 0}, ) as pck_client: module = pck_client.get_address_conn(LcnAddr(0, 10, False)) await module.dim_output(0, 100, 0) await asyncio.sleep(1) await module.dim_output(0, 0, 0) asyncio.run(main()) ``` ## pypck REPL in ipython **pypck** relies heavily on asyncio for talking to the LCN-PCHK software. This makes it unusable with the standard python interactive interpreter. Fortunately, ipython provides some support for asyncio in its interactive interpreter, see [ipython autoawait](https://ipython.readthedocs.io/en/stable/interactive/autoawait.html#). ### Requirements - **ipython** at least version 7.0 (autoawait support) - **pypck** ### Example session ``` Python 3.8.3 (default, Jun 9 2020, 17:39:39) Type 'copyright', 'credits' or 'license' for more information IPython 7.19.0 -- An enhanced Interactive Python. Type '?' for help. In [1]: from pypck.connection import PchkConnectionManager ...: from pypck.lcn_addr import LcnAddr ...: import asyncio In [2]: connection = PchkConnectionManager(host='localhost', port=4114, username='lcn', password='lcn') In [3]: await connection.async_connect() In [4]: module = connection.get_address_conn(LcnAddr(seg_id=0, addr_id=10, is_group=False), request_serials=False) In [5]: await module.request_serials() Out[5]: {'hardware_serial': 127977263668, 'manu': 1, 'software_serial': 1771023, 'hardware_type': } In [6]: await module.dim_output(0, 100, 0) ...: await asyncio.sleep(1) ...: await module.dim_output(0, 0, 0) Out[6]: True ``` ### Caveats ipython starts and stops the asyncio event loop for each toplevel command sequence. Also it only starts the loop if the toplevel commands includes async code (like await or a call to an async function). This can lead to unexpected behavior. For example, background tasks run only while ipython is executing toplevel commands that started the event loop. Functions that use the event loop only internally may fail, e.g. the following would fail: ``` In [4]: module = connection.get_address_conn(LcnAddr(seg_id=0, addr_id=10, is_group=False), request_serials=True) --------------------------------------------------------------------------- RuntimeError Traceback (most recent call last) in ----> 1 module = connection.get_address_conn(modaddr) /pypck/connection.py in get_address_conn(self, addr, request_serials) 457 address_conn = ModuleConnection(self, addr) 458 if request_serials: --> 459 self.request_serials_task = asyncio.create_task( 460 address_conn.request_serials() 461 ) /usr/local/lib/python3.8/asyncio/tasks.py in create_task(coro, name) 379 Return a Task object. 380 """ --> 381 loop = events.get_running_loop() 382 task = loop.create_task(coro) 383 _set_task_name(task, name) RuntimeError: no running event loop ``` See [ipython autoawait internals](https://ipython.readthedocs.io/en/stable/interactive/autoawait.html#internals) for details. pypck-0.8.5/VERSION000066400000000000000000000000071475005466200137300ustar00rootroot000000000000000.dev0 pypck-0.8.5/docs/000077500000000000000000000000001475005466200136135ustar00rootroot00000000000000pypck-0.8.5/docs/Makefile000066400000000000000000000011361475005466200152540ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build SPHINXPROJ = PyPCK 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)pypck-0.8.5/docs/make.bat000066400000000000000000000014111475005466200152150ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=source set BUILDDIR=build set SPHINXPROJ=PyPCK if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% :end popd pypck-0.8.5/docs/source/000077500000000000000000000000001475005466200151135ustar00rootroot00000000000000pypck-0.8.5/docs/source/api/000077500000000000000000000000001475005466200156645ustar00rootroot00000000000000pypck-0.8.5/docs/source/api/connection.rst000066400000000000000000000002551475005466200205570ustar00rootroot00000000000000:mod:`pypck.connection` ----------------------- .. automodule:: pypck.connection .. autoclass:: PchkConnection :members: .. autoclass:: PchkConnectionManager :members: pypck-0.8.5/docs/source/api/inputs.rst000066400000000000000000000001171475005466200177370ustar00rootroot00000000000000:mod:`pypck.input` ------------------ .. automodule:: pypck.inputs :members: pypck-0.8.5/docs/source/api/lcn_addr.rst000066400000000000000000000001571475005466200201670ustar00rootroot00000000000000:mod:`pypck.lcn_addr` --------------------- .. automodule:: pypck.lcn_addr .. autoclass:: LcnAddr :members: pypck-0.8.5/docs/source/api/lcn_defs.rst000066400000000000000000000001551475005466200201740ustar00rootroot00000000000000:mod:`pypck.lcn_defs` --------------------- .. automodule:: pypck.lcn_defs :members: :inherited-members: pypck-0.8.5/docs/source/api/module.rst000066400000000000000000000001201475005466200176740ustar00rootroot00000000000000:mod:`pypck.module` ------------------- .. automodule:: pypck.module :members:pypck-0.8.5/docs/source/api/pck_commands.rst000066400000000000000000000001421475005466200210510ustar00rootroot00000000000000:mod:`pypck.pck_commands` ------------------------- .. automodule:: pypck.pck_commands :members:pypck-0.8.5/docs/source/api/timeout_retry.rst000066400000000000000000000001451475005466200213310ustar00rootroot00000000000000:mod:`pypck.timeout_retry` -------------------------- .. automodule:: pypck.timeout_retry :members:pypck-0.8.5/docs/source/conf.py000066400000000000000000000116171475005466200164200ustar00rootroot00000000000000# # Configuration file for the Sphinx documentation builder. # # This file does only contain a selection of the most common options. For a # full list see the documentation: # http://www.sphinx-doc.org/en/master/config # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # import os import sys sys.path.insert(0, os.path.abspath("../../")) # -- Project information ----------------------------------------------------- project = "PyPCK" copyright = "2018, Andre Lengwenus" author = "Andre Lengwenus" # The short X.Y version version = "" # The full version, including alpha/beta/rc tags release = "" # -- General configuration --------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = ["sphinx.ext.autodoc"] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = ".rst" # The master toctree document. master_doc = "index" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path . exclude_patterns = [] # The name of the Pygments (syntax highlighting) style to use. pygments_style = "default" # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "nature" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # # html_theme_options = {} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". # html_static_path = ['_static'] # Custom sidebar templates, must be a dictionary that maps document names # to template names. # # This is required for the alabaster theme # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars html_sidebars = { "**": [ "relations.html", # needs 'show_related': True theme option to display "searchbox.html", ] } # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. htmlhelp_basename = "PyPCKdoc" # -- Options for LaTeX output ------------------------------------------------ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, "PyPCK.tex", "PyPCK Documentation", "Andre Lengwenus", "manual"), ] # -- Options for manual page output ------------------------------------------ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [(master_doc, "pypck", "PyPCK Documentation", [author], 1)] # -- Options for Texinfo output ---------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( master_doc, "PyPCK", "PyPCK Documentation", author, "PyPCK", "One line description of project.", "Miscellaneous", ), ] # -- Extension configuration ------------------------------------------------- # -- Options for intersphinx extension --------------------------------------- # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = {"https://docs.python.org/": None} pypck-0.8.5/docs/source/index.rst000066400000000000000000000007031475005466200167540ustar00rootroot00000000000000.. PyPCK documentation master file, created by sphinx-quickstart on Fri Oct 12 08:58:34 2018. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. PyPCK API documentation ======================= API documentation for PyPCK. Contents: .. toctree:: :maxdepth: 2 :glob: api/* Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` pypck-0.8.5/pypck/000077500000000000000000000000001475005466200140115ustar00rootroot00000000000000pypck-0.8.5/pypck/__init__.py000066400000000000000000000004751475005466200161300ustar00rootroot00000000000000"""Init file for pypck.""" from pypck import ( connection, helpers, inputs, lcn_addr, lcn_defs, module, pck_commands, timeout_retry, ) __all__ = [ "connection", "inputs", "helpers", "lcn_addr", "lcn_defs", "module", "pck_commands", "timeout_retry", ] pypck-0.8.5/pypck/connection.py000066400000000000000000000575521475005466200165400ustar00rootroot00000000000000"""Connection classes for pypck.""" from __future__ import annotations import asyncio import logging import time from collections.abc import Callable, Iterable from types import TracebackType from typing import Any from pypck import inputs, lcn_defs from pypck.helpers import TaskRegistry from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import LcnEvent from pypck.module import AbstractConnection, GroupConnection, ModuleConnection from pypck.pck_commands import PckGenerator _LOGGER = logging.getLogger(__name__) class PchkLicenseError(Exception): """Exception which is raised if a license error occurred.""" def __init__(self, message: str | None = None): """Initialize instance.""" if message is None: message = ( "License Error: Maximum number of connections was reached. An " "additional license key is required." ) super().__init__(message) class PchkAuthenticationError(Exception): """Exception which is raised if authentication failed.""" def __init__(self, message: str | None = None): """Initialize instance.""" if message is None: message = "Authentication failed" super().__init__(message) class PchkConnectionRefusedError(Exception): """Exception which is raised if connection was refused.""" def __init__(self, message: str | None = None): """Initialize instance.""" if message is None: message = "Connection refused" super().__init__(message) class PchkConnectionFailedError(Exception): """Exception which is raised if connection was refused.""" def __init__(self, message: str | None = None): """Initialize instance.""" if message is None: message = "Connection failed" super().__init__(message) class PchkLcnNotConnectedError(Exception): """Exception which is raised if there is no connection to the LCN bus.""" def __init__(self, message: str | None = None): """Initialize instance.""" if message is None: message = "LCN not connected." super().__init__(message) class PchkConnectionManager: """Connection to LCN-PCHK.""" last_ping: float ping_timeout_handle: asyncio.TimerHandle | None authentication_completed_future: asyncio.Future[bool] license_error_future: asyncio.Future[bool] def __init__( self, host: str, port: int, username: str, password: str, settings: dict[str, Any] | None = None, connection_id: str = "PCHK", ) -> None: """Construct PchkConnectionManager.""" self.task_registry = TaskRegistry() self.host = host self.port = port self.connection_id = connection_id self.reader: asyncio.StreamReader | None = None self.writer: asyncio.StreamWriter | None = None self.buffer: asyncio.Queue[bytes] = asyncio.Queue() self.last_bus_activity = time.time() self.username = username self.password = password # Settings if settings is None: settings = {} self.settings = lcn_defs.default_connection_settings self.settings.update(settings) self.idle_time = self.settings["BUS_IDLE_TIME"] self.ping_send_delay = self.settings["PING_SEND_DELAY"] self.ping_recv_timeout = self.settings["PING_RECV_TIMEOUT"] self.ping_timeout_handle = None self.ping_counter = 0 self.dim_mode = self.settings["DIM_MODE"] self.status_mode = lcn_defs.OutputPortStatusMode.PERCENT self.is_lcn_connected = True self.local_seg_id = 0 # Events, Futures, Locks for synchronization self.segment_scan_completed_event = asyncio.Event() self.authentication_completed_future = asyncio.Future() self.license_error_future = asyncio.Future() self.module_serial_number_received = asyncio.Lock() self.segment_coupler_response_received = asyncio.Lock() # All modules from or to a communication occurs are represented by a # unique ModuleConnection object. All ModuleConnection objects are # stored in this dictionary. Communication to groups is handled by # GroupConnection object that are created on the fly and not stored # permanently. self.address_conns: dict[LcnAddr, ModuleConnection] = {} self.segment_coupler_ids: list[int] = [] self.input_callbacks: set[Callable[[inputs.Input], None]] = set() self.event_callbacks: set[Callable[[LcnEvent], None]] = set() self.register_for_events(self.event_callback) # Socket read/write async def read_data_loop(self) -> None: """Processes incoming data.""" assert self.reader is not None assert self.writer is not None _LOGGER.debug("Read data loop started") try: while not self.writer.is_closing(): try: data = await self.reader.readuntil( PckGenerator.TERMINATION.encode() ) self.last_bus_activity = time.time() except ( asyncio.IncompleteReadError, TimeoutError, OSError, ): _LOGGER.debug("Connection to %s lost", self.connection_id) self.fire_event(LcnEvent.CONNECTION_LOST) await self.async_close() break try: message = data.decode("utf-8").split(PckGenerator.TERMINATION)[0] except UnicodeDecodeError as err: try: message = data.decode("cp1250").split(PckGenerator.TERMINATION)[ 0 ] _LOGGER.warning( "Incorrect PCK encoding detected, possibly caused by LinHK: %s - PCK recovered using cp1250", err, ) except UnicodeDecodeError as err2: _LOGGER.warning( "PCK decoding error: %s - skipping received PCK message", err2, ) continue await self.process_message(message) finally: _LOGGER.debug("Read data loop closed") async def write_data_loop(self) -> None: """Processes queue and writes data.""" assert self.writer is not None try: _LOGGER.debug("Write data loop started") while not self.writer.is_closing(): data = await self.buffer.get() while (time.time() - self.last_bus_activity) < self.idle_time: await asyncio.sleep(self.idle_time) _LOGGER.debug( "to %s: %s", self.connection_id, data.decode().rstrip(PckGenerator.TERMINATION), ) self.writer.write(data) await self.writer.drain() self.last_bus_activity = time.time() finally: # empty the queue while not self.buffer.empty(): await self.buffer.get() _LOGGER.debug("Write data loop closed") # Open/close connection, authentication & setup. async def async_connect(self, timeout: float = 30) -> None: """Establish a connection to PCHK at the given socket.""" self.authentication_completed_future = asyncio.Future() self.license_error_future = asyncio.Future() _LOGGER.debug( "Starting connection attempt to %s server at %s:%d", self.connection_id, self.host, self.port, ) done: Iterable[asyncio.Future[Any]] pending: Iterable[asyncio.Future[Any]] done, pending = await asyncio.wait( ( asyncio.create_task(self.open_connection()), self.license_error_future, self.authentication_completed_future, ), timeout=timeout, return_when=asyncio.FIRST_EXCEPTION, ) # Raise any exception which occurs # (ConnectionRefusedError, PchkAuthenticationError, PchkLicenseError) for awaitable in done: if not awaitable.cancelled(): if exc := awaitable.exception(): await self.async_close() if isinstance(exc, (ConnectionRefusedError, OSError)): raise PchkConnectionRefusedError() else: raise awaitable.exception() # type: ignore if pending: for awaitable in pending: awaitable.cancel() await self.async_close() raise PchkConnectionFailedError() if not self.is_lcn_connected: raise PchkLcnNotConnectedError() # start segment scan await self.scan_segment_couplers( self.settings["SK_NUM_TRIES"], self.settings["DEFAULT_TIMEOUT"] ) async def open_connection(self) -> None: """Connect to PCHK server (no authentication or license error check).""" self.reader, self.writer = await asyncio.open_connection(self.host, self.port) address = self.writer.get_extra_info("peername") _LOGGER.debug("%s server connected at %s:%d", self.connection_id, *address) # main write loop self.task_registry.create_task(self.write_data_loop()) # main read loop self.task_registry.create_task(self.read_data_loop()) async def async_close(self) -> None: """Close the active connection.""" await self.cancel_requests() if self.ping_timeout_handle is not None: self.ping_timeout_handle.cancel() await self.task_registry.cancel_all_tasks() if self.writer: self.writer.close() try: await self.writer.wait_closed() except OSError: # occurs when TCP connection is lost pass _LOGGER.debug("Connection to %s closed.", self.connection_id) async def wait_closed(self) -> None: """Wait until connection to PCHK server is closed.""" if self.writer is not None: await self.writer.wait_closed() async def __aenter__(self) -> "PchkConnectionManager": """Context manager enter method.""" await self.async_connect() return self async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, exc_traceback: TracebackType | None, ) -> None: """Context manager exit method.""" await self.async_close() return None async def on_auth(self, success: bool) -> None: """Is called after successful authentication.""" if success: _LOGGER.debug("%s authorization successful!", self.connection_id) self.authentication_completed_future.set_result(True) # Try to set the PCHK decimal mode await self.send_command(PckGenerator.set_dec_mode(), to_host=True) else: _LOGGER.debug("%s authorization failed!", self.connection_id) self.authentication_completed_future.set_exception(PchkAuthenticationError) async def on_license_error(self) -> None: """Is called if a license error occurs during connection.""" _LOGGER.debug("%s: License Error.", self.connection_id) self.license_error_future.set_exception(PchkLicenseError()) async def on_successful_login(self) -> None: """Is called after connection to LCN bus system is established.""" _LOGGER.debug("%s login successful.", self.connection_id) await self.send_command( PckGenerator.set_operation_mode(self.dim_mode, self.status_mode), to_host=True, ) self.task_registry.create_task(self.ping()) async def lcn_connection_status_changed(self, is_lcn_connected: bool) -> None: """Set the current connection state to the LCN bus.""" self.is_lcn_connected = is_lcn_connected self.fire_event(LcnEvent.BUS_CONNECTION_STATUS_CHANGED) if is_lcn_connected: _LOGGER.debug("%s: LCN is connected.", self.connection_id) self.fire_event(LcnEvent.BUS_CONNECTED) else: _LOGGER.debug("%s: LCN is not connected.", self.connection_id) self.fire_event(LcnEvent.BUS_DISCONNECTED) async def ping_received(self, count: int | None) -> None: """Ping was received.""" if self.ping_timeout_handle is not None: self.ping_timeout_handle.cancel() self.last_ping = time.time() def is_ready(self) -> bool: """Retrieve the overall connection state.""" return self.segment_scan_completed_event.is_set() # Addresses, modules and groups def set_local_seg_id(self, local_seg_id: int) -> None: """Set the local segment id.""" old_local_seg_id = self.local_seg_id self.local_seg_id = local_seg_id # replace all address_conns with current local_seg_id with new # local_seg_id for addr in list(self.address_conns): if addr.seg_id == old_local_seg_id: address_conn = self.address_conns.pop(addr) address_conn.addr = LcnAddr( self.local_seg_id, addr.addr_id, addr.is_group ) self.address_conns[address_conn.addr] = address_conn def physical_to_logical(self, addr: LcnAddr) -> LcnAddr: """Convert the physical segment id of an address to the logical one.""" return LcnAddr( self.local_seg_id if addr.seg_id in (0, 4) else addr.seg_id, addr.addr_id, addr.is_group, ) def get_module_conn( self, addr: LcnAddr, request_serials: bool = True ) -> ModuleConnection: """Create and/or return the given LCN module.""" assert not addr.is_group if addr.seg_id == 0 and self.local_seg_id != -1: addr = LcnAddr(self.local_seg_id, addr.addr_id, addr.is_group) address_conn = self.address_conns.get(addr, None) if address_conn is None: address_conn = ModuleConnection( self, addr, wants_ack=self.settings["ACKNOWLEDGE"] ) if request_serials: self.task_registry.create_task(address_conn.request_serials()) self.address_conns[addr] = address_conn return address_conn def get_group_conn(self, addr: LcnAddr) -> GroupConnection: """Create and return the GroupConnection for the given group.""" assert addr.is_group if addr.seg_id == 0 and self.local_seg_id != -1: addr = LcnAddr(self.local_seg_id, addr.addr_id, addr.is_group) return GroupConnection(self, addr) def get_address_conn( self, addr: LcnAddr, request_serials: bool = True ) -> AbstractConnection: """Create and/or return an AbstractConnection to the given module or group.""" if addr.is_group: return self.get_group_conn(addr) return self.get_module_conn(addr, request_serials) # Other def dump_modules(self) -> dict[str, dict[str, dict[str, Any]]]: """Dump all modules and information about them in a JSON serializable dict.""" dump: dict[str, dict[str, dict[str, Any]]] = {} for address_conn in self.address_conns.values(): seg = f"{address_conn.addr.seg_id:d}" addr = f"{address_conn.addr.addr_id}" if seg not in dump: dump[seg] = {} dump[seg][addr] = address_conn.dump_details() return dump # Command sending / retrieval. async def send_command( self, pck: bytes | str, to_host: bool = False, **kwargs: Any ) -> bool: """Send a PCK command to the PCHK server.""" if not self.is_lcn_connected and not to_host: return False assert self.writer is not None if not self.writer.is_closing(): if isinstance(pck, str): data = (pck + PckGenerator.TERMINATION).encode() else: data = pck + PckGenerator.TERMINATION.encode() await self.buffer.put(data) return True return False async def process_message(self, message: str) -> None: """Is called when a new text message is received from the PCHK server.""" _LOGGER.debug("from %s: %s", self.connection_id, message) inps = inputs.InputParser.parse(message) if inps is not None: for inp in inps: await self.async_process_input(inp) async def async_process_input(self, inp: inputs.Input) -> None: """Process an input command.""" # Inputs from Host if isinstance(inp, inputs.AuthUsername): await self.send_command(self.username, to_host=True) elif isinstance(inp, inputs.AuthPassword): await self.send_command(self.password, to_host=True) elif isinstance(inp, inputs.AuthOk): await self.on_auth(True) elif isinstance(inp, inputs.AuthFailed): await self.on_auth(False) elif isinstance(inp, inputs.LcnConnState): await self.lcn_connection_status_changed(inp.is_lcn_connected) elif isinstance(inp, inputs.LicenseError): await self.on_license_error() elif isinstance(inp, inputs.DecModeSet): self.license_error_future.set_result(True) await self.on_successful_login() elif isinstance(inp, inputs.CommandError): _LOGGER.debug("LCN command error: %s", inp.message) elif isinstance(inp, inputs.Ping): await self.ping_received(inp.count) elif isinstance(inp, inputs.ModSk): if inp.physical_source_addr.seg_id == 0: self.set_local_seg_id(inp.reported_seg_id) if self.segment_coupler_response_received.locked(): self.segment_coupler_response_received.release() # store reported segment coupler id if inp.reported_seg_id not in self.segment_coupler_ids: self.segment_coupler_ids.append(inp.reported_seg_id) elif isinstance(inp, inputs.Unknown): return # Inputs from bus elif self.is_ready(): if isinstance(inp, inputs.ModInput): logical_source_addr = self.physical_to_logical(inp.physical_source_addr) if not logical_source_addr.is_group: module_conn = self.get_module_conn(logical_source_addr) if isinstance(inp, inputs.ModSn): # used to extend scan_modules() timeout if self.module_serial_number_received.locked(): self.module_serial_number_received.release() await module_conn.async_process_input(inp) # Forward all known inputs to callback listeners. for input_callback in self.input_callbacks: input_callback(inp) async def ping(self) -> None: """Send pings.""" assert self.writer is not None while not self.writer.is_closing(): await self.send_command(f"^ping{self.ping_counter:d}", to_host=True) self.ping_timeout_handle = asyncio.get_running_loop().call_later( self.ping_recv_timeout, lambda: self.fire_event(LcnEvent.PING_TIMEOUT) ) self.ping_counter += 1 await asyncio.sleep(self.ping_send_delay) async def scan_modules(self, num_tries: int = 3, timeout: float = 3) -> None: """Scan for modules on the bus. This is a convenience coroutine which handles all the logic when scanning modules on the bus. Because of heavy bus traffic, not all modules might respond to a scan command immediately. The coroutine will make 'num_tries' attempts to send a scan command and waits 'timeout' after the last module response before proceeding to the next try. """ segment_coupler_ids = ( self.segment_coupler_ids if self.segment_coupler_ids else [0] ) for _ in range(num_tries): for segment_id in segment_coupler_ids: if segment_id == self.local_seg_id: segment_id = 0 await self.send_command( PckGenerator.generate_address_header( LcnAddr(segment_id, 3, True), self.local_seg_id, True ) + PckGenerator.empty() ) # Wait loop which is extended on every serial number received while True: try: await asyncio.wait_for( self.module_serial_number_received.acquire(), timeout, ) except asyncio.TimeoutError: break async def scan_segment_couplers( self, num_tries: int = 3, timeout: float = 1.5 ) -> None: """Scan for segment couplers on the bus. This is a convenience coroutine which handles all the logic when scanning segment couplers on the bus. Because of heavy bus traffic, not all segment couplers might respond to a scan command immediately. The coroutine will make 'num_tries' attempts to send a scan command and waits 'timeout' after the last segment coupler response before proceeding to the next try. """ for _ in range(num_tries): await self.send_command( PckGenerator.generate_address_header( LcnAddr(3, 3, True), self.local_seg_id, False ) + PckGenerator.segment_coupler_scan() ) # Wait loop which is extended on every segment coupler response while True: try: await asyncio.wait_for( self.segment_coupler_response_received.acquire(), timeout, ) except asyncio.TimeoutError: break # No segment coupler expected (num_tries=0) if len(self.segment_coupler_ids) == 0: _LOGGER.debug("%s: No segment coupler found.", self.connection_id) self.segment_scan_completed_event.set() # Status requests, responses async def cancel_requests(self) -> None: """Cancel all TimeoutRetryHandlers.""" cancel_tasks = [ asyncio.create_task(address_conn.cancel_requests()) for address_conn in self.address_conns.values() if isinstance(address_conn, ModuleConnection) ] if cancel_tasks: await asyncio.wait(cancel_tasks) # Callbacks for inputs and events def register_for_inputs( self, callback: Callable[[inputs.Input], None] ) -> Callable[..., None]: """Register a function for callback on PCK message received. Returns a function to unregister the callback. """ self.input_callbacks.add(callback) return lambda callback=callback: self.input_callbacks.remove(callback) def fire_event(self, event: LcnEvent) -> None: """Fire event.""" for event_callback in self.event_callbacks: event_callback(event) def register_for_events( self, callback: Callable[[lcn_defs.LcnEvent], None] ) -> Callable[..., None]: """Register a function for callback on LCN events. Return a function to unregister the callback. """ self.event_callbacks.add(callback) return lambda callback=callback: self.event_callbacks.remove(callback) def event_callback(self, event: LcnEvent) -> None: """Handle events from PchkConnection.""" _LOGGER.debug("%s: LCN-Event: %s", self.connection_id, event) pypck-0.8.5/pypck/helpers.py000066400000000000000000000023631475005466200160310ustar00rootroot00000000000000"""Helper functions for pypck.""" import asyncio from collections.abc import Awaitable from typing import Any async def cancel_task(task: "asyncio.Task[Any]") -> bool: """Cancel a task. Wait for cancellation completed but do not propagate a possible CancelledError. """ success = task.cancel() try: await task except asyncio.CancelledError: pass return success # was not already done class TaskRegistry: """Keep track of running tasks.""" def __init__(self) -> None: """Init task registry instance.""" self.tasks: list["asyncio.Task[Any]"] = [] def remove_task(self, task: "asyncio.Task[None]") -> None: """Remove a task from the task registry.""" if task in self.tasks: self.tasks.remove(task) def create_task(self, coro: Awaitable[Any]) -> "asyncio.Task[None]": """Create a task and store a reference in the task registry.""" task = asyncio.create_task(coro) # type: ignore task.add_done_callback(self.remove_task) self.tasks.append(task) return task async def cancel_all_tasks(self) -> None: """Cancel all pypck tasks.""" while self.tasks: await cancel_task(self.tasks.pop()) pypck-0.8.5/pypck/inputs.py000066400000000000000000001172271475005466200157170ustar00rootroot00000000000000"""Parsers for incoming PCK messages.""" from __future__ import annotations import logging from pypck import lcn_defs from pypck.lcn_addr import LcnAddr from pypck.pck_commands import PckParser _LOGGER = logging.getLogger(__name__) class Input: """Parent class for all input data read from LCN-PCHK. An implementation of :class:`~pypck.input.Input` has to provide easy accessible attributes and/or methods to expose the PCK command properties to the user. Each Input object provides an implementation of :func:`~pypck.input.Input.try_parse` static method, to parse the given plain text PCK command. If the command can be parsed by the Input object, a list of instances of :class:`~pypck.input.Input` is returned. Otherwise, nothing is returned. """ def __init__(self) -> None: """Construct Input object.""" @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). """ raise NotImplementedError class ModInput(Input): # pylint: disable=too-few-public-methods, abstract-method """Parent class of all inputs having an LCN module as its source. The class in inherited from :class:`~pypck.input.Input` """ def __init__(self, physical_source_addr: LcnAddr): """Construct ModInput object.""" super().__init__() self.physical_source_addr = physical_source_addr # ## Plain text inputs class AuthUsername(Input): """Authentication username message received from PCHK.""" @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs :rtype: List with instances of :class:`~pypck.input.Input` """ if data == PckParser.AUTH_USERNAME: return [AuthUsername()] return None class AuthPassword(Input): """Authentication password message received from PCHK.""" @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs :rtype: List with instances of :class:`~pypck.input.Input` """ if data == PckParser.AUTH_PASSWORD: return [AuthPassword()] return None class AuthOk(Input): """Authentication ok message received from PCHK.""" @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ if data == PckParser.AUTH_OK: return [AuthOk()] return None class AuthFailed(Input): """Authentication failed message received from PCHK.""" @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ if data == PckParser.AUTH_FAILED: return [AuthFailed()] return None class LcnConnState(Input): """LCN bus connected message received from PCHK.""" def __init__(self, is_lcn_connected: bool): """Construct Input object.""" super().__init__() self._is_lcn_connected = is_lcn_connected @property def is_lcn_connected(self) -> bool: """Return the LCN bus connection status. :return: True if connection to hardware bus was established, otherwise False. :rtype: bool """ return self._is_lcn_connected @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ if data == PckParser.LCNCONNSTATE_CONNECTED: return [LcnConnState(True)] if data == PckParser.LCNCONNSTATE_DISCONNECTED: return [LcnConnState(False)] return None class LicenseError(Input): """LCN bus connected message received from PCHK.""" @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ if data == PckParser.LICENSE_ERROR: return [LicenseError()] return None class DecModeSet(Input): """Decimal mode set received from PCHK.""" @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ if data == PckParser.DEC_MODE_SET: return [DecModeSet()] return None class CommandError(Input): """Command error received from PCHK.""" def __init__(self, message: str): """Construct Input object.""" super().__init__() self.message = message @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ matcher = PckParser.PATTERN_COMMAND_ERROR.match(data) if matcher: return [CommandError(matcher.group("message"))] return None class Ping(Input): """Ping message received from PCHK.""" def __init__(self, count: int | None): """Construct Input object.""" super().__init__() self._count = count @property def count(self) -> int | None: """Return the ping count. :return: Ping count :rtype: int | None """ return self._count @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ matcher = PckParser.PATTERN_PING.match(data) if matcher: count = matcher.group("count") if count == "": return [Ping(None)] return [Ping(int(count))] return None # ## Inputs received from modules class ModAck(ModInput): """Acknowledge message received from module.""" def __init__(self, physical_source_addr: LcnAddr, code: int): """Construct ModInput object.""" super().__init__(physical_source_addr) self.code = code def get_code(self) -> int: """Return the acknowledge code. :return: Acknowledge code. :rtype: int """ return self.code @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ matcher_pos = PckParser.PATTERN_ACK_POS.match(data) if matcher_pos: addr = LcnAddr( int(matcher_pos.group("seg_id")), int(matcher_pos.group("mod_id")) ) return [ModAck(addr, -1)] matcher_neg = PckParser.PATTERN_ACK_NEG.match(data) if matcher_neg: addr = LcnAddr( int(matcher_neg.group("seg_id")), int(matcher_neg.group("mod_id")) ) return [ModAck(addr, int(matcher_neg.group("code")))] return None class ModSk(ModInput): """Segment information received from an LCN segment coupler.""" def __init__(self, physical_source_addr: LcnAddr, reported_seg_id: int): """Construct ModInput object.""" super().__init__(physical_source_addr) self.reported_seg_id = reported_seg_id def get_reported_seg_id(self) -> int: """Return the segment id reported from segment coupler. :return: Reported segment id. :rtype: int """ return self.reported_seg_id @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ matcher = PckParser.PATTERN_SK_RESPONSE.match(data) if matcher: addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) return [ModSk(addr, int(matcher.group("id")))] return None class ModSn(ModInput): """Serial number and firmware version received from an LCN module.""" def __init__( self, physical_source_addr: LcnAddr, hardware_serial: int, manu: int, software_serial: int, hardware_type: lcn_defs.HardwareType, ): """Construct ModInput object.""" super().__init__(physical_source_addr) self.hardware_serial = hardware_serial self.manu = manu self.software_serial = software_serial self.hardware_type = hardware_type @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ matcher = PckParser.PATTERN_SN.match(data) if matcher: addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) hardware_serial = int(matcher.group("hardware_serial"), 16) try: manu = int(matcher.group("manu"), 16) except ( ValueError ): # unconventional manufacturer code (e.g., due to LinHK VM) manu = 0xFF _LOGGER.debug( "Unconventional manufacturer code: %s. Defaulting to 0x%02X", matcher.group("manu"), manu, ) software_serial = int(matcher.group("software_serial"), 16) try: hardware_type = lcn_defs.HardwareType( int(matcher.group("hardware_type")) ) except ValueError: # unknown hardware type hardware_type = lcn_defs.HardwareType(-1) return [ModSn(addr, hardware_serial, manu, software_serial, hardware_type)] return None class ModNameComment(ModInput): """Name or comment received from an LCN module.""" def __init__( self, physical_source_addr: LcnAddr, command: str, block_id: int, text: str ): """Construct ModInput object.""" super().__init__(physical_source_addr) self.command = command self.block_id = block_id self.text = text @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ matcher = PckParser.PATTERN_NAME_COMMENT.match(data) if matcher: addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) command = matcher.group("command") block_id = int(matcher.group("block_id")) - 1 text = matcher.group("text") return [ModNameComment(addr, command, block_id, text)] return None class ModStatusGroups(ModInput): """Group memberships status received from an LCN module.""" def __init__( self, physical_source_addr: LcnAddr, dynamic: bool, max_groups: int, groups: list[LcnAddr], ): """Construct ModInput object.""" super().__init__(physical_source_addr) self.dynamic = dynamic self.max_groups = max_groups self.groups = groups @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ matcher = PckParser.PATTERN_STATUS_GROUPS.match(data) if matcher: addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) dynamic = matcher.group("kind") == "D" max_groups = int(matcher.group("max_groups")) groups = [ LcnAddr(addr.seg_id, int(group), True) for group in matcher.groups()[4:] if group is not None ] return [ModStatusGroups(addr, dynamic, max_groups, groups)] return None class ModStatusOutput(ModInput): """Status of an output-port in percent received from an LCN module.""" def __init__(self, physical_source_addr: LcnAddr, output_id: int, percent: float): """Construct ModInput object.""" super().__init__(physical_source_addr) self.output_id = output_id self.percent = percent def get_output_id(self) -> int: """Return the output port id. :return: Output port id. :rtype: int """ return self.output_id def get_percent(self) -> float: """Return the output brightness in percent. :return: Brightness in percent. :rtype: float """ return self.percent @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ matcher = PckParser.PATTERN_STATUS_OUTPUT_PERCENT.match(data) if matcher: addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) return [ ModStatusOutput( addr, int(matcher.group("output_id")) - 1, float(matcher.group("percent")), ) ] return None class ModStatusOutputNative(ModInput): """Status of an output-port in native units received from an LCN module.""" def __init__(self, physical_source_addr: LcnAddr, output_id: int, value: int): """Construct ModInput object.""" super().__init__(physical_source_addr) self.output_id = output_id self.value = value def get_output_id(self) -> int: """Return the output port id. :return: Output port id. :rtype: int """ return self.output_id def get_value(self) -> int: """Return the output brightness in native units. :return: Brightness in percent. :rtype: float """ return self.value @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ matcher = PckParser.PATTERN_STATUS_OUTPUT_NATIVE.match(data) if matcher: addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) return [ ModStatusOutputNative( addr, int(matcher.group("output_id")) - 1, int(matcher.group("value")), ) ] return None class ModStatusRelays(ModInput): """Status of 8 relays received from an LCN module.""" def __init__(self, physical_source_addr: LcnAddr, states: list[bool]): """Construct ModInput object.""" super().__init__(physical_source_addr) self.states = states def get_state(self, relay_id: int) -> bool: """ Get the state of a single relay. :param int relay_id: Relay id (0..7) :return: The relay's state :rtype: bool """ return self.states[relay_id] @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ matcher = PckParser.PATTERN_STATUS_RELAYS.match(data) if matcher: addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) return [ ModStatusRelays( addr, PckParser.get_boolean_value(int(matcher.group("byte_value"))) ) ] return None class ModStatusBinSensors(ModInput): """Status of 8 binary sensors received from an LCN module.""" def __init__(self, physical_source_addr: LcnAddr, states: list[bool]): """Construct ModInput object.""" super().__init__(physical_source_addr) self.states = states def get_state(self, bin_sensor_id: int) -> bool: """Get the state of a single binary-sensor. :param int bin_sensor_id: Binary sensor id (0..7) :return: The binary-sensor's state :rtype: bool """ return self.states[bin_sensor_id] @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ matcher = PckParser.PATTERN_STATUS_BINSENSORS.match(data) if matcher: addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) return [ ModStatusBinSensors( addr, PckParser.get_boolean_value(int(matcher.group("byte_value"))) ) ] return None class ModStatusVar(ModInput): """Status of a variable received from an LCN module.""" def __init__( self, physical_source_addr: LcnAddr, orig_var: lcn_defs.Var, value: lcn_defs.VarValue, ): """Construct ModInput object.""" super().__init__(physical_source_addr) self.orig_var = orig_var self.value = value self.var = self.orig_var def get_var(self) -> lcn_defs.Var: """Get the variable's real type. :return: The real type :rtype: :class:`~pypck.lcn_defs.Var` """ return self.var def get_value(self) -> lcn_defs.VarValue: """Get the variable's value. :return: The value of the variable. :rtype: int """ return self.value @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ matcher = PckParser.PATTERN_STATUS_VAR.match(data) if matcher: addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) var = lcn_defs.Var.var_id_to_var(int(matcher.group("id")) - 1) value = lcn_defs.VarValue.from_native(int(matcher.group("value"))) return [ModStatusVar(addr, var, value)] matcher = PckParser.PATTERN_STATUS_SETVAR.match(data) if matcher: addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) var = lcn_defs.Var.set_point_id_to_var(int(matcher.group("id")) - 1) value = lcn_defs.VarValue.from_native(int(matcher.group("value"))) return [ModStatusVar(addr, var, value)] matcher = PckParser.PATTERN_STATUS_THRS.match(data) if matcher: addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) var = lcn_defs.Var.thrs_id_to_var( int(matcher.group("register_id")) - 1, int(matcher.group("thrs_id")) - 1 ) value = lcn_defs.VarValue.from_native(int(matcher.group("value"))) return [ModStatusVar(addr, var, value)] matcher = PckParser.PATTERN_STATUS_S0INPUT.match(data) if matcher: addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) var = lcn_defs.Var.s0_id_to_var(int(matcher.group("id")) - 1) value = lcn_defs.VarValue.from_native(int(matcher.group("value"))) return [ModStatusVar(addr, var, value)] matcher = PckParser.PATTERN_VAR_GENERIC.match(data) if matcher: addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) var = lcn_defs.Var.UNKNOWN value = lcn_defs.VarValue.from_native(int(matcher.group("value"))) return [ModStatusVar(addr, var, value)] matcher = PckParser.PATTERN_THRS5.match(data) if matcher: ret: list[Input] = [] addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) for thrs_id in range(5): var = lcn_defs.Var.thrs_id_to_var(0, thrs_id) value = lcn_defs.VarValue.from_native( int(matcher.group(f"value{thrs_id + 1}")) ) ret.append(ModStatusVar(addr, var, value)) return ret return None class ModStatusLedsAndLogicOps(ModInput): """Status of LEDs and logic-operations received from an LCN module. :param int physicalSourceAddr: The physical source address :param states_led: The 12 LED states :type states_led: list(:class:`~pypck.lcn_defs.LedStatus`) :param states_logic_ops: The 4 logic-operation states :type states_logic_ops: list(:class:`~pypck.lcn_defs.LogicOpStatus`) """ def __init__( self, physical_source_addr: LcnAddr, states_led: list[lcn_defs.LedStatus], states_logic_ops: list[lcn_defs.LogicOpStatus], ): """Construct ModInput object.""" super().__init__(physical_source_addr) self.states_led = states_led # 12x LED status. self.states_logic_ops = states_logic_ops # 4x logic-operation status. def get_led_state(self, led_id: int) -> lcn_defs.LedStatus: """Get the status of a single LED. :param int led_id: LED id (0..11) :return: The LED's status :rtype: list(:class:`~pypck.lcn_defs.LedStatus`) """ return self.states_led[led_id] def get_logic_op_state(self, logic_op_id: int) -> lcn_defs.LogicOpStatus: """Get the status of a single logic operation. :param int logic_op_id: Logic operation id (0..3) :return: The logic-operation's status :rtype: list(:class:`~pypck.lcn_defs.LogicOpStatus`) """ return self.states_logic_ops[logic_op_id] @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ matcher = PckParser.PATTERN_STATUS_LEDSANDLOGICOPS.match(data) if matcher: addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) led_states = matcher.group("led_states").upper() states_leds = [lcn_defs.LedStatus(led_state) for led_state in led_states] logic_op_states = matcher.group("logic_op_states").upper() states_logic_ops = [ lcn_defs.LogicOpStatus(logic_op_state) for logic_op_state in logic_op_states ] return [ModStatusLedsAndLogicOps(addr, states_leds, states_logic_ops)] return None class ModStatusKeyLocks(ModInput): """Status of locked keys received from an LCN module. :param int physicalSourceAddr: The source address :param list(list(bool)) states: The 4x8 key-lock states """ def __init__(self, physical_source_id: LcnAddr, states: list[list[bool]]): """Construct ModInput object.""" super().__init__(physical_source_id) self.states = states def get_state(self, table_id: int, key_id: int) -> bool: """Get the lock-state of a single key. :param int tableId: Table id: (0..3 => A..D) :param int keyId: Key id (0..7 => 1..8) :return: The key's lock-state :rtype: bool """ return self.states[table_id][key_id] @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ matcher = PckParser.PATTERN_STATUS_KEYLOCKS.match(data) states: list[list[bool]] = [] if matcher: addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) for i in range(4): state = matcher.group(f"table{i:d}") if state is not None: states.append(PckParser.get_boolean_value(int(state))) return [ModStatusKeyLocks(addr, states)] return None class ModStatusAccessControl(ModInput): """Status of a transmitter, transponder or fingerprint sensor.""" def __init__( self, physical_source_addr: LcnAddr, periphery: lcn_defs.AccessControlPeriphery, code: str, level: int | None = None, key: int | None = None, action: lcn_defs.KeyAction | None = None, battery: lcn_defs.BatteryStatus | None = None, ): """Construct ModInput object.""" super().__init__(physical_source_addr) self.periphery = periphery self.code = code self.level = level self.key = key self.action = action self.battery = battery @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ matcher = PckParser.PATTERN_STATUS_TRANSMITTER.match(data) if matcher: periphery = lcn_defs.AccessControlPeriphery.TRANSMITTER addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) code = ( f"{int(matcher.group('code1')):02x}" f"{int(matcher.group('code2')):02x}" f"{int(matcher.group('code3')):02x}" ) level = int(matcher.group("level")) key = int(matcher.group("key")) - 1 actions = { "1": lcn_defs.KeyAction.HIT, "2": lcn_defs.KeyAction.MAKE, "3": lcn_defs.KeyAction.BREAK, } battery_status = { "0": lcn_defs.BatteryStatus.FULL, "1": lcn_defs.BatteryStatus.WEAK, } action = actions[matcher.group("action")[2]] battery = battery_status[matcher.group("action")[1]] return [ ModStatusAccessControl( addr, periphery, code, level, key, action, battery ) ] matcher = PckParser.PATTERN_STATUS_TRANSPONDER.match(data) if matcher: periphery = lcn_defs.AccessControlPeriphery.TRANSPONDER addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) code = ( f"{int(matcher.group('code1')):02x}" f"{int(matcher.group('code2')):02x}" f"{int(matcher.group('code3')):02x}" ) return [ModStatusAccessControl(addr, periphery, code)] matcher = PckParser.PATTERN_STATUS_FINGERPRINT.match(data) if matcher: periphery = lcn_defs.AccessControlPeriphery.FINGERPRINT addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) code = ( f"{int(matcher.group('code1')):02x}" f"{int(matcher.group('code2')):02x}" f"{int(matcher.group('code3')):02x}" ) return [ModStatusAccessControl(addr, periphery, code)] matcher = PckParser.PATTERN_STATUS_CODELOCK.match(data) if matcher: periphery = lcn_defs.AccessControlPeriphery.CODELOCK addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) code = ( f"{int(matcher.group('code1')):02x}" f"{int(matcher.group('code2')):02x}" f"{int(matcher.group('code3')):02x}" ) return [ModStatusAccessControl(addr, periphery, code)] return None class ModStatusSceneOutputs(ModInput): """Status of the output values and ramp values received from an LCN module.""" def __init__( self, physical_source_addr: LcnAddr, scene_id: int, values: list[int], ramps: list[int], ): """Construct ModInput object.""" super().__init__(physical_source_addr) self.scene_id = scene_id self.values = values self.ramps = ramps @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ matcher = PckParser.PATTERN_STATUS_SCENE_OUTPUTS.match(data) if matcher: addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) scene_id = int(matcher.group("scene_id")) values = [int(matcher.group(f"output{i+1:d}")) for i in range(4)] ramps = [int(matcher.group(f"ramp{i+1:d}")) for i in range(4)] return [ModStatusSceneOutputs(addr, scene_id, values, ramps)] return None class ModSendCommandHost(ModInput): """Send command to host message from module.""" def __init__(self, physical_source_addr: LcnAddr, parameters: tuple[int, ...]): """Construct ModSendCommandHost object.""" super().__init__(physical_source_addr) self.parameters = parameters def get_parameters(self) -> tuple[int, ...]: """Return the received parameters. :return: Parameters :rtype: List with parameters of type int. """ return self.parameters @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ matcher = PckParser.PATTERN_SEND_COMMAND_HOST.match(data) if matcher: addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) parameters = tuple( int(param) for param in matcher.groups()[3:] if param is not None ) return [ModSendCommandHost(addr, parameters)] return None class ModSendKeysHost(ModInput): """Send command to host message from module.""" def __init__( self, physical_source_addr: LcnAddr, actions: list[lcn_defs.SendKeyCommand], keys: list[bool], ): """Construct ModSendKeysHost object.""" super().__init__(physical_source_addr) self.actions = actions self.keys = keys def get_actions(self) -> list[lcn_defs.SendKeyCommand]: """Get key actions for each table. :returns: List of length 3 with key actions for each table A, B, C. :rtype: list(:class:`~pypck.lcn_defs.SendKeyCommand`) """ return self.actions def get_keys(self) -> list[bool]: """Get keys which should be triggered. :returns: List of booleans (length 8) for each key (True: trigger, False: do nothing). :rtype: list(bool) """ return self.keys @staticmethod def try_parse(data: str) -> list[Input] | None: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ matcher = PckParser.PATTERN_SEND_KEYS_HOST.match(data) if matcher: addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id"))) actions_value = int(matcher.group("actions")) keys_value = int(matcher.group("keys")) mapping = ( lcn_defs.SendKeyCommand.DONTSEND, lcn_defs.SendKeyCommand.HIT, lcn_defs.SendKeyCommand.MAKE, lcn_defs.SendKeyCommand.BREAK, ) actions = [] for idx in range(3): action = mapping[(actions_value >> 2 * idx) & 0x03] actions.append(action) keys = [bool(keys_value >> bit & 0x01) for bit in range(8)] return [ModSendKeysHost(addr, actions, keys)] return None # ## Other inputs class Unknown(Input): """Handle all unknown input data.""" def __init__(self, data: str): """Construct Input object.""" super().__init__() self._data = data @staticmethod def try_parse(data: str) -> list[Input]: """Try to parse the given input text. Will return a list of parsed Inputs. The list might be empty (but not null). :param data str: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ return [Unknown(data)] @property def data(self) -> str: """Return the received data. :return: Received data. :rtype: str """ return self._data class InputParser: """Parse all input objects for given input data.""" parsers: list[type[Input]] = [ AuthUsername, AuthPassword, AuthOk, AuthFailed, LcnConnState, LicenseError, DecModeSet, CommandError, Ping, ModAck, ModNameComment, ModSk, ModSn, ModStatusGroups, ModStatusOutput, ModStatusOutputNative, ModStatusRelays, ModStatusBinSensors, ModStatusVar, ModStatusLedsAndLogicOps, ModStatusKeyLocks, ModStatusAccessControl, ModStatusSceneOutputs, ModSendCommandHost, ModSendKeysHost, Unknown, ] @staticmethod def parse(data: str) -> list[Input]: """Parse all input objects for given input data. :param str data: The input data received from LCN-PCHK :return: The parsed Inputs (never null) :rtype: List with instances of :class:`~pypck.input.Input` """ for parser in InputParser.parsers: ret: list[Input] | None = parser.try_parse(data) if ret is not None: return ret # We must never get to this point since the Unknown parser matches # everything. assert False pypck-0.8.5/pypck/lcn_addr.py000066400000000000000000000036321475005466200161350ustar00rootroot00000000000000"""Classes to store module and group addresses.""" from dataclasses import dataclass @dataclass(frozen=True) class LcnAddr: """Represents a LCN address (module or group). If the segment id is 0, the address object points to modules/groups which are in the segment where the bus coupler is connected to. This is also the case if no segment coupler is present at all. :param int seg_id: Segment id (0 = Local, 1..2 = Not allowed (but "seen in the wild") 3 = Broadcast, 4 = Status messages, 5..127, 128 = Segment-bus disabled (valid value)) :param bool is_group: Indicates whether address point to a module (False) or a group (True) If address represents a **module**: :param int addr_id: Module id (1 = LCN-PRO, 2 = LCN-GVS/LCN-W, 4 = PCHK, 5..254, 255 = Unprog. (valid, but irrelevant here)) If address represents a **group**: :param int addr_id: Group id (3 = Broadcast, 4 = Status messages, 5..254) """ seg_id: int addr_id: int is_group: bool = False def get_physical_seg_id(self, local_seg_id: int) -> int: """Get the physical segment id ("local" segment replaced with 0). Can be used to send data into the LCN bus. :param int local_seg_id: The segment id of the local segment :return: The physical segment id :rtype: int """ return 0 if (self.seg_id == local_seg_id) else self.seg_id pypck-0.8.5/pypck/lcn_defs.py000066400000000000000000001172421475005466200161470ustar00rootroot00000000000000"""Definitions and constants for pypck.""" from __future__ import annotations import math import re from enum import Enum, auto from typing import Any, no_type_check LCN_ENCODING = "utf-8" PATTERN_SPLIT_PORT_PIN = re.compile(r"(?P[a-zA-Z]+)(?P\d+)") def split_port_pin(portpin: str) -> tuple[str, int]: """Split the port and the pin from the given input string. :param str portpin: Input string """ res = PATTERN_SPLIT_PORT_PIN.findall(portpin) return res[0][0], int(res[0][1]) class OutputPort(Enum): """Output port of LCN module.""" OUTPUT1 = 0 OUTPUT2 = 1 OUTPUT3 = 2 OUTPUT4 = 3 OUTPUTUP = 0 OUTPUTDOWN = 1 class RelayPort(Enum): """Relay port of LCN module.""" RELAY1 = 0 RELAY2 = 1 RELAY3 = 2 RELAY4 = 3 RELAY5 = 4 RELAY6 = 5 RELAY7 = 6 RELAY8 = 7 MOTORONOFF1 = 0 MOTORUPDOWN1 = 1 MOTORONOFF2 = 2 MOTORUPDOWN2 = 3 MOTORONOFF3 = 4 MOTORUPDOWN3 = 5 MOTORONOFF4 = 6 MOTORUPDOWN4 = 7 class MotorPort(Enum): """Motor ports of LCN module.""" MOTOR1 = 0 MOTOR2 = 1 MOTOR3 = 2 MOTOR4 = 3 OUTPUTS = 4 class LedPort(Enum): """LED port of LCN module.""" LED1 = 0 LED2 = 1 LED3 = 2 LED4 = 3 LED5 = 4 LED6 = 5 LED7 = 6 LED8 = 7 LED9 = 8 LED10 = 9 LED11 = 10 LED12 = 11 class LogicOpPort(Enum): """Logic Operation port of LCN module.""" LOGICOP1 = 0 LOGICOP2 = 1 LOGICOP3 = 2 LOGICOP4 = 3 class BinSensorPort(Enum): """Binary sensor port of LCN module.""" BINSENSOR1 = 0 BINSENSOR2 = 1 BINSENSOR3 = 2 BINSENSOR4 = 3 BINSENSOR5 = 4 BINSENSOR6 = 5 BINSENSOR7 = 6 BINSENSOR8 = 7 class Key(Enum): """Keys of LCN module.""" A1 = 0 A2 = 1 A3 = 2 A4 = 3 A5 = 4 A6 = 5 A7 = 6 A8 = 7 B1 = 8 B2 = 9 B3 = 10 B4 = 11 B5 = 12 B6 = 13 B7 = 14 B8 = 15 C1 = 16 C2 = 17 C3 = 18 C4 = 19 C5 = 20 C6 = 21 C7 = 22 C8 = 23 D1 = 24 D2 = 25 D3 = 26 D4 = 27 D5 = 28 D6 = 29 D7 = 30 D8 = 31 class KeyAction(Enum): """Action types for LCN keys.""" HIT = "hit" MAKE = "make" BREAK = "break" class BatteryStatus(Enum): """Battery status.""" WEAK = "weak" FULL = "full" class OutputPortDimMode(Enum): """LCN dimming mode. If solely modules with firmware 170206 or newer are present, LCN-PRO automatically programs STEPS200. Otherwise the default is STEPS50. Since LCN-PCHK doesn't know the current mode, it must explicitly be set. """ STEPS50 = 0 # 0..50 dimming steps (all LCN module generations) STEPS200 = 1 # 0..200 dimming steps (since 170206) class OutputPortStatusMode(Enum): """Tells LCN-PCHK how to format output-port status-messages. PERCENT: allows to show the status in half-percent steps (e.g. "10.5"). NATIVE: is completely backward compatible and there are no restrictions concerning the LCN module generations. It requires LCN-PCHK 2.3 or higher though. """ PERCENT = "P" # Default (compatible with all versions of LCN-PCHK) NATIVE = "N" # 0..200 steps (since LCN-PCHK 2.3) def time_to_ramp_value(time_msec: int) -> int: """Convert the given time into an LCN ramp value. :param int time_msec: The time in milliseconds. :returns: The (LCN-internal) ramp value (0..250). :rtype: int """ if time_msec < 250: ret = 0 elif time_msec < 500: ret = 1 elif time_msec < 660: ret = 2 elif time_msec < 1000: ret = 3 elif time_msec < 1400: ret = 4 elif time_msec < 2000: ret = 5 elif time_msec < 3000: ret = 6 elif time_msec < 4000: ret = 7 elif time_msec < 5000: ret = 8 elif time_msec < 6000: ret = 9 else: ramp = (time_msec / 1000 - 6) / 2 + 10 ramp = min(ramp, 250) ret = int(ramp) return ret def ramp_value_to_time(ramp_value: int) -> int: """Convert the given LCN ramp value into a time. :param int ramp_value: The LCN ramp value (0..250). :returns: The ramp time in milliseconds. :rtype: int """ if not 0 <= ramp_value <= 250: raise ValueError("Ramp value has to be in range 0..250.") if ramp_value < 10: times = [0, 250, 500, 660, 1000, 1400, 2000, 3000, 4000, 5000] ramp_time = times[ramp_value] else: ramp_time = int(((ramp_value - 10) * 2 + 6) * 1000) return ramp_time def time_to_native_value(time_msec: int) -> int: """Convert time to native LCN time value. Scales the given time value in milliseconds to a byte value (0..255). Used for RelayTimer. :param int time_msec: Duration of timer in milliseconds :returns: The duration in native LCN units :rtype: int """ if not 0 <= time_msec <= 240960: raise ValueError("Time has to be in range 0..240960ms.") time_scaled = time_msec / (1000 * 0.03 * 32.0) + 1.0 pre_decimal = int(time_scaled).bit_length() - 1 decimal = time_scaled / (1 << pre_decimal) - 1 value = pre_decimal + decimal return int(32 * value) def native_value_to_time(value: int) -> int: """Convert native LCN value to time. Scales the given byte value (0..255) to a time value in milliseconds. Inverse to time_to_native_value(). :param int value: Duration of timer in native LCN units :returns: The duration in milliseconds :rtype: int """ if not 0 <= value <= 255: raise ValueError("Value has to be in range 0..255.") pre_decimal = value // 32 decimal = value / 32 - pre_decimal time_scaled = (1 << pre_decimal) * (decimal + 1) time_msec = (time_scaled - 1) * 1000 * 0.03 * 32 return int(time_msec) class Var(Enum): """LCN variable types.""" UNKNOWN = -1 # Used if the real type is not known (yet) VAR1ORTVAR = 0 TVAR = 0 VAR1 = 0 VAR2ORR1VAR = 1 R1VAR = 1 VAR2 = 1 VAR3ORR2VAR = 2 R2VAR = 2 VAR3 = 2 VAR4 = 3 VAR5 = 4 VAR6 = 5 VAR7 = 6 VAR8 = 7 VAR9 = 8 VAR10 = 9 VAR11 = 10 VAR12 = 11 # Since 170206 R1VARSETPOINT = auto() R2VARSETPOINT = auto() # Set-points for regulators THRS1 = auto() THRS2 = auto() THRS3 = auto() THRS4 = auto() THRS5 = auto() # Register 1 (THRS5 only before 170206) THRS2_1 = auto() THRS2_2 = auto() THRS2_3 = auto() THRS2_4 = auto() # Register 2 (since 2012) THRS3_1 = auto() THRS3_2 = auto() THRS3_3 = auto() THRS3_4 = auto() # Register 3 (since 2012) THRS4_1 = auto() THRS4_2 = auto() THRS4_3 = auto() THRS4_4 = auto() # Register 4 (since 2012) S0INPUT1 = auto() S0INPUT2 = auto() S0INPUT3 = auto() S0INPUT4 = auto() # LCN-BU4LJVarValue @staticmethod def var_id_to_var(var_id: int) -> Var: """Translate a given id into a variable type. :param int varId: The variable id (0..11) :returns: The translated variable enum. :rtype: Var """ if (var_id < 0) or (var_id >= len(Var.variables)): # type: ignore raise ValueError("Bad var_id.") return Var.variables[var_id] # type: ignore @staticmethod def set_point_id_to_var(set_point_id: int) -> Var: """Translate a given id into a LCN set-point variable type. :param int set_point_id: Set-point id 0..1 :return: The translated var :rtype: Var """ if (set_point_id < 0) or (set_point_id >= len(Var.set_points)): # type: ignore raise ValueError("Bad set_point_id.") return Var.set_points[set_point_id] # type: ignore @staticmethod def thrs_id_to_var(register_id: int, thrs_id: int) -> Var: """Translate given ids into a LCN threshold variable type. :param int register_id: Register id 0..3 :param int thrs_id: Threshold id 0..4 for register 0, 0..3 for registers 1..3 :return: The translated var :rtype: Var """ if ( (register_id < 0) or (register_id >= len(Var.thresholds)) # type: ignore or (thrs_id < 0) or (thrs_id >= (5 if (register_id == 0) else 4)) ): raise ValueError("Bad register_id and/or thrs_id.") return Var.thresholds[register_id][thrs_id] # type: ignore @staticmethod def s0_id_to_var(s0_id: int) -> Var: """Translate a given id into a LCN S0-input variable type. :param int s0_id: S0 id 0..3 :return: The translated var :rtype: Var """ if (s0_id < 0) or (s0_id >= len(Var.s0s)): # type: ignore raise ValueError("Bad s0_id.") return Var.s0s[s0_id] # type: ignore @staticmethod def to_var_id(var: Var) -> int: """Translate a given variable type into a variable id. :param Var var: The variable type to translate :return: Variable id 0..11 or -1 if wrong type :rtype: int """ if var == Var.VAR1ORTVAR: var_id = 0 elif var == Var.VAR2ORR1VAR: var_id = 1 elif var == Var.VAR3ORR2VAR: var_id = 2 elif var == Var.VAR4: var_id = 3 elif var == Var.VAR5: var_id = 4 elif var == Var.VAR6: var_id = 5 elif var == Var.VAR7: var_id = 6 elif var == Var.VAR8: var_id = 7 elif var == Var.VAR9: var_id = 8 elif var == Var.VAR10: var_id = 9 elif var == Var.VAR11: var_id = 10 elif var == Var.VAR12: var_id = 11 else: var_id = -1 return var_id @staticmethod def to_set_point_id(var: Var) -> int: """Translate a given variable type into a set-point id. :param Var var: The variable type to translate :return: Variable id 0..1 or -1 if wrong type :rtype: int """ if var == Var.R1VARSETPOINT: set_point_id = 0 elif var == Var.R2VARSETPOINT: set_point_id = 1 else: set_point_id = -1 return set_point_id @staticmethod def to_thrs_register_id(var: Var) -> int: """Translate a given variable type into a threshold register id. :param Var var: The variable type to translate :return: Register id 0..3 or -1 if wrong type :rtype: int """ if var in [Var.THRS1, Var.THRS2, Var.THRS3, Var.THRS4, Var.THRS5]: thrs_register_id = 0 elif var in [Var.THRS2_1, Var.THRS2_2, Var.THRS2_3, Var.THRS2_4]: thrs_register_id = 1 elif var in [Var.THRS3_1, Var.THRS3_2, Var.THRS3_3, Var.THRS3_4]: thrs_register_id = 2 elif var in [Var.THRS4_1, Var.THRS4_2, Var.THRS4_3, Var.THRS4_4]: thrs_register_id = 3 else: thrs_register_id = -1 return thrs_register_id @staticmethod def to_thrs_id(var: Var) -> int: """Translate a given variable type into a threshold id. :param Var var: The variable type to translate :return: Threshold id 0..4 or -1 if wrong type :rtype: int """ if var in [Var.THRS1, Var.THRS2_1, Var.THRS3_1, Var.THRS4_1]: thrs_id = 0 elif var in [Var.THRS2, Var.THRS2_2, Var.THRS3_2, Var.THRS4_2]: thrs_id = 1 elif var in [Var.THRS3, Var.THRS2_3, Var.THRS3_3, Var.THRS4_3]: thrs_id = 2 elif var in [Var.THRS4, Var.THRS2_4, Var.THRS3_4, Var.THRS4_4]: thrs_id = 3 elif var == Var.THRS5: thrs_id = 4 else: thrs_id = -1 return thrs_id @staticmethod def to_s0_id(var: Var) -> int: """Translate a given variable type into an S0-input id. :param Var var: The variable type to translate :return: S0 id 0..3 or -1 if wrong type :rtype: int """ if var == Var.S0INPUT1: s0_id = 0 elif var == Var.S0INPUT2: s0_id = 1 elif var == Var.S0INPUT3: s0_id = 2 elif var == Var.S0INPUT4: s0_id = 3 else: s0_id = -1 return s0_id @staticmethod def is_lockable_regulator_source(var: Var) -> bool: """Check if the the given variable type is lockable. :param Var var: The variable type to check :return: True if lockable, otherwise False :rtype: bool """ return var in [Var.R1VARSETPOINT, Var.R2VARSETPOINT] @staticmethod def use_lcn_special_values(var: Var) -> bool: """Check if the given variable type uses special values. Examples for special values: 'No value yet', 'sensor defective' etc. :param Var var: The variable type to check :return: True if special values are in use, otherwise False :rtype: bool """ return var not in [Var.S0INPUT1, Var.S0INPUT2, Var.S0INPUT3, Var.S0INPUT4] @staticmethod def has_type_in_response(var: Var, software_serial: int) -> bool: """Module-generation check. Check if the given variable type would receive a typed response if its status was requested. :param Var var: The variable type to check :param int swAge: The target LCN-modules firmware version :return: True if a response would contain the variable's type, otherwise False :rtype: bool """ if software_serial < 0x170206: if var in [ Var.VAR1ORTVAR, Var.VAR2ORR1VAR, Var.VAR3ORR2VAR, Var.R1VARSETPOINT, Var.R2VARSETPOINT, ]: return False return True @staticmethod def is_event_based(var: Var, software_serial: int) -> bool: """Module-generation check. Check if the given variable type automatically sends status-updates on value-change. It must be polled otherwise. :param Var var: The variable type to check :param int swAge: The target LCN-module's firmware version :return: True if the LCN module supports automatic status-messages for this var, otherwise False :rtype: bool """ if (Var.to_set_point_id(var) != -1) or (Var.to_s0_id(var) != -1): return True return software_serial >= 0x170206 @staticmethod def should_poll_status_after_command(var: Var, is2013: bool) -> bool: """Module-generation check. Check if the target LCN module would automatically send status-updates if the given variable type was changed by command. :param Var var: The variable type to check :param bool is2013: The target module's-generation :return: True if a poll is required to get the new status-value, otherwise False :rtype: bool """ # Regulator set-points will send status-messages on every change # (all firmware versions) if Var.to_set_point_id(var) != -1: return False # Thresholds since 170206 will send status-messages on every change if is2013 and (Var.to_thrs_register_id(var) != -1): return False # Others: # - Variables before 170206 will never send any status-messages # - Variables since 170206 only send status-messages on "big" changes # - Thresholds before 170206 will never send any status-messages # - S0-inputs only send status-messages on "big" changes # (all "big changes" cases force us to poll the status to get faster # updates) return True @staticmethod def should_poll_status_after_regulator_lock( software_serial: int, lock_state: int ) -> bool: """Module-generation check. Check if the target LCN module would automatically send status-updates if the given regulator's lock-state was changed by command. :param int swAge: The target LCN-module's firmware version :param int lockState: The lock-state sent via command :return: True if a poll is required to get the new status-value, otherwise False :rtype: bool """ # LCN modules before 170206 will send an automatic status-message for # "lock", but not for "unlock" return (not lock_state) and (software_serial < 0x170206) # Helper list to get var by numeric id. Var.variables = [ # type: ignore Var.VAR1ORTVAR, Var.VAR2ORR1VAR, Var.VAR3ORR2VAR, Var.VAR4, Var.VAR5, Var.VAR6, Var.VAR7, Var.VAR8, Var.VAR9, Var.VAR10, Var.VAR11, Var.VAR12, ] # Helper list to get set-point var by numeric id. Var.set_points = [Var.R1VARSETPOINT, Var.R2VARSETPOINT] # type: ignore # Helper list to get threshold var by numeric id. Var.thresholds = [ # type: ignore [Var.THRS1, Var.THRS2, Var.THRS3, Var.THRS4, Var.THRS5], [Var.THRS2_1, Var.THRS2_2, Var.THRS2_3, Var.THRS2_4], [Var.THRS3_1, Var.THRS3_2, Var.THRS3_3, Var.THRS3_4], [Var.THRS4_1, Var.THRS4_2, Var.THRS4_3, Var.THRS4_4], ] # Helper list to get S0-input var by numeric id. Var.s0s = [ # type: ignore Var.S0INPUT1, Var.S0INPUT2, Var.S0INPUT3, Var.S0INPUT4, ] class VarUnit(Enum): """Measurement units used with LCN variables.""" NATIVE = "" # LCN internal representation (0 = -100C for absolute values) CELSIUS = "\u00b0C" KELVIN = "\u00b0K" FAHRENHEIT = "\u00b0F" LUX_T = "Lux_T" LUX_I = "Lux_I" METERPERSECOND = "m/s" # Used for LCN-WIH wind speed PERCENT = "%" # Used for humidity PPM = "ppm" # Used by CO2 sensor VOLT = "V" AMPERE = "A" DEGREE = "\u00b0" # Used for angles, @staticmethod def parse(unit: str) -> VarUnit: """Parse the given unit string and return VarUnit. :param str unit: The input unit """ unit = unit.upper() if unit in ["", "NATIVE", "LCN"]: var_unit = VarUnit.NATIVE elif unit in ["CELSIUS", "\u00b0CELSIUS", "\u00b0C"]: var_unit = VarUnit.CELSIUS elif unit in ["KELVIN", "\u00b0KELVIN", "\u00b0K", "K"]: var_unit = VarUnit.KELVIN elif unit in ["FAHRENHEIT", "\u00b0FAHRENHEIT", "\u00b0F"]: var_unit = VarUnit.FAHRENHEIT elif unit in ["LUX_T", "LX_T"]: var_unit = VarUnit.LUX_T elif unit in ["LUX", "LUX_I", "LX"]: var_unit = VarUnit.LUX_I elif unit in ["M/S", "METERPERSECOND"]: var_unit = VarUnit.METERPERSECOND elif unit in ["%", "PERCENT"]: var_unit = VarUnit.PERCENT elif unit == "PPM": var_unit = VarUnit.PPM elif unit in ["VOLT", "V"]: var_unit = VarUnit.VOLT elif unit in ["AMPERE", "AMP", "A"]: var_unit = VarUnit.AMPERE elif unit in ["DEGREE", "\u00b0"]: var_unit = VarUnit.DEGREE else: raise ValueError("Bad input unit.") return var_unit class VarValue: """A value of an LCN variable. It internally stores the native LCN value and allows to convert from/into other units. Some conversions allow to specify whether the source value is absolute or relative. Relative values are used to create varvalues that can be added/subtracted from other (absolute) varvalues. :param int native_value: The native value """ def __init__(self, native_value: int) -> None: """Construct with native LCN value.""" self.native_value = native_value def __eq__(self, other: object) -> bool: """Return if instance equals the given object.""" if isinstance(other, VarValue): return self.native_value == other.native_value return False def __hash__(self) -> int: """Calculate the instance hash value.""" return self.native_value.__hash__() def is_locked_regulator(self) -> bool: """Return if regulator is locked.""" return (self.native_value & 0x8000) != 0 @staticmethod def from_var_unit(value: float, unit: VarUnit, is_abs: bool) -> VarValue: """Create a variable value from any input. :param float value: The input value :param VarUnit unit: The input value's unit :param bool is_abs: True for absolute values (relative values are used to add/subtract from other VarValues), otherwise False :return: The variable value (never null) :rtype: VarValue """ if unit == VarUnit.NATIVE: var_value = VarValue.from_native(int(value)) elif unit == VarUnit.CELSIUS: var_value = VarValue.from_celsius(value, is_abs) elif unit == VarUnit.KELVIN: var_value = VarValue.from_kelvin(value, is_abs) elif unit == VarUnit.FAHRENHEIT: var_value = VarValue.from_fahrenheit(value, is_abs) elif unit == VarUnit.LUX_T: var_value = VarValue.from_lux_t(value) elif unit == VarUnit.LUX_I: var_value = VarValue.from_lux_i(value) elif unit == VarUnit.METERPERSECOND: var_value = VarValue.from_meters_per_second(value) elif unit == VarUnit.PERCENT: var_value = VarValue.from_percent(value) elif unit == VarUnit.PPM: var_value = VarValue.from_ppm(value) elif unit == VarUnit.VOLT: var_value = VarValue.from_volt(value) elif unit == VarUnit.AMPERE: var_value = VarValue.from_ampere(value) elif unit == VarUnit.DEGREE: var_value = VarValue.from_degree(value, is_abs) else: raise ValueError("Wrong unit.") return var_value @staticmethod def from_native(value: int) -> VarValue: """Create a variable value from native input. :param int value: The input value :return: The variable value (never null) :rtype: VarValue """ return VarValue(value) @staticmethod def from_celsius(value: float, is_abs: bool = True) -> VarValue: """Create a variable value from Celsius input. :param float value: The input value :param bool is_abs: True for absolute values (relative values are used to add/subtract from other VarValues), otherwise False :return: The variable value (never null) :rtype: VarValue """ number = int(round(value * 10)) return VarValue(number + 1000 if is_abs else number) @staticmethod def from_kelvin(value: float, is_abs: bool = True) -> VarValue: """Create a variable value from Kelvin input. :param float value: The input value :param bool is_abs: True for absolute values (relative values are used to add/subtract from other VarValues), otherwise False :return: The variable value (never null) :rtype: VarValue """ if is_abs: value -= 273.15 number = int(round(value * 10)) return VarValue(number + 1000 if is_abs else number) @staticmethod def from_fahrenheit(value: float, is_abs: bool = True) -> VarValue: """Create a variable value from Fahrenheit input. :param float value: The input value :param bool is_abs: True for absolute values (relative values are used to add/subtract from other VarValues), otherwise False :return: The variable value (never null) :rtype: VarValue """ if is_abs: value -= 32 number = int(round(value / 0.18)) return VarValue(number + 1000 if is_abs else number) @staticmethod def from_lux_t(lux: float) -> VarValue: """Create a variable value from lx input. Target must be connected to T-port. :param float l: The input value :return: The variable value (never null) :rtype: VarValue """ return VarValue(int(round(math.log(lux) - 1.689646994) / 0.010380664)) @staticmethod def from_lux_i(lux: float) -> VarValue: """Create a variable value from lx input. Target must be connected to I-port. :param float l: The input value :return: The variable value (never null) :rtype: VarValue """ return VarValue(int(round(math.log(lux) * 100))) @staticmethod def from_percent(value: float) -> VarValue: """Create a variable value from % input. :param float value: The input value :return: The variable value (never null) :rtype: VarValue """ return VarValue(int(round(value))) @staticmethod def from_ppm(value: float) -> VarValue: """Create a variable value from ppm input. Used for CO2 sensors. :param float value: The input value :return: The variable value (never null) :rtype: VarValue """ return VarValue(int(round(value))) @staticmethod def from_meters_per_second(value: float) -> VarValue: """Create a variable value from m/s input. Used for LCN-WIH wind speed. :param float value: The input value :return: The variable value (never null) :rtype: VarValue """ return VarValue(int(round(value * 10))) @staticmethod def from_volt(value: float) -> VarValue: """Create a variable value from V input. :param float value: The input value :return: The variable value (never null) :rtype: VarValue """ return VarValue(int(round(value * 400))) @staticmethod def from_ampere(value: float) -> VarValue: """Create a variable value from A input. :param float value: The input value :return: The variable value (never null) :rtype: VarValue """ return VarValue(int(round(value * 100000))) @staticmethod def from_degree(value: float, is_abs: bool = True) -> VarValue: """Create a variable value from degree (angle) input. :param float value: The input value :param bool is_abs: True for absolute values (relative values are used to add/subtract from other VarValues), otherwise False :return: The variable value (never null) :rtype: VarValue """ number = int(round(value * 10)) return VarValue(number + 1000 if is_abs else number) def to_var_unit( self, unit: VarUnit, is_lockable_regulator_source: bool = False, ) -> int | float: """Convert the given unit to a VarValue. :param VarUnit unit: The variable unit :param bool is_lockable_regulator_source: Is lockable source :return: The variable value :rtype: Union[int,float] """ var_value = VarValue( self.native_value & 0x7FFF if is_lockable_regulator_source else self.native_value ) if unit == VarUnit.NATIVE: return var_value.to_native() if unit == VarUnit.CELSIUS: return var_value.to_celsius() if unit == VarUnit.KELVIN: return var_value.to_kelvin() if unit == VarUnit.FAHRENHEIT: return var_value.to_fahrenheit() if unit == VarUnit.LUX_T: return var_value.to_lux_t() if unit == VarUnit.LUX_I: return var_value.to_lux_i() if unit == VarUnit.METERPERSECOND: return var_value.to_meters_per_second() if unit == VarUnit.PERCENT: return var_value.to_percent() if unit == VarUnit.PPM: return var_value.to_ppm() if unit == VarUnit.VOLT: return var_value.to_volt() if unit == VarUnit.AMPERE: return var_value.to_ampere() if unit == VarUnit.DEGREE: return var_value.to_degree() raise ValueError("Wrong unit.") def to_native(self) -> int: """Convert to native value. :return: The converted value :rtype: int """ return self.native_value def to_celsius(self) -> float: """Convert to Celsius value. :return: The converted value :rtype: float """ return (self.native_value - 1000) / 10.0 def to_kelvin(self) -> float: """Convert to Kelvin value. :return: The converted value :rtype: float """ return (self.native_value - 1000) / 10.0 + 273.15 def to_fahrenheit(self) -> float: """Convert to Fahrenheit value. :return: The converted value :rtype: float """ return (self.native_value - 1000) * 0.18 + 32.0 def to_lux_t(self) -> float: """Convert to lx value. Source must be connected to T-port. :return: The converted value :rtype: float """ return math.exp(0.010380664 * self.native_value + 1.689646994) def to_lux_i(self) -> float: """Convert to lx value. Source must be connected to I-port. :return: The converted value :rtype: float """ return math.exp(self.native_value / 100) def to_percent(self) -> int: """Convert to % value. :return: The converted value :rtype: int """ return self.native_value def to_ppm(self) -> int: """Convert to ppm value. :return: The converted value :rtype: int """ return self.native_value def to_meters_per_second(self) -> float: """Convert to m/s value. :return: The converted value :rtype: float """ return self.native_value / 10.0 def to_volt(self) -> float: """Convert to V value. :return: The converted value :rtype: float """ return self.native_value / 400.0 def to_ampere(self) -> float: """Convert to A value. :return: The converted value :rtype: float """ return self.native_value / 100000.0 def to_degree(self) -> float: """Convert to degree value. :return: The converted value :rtype: float """ return (self.native_value - 1000) / 10.0 def to_var_unit_string( self, unit: VarUnit, is_lockable_regulator_source: bool = False, use_lcn_special_values: bool = False, ) -> str: """Convert the given unit into a string representation. :param VarUnit unit: The input unit :param bool is_lockable_regulator_source: Is lockable source :param bool use_lcn_special_values: Use LCN special values :return: The string representation of input unit. :rtype: str """ if use_lcn_special_values and (self.native_value == 0xFFFF): # No value ret = "---" elif use_lcn_special_values and ( (self.native_value & 0xFF00) == 0x8100 ): # Undefined ret = "---" elif use_lcn_special_values and ( (self.native_value & 0xFF00) == 0x7F00 ): # Defective ret = "!!!" else: var = VarValue( (self.native_value & 0x7FF) if is_lockable_regulator_source else self.native_value ) if unit == VarUnit.NATIVE: ret = f"{var.to_native():.0f}" elif unit == VarUnit.CELSIUS: ret = f"{var.to_celsius():.01f}" elif unit == VarUnit.KELVIN: ret = f"{var.to_kelvin():.01f}" elif unit == VarUnit.FAHRENHEIT: ret = f"{var.to_fahrenheit():.01f}" elif unit == VarUnit.LUX_T: if var.to_native() > 1152: # Max. value the HW can do ret = "---" else: ret = f"{var.to_lux_t():.0f}" elif unit == VarUnit.LUX_I: if var.to_native() > 1152: # Max. value the HW can do ret = "---" else: ret = f"{var.to_lux_i():.0f}" elif unit == VarUnit.METERPERSECOND: ret = f"{var.to_meters_per_second():.0f}" elif unit == VarUnit.PERCENT: ret = f"{var.to_percent():.0f}" elif unit == VarUnit.PPM: ret = f"{var.to_ppm():.0f}" elif unit == VarUnit.VOLT: ret = f"{var.to_volt():.0f}" elif unit == VarUnit.AMPERE: ret = f"{var.to_ampere():.0f}" elif unit == VarUnit.DEGREE: ret = f"{var.to_degree():.0f}" else: raise ValueError("Wrong unit.") # handle locked regulators if is_lockable_regulator_source and self.is_locked_regulator(): ret = f"({ret:s})" return ret class LedStatus(Enum): """Possible states for LCN LEDs.""" OFF = "A" ON = "E" BLINK = "B" FLICKER = "F" class LogicOpStatus(Enum): """Possible states for LCN logic-operations.""" NONE = "N" SOME = "T" # Note: Actually not correct since AND won't be OR also ALL = "V" class TimeUnit(Enum): """Time units used for several LCN commands.""" SECONDS = "S" MINUTES = "M" HOURS = "H" DAYS = "D" @staticmethod def parse(unit: str) -> TimeUnit: """Parse the given time_unit into a time unit. It supports several alternative terms. :param str time_unit: The text to parse :return: TimeUnit enum :rtype: TimeUnit """ unit = unit.upper() if unit in ["SECONDS", "SECOND", "SEC", "S"]: time_unit = TimeUnit.SECONDS elif unit in ["MINUTES", "MINUTE", "MIN", "M"]: time_unit = TimeUnit.MINUTES elif unit in ["HOURS", "HOUR", "H"]: time_unit = TimeUnit.HOURS elif unit in ["DAYS", "DAY", "D"]: time_unit = TimeUnit.DAYS else: raise ValueError("Bad time unit input.") return time_unit class RelayStateModifier(Enum): """Relay-state modifiers used in LCN commands.""" ON = "1" OFF = "0" TOGGLE = "U" NOCHANGE = "-" class MotorStateModifier(Enum): """Motor-state modifiers used in LCN commands. LCN module has to be configured for motors connected to relays. """ UP = "U" DOWN = "D" STOP = "S" TOGGLEONOFF = "T" # toggle on/off TOGGLEDIR = "R" # toggle direction CYCLE = "C" # up, stop, down, stop, ... NOCHANGE = "-" class MotorReverseTime(Enum): """Motor reverse time user in LCN commands. For modules with FW<190C the release time has to be specified. """ RT70 = auto() # 70ms RT600 = auto() # 600ms RT1200 = auto() # 1200ms class RelVarRef(Enum): """Value-reference for relative LCN variable commands.""" CURRENT = auto() PROG = auto() # Programmed value (LCN-PRO). Set-points and thresholds. class SendKeyCommand(Enum): """Command types used when sending LCN keys.""" HIT = "K" MAKE = "L" BREAK = "O" DONTSEND = "-" class KeyLockStateModifier(Enum): """Key-lock modifiers used in LCN commands.""" ON = "1" OFF = "0" TOGGLE = "U" NOCHANGE = "-" class BeepSound(Enum): """Beep sounds supported by LCN modules.""" NORMAL = "N" SPECIAL = "S" HARDWARE_DESCRIPTIONS = dict( [ (-1, "UnknownModuleType"), (1, "LCN-SW1.0"), (2, "LCN-SW1.1"), (3, "LCN-UP1.0"), (4, "LCN-UP2"), (5, "LCN-SW2"), (6, "LCN-UP-Profi1-Plus"), (7, "LCN-DI12"), (8, "LCN-HU"), (9, "LCN-SH"), (10, "LCN-UP2"), (11, "LCN-UPP"), (12, "LCN-SK"), (14, "LCN-LD"), (15, "LCN-SH-Plus"), (17, "LCN-UPS"), (18, "LCN_UPS24V"), (19, "LCN-GTM"), (20, "LCN-SHS"), (21, "LCN-ESD"), (22, "LCN-EB2"), (23, "LCN-MRS"), (24, "LCN-EB11"), (25, "LCN-UMR"), (26, "LCN-UPU"), (27, "LCN-UMR24V"), (28, "LCN-SHD"), (29, "LCN-SHU"), (30, "LCN-SR6"), (31, "LCN-UMF"), (32, "LCN-WBH"), ] ) class HardwareType(Enum): """Hardware types as returned by serial number request.""" UNKNOWN = -1 SW1_0 = 1 SW1_1 = 2 UP1_0 = 3 UP2 = 4 SW2 = 5 UP_PROFI1_PLUS = 6 DI12 = 7 HU = 8 SH = 9 UPP = 11 SK = 12 LD = 14 SH_PLUS = 15 UPS = 17 UPS24V = 18 GTM = 19 SHS = 20 ESD = 21 EB2 = 22 MRS = 23 EB11 = 24 UMR = 25 UPU = 26 UMR24V = 27 SHD = 28 SHU = 29 SR6 = 30 UMF = 31 WBH = 32 @property def identifier(self) -> Any: """Get the LCN hardware identifier.""" return self.value @property def description(self) -> str: """Get the LCN hardware name.""" return HARDWARE_DESCRIPTIONS[self.value] @no_type_check def hw_type_new(cls, value): """Replace Hardwaretype.__new__.""" if value == 10: value = 4 return super(HardwareType, cls).__new__(cls, value) setattr(HardwareType, "__new__", hw_type_new) class AccessControlPeriphery(Enum): """Action types for LCN keys.""" TRANSMITTER = "transmitter" TRANSPONDER = "transponder" FINGERPRINT = "fingerprint" CODELOCK = "codelock" class LcnEvent(Enum): """LCN events.""" CONNECTION_ESTABLISHED = "connection-established" CONNECTION_LOST = "connection-lost" CONNECTION_REFUSED = "connection-refused" CONNECTION_TIMEOUT = "connection-timeout" PING_TIMEOUT = "ping-timeout" TIMEOUT_ERROR = "timeout-error" LICENSE_ERROR = "license-error" AUTHENTICATION_ERROR = "authentication-error" BUS_CONNECTED = "bus-connected" BUS_DISCONNECTED = "bus-disconnected" BUS_CONNECTION_STATUS_CHANGED = "bus-connection-status-changed" default_connection_settings: dict[str, Any] = { "NUM_TRIES": 3, # Total number of request to sent before going into # failed-state. "SK_NUM_TRIES": 3, # Total number of segment coupler scan tries "DIM_MODE": OutputPortDimMode.STEPS50, "ACKNOWLEDGE": True, # modules request an acknowledge command "DEFAULT_TIMEOUT": 3.5, # Default timeout for send command retries "MAX_STATUS_EVENTBASED_VALUEAGE": 600, # Poll interval for # status values that # automatically send # their values on change "MAX_STATUS_POLLED_VALUEAGE": 30, # Poll interval for status # values that do not send # their values on change # (always polled) "STATUS_REQUEST_DELAY_AFTER_COMMAND": 2, # Status request delay # after a command has # been send which # potentially changed # that status "BUS_IDLE_TIME": 0.05, # Time to wait for message traffic before sending "PING_SEND_DELAY": 600, # The default timeout for pings sent to PCHK "PING_RECV_TIMEOUT": 10, # The default timeout for pings expected from PCHK } pypck-0.8.5/pypck/module.py000066400000000000000000001107021475005466200156510ustar00rootroot00000000000000"""Module and group classes.""" from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable, Sequence from typing import TYPE_CHECKING, Any, cast from pypck import inputs, lcn_defs from pypck.helpers import TaskRegistry from pypck.lcn_addr import LcnAddr from pypck.pck_commands import PckGenerator from pypck.request_handlers import ( CommentRequestHandler, GroupMembershipDynamicRequestHandler, GroupMembershipStaticRequestHandler, NameRequestHandler, OemTextRequestHandler, SerialRequestHandler, StatusRequestsHandler, ) if TYPE_CHECKING: from pypck.connection import PchkConnectionManager class AbstractConnection: """Organizes communication with a specific module. Sends status requests to the connection and handles status responses. """ def __init__( self, conn: PchkConnectionManager, addr: LcnAddr, software_serial: int | None = None, wants_ack: bool = False, ) -> None: """Construct AbstractConnection instance.""" self.conn = conn self.addr = addr self.wants_ack = wants_ack if software_serial is None: software_serial = -1 self._software_serial: int = software_serial @property def task_registry(self) -> TaskRegistry: """Get the task registry.""" return self.conn.task_registry @property def seg_id(self) -> int: """Get the segment id.""" return self.addr.seg_id @property def addr_id(self) -> int: """Get the module or group id.""" return self.addr.addr_id @property def is_group(self) -> int: """Return whether this connection refers to a module or group.""" return self.addr.is_group @property def serials(self) -> dict[str, int | lcn_defs.HardwareType]: """Return serial numbers of a module.""" return { "hardware_serial": -1, "manu": -1, "software_serial": self._software_serial, "hardware_type": lcn_defs.HardwareType.UNKNOWN, } @property def hardware_serial(self) -> int: """Get the hardware serial number.""" return cast(int, self.serials["hardware_serial"]) @property def software_serial(self) -> int: """Get the software serial number.""" return cast(int, self.serials["software_serial"]) @property def manu(self) -> int: """Get the manufacturing number.""" return cast(int, self.serials["manu"]) @property def hardware_type(self) -> lcn_defs.HardwareType: """Get the hardware type.""" return cast(lcn_defs.HardwareType, self.serials["hardware_type"]) @property def serial_known(self) -> Awaitable[bool]: """Check if serials have already been received from module.""" event = asyncio.Event() event.set() return event.wait() async def request_serials(self) -> dict[str, int | lcn_defs.HardwareType]: """Request module serials.""" return self.serials async def send_command(self, wants_ack: bool, pck: str | bytes) -> bool: """Send a command to the module represented by this class. :param bool wants_ack: Also send a request for acknowledge. :param str pck: PCK command (without header). """ header = PckGenerator.generate_address_header( self.addr, self.conn.local_seg_id, wants_ack ) if isinstance(pck, str): return await self.conn.send_command(header + pck) return await self.conn.send_command(header.encode() + pck) # ## # ## Methods for sending PCK commands # ## async def dim_output(self, output_id: int, percent: float, ramp: int) -> bool: """Send a dim command for a single output-port. :param int output_id: Output id 0..3 :param float percent: Brightness in percent 0..100 :param int ramp: Ramp time in milliseconds :returns: True if command was sent successfully, False otherwise :rtype: bool """ return await self.send_command( self.wants_ack, PckGenerator.dim_output(output_id, percent, ramp) ) async def dim_all_outputs( self, percent: float, ramp: int, software_serial: int | None = None ) -> bool: """Send a dim command for all output-ports. :param float percent: Brightness in percent 0..100 :param int ramp: Ramp time in milliseconds. :param int software_serial: The minimum firmware version expected by any receiving module. :returns: True if command was sent successfully, False otherwise :rtype: bool """ if software_serial is None: await self.serial_known software_serial = self.software_serial return await self.send_command( self.wants_ack, PckGenerator.dim_all_outputs(percent, ramp, software_serial), ) async def rel_output(self, output_id: int, percent: float) -> bool: """Send a command to change the value of an output-port. :param int output_id: Output id 0..3 :param float percent: Relative brightness in percent -100..100 :returns: True if command was sent successfully, False otherwise :rtype: bool """ return await self.send_command( self.wants_ack, PckGenerator.rel_output(output_id, percent) ) async def toggle_output(self, output_id: int, ramp: int) -> bool: """Send a command that toggles a single output-port. Toggle mode: (on->off, off->on). :param int output_id: Output id 0..3 :param int ramp: Ramp time in milliseconds :returns: True if command was sent successfully, False otherwise :rtype: bool """ return await self.send_command( self.wants_ack, PckGenerator.toggle_output(output_id, ramp) ) async def toggle_all_outputs(self, ramp: int) -> bool: """Generate a command that toggles all output-ports. Toggle Mode: (on->off, off->on). :param int ramp: Ramp time in milliseconds :returns: True if command was sent successfully, False otherwise :rtype: bool """ return await self.send_command( self.wants_ack, PckGenerator.toggle_all_outputs(ramp) ) async def control_relays(self, states: list[lcn_defs.RelayStateModifier]) -> bool: """Send a command to control relays. :param states: The 8 modifiers for the relay states as alist :type states: list(:class:`~pypck.lcn_defs.RelayStateModifier`) :returns: True if command was sent successfully, False otherwise :rtype: bool """ return await self.send_command( self.wants_ack, PckGenerator.control_relays(states) ) async def control_relays_timer( self, time_msec: int, states: list[lcn_defs.RelayStateModifier] ) -> bool: """Send a command to control relays. :param int time_msec: Duration of timer in milliseconds :param states: The 8 modifiers for the relay states as alist :type states: list(:class:`~pypck.lcn_defs.RelayStateModifier`) :returns: True if command was sent successfully, False otherwise :rtype: bool """ return await self.send_command( self.wants_ack, PckGenerator.control_relays_timer(time_msec, states) ) async def control_motors_relays( self, states: list[lcn_defs.MotorStateModifier] ) -> bool: """Send a command to control motors via relays. :param states: The 4 modifiers for the cover states as a list :type states: list(:class: `~pypck.lcn-defs.MotorStateModifier`) :returns: True if command was sent successfully, False otherwise :rtype: bool """ return await self.send_command( self.wants_ack, PckGenerator.control_motors_relays(states) ) async def control_motors_outputs( self, state: lcn_defs.MotorStateModifier, reverse_time: lcn_defs.MotorReverseTime | None = None, ) -> bool: """Send a command to control a motor via output ports 1+2. :param MotorStateModifier state: The modifier for the cover state :param MotorReverseTime reverse_time: Reverse time for modules with FW<190C :type state: :class: `~pypck.lcn-defs.MotorStateModifier` :returns: True if command was sent successfully, False otherwise :rtype: bool """ return await self.send_command( self.wants_ack, PckGenerator.control_motors_outputs(state, reverse_time), ) async def activate_scene( self, register_id: int, scene_id: int, output_ports: Sequence[lcn_defs.OutputPort] = (), relay_ports: Sequence[lcn_defs.RelayPort] = (), ramp: int | None = None, ) -> bool: """Activate the stored states for the given scene. :param int register_id: Register id 0..9 :param int scene_id: Scene id 0..9 :param list(OutputPort) output_ports: Output ports to activate as list :param list(RelayPort) relay_ports: Relay ports to activate as list :param int ramp: Ramp value :returns: True if command was sent successfully, False otherwise :rtype: bool """ success = await self.send_command( self.wants_ack, PckGenerator.change_scene_register(register_id) ) if not success: return False result = True if output_ports: result &= await self.send_command( self.wants_ack, PckGenerator.activate_scene_output(scene_id, output_ports, ramp), ) if relay_ports: result &= await self.send_command( self.wants_ack, PckGenerator.activate_scene_relay(scene_id, relay_ports), ) return result async def store_scene( self, register_id: int, scene_id: int, output_ports: Sequence[lcn_defs.OutputPort] = (), relay_ports: Sequence[lcn_defs.RelayPort] = (), ramp: int | None = None, ) -> bool: """Store states in the given scene. :param int register_id: Register id 0..9 :param int scene_id: Scene id 0..9 :param list(OutputPort) output_ports: Output ports to store as list :param list(RelayPort) relay_ports: Relay ports to store as list :param int ramp: Ramp value :returns: True if command was sent successfully, False otherwise :rtype: bool """ success = await self.send_command( self.wants_ack, PckGenerator.change_scene_register(register_id) ) if not success: return False result = True if output_ports: result &= await self.send_command( self.wants_ack, PckGenerator.store_scene_output(scene_id, output_ports, ramp), ) if relay_ports: result &= await self.send_command( self.wants_ack, PckGenerator.store_scene_relay(scene_id, relay_ports), ) return result async def store_scene_outputs_direct( self, register_id: int, scene_id: int, percents: Sequence[float], ramps: Sequence[int], ) -> bool: """Store the given output values and ramps in the given scene. :param int register_id: Register id 0..9 :param int scene_id: Scene id 0..9 :param list(float) percents: Output values in percent as list :param list(int) ramp: Ramp values as list :returns: True if command was sent successfully, False otherwise :rtype: bool """ return await self.send_command( self.wants_ack, PckGenerator.store_scene_outputs_direct( register_id, scene_id, percents, ramps ), ) async def var_abs( self, var: lcn_defs.Var, value: float | lcn_defs.VarValue, unit: lcn_defs.VarUnit = lcn_defs.VarUnit.NATIVE, software_serial: int | None = None, ) -> bool: """Send a command to set the absolute value to a variable. :param Var var: Variable :param float value: Absolute value to set :param VarUnit unit: Unit of variable :returns: True if command was sent successfully, False otherwise :rtype: bool """ if not isinstance(value, lcn_defs.VarValue): value = lcn_defs.VarValue.from_var_unit(value, unit, True) if software_serial is None: await self.serial_known software_serial = self.software_serial if lcn_defs.Var.to_var_id(var) != -1: # Absolute commands for variables 1-12 are not supported if self.addr_id == 4 and self.is_group: # group 4 are status messages return await self.send_command( self.wants_ack, PckGenerator.update_status_var(var, value.to_native()), ) # We fake the missing command by using reset and relative # commands. success = await self.send_command( self.wants_ack, PckGenerator.var_reset(var, software_serial) ) if not success: return False return await self.send_command( self.wants_ack, PckGenerator.var_rel( var, lcn_defs.RelVarRef.CURRENT, value.to_native(), software_serial ), ) return await self.send_command( self.wants_ack, PckGenerator.var_abs(var, value.to_native()) ) async def var_reset( self, var: lcn_defs.Var, software_serial: int | None = None ) -> bool: """Send a command to reset the variable value. :param Var var: Variable :returns: True if command was sent successfully, False otherwise :rtype: bool """ if software_serial is None: await self.serial_known software_serial = self.software_serial return await self.send_command( self.wants_ack, PckGenerator.var_reset(var, software_serial) ) async def var_rel( self, var: lcn_defs.Var, value: float | lcn_defs.VarValue, unit: lcn_defs.VarUnit = lcn_defs.VarUnit.NATIVE, value_ref: lcn_defs.RelVarRef = lcn_defs.RelVarRef.CURRENT, software_serial: int | None = None, ) -> bool: """Send a command to change the value of a variable. :param Var var: Variable :param float value: Relative value to add (may also be negative) :param VarUnit unit: Unit of variable :returns: True if command was sent successfully, False otherwise :rtype: bool """ if not isinstance(value, lcn_defs.VarValue): value = lcn_defs.VarValue.from_var_unit(value, unit, False) if software_serial is None: await self.serial_known software_serial = self.software_serial return await self.send_command( self.wants_ack, PckGenerator.var_rel(var, value_ref, value.to_native(), software_serial), ) async def lock_regulator( self, reg_id: int, state: bool, target_value: float = -1 ) -> bool: """Send a command to lock a regulator. :param int reg_id: Regulator id :param bool state: Lock state (locked=True, unlocked=False) :param float target_value: Target value in percent (use -1 to ignore) :returns: True if command was sent successfully, False otherwise :rtype: bool """ return await self.send_command( self.wants_ack, PckGenerator.lock_regulator( reg_id, state, self.software_serial, target_value ), ) async def control_led( self, led: lcn_defs.LedPort, state: lcn_defs.LedStatus ) -> bool: """Send a command to control a led. :param LedPort led: Led port :param LedStatus state: Led status """ return await self.send_command( self.wants_ack, PckGenerator.control_led(led.value, state) ) async def send_keys( self, keys: list[list[bool]], cmd: lcn_defs.SendKeyCommand ) -> list[bool]: """Send a command to send keys. :param list(bool)[4][8] keys: 2d-list with [table_id][key_id] bool values, if command should be sent to specific key :param SendKeyCommand cmd: command to send for each table :returns: True if command was sent successfully, False otherwise :rtype: list of bool """ results: list[bool] = [] for table_id, key_states in enumerate(keys): if True in key_states: cmds = [lcn_defs.SendKeyCommand.DONTSEND] * 4 cmds[table_id] = cmd results.append( await self.send_command( self.wants_ack, PckGenerator.send_keys(cmds, key_states) ) ) return results async def send_keys_hit_deferred( self, keys: list[list[bool]], delay_time: int, delay_unit: lcn_defs.TimeUnit ) -> list[bool]: """Send a command to send keys deferred. :param list(bool)[4][8] keys: 2d-list with [table_id][key_id] bool values, if command should be sent to specific key :param int delay_time: Delay time :param TimeUnit delay_unit: Unit of time :returns: True if command was sent successfully, False otherwise :rtype: list of bool """ results: list[bool] = [] for table_id, key_states in enumerate(keys): if True in key_states: results.append( await self.send_command( self.wants_ack, PckGenerator.send_keys_hit_deferred( table_id, delay_time, delay_unit, key_states ), ), ) return results async def lock_keys( self, table_id: int, states: list[lcn_defs.KeyLockStateModifier] ) -> bool: """Send a command to lock keys. :param int table_id: Table id: 0..3 :param keyLockStateModifier states: The 8 modifiers for the key lock states as a list :returns: True if command was sent successfully, False otherwise :rtype: bool """ return await self.send_command( self.wants_ack, PckGenerator.lock_keys(table_id, states) ) async def lock_keys_tab_a_temporary( self, delay_time: int, delay_unit: lcn_defs.TimeUnit, states: list[bool] ) -> bool: """Send a command to lock keys in table A temporary. :param int delay_time: Time to lock keys :param TimeUnit delay_unit: Unit of time :param list(bool) states: The 8 lock states of the keys as list (locked=True, unlocked=False) :returns: True if command was sent successfully, False otherwise :rtype: bool """ return await self.send_command( self.wants_ack, PckGenerator.lock_keys_tab_a_temporary(delay_time, delay_unit, states), ) async def clear_dyn_text(self, row_id: int) -> bool: """Clear previously sent dynamic text. :param int row_id: Row id 0..3 :returns: True if command was sent successfully, False otherwise :rtype: bool """ return await self.dyn_text(row_id, "") async def dyn_text(self, row_id: int, text: str) -> bool: """Send dynamic text to a module. :param int row_id: Row id 0..3 :param str text: Text to send (up to 60 bytes) :returns: True if command was sent successfully, False otherwise :rtype: bool """ encoded_text = text.encode(lcn_defs.LCN_ENCODING) parts = [encoded_text[12 * part : 12 * part + 12] for part in range(5)] result = True for part_id, part in enumerate(parts): result &= await self.send_command( self.wants_ack, PckGenerator.dyn_text_part(row_id, part_id, part), ) return result async def beep(self, sound: lcn_defs.BeepSound, count: int) -> bool: """Send a command to make count number of beep sounds. :param BeepSound sound: Beep sound style :param int count: Number of beeps (1..15) :returns: True if command was sent successfully, False otherwise :rtype: bool """ return await self.send_command(self.wants_ack, PckGenerator.beep(sound, count)) async def ping(self) -> bool: """Send a command that does nothing and request an acknowledgement.""" return await self.send_command(True, PckGenerator.empty()) async def pck(self, pck: str) -> bool: """Send arbitrary PCK command. :param str pck: PCK command :returns: True if command was sent successfully, False otherwise :rtype: bool """ return await self.send_command(self.wants_ack, pck) class GroupConnection(AbstractConnection): """Organizes communication with a specific group. It is assumed that all modules within this group are newer than FW170206 """ def __init__( self, conn: PchkConnectionManager, addr: LcnAddr, software_serial: int = 0x170206, ): """Construct GroupConnection instance.""" assert addr.is_group super().__init__(conn, addr, software_serial=software_serial, wants_ack=False) async def var_abs( self, var: lcn_defs.Var, value: float | lcn_defs.VarValue, unit: lcn_defs.VarUnit = lcn_defs.VarUnit.NATIVE, software_serial: int | None = None, ) -> bool: """Send a command to set the absolute value to a variable. :param Var var: Variable :param float value: Absolute value to set :param VarUnit unit: Unit of variable """ result = True # for new modules (>=0x170206) result &= await super().var_abs(var, value, unit, 0x170206) # for old modules (<0x170206) if var in [ lcn_defs.Var.TVAR, lcn_defs.Var.R1VAR, lcn_defs.Var.R2VAR, lcn_defs.Var.R1VARSETPOINT, lcn_defs.Var.R2VARSETPOINT, ]: result &= await super().var_abs(var, value, unit, 0x000000) return result async def var_reset( self, var: lcn_defs.Var, software_serial: int | None = None ) -> bool: """Send a command to reset the variable value. :param Var var: Variable """ result = True result &= await super().var_reset(var, 0x170206) if var in [ lcn_defs.Var.TVAR, lcn_defs.Var.R1VAR, lcn_defs.Var.R2VAR, lcn_defs.Var.R1VARSETPOINT, lcn_defs.Var.R2VARSETPOINT, ]: result &= await super().var_reset(var, 0) return result async def var_rel( self, var: lcn_defs.Var, value: float | lcn_defs.VarValue, unit: lcn_defs.VarUnit = lcn_defs.VarUnit.NATIVE, value_ref: lcn_defs.RelVarRef = lcn_defs.RelVarRef.CURRENT, software_serial: int | None = None, ) -> bool: """Send a command to change the value of a variable. :param Var var: Variable :param float value: Relative value to add (may also be negative) :param VarUnit unit: Unit of variable """ result = True result &= await super().var_rel(var, value, software_serial=0x170206) if var in [ lcn_defs.Var.TVAR, lcn_defs.Var.R1VAR, lcn_defs.Var.R2VAR, lcn_defs.Var.R1VARSETPOINT, lcn_defs.Var.R2VARSETPOINT, lcn_defs.Var.THRS1, lcn_defs.Var.THRS2, lcn_defs.Var.THRS3, lcn_defs.Var.THRS4, lcn_defs.Var.THRS5, ]: result &= await super().var_rel(var, value, software_serial=0) return result async def activate_status_request_handler(self, item: Any) -> None: """Activate a specific TimeoutRetryHandler for status requests.""" await self.conn.segment_scan_completed_event.wait() async def activate_status_request_handlers(self) -> None: """Activate all TimeoutRetryHandlers for status requests.""" # self.request_serial.activate() await self.conn.segment_scan_completed_event.wait() class ModuleConnection(AbstractConnection): """Organizes communication with a specific module or group.""" def __init__( self, conn: PchkConnectionManager, addr: LcnAddr, activate_status_requests: bool = False, has_s0_enabled: bool = False, software_serial: int | None = None, wants_ack: bool = True, ): """Construct ModuleConnection instance.""" assert not addr.is_group super().__init__( conn, addr, software_serial=software_serial, wants_ack=wants_ack ) self.activate_status_requests = activate_status_requests self.has_s0_enabled = has_s0_enabled self.input_callbacks: set[Callable[[inputs.Input], None]] = set() # List of queued acknowledge codes from the LCN modules. self.acknowledges: asyncio.Queue[int] = asyncio.Queue() # RequestHandlers num_tries: int = self.conn.settings["NUM_TRIES"] timeout: int = self.conn.settings["DEFAULT_TIMEOUT"] # Serial Number request self.serials_request_handler = SerialRequestHandler( self, num_tries, timeout, software_serial=software_serial, ) # Name, Comment, OemText requests self.name_request_handler = NameRequestHandler(self, num_tries, timeout) self.comment_request_handler = CommentRequestHandler(self, num_tries, timeout) self.oem_text_request_handler = OemTextRequestHandler(self, num_tries, timeout) # Group membership request self.static_groups_request_handler = GroupMembershipStaticRequestHandler( self, num_tries, timeout ) self.dynamic_groups_request_handler = GroupMembershipDynamicRequestHandler( self, num_tries, timeout ) self.status_requests_handler = StatusRequestsHandler(self) if self.activate_status_requests: self.task_registry.create_task(self.activate_status_request_handlers()) async def send_command(self, wants_ack: bool, pck: str | bytes) -> bool: """Send a command to the module represented by this class. :param bool wants_ack: Also send a request for acknowledge. :param str pck: PCK command (without header). """ if wants_ack: return await self.send_command_with_ack(pck) return await super().send_command(False, pck) # ## # ## Retry logic if an acknowledge is requested # ## async def send_command_with_ack(self, pck: str | bytes) -> bool: """Send a PCK command and ensure receiving of an acknowledgement. Resends the PCK command if no acknowledgement has been received within timeout. :param str pck: PCK command (without header). :returns: True if acknowledge was received, False otherwise :rtype: bool """ count = 0 while count < self.conn.settings["NUM_TRIES"]: await super().send_command(True, pck) try: code = await asyncio.wait_for( self.acknowledges.get(), timeout=self.conn.settings["DEFAULT_TIMEOUT"], ) except asyncio.TimeoutError: count += 1 continue if code == -1: return True break return False async def on_ack(self, code: int = -1) -> None: """Is called whenever an acknowledge is received from the LCN module. :param int code: The LCN internal code. -1 means "positive" acknowledge """ await self.acknowledges.put(code) async def activate_status_request_handler(self, item: Any) -> None: """Activate a specific TimeoutRetryHandler for status requests.""" self.task_registry.create_task(self.status_requests_handler.activate(item)) async def activate_status_request_handlers(self) -> None: """Activate all TimeoutRetryHandlers for status requests.""" self.task_registry.create_task( self.status_requests_handler.activate_all(activate_s0=self.has_s0_enabled) ) async def cancel_status_request_handler(self, item: Any) -> None: """Cancel a specific TimeoutRetryHandler for status requests.""" await self.status_requests_handler.cancel(item) async def cancel_status_request_handlers(self) -> None: """Canecl all TimeoutRetryHandlers for status requests.""" await self.status_requests_handler.cancel_all() async def cancel_requests(self) -> None: """Cancel all TimeoutRetryHandlers.""" await self.cancel_status_request_handlers() await self.serials_request_handler.cancel() await self.name_request_handler.cancel() await self.oem_text_request_handler.cancel() await self.static_groups_request_handler.cancel() await self.dynamic_groups_request_handler.cancel() def set_s0_enabled(self, s0_enabled: bool) -> None: """Set the activation status for S0 variables. :param bool s0_enabled: If True, a BU4L has to be connected to the hardware module and S0 mode has to be activated in LCN-PRO. """ self.has_s0_enabled = s0_enabled def get_s0_enabled(self) -> bool: """Get the activation status for S0 variables.""" return self.has_s0_enabled # ## # ## Methods for handling input objects # ## def register_for_inputs( self, callback: Callable[[inputs.Input], None] ) -> Callable[..., None]: """Register a function for callback on PCK message received. Returns a function to unregister the callback. """ self.input_callbacks.add(callback) return lambda callback=callback: self.input_callbacks.remove(callback) async def async_process_input(self, inp: inputs.Input) -> None: """Is called by input object's process method. Method to handle incoming commands for this specific module (status, toggle_output, switch_relays, ...) """ if isinstance(inp, inputs.ModAck): await self.on_ack(inp.code) return None # handle typeless variable responses if isinstance(inp, inputs.ModStatusVar): inp = self.status_requests_handler.preprocess_modstatusvar(inp) for input_callback in self.input_callbacks: input_callback(inp) def dump_details(self) -> dict[str, Any]: """Dump detailed information about this module.""" is_local_segment = self.addr.seg_id in (0, self.conn.local_seg_id) return { "segment": self.addr.seg_id, "address": self.addr.addr_id, "is_local_segment": is_local_segment, "serials": { "hardware_serial": f"{self.hardware_serial:10X}", "manu": f"{self.manu:02X}", "software_serial": f"{self.software_serial:06X}", "hardware_type": f"{self.hardware_type.value:d}", "hardware_name": self.hardware_type.description, }, "name": self.name, "comment": self.comment, "oem_text": self.oem_text, "groups": { "static": sorted(addr.addr_id for addr in self.static_groups), "dynamic": sorted(addr.addr_id for addr in self.dynamic_groups), }, } # ## # ## Requests # ## # ## properties @property def serials(self) -> dict[str, int | lcn_defs.HardwareType]: """Return serials number information.""" return self.serials_request_handler.serials @property def name(self) -> str: """Return stored name.""" return self.name_request_handler.name @property def comment(self) -> str: """Return stored comments.""" return self.comment_request_handler.comment @property def oem_text(self) -> list[str]: """Return stored OEM text.""" return self.oem_text_request_handler.oem_text @property def static_groups(self) -> set[LcnAddr]: """Return static group membership.""" return self.static_groups_request_handler.groups @property def dynamic_groups(self) -> set[LcnAddr]: """Return dynamic group membership.""" return self.dynamic_groups_request_handler.groups @property def groups(self) -> set[LcnAddr]: """Return static and dynamic group membership.""" return self.static_groups | self.dynamic_groups # ## future properties @property def serial_known(self) -> Awaitable[bool]: """Check if serials have already been received from module.""" return self.serials_request_handler.serial_known.wait() async def request_serials(self) -> dict[str, int | lcn_defs.HardwareType]: """Request module serials.""" return await self.serials_request_handler.request() async def request_name(self) -> str: """Request module name.""" return await self.name_request_handler.request() async def request_comment(self) -> str: """Request comments from a module.""" return await self.comment_request_handler.request() async def request_oem_text(self) -> list[str]: """Request OEM text from a module.""" return await self.oem_text_request_handler.request() async def request_static_groups(self) -> set[LcnAddr]: """Request module static group memberships.""" return set(await self.static_groups_request_handler.request()) async def request_dynamic_groups(self) -> set[LcnAddr]: """Request module dynamic group memberships.""" return set(await self.dynamic_groups_request_handler.request()) async def request_groups(self) -> set[LcnAddr]: """Request module group memberships.""" static_groups = await self.static_groups_request_handler.request() dynamic_groups = await self.dynamic_groups_request_handler.request() return static_groups | dynamic_groups pypck-0.8.5/pypck/pck_commands.py000066400000000000000000001333111475005466200170230ustar00rootroot00000000000000"""PCK command parsers and generators.""" from __future__ import annotations import re from collections.abc import Sequence from pypck import lcn_defs from pypck.lcn_addr import LcnAddr class PckParser: """Helpers to parse LCN-PCK commands. LCN-PCK is the command-syntax used by LCN-PCHK to send and receive LCN commands. """ # Authentication at LCN-PCHK: Request user name. AUTH_USERNAME = "Username:" # Authentication at LCN-PCHK: Request password. AUTH_PASSWORD = "Password:" # Authentication at LCN-PCHK succeeded. AUTH_OK = "OK" # Authentication at LCN-PCHK failed. AUTH_FAILED = "Authentification failed." # LCN-PK/PKU is connected. LCNCONNSTATE_CONNECTED = "$io:#LCN:connected" # LCN-PK/PKU is disconnected. LCNCONNSTATE_DISCONNECTED = "$io:#LCN:disconnected" # Decimal mode set DEC_MODE_SET = "(dec-mode)" # License Error LICENSE_ERROR = "$err:(license?)" # Pattern to parse error messages. PATTERN_COMMAND_ERROR = re.compile(r"\((?P.+)\?\)") # Pattern to parse ping messages. PATTERN_PING = re.compile(r"\^ping(?P\d*)-") # Pattern to parse positive acknowledges. PATTERN_ACK_POS = re.compile(r"-M(?P\d{3})(?P\d{3})!") # Pattern to parse negative acknowledges. PATTERN_ACK_NEG = re.compile(r"-M(?P\d{3})(?P\d{3})(?P\d+)") # Pattern to parse segment coupler responses. PATTERN_SK_RESPONSE = re.compile( r"=M(?P\d{3})(?P\d{3})\.SK(?P\d+)" ) # Pattern to parse serial number and firmware date responses. PATTERN_SN = re.compile( r"=M(?P\d{3})(?P\d{3})\.SN(?P[0-9|A-F]{10})" r"(?P.{2})FW(?P[0-9|A-F]{6})" r"HW(?P\d+)" ) # Pattern to parse module name and comment PATTERN_NAME_COMMENT = re.compile( r"=M(?P\d{3})(?P\d{3})\.(?P[NKO])" r"(?P\d)(?P.{0,12})" ) # Pattern to parse the static and dynamic group membership status PATTERN_STATUS_GROUPS = re.compile( r"=M(?P\d{3})(?P\d{3})\.G(?P[DP])(?P\d{3})" r"(?:(?P\d{3}))?(?:(?P\d{3}))?(?:(?P\d{3}))?(?:(?P\d{3}))?" r"(?:(?P\d{3}))?(?:(?P\d{3}))?(?:(?P\d{3}))?(?:(?P\d{3}))?" r"(?:(?P\d{3}))?(?:(?P\d{3}))?(?:(?P\d{3}))?(?:(?P\d{3}))?" ) # Pattern to parse output-port status responses in percent. PATTERN_STATUS_OUTPUT_PERCENT = re.compile( r":M(?P\d{3})(?P\d{3})A(?P\d)(?P\d+)" ) # Pattern to parse output-port status responses in native format (0..200). PATTERN_STATUS_OUTPUT_NATIVE = re.compile( r":M(?P\d{3})(?P\d{3})O(?P\d)(?P\d+)" ) # Pattern to parse relays status responses. PATTERN_STATUS_RELAYS = re.compile( r":M(?P\d{3})(?P\d{3})Rx(?P\d+)" ) # Pattern to parse binary-sensors status responses. PATTERN_STATUS_BINSENSORS = re.compile( r":M(?P\d{3})(?P\d{3})Bx(?P\d+)" ) # Pattern to parse variable 1-12 status responses (since 170206). PATTERN_STATUS_VAR = re.compile( r"%M(?P\d{3})(?P\d{3})\.A(?P\d{3})(?P\d+)" ) # Pattern to parse set-point variable status responses (since 170206). PATTERN_STATUS_SETVAR = re.compile( r"%M(?P\d{3})(?P\d{3})\.S(?P\d)(?P\d+)" ) # Pattern to parse threshold status responses (since 170206). PATTERN_STATUS_THRS = re.compile( r"%M(?P\d{3})(?P\d{3})\.T(?P\d)" r"(?P\d)(?P\d+)" ) # Pattern to parse S0-input status responses (LCN-BU4L). PATTERN_STATUS_S0INPUT = re.compile( r"%M(?P\d{3})(?P\d{3})\.C(?P\d)(?P\d+)" ) # Pattern to parse generic variable status responses (concrete type # unknown, before 170206). PATTERN_VAR_GENERIC = re.compile( r"%M(?P\d{3})(?P\d{3})\.(?P\d+)" ) # Pattern to parse threshold register 1 status responses (5 values, # before 170206). */ PATTERN_THRS5 = re.compile( r"=M(?P\d{3})(?P\d{3})\.S1(?P\d{5})" r"(?P\d{5})(?P\d{5})(?P\d{5})" r"(?P\d{5})(?P\d{5})" ) # Pattern to parse status of LEDs and logic-operations responses. PATTERN_STATUS_LEDSANDLOGICOPS = re.compile( r"=M(?P\d{3})(?P\d{3})\.TL(?P[AEBF]{12})" r"(?P[NTV]{4})" ) # Pattern to parse key-locks status responses. PATTERN_STATUS_KEYLOCKS = re.compile( r"=M(?P\d{3})(?P\d{3})\.TX(?P\d{3})" r"(?P\d{3})(?P\d{3})((?P\d{3}))?" ) # Pattern to parse scene output status messages. PATTERN_STATUS_SCENE_OUTPUTS = re.compile( r"=M(?P\d{3})(?P\d{3})\.SZ(?P\d{3})" r"(?P\d{3})(?P\d{3})(?P\d{3})(?P\d{3})" r"(?P\d{3})(?P\d{3})(?P\d{3})(?P\d{3})" ) # Pattern to parse send command to host messages. PATTERN_SEND_COMMAND_HOST = re.compile( r"(\$M|\+M004)(?P\d{3})(?P\d{3})\.SKH" r"(?P\d{3})(?P\d{3})" r"(?:(?P\d{3})(?P\d{3})(?P\d{3})(?P\d{3}))?" r"(?:(?P\d{3})(?P\d{3})(?P\d{3})(?P\d{3})" r"(?P\d{3})(?P\d{3})(?P\d{3})(?P\d{3}))?" ) # Pattern to parse send key to host messages. PATTERN_SEND_KEYS_HOST = re.compile( r"(\$M|\+M004)(?P\d{3})(?P\d{3})\.STH" r"(?P\d{3})(?P\d{3})" ) # Pattern to parse transmitter status messages. PATTERN_STATUS_TRANSMITTER = re.compile( r"=M(?P\d{3})(?P\d{3})\.ZI" r"(?P\d{3})(?P\d{3})(?P\d{3})" r"(?P\d{2})(?P\d)(?P\d{3})" ) # Pattern to parse transponder status messages. PATTERN_STATUS_TRANSPONDER = re.compile( r"=M(?P\d{3})(?P\d{3})\.ZT" r"(?P\d{3})(?P\d{3})(?P\d{3})" ) # Pattern to parse fingerprint status messages. PATTERN_STATUS_FINGERPRINT = re.compile( r"=M(?P\d{3})(?P\d{3})\.ZF" r"(?P\d{3})(?P\d{3})(?P\d{3})" ) # Pattern to parse codelock status messages. PATTERN_STATUS_CODELOCK = re.compile( r"=M(?P\d{3})(?P\d{3})\.ZC" r"(?P\d{3})(?P\d{3})(?P\d{3})" ) @staticmethod def get_boolean_value(input_byte: int) -> list[bool]: """Get boolean representation for the given byte. :param int input_byte: Input byte as int8. :return: List with 8 boolean values. :rtype: list """ if (input_byte < 0) or (input_byte > 255): raise ValueError("Invalid input_byte.") result = [] for i in range(8): result.append((input_byte & (1 << i)) != 0) return result class PckGenerator: """Helpers to generate LCN-PCK commands. LCN-PCK is the command-syntax used by LCN-PCHK to send and receive LCN commands. """ # Terminates a PCK command TERMINATION = "\n" TABLE_NAMES = ["A", "B", "C", "D"] @staticmethod def ping(counter: int) -> str: """Generate a keep-alive. LCN-PCHK will close the connection if it does not receive any commands specific period (10 minutes by default). :param int counter: The current ping's id (optional, but 'best practice'). Should start with 1 :return: The PCK command as text :rtype: str """ return f"^ping{counter:d}" @staticmethod def set_dec_mode() -> str: """Generate PCK command to set used number system to decimal.""" return "!CHD" @staticmethod def set_operation_mode( dim_mode: lcn_defs.OutputPortDimMode, status_mode: lcn_defs.OutputPortStatusMode ) -> str: """Generate a PCK command to set the connection's operation mode. This influences how output-port commands and status are interpreted and must be in sync with the LCN bus. :param OuputPortDimMode dimMode: The dimming mode (50/200 steps) :param OutputPortStatusMode statusMode: The status mode (percent/native) :return: The PCK command as text :rtype: str """ return ( "!OM" f"{'1' if (dim_mode == lcn_defs.OutputPortDimMode.STEPS200) else '0'}" f"{'P' if (status_mode == lcn_defs.OutputPortStatusMode.PERCENT) else 'N'}" ) @staticmethod def generate_address_header( addr: LcnAddr, local_seg_id: int, wants_ack: bool ) -> str: """Generate a PCK command address header. :param addr: The module's/group's address :type addr: :class:`~LcnAddr` :param int local_seg_id: The local segment id :param bool wants_ack: Is an acknowledge requested. :return: The PCK address header string. :rtype: str """ return ( ">" f"{'G' if addr.is_group else 'M'}" f"{addr.get_physical_seg_id(local_seg_id):03d}" f"{addr.addr_id:03d}" f"{'!' if wants_ack else '.'}" ) @staticmethod def segment_coupler_scan() -> str: """Generate a scan-command for LCN segment-couplers. Used to detect the local segment (where the physical bus connection is located). :return: The PCK command (without address header) as text :rtype: str """ return "SK" @staticmethod def request_serial() -> str: """Generate a firmware/serial-number request. :return: The PCK command (without address header) as text :rtype: str """ return "SN" @staticmethod def request_name(block_id: int) -> str: """Generate a name request. :return The PCK command (without address header) as text :rtype: str """ if block_id not in [0, 1]: raise ValueError("Invalid block_id.") return f"NMN{block_id + 1}" @staticmethod def request_comment(block_id: int) -> str: """Generate a comment request. :return The PCK command (without address header) as text :rtype: str """ if block_id not in [0, 1, 2]: raise ValueError("Invalid block_id.") return f"NMK{block_id + 1}" @staticmethod def request_oem_text(block_id: int) -> str: """Generate an oem text request. :return The PCK command (without address header) as text :rtype: str """ if block_id not in [0, 1, 2, 3]: raise ValueError("Invalid block_id.") return f"NMO{block_id + 1}" @staticmethod def request_group_membership_static() -> str: """Generate a group membership request for static membership (EEPROM). :return The PCK command (without address header) as text :rtype: str """ return "GP" @staticmethod def request_group_membership_dynamic() -> str: """Generate a group membership request for dynamic membership. :return The PCK command (without address header) as text :rtype: str """ return "GD" @staticmethod def request_output_status(output_id: int) -> str: """Generate an output-port status request. :param int output_id: Output id 0..3 :return: The PCK command (without address header) as text :rtype: str """ if (output_id < 0) or (output_id > 3): raise ValueError("Invalid output_id.") return f"SMA{output_id + 1}" @staticmethod def dim_output(output_id: int, percent: float, ramp: int) -> str: """Generate a dim command for a single output-port. :param int output_id: Output id 0..3 :param float percent: Brightness in percent 0..100 :param int ramp: Ramp value :return: The PCK command (without address header) as text :rtype: str """ if (output_id < 0) or (output_id > 3): raise ValueError("Invalid output_id.") percent_round = int(round(percent * 2)) ramp = int(ramp) if (percent_round % 2) == 0: # Use the percent command (supported by all LCN-PCHK versions) pck = f"A{output_id + 1}DI{percent_round // 2:03d}{ramp:03d}" else: # We have a ".5" value. Use the native command (supported since # LCN-PCHK 2.3) pck = f"O{output_id + 1}DI{percent_round:03d}{ramp:03d}" return pck @staticmethod def dim_all_outputs(percent: float, ramp: int, software_serial: int) -> str: """Generate a dim command for all output-ports. :param float percent: Brightness in percent 0..100 :param int ramp: Ramp value :param int software_serial: The expected firmware version of all receiving modules. :return: The PCK command (without address header) as text :rtype: str """ percent_round = int(round(percent * 2)) if software_serial >= 0x180501: # Supported since LCN-PCHK 2.61 pck = ( "OY" f"{percent_round:03d}" f"{percent_round:03d}" f"{percent_round:03d}" f"{percent_round:03d}" f"{ramp:03d}" ) elif percent_round == 0: # All off pck = f"AA{ramp:03d}" elif percent_round == 200: # All on pck = f"AE{ramp:03d}" else: # This is our worst-case: No high-res, no ramp pck = f"AH{percent_round // 2:03d}" return pck @staticmethod def rel_output(output_id: int, percent: float) -> str: """Generate a command to change the value of an output-port. :param int output_id: Output id 0..3 :param float percent: Relative percentage -100..100 :return: The PCK command (without address header) as text :rtype: str """ if (output_id < 0) or (output_id > 3): raise ValueError("Invalid output_id.") percent_round = int(round(percent * 2)) if percent_round % 2 == 0: # Use the percent command (supported by all LCN-PCHK versions) pck = ( "A" f"{output_id + 1}" f"{'AD' if percent >= 0 else 'SB'}" f"{abs(percent_round // 2):03d}" ) else: # We have a ".5" value. Use the native command (supported since # LCN-PCHK 2.3) pck = ( "O" f"{output_id + 1}" f"{'AD' if percent >= 0 else 'SB'}" f"{abs(percent_round):03d}" ) return pck @staticmethod def toggle_output(output_id: int, ramp: int) -> str: """Generate a command that toggles a single output-port. Toggle mode: (on->off, off->on). :param int output_id: Output id 0..3 :param int ramp: Ramp value :return: The PCK command (without address header) as text :rtype: str """ if (output_id < 0) or (output_id > 3): raise ValueError("Invalid output_id.") return f"A{output_id + 1}TA{ramp:03d}" @staticmethod def toggle_all_outputs(ramp: int) -> str: """Generate a command that toggles all output-ports. Toggle mode: (on->off, off->on). :param int ramp: Ramp value :return: The PCK command (without address header) as text :rtype: str """ return f"AU{ramp:03d}" @staticmethod def request_relays_status() -> str: """Generate a relays-status request. :return: The PCK command (without address header) as text :rtype: str """ return "SMR" @staticmethod def control_relays(states: list[lcn_defs.RelayStateModifier]) -> str: """Generate a command to control relays. :param RelayStateModifier states: The 8 modifiers for the relay states as a list :return: The PCK command (without address header) as text :rtype: str """ if len(states) != 8: raise ValueError("Invalid states length.") ret = "R8" for state in states: ret += state.value return ret @staticmethod def control_relays_timer( time_msec: int, states: list[lcn_defs.RelayStateModifier] ) -> str: """Generate a command to control relays. :param int time_msec: Duration of timer in milliseconds :param RelayStateModifier states: The 8 modifiers for the relay states as a list (only ON and OFF allowed) :return: The PCK command (without address header) as text :rtype: str """ if len(states) != 8: raise ValueError("Invalid states length.") value = lcn_defs.time_to_native_value(time_msec) ret = f"R8T{value:03d}" for state in states: assert state in ( lcn_defs.RelayStateModifier.ON, lcn_defs.RelayStateModifier.OFF, ) ret += state.value return ret @staticmethod def control_motors_relays(states: list[lcn_defs.MotorStateModifier]) -> str: """Generate a command to control motors via relays. :param MotorStateModifier states: The 4 modifiers for the motor states as a list :return: The PCK command (without address header) as text :rtype: str """ if len(states) != 4: raise ValueError("Invalid states length.") ret = "R8" for state in states: if state == lcn_defs.MotorStateModifier.UP: ret += lcn_defs.RelayStateModifier.ON.value ret += lcn_defs.RelayStateModifier.OFF.value elif state == lcn_defs.MotorStateModifier.DOWN: ret += lcn_defs.RelayStateModifier.ON.value ret += lcn_defs.RelayStateModifier.ON.value elif state == lcn_defs.MotorStateModifier.STOP: ret += lcn_defs.RelayStateModifier.OFF.value ret += lcn_defs.RelayStateModifier.NOCHANGE.value elif state == lcn_defs.MotorStateModifier.TOGGLEONOFF: ret += lcn_defs.RelayStateModifier.TOGGLE.value ret += lcn_defs.RelayStateModifier.NOCHANGE.value elif state == lcn_defs.MotorStateModifier.TOGGLEDIR: ret += lcn_defs.RelayStateModifier.NOCHANGE.value ret += lcn_defs.RelayStateModifier.TOGGLE.value elif state == lcn_defs.MotorStateModifier.CYCLE: ret += lcn_defs.RelayStateModifier.TOGGLE.value ret += lcn_defs.RelayStateModifier.TOGGLE.value elif state == lcn_defs.MotorStateModifier.NOCHANGE: ret += lcn_defs.RelayStateModifier.NOCHANGE.value ret += lcn_defs.RelayStateModifier.NOCHANGE.value return ret @staticmethod def control_motors_outputs( state: lcn_defs.MotorStateModifier, reverse_time: lcn_defs.MotorReverseTime | None = None, ) -> str: """Generate a command to control a motor via output ports 1+2. :param MotorStateModifier state: The modifier for the motor state :param MotorReverseTime reverse_time: Reverse time for modules with FW<190C :return: The PCK command (without address header) as text :rtype: str """ if state == lcn_defs.MotorStateModifier.UP: if reverse_time in [None, lcn_defs.MotorReverseTime.RT70]: params = (0x01, 0xE4, 0x00) ret = f"X2{params[0]:03d}{params[1]:03d}{params[2]:03d}" elif reverse_time == lcn_defs.MotorReverseTime.RT600: ret = PckGenerator.dim_output(0, 100, 8) elif reverse_time == lcn_defs.MotorReverseTime.RT1200: ret = PckGenerator.dim_output(0, 100, 11) else: raise ValueError("Wrong MotorReverseTime.") elif state == lcn_defs.MotorStateModifier.DOWN: if reverse_time in [None, lcn_defs.MotorReverseTime.RT70]: params = (0x01, 0x00, 0xE4) ret = f"X2{params[0]:03d}{params[1]:03d}{params[2]:03d}" elif reverse_time == lcn_defs.MotorReverseTime.RT600: ret = PckGenerator.dim_output(1, 100, 8) elif reverse_time == lcn_defs.MotorReverseTime.RT1200: ret = PckGenerator.dim_output(1, 100, 11) else: raise ValueError("Wrong MotorReverseTime.") elif state == lcn_defs.MotorStateModifier.STOP: ret = "AY000000" elif state == lcn_defs.MotorStateModifier.CYCLE: ret = "JE" else: raise ValueError("MotorStateModifier is not supported by output ports.") return ret @staticmethod def request_bin_sensors_status() -> str: """Generate a binary-sensors status request. :return: The PCK command (without address header) as text :rtype: str """ return "SMB" @staticmethod def var_abs(var: lcn_defs.Var, value: int) -> str: """Generate a command that sets a variable absolute. :param Var var: The target variable to set :param int value: The absolute value to set :return: The PCK command (without address header) as text :rtype: str """ set_point_id = lcn_defs.Var.to_set_point_id(var) if set_point_id != -1: # Set absolute (not in PCK yet) byte1 = set_point_id << 6 # 01000000 byte1 |= 0x20 # xx10xxxx (set absolute) value -= 1000 # Offset byte1 |= (value >> 8) & 0x0F # xxxx1111 byte2 = value & 0xFF return f"X2{30:03d}{byte1:03d}{byte2:03d}" # Setting variables and thresholds absolute not implemented in LCN # firmware yet raise ValueError("Wrong variable type.") @staticmethod def update_status_var(var: lcn_defs.Var, value: int) -> str: """Generate a command that send variable status updates. PCHK provides this variables by itself on selected segments is only possible with group 4 :param Var var: The target variable to set :param int value: The absolute value to set :return: The PCK command (without address header) as text :rtype: str """ var_id = lcn_defs.Var.to_var_id(var) if var_id != -1: # define variable to set, offset 0x01000000 x2cmd = var_id | 0x40 byte1 = (value >> 8) & 0xFF byte2 = value & 0xFF return f"X2{x2cmd:03d}{byte1:03d}{byte2:03d}" # Setting variables and thresholds absolute not implemented in LCN # firmware yet raise ValueError("Wrong variable type.") @staticmethod def var_reset(var: lcn_defs.Var, software_serial: int) -> str: """Generate a command that resets a variable to 0. :param Var var: The target variable to set 0 :param int software_serial: The expected firmware version of all receiving modules. :return: The PCK command (without address header) as text :rtype: str """ var_id = lcn_defs.Var.to_var_id(var) if var_id != -1: if software_serial >= 0x170206: pck = f"Z-{var_id + 1:03d}{4090:04d}" else: if var_id == 0: pck = "ZS30000" else: raise ValueError("Wrong variable type.") return pck set_point_id = lcn_defs.Var.to_set_point_id(var) if set_point_id != -1: # Set absolute = 0 (not in PCK yet) byte1 = set_point_id << 6 # 01000000 byte1 |= 0x20 # xx10xxxx 9set absolute) byte2 = 0 return f"X2{30:03d}{byte1:03d}{byte2:03d}" # Reset for threshold not implemented in LCN firmware yet raise ValueError("Wrong variable type.") @staticmethod def var_rel( var: lcn_defs.Var, rel_var_ref: lcn_defs.RelVarRef, value: int, software_serial: int, ) -> str: """Generate a command to change the value of a variable. :param Var var: The target variable to change :param RelVarRef rel_var_ref: The reference-point :param int value: The native LCN value to add/subtract (can be negative) :param int software_serial: The expected firmware version of all receiving modules. :return: The PCK command (without address header) as text :rtype: str """ var_id = lcn_defs.Var.to_var_id(var) if var_id != -1: if var_id == 0: # Old command for variable 1 / T-var (compatible with all # modules) pck = "Z" f"{'A' if value >= 0 else 'S'}" f"{abs(value)}" else: # New command for variable 1-12 (compatible with all modules, # since LCN-PCHK 2.8) pck = ( "Z" f"{'+' if value >= 0 else '-'}" f"{var_id + 1:03d}" f"{abs(value)}" ) return pck set_point_id = lcn_defs.Var.to_set_point_id(var) if set_point_id != -1: pck = ( "RE" f"{'A' if set_point_id == 0 else 'B'}" f"S{'A' if rel_var_ref == lcn_defs.RelVarRef.CURRENT else 'P'}" f"{'+' if value >= 0 else '-'}" f"{abs(value)}" ) return pck thrs_register_id = lcn_defs.Var.to_thrs_register_id(var) thrs_id = lcn_defs.Var.to_thrs_id(var) if (thrs_register_id != -1) and (thrs_id != -1): if software_serial >= 0x170206: # New command for registers 1-4 (since 170206, LCN-PCHK 2.8) pck = ( "SS" f"{'R' if rel_var_ref == lcn_defs.RelVarRef.CURRENT else 'E'}" f"{abs(value):04d}" f"{'A' if value >= 0 else 'S'}" f"R{thrs_register_id + 1}" f"{thrs_id + 1}" ) elif thrs_register_id == 0: # Old command for register 1 (before 170206) pck = ( "SS" f"{'R' if rel_var_ref == lcn_defs.RelVarRef.CURRENT else 'E'}" f"{abs(value):04d}" f"{'A' if value >= 0 else 'S'}" f"{'1' if thrs_id == 0 else '0'}" f"{'1' if thrs_id == 1 else '0'}" f"{'1' if thrs_id == 2 else '0'}" f"{'1' if thrs_id == 3 else '0'}" f"{'1' if thrs_id == 4 else '0'}" ) return pck raise ValueError("Wrong variable type.") @staticmethod def request_var_status(var: lcn_defs.Var, software_serial: int) -> str: """Generate a variable value request. :param Var var: The variable to request :param int software_serial: The target module's firmware version :return: The PCK command (without address header) as text :rtype: str """ if software_serial >= 0x170206: var_id = lcn_defs.Var.to_var_id(var) if var_id != -1: return f"MWT{var_id + 1:03d}" set_point_id = lcn_defs.Var.to_set_point_id(var) if set_point_id != -1: return f"MWS{set_point_id + 1:03d}" thrs_register_id = lcn_defs.Var.to_thrs_register_id(var) if thrs_register_id != -1: # Whole register return f"SE{thrs_register_id + 1:03d}" s0_id = lcn_defs.Var.to_s0_id(var) if s0_id != -1: return f"MWC{s0_id + 1:03d}" else: if var == lcn_defs.Var.VAR1ORTVAR: pck = "MWV" elif var == lcn_defs.Var.VAR2ORR1VAR: pck = "MWTA" elif var == lcn_defs.Var.VAR3ORR2VAR: pck = "MWTB" elif var == lcn_defs.Var.R1VARSETPOINT: pck = "MWSA" elif var == lcn_defs.Var.R2VARSETPOINT: pck = "MWSB" elif var in [ lcn_defs.Var.THRS1, lcn_defs.Var.THRS2, lcn_defs.Var.THRS3, lcn_defs.Var.THRS4, lcn_defs.Var.THRS5, ]: pck = "SL1" # Whole register return pck raise ValueError("Wrong variable type.") @staticmethod def request_leds_and_logic_ops() -> str: """Generate a request for LED and logic-operations states. :return: The PCK command (without address header) as text :rtype: str """ return "SMT" @staticmethod def control_led(led_id: int, state: lcn_defs.LedStatus) -> str: """Generate a command to the set the state of a single LED. :param int led_id: Led id 0..11 :param LedStatus state: The state to set :return: The PCK command (without address header) as text :rtype: str """ if (led_id < 0) or (led_id > 11): raise ValueError("Bad led_id.") return f"LA{led_id + 1:03d}{state.value}" @staticmethod def send_keys(cmds: list[lcn_defs.SendKeyCommand], keys: list[bool]) -> str: """Generate a command to send LCN keys. :param SendKeyCommand cmds: The 4 concrete commands to send for the tables (A-D) as list :param list(bool) keys: The tables' 8 key-states (True means "send") as list :return: The PCK command (without address header) as text :rtype: str """ if (len(cmds) != 4) or (len(keys) != 8): raise ValueError("Wrong cmds length or wrong keys length.") ret = "TS" for i, cmd in enumerate(cmds): if (cmd == lcn_defs.SendKeyCommand.DONTSEND) and (i == 3): # By skipping table D (if it is not used), we use the old # command # for table A-C which is compatible with older LCN modules break ret += cmd.value for key in keys: ret += "1" if key else "0" return ret @staticmethod def send_keys_hit_deferred( table_id: int, time: int, time_unit: lcn_defs.TimeUnit, keys: list[bool] ) -> str: """Generate a command to send LCN keys deferred / delayed. :param int table_id: Table id 0(A)..3(D) :param int time: The delay time :param TimeUnit time_unit: The time unit :param list(bool) keys: The table's 8 key-states (True means "send") as list :return: The PCK command (without address header) as text :rtype: str """ if (table_id < 0) or (table_id > 3) or (len(keys) != 8): raise ValueError("Bad table_id or keys.") ret = "TV" try: ret += PckGenerator.TABLE_NAMES[table_id] except IndexError as exc: raise ValueError("Wrong table_id.") from exc ret += f"{time:03d}" if time_unit == lcn_defs.TimeUnit.SECONDS: if (time < 1) or (time > 60): raise ValueError("Wrong time.") ret += "S" elif time_unit == lcn_defs.TimeUnit.MINUTES: if (time < 1) or (time > 90): raise ValueError("Wrong time.") ret += "M" elif time_unit == lcn_defs.TimeUnit.HOURS: if (time < 1) or (time > 50): raise ValueError("Wrong time.") ret += "H" elif time_unit == lcn_defs.TimeUnit.DAYS: if (time < 1) or (time > 45): raise ValueError("Wrong time.") ret += "D" else: raise ValueError("Wrong time_unit.") for key in keys: ret += "1" if key else "0" return ret @staticmethod def request_key_lock_status() -> str: """Generate a request for key-lock states. Always requests table A-D. Supported since LCN-PCHK 2.8. :return: The PCK command (without address header) as text :rtype: str """ return "STX" @staticmethod def lock_keys(table_id: int, states: list[lcn_defs.KeyLockStateModifier]) -> str: """Generate a command to lock keys. :param int table_id: Table id 0(A)..3(D) :param list(bool) states: The 8 key-lock modifiers as list :return: The PCK command (without address header) as text :rtype: str """ if (table_id < 0) or (table_id > 3) or (len(states) != 8): raise ValueError("Bad table_id or states.") try: ret = f"TX{PckGenerator.TABLE_NAMES[table_id]}" except IndexError as exc: raise ValueError("Wrong table_id.") from exc for state in states: ret += state.value return ret @staticmethod def lock_keys_tab_a_temporary( time: int, time_unit: lcn_defs.TimeUnit, keys: list[bool] ) -> str: """Generate a command to lock keys for table A temporary. There is no hardware-support for locking tables B-D. :param int time: The lock time :param TimeUnit time_unit: The time unit :param list(bool) keys: The 8 key-lock states (True means lock) as list :return: The PCK command (without address header) as text :rtype: str """ if len(keys) != 8: raise ValueError("Wrong keys length.") ret = f"TXZA{time:03d}" if time_unit == lcn_defs.TimeUnit.SECONDS: if (time < 1) or (time > 60): raise ValueError("Wrong time.") ret += "S" elif time_unit == lcn_defs.TimeUnit.MINUTES: if (time < 1) or (time > 90): raise ValueError("Wrong time.") ret += "M" elif time_unit == lcn_defs.TimeUnit.HOURS: if (time < 1) or (time > 50): raise ValueError("Wrong time.") ret += "H" elif time_unit == lcn_defs.TimeUnit.DAYS: if (time < 1) or (time > 45): raise ValueError("Wrong time.") ret += "D" else: raise ValueError("Wrong time_unit.") for key in keys: ret += "1" if key else "0" return ret @staticmethod def dyn_text_part(row_id: int, part_id: int, part: bytes) -> bytes: """Generate the command header / start for sending dynamic texts. Used by LCN-GTxD periphery (supports 4 text rows). To complete the command, the text to send must be appended (UTF-8 encoding). Texts are split up into up to 5 parts with 12 "UTF-8 bytes" each. :param int row_id: Row id 0..3 :param int part_id: Part id 0..4 :param bytes part: Text part (up to 12 bytes), encoded as lcn_defs.LCN_ENCODING :return: The PCK command (without address header) as encoded bytes :rtype: bytes """ if ( (row_id < 0) or (row_id > 3) or (part_id < 0) or (part_id > 4) or (len(part) > 12) ): raise ValueError("Wrong row_id, part_id or part length.") return f"GTDT{row_id + 1}{part_id + 1}".encode() + part @staticmethod def lock_regulator( reg_id: int, state: bool, software_serial: int, target_value: float = -1, ) -> str: """Generate a command to lock a regulator. :param int reg_id: Regulator id 0..1 :param bool state: The lock state :param int software_serial: The expected firmware version of all receiving modules. :param foat target_value: The target value in percent (use -1 to ignore) :return: The PCK command (without address header) as text :rtype: str """ if (reg_id < 0) or (reg_id > 1): raise ValueError("Wrong reg_id.") if ((target_value < 0) or (target_value > 100)) and (target_value != -1): raise ValueError("Wrong target_value.") if (target_value != -1) and (software_serial >= 0x120301) and state: reg_byte = reg_id * 0x40 + 0x07 return f"X2{0x1E:03d}{reg_byte:03d}{int(2*target_value):03d}" return f"RE{'A' if reg_id == 0 else 'B'}X{'S' if state else 'A'}" @staticmethod def change_scene_register(register_id: int) -> str: """Change the active scene register. :param int register_id: Register id 0..9 :return: The PCK command (without address header) as text :rtype: str """ if (register_id < 0) or (register_id > 9): raise ValueError("Wrong register_id.") return f"SZW{register_id:03d}" @staticmethod def store_scene_outputs_direct( register_id: int, scene_id: int, percents: Sequence[float], ramps: Sequence[int] ) -> str: """Store the given output values and ramps in the given scene. :param int register_id: Register id 0..9 :param int scene_id: Scene id 0..9 :param list(float) percents: Output values in percent as list :param list(int) ramp: Ramp values as list :return: The PCK command (without address header) as text :rtype: str """ if (scene_id < 0) or (scene_id > 9): raise ValueError("Wrong scene_id.") if len(percents) not in (2, 4): raise ValueError("Need 2 or 4 output percent values.") if len(ramps) != len(percents): raise ValueError("Need as many ramp values as output percent values.") cmd = f"SZD{register_id:03d}{scene_id:03d}" for i, percent in enumerate(percents): cmd += f"{int(percent * 2):03d}{ramps[i]:03d}" return cmd @staticmethod def activate_scene_output( scene_id: int, output_ports: Sequence[lcn_defs.OutputPort] = (), ramp: int | None = None, ) -> str: """Activate the stored output states for the given scene. Please note: The output ports 3 and 4 can only be activated simultaneously. If one of them is given, the other one is activated, too. :param int scene_id: Scene id 0..9 :param list(OutputPort) output_ports: Output ports to activate as list :param int ramp: Ramp value :return: The PCK command (without address header) as text :rtype: str """ return PckGenerator._activate_or_store_scene_output( scene_id, output_ports, ramp, store=False ) @staticmethod def store_scene_output( scene_id: int, output_ports: Sequence[lcn_defs.OutputPort] = (), ramp: int | None = None, ) -> str: """Store the current output states in the given scene. Please note: The output ports 3 and 4 can only be stored simultaneously. If one of them is given, the other one is stored, too. :param int scene_id: Scene id 0..9 :param list(OutputPort) output_ports: Output ports to store as list :param int ramp: Ramp value :return: The PCK command (without address header) as text :rtype: str """ return PckGenerator._activate_or_store_scene_output( scene_id, output_ports, ramp, store=True ) @staticmethod def _activate_or_store_scene_output( scene_id: int, output_ports: Sequence[lcn_defs.OutputPort] = (), ramp: int | None = None, store: bool = False, ) -> str: if (scene_id < 0) or (scene_id > 9): raise ValueError("Wrong scene_id.") if not output_ports: raise ValueError("output_port list is empty.") output_mask = 0 if lcn_defs.OutputPort.OUTPUT1 in output_ports: output_mask += 1 if lcn_defs.OutputPort.OUTPUT2 in output_ports: output_mask += 2 if ( lcn_defs.OutputPort.OUTPUT3 in output_ports or lcn_defs.OutputPort.OUTPUT4 in output_ports ): output_mask += 4 if store: action = "S" else: action = "A" if ramp is None: pck = f"SZ{action:s}{output_mask:1d}{scene_id:03d}" else: pck = f"SZ{action:s}{output_mask:1d}{scene_id:03d}{ramp:03d}" return pck @staticmethod def activate_scene_relay( scene_id: int, relay_ports: Sequence[lcn_defs.RelayPort] = () ) -> str: """Activate the stored relay states for the given scene. :param int scene_id: Scene id 0..9 :param list(RelayPort) relay_ports: Relay ports to activate as list :return: The PCK command (without address header) as text :rtype: str """ return PckGenerator._activate_or_store_scene_relay( scene_id, relay_ports, store=False ) @staticmethod def store_scene_relay( scene_id: int, relay_ports: Sequence[lcn_defs.RelayPort] = () ) -> str: """Store the current relay states in the given scene. :param int scene_id: Scene id 0..9 :param list(RelayPort) relay_ports: Relay ports to store as list :return: The PCK command (without address header) as text :rtype: str """ return PckGenerator._activate_or_store_scene_relay( scene_id, relay_ports, store=True ) @staticmethod def _activate_or_store_scene_relay( scene_id: int, relay_ports: Sequence[lcn_defs.RelayPort] = (), store: bool = False, ) -> str: if (scene_id < 0) or (scene_id > 9): raise ValueError("Wrong scene_id.") if not relay_ports: raise ValueError("relay_port list is empty.") relays_mask = ["0"] * 8 for port in relay_ports: relays_mask[port.value] = "1" if store: action = "S" else: action = "A" return f"SZ{action}0{scene_id:03d}{''.join(relays_mask)}" @staticmethod def request_status_scene(register_id: int, scene_id: int) -> str: """Request the stored output and ramp values for the given scene. :param int register_id: Register id 0..9 :param int register_id: Scene id 0..9 :return: The PCK command (without address header) as text :rtype: str """ if (register_id < 0) or (register_id > 9): raise ValueError("Wrong register_id.") if (scene_id < 0) or (scene_id > 9): raise ValueError("Wrong scene_id.") return f"SZR{register_id:03d}{scene_id:03d}" @staticmethod def beep(sound: lcn_defs.BeepSound, count: int) -> str: """Make count number of beep sounds. :param BeepSound sound: Beep sound style :param int count: Number of beeps (1..15) :return: The PCK command (without address header) as text :rtype: str """ if (count < 1) or (count > 15): raise ValueError("Wrong number of beeps.") return f"PI{sound.value:s}{count:03d}" @staticmethod def empty() -> str: """Generate an empty command (LEER) that does nothing. Combine with request for acknowledgement to discover and ping modules and to discover and verify group memberships. :return: The PCK command (without address header) as text :rtype: str """ return "LEER" pypck-0.8.5/pypck/request_handlers.py000066400000000000000000000571441475005466200177460ustar00rootroot00000000000000"""Handlers for requests.""" from __future__ import annotations import asyncio from typing import TYPE_CHECKING, Any from pypck import inputs, lcn_defs from pypck.helpers import TaskRegistry from pypck.lcn_addr import LcnAddr from pypck.pck_commands import PckGenerator from pypck.timeout_retry import TimeoutRetryHandler if TYPE_CHECKING: from pypck.module import ModuleConnection class RequestHandler: """Base RequestHandler class.""" def __init__( self, addr_conn: ModuleConnection, num_tries: int = 3, timeout: float = 1.5, ): """Initialize class instance.""" self.addr_conn = addr_conn self.trh = TimeoutRetryHandler(self.task_registry, num_tries, timeout) self.trh.set_timeout_callback(self.timeout) # callback addr_conn.register_for_inputs(self.process_input) @property def task_registry(self) -> TaskRegistry: """Get the task registry.""" return self.addr_conn.task_registry async def request(self) -> Any: """Request information from module.""" raise NotImplementedError() def process_input(self, inp: inputs.Input) -> None: """Create a task to process the input object concurrently.""" self.task_registry.create_task(self.async_process_input(inp)) async def async_process_input(self, inp: inputs.Input) -> None: """Process incoming input object. Method to handle incoming commands for this request handler. """ raise NotImplementedError() async def timeout(self, failed: bool = False) -> None: """Is called on serial request timeout.""" raise NotImplementedError() async def cancel(self) -> None: """Cancel request.""" await self.trh.cancel() class SerialRequestHandler(RequestHandler): """Request handler to request serial number information from module.""" def __init__( self, addr_conn: ModuleConnection, num_tries: int = 3, timeout: float = 1.5, software_serial: int | None = None, ): """Initialize class instance.""" self.hardware_serial = -1 self.manu = -1 if software_serial is None: software_serial = -1 self.software_serial = software_serial self.hardware_type = lcn_defs.HardwareType.UNKNOWN # events self.serial_known = asyncio.Event() super().__init__(addr_conn, num_tries, timeout) async def async_process_input(self, inp: inputs.Input) -> None: """Process incoming input object. Method to handle incoming commands for this specific request handler. """ if isinstance(inp, inputs.ModSn): self.hardware_serial = inp.hardware_serial self.manu = inp.manu self.software_serial = inp.software_serial self.hardware_type = inp.hardware_type self.serial_known.set() await self.cancel() async def timeout(self, failed: bool = False) -> None: """Is called on serial request timeout.""" if not failed: await self.addr_conn.send_command(False, PckGenerator.request_serial()) else: self.serial_known.set() async def request(self) -> dict[str, int | lcn_defs.HardwareType]: """Request serial number.""" await self.addr_conn.conn.segment_scan_completed_event.wait() self.serial_known.clear() self.trh.activate() await self.serial_known.wait() return self.serials @property def serials(self) -> dict[str, int | lcn_defs.HardwareType]: """Return serial numbers of a module.""" return { "hardware_serial": self.hardware_serial, "manu": self.manu, "software_serial": self.software_serial, "hardware_type": self.hardware_type, } class NameRequestHandler(RequestHandler): """Request handler to request name of a module.""" def __init__( self, addr_conn: ModuleConnection, num_tries: int = 3, timeout: float = 1.5, ): """Initialize class instance.""" self._name: list[str | None] = [None] * 2 self.name_known = asyncio.Event() super().__init__(addr_conn, num_tries, timeout) self.trhs = [] for block_id in range(2): trh = TimeoutRetryHandler(self.task_registry, num_tries, timeout) trh.set_timeout_callback(self.timeout, block_id=block_id) self.trhs.append(trh) async def async_process_input(self, inp: inputs.Input) -> None: """Process incoming input object. Method to handle incoming commands for this specific request handler. """ if isinstance(inp, inputs.ModNameComment): command = inp.command block_id = inp.block_id text = inp.text if command == "N": self._name[block_id] = f"{text:10s}" await self.cancel(block_id) if None not in self._name: self.name_known.set() await self.cancel() # pylint: disable=arguments-differ async def timeout(self, failed: bool = False, block_id: int = 0) -> None: """Is called on name request timeout.""" if not failed: await self.addr_conn.send_command( False, PckGenerator.request_name(block_id) ) else: self.name_known.set() async def request(self) -> str: """Request name from a module.""" self._name = [None] * 2 await self.addr_conn.conn.segment_scan_completed_event.wait() self.name_known.clear() for trh in self.trhs: trh.activate() await self.name_known.wait() return self.name # pylint: disable=arguments-differ async def cancel(self, block_id: int | None = None) -> None: """Cancel name request task.""" if block_id is None: # cancel all for trh in self.trhs: await trh.cancel() else: await self.trhs[block_id].cancel() @property def name(self) -> str: """Return stored name.""" return "".join([block for block in self._name if block]).strip() class CommentRequestHandler(RequestHandler): """Request handler to request comment of a module.""" def __init__( self, addr_conn: ModuleConnection, num_tries: int = 3, timeout: float = 1.5, ): """Initialize class instance.""" self._comment: list[str | None] = [None] * 3 self.comment_known = asyncio.Event() super().__init__(addr_conn, num_tries, timeout) self.trhs = [] for block_id in range(3): trh = TimeoutRetryHandler(self.task_registry, num_tries, timeout) trh.set_timeout_callback(self.timeout, block_id=block_id) self.trhs.append(trh) async def async_process_input(self, inp: inputs.Input) -> None: """Process incoming input object. Method to handle incoming commands for this specific request handler. """ if isinstance(inp, inputs.ModNameComment): command = inp.command block_id = inp.block_id text = inp.text if command == "K": self._comment[block_id] = f"{text:12s}" await self.cancel(block_id) if None not in self._comment: self.comment_known.set() await self.cancel() # pylint: disable=arguments-differ async def timeout(self, failed: bool = False, block_id: int = 0) -> None: """Is called on comment request timeout.""" if not failed: await self.addr_conn.send_command( False, PckGenerator.request_comment(block_id) ) else: self.comment_known.set() async def request(self) -> str: """Request comments from a module.""" self._comment = [None] * 3 await self.addr_conn.conn.segment_scan_completed_event.wait() self.comment_known.clear() for trh in self.trhs: trh.activate() await self.comment_known.wait() return self.comment # pylint: disable=arguments-differ async def cancel(self, block_id: int | None = None) -> None: """Cancel comment request task.""" if block_id is None: # cancel all for trh in self.trhs: await trh.cancel() else: await self.trhs[block_id].cancel() @property def comment(self) -> str: """Return stored comment.""" return "".join([block for block in self._comment if block]).strip() class OemTextRequestHandler(RequestHandler): """Request handler to request OEM text of a module.""" def __init__( self, addr_conn: ModuleConnection, num_tries: int = 3, timeout: float = 1.5, ): """Initialize class instance.""" self._oem_text: list[str | None] = [None] * 4 self.oem_text_known = asyncio.Event() super().__init__(addr_conn, num_tries, timeout) self.trhs = [] for block_id in range(4): trh = TimeoutRetryHandler(self.task_registry, num_tries, timeout) trh.set_timeout_callback(self.timeout, block_id=block_id) self.trhs.append(trh) async def async_process_input(self, inp: inputs.Input) -> None: """Process incoming input object. Method to handle incoming commands for this specific request handler. """ if isinstance(inp, inputs.ModNameComment): command = inp.command block_id = inp.block_id text = inp.text if command == "O": self._oem_text[block_id] = f"{text:12s}" await self.cancel(block_id) if None not in self._oem_text: self.oem_text_known.set() await self.cancel() # pylint: disable=arguments-differ async def timeout(self, failed: bool = False, block_id: int = 0) -> None: """Is called on OEM text request timeout.""" if not failed: await self.addr_conn.send_command( False, PckGenerator.request_oem_text(block_id) ) else: self.oem_text_known.set() async def request(self) -> list[str]: """Request OEM text from a module.""" self._oem_text = [None] * 4 await self.addr_conn.conn.segment_scan_completed_event.wait() self.oem_text_known.clear() for trh in self.trhs: trh.activate() await self.oem_text_known.wait() return self.oem_text # pylint: disable=arguments-differ async def cancel(self, block_id: int | None = None) -> None: """Cancel OEM text request task.""" if block_id is None: # cancel all for trh in self.trhs: await trh.cancel() else: await self.trhs[block_id].cancel() @property def oem_text(self) -> list[str]: """Return stored OEM text.""" return [block.strip() if block else "" for block in self._oem_text] # return {'block{}'.format(idx):text # for idx, text in enumerate(self._oem_text)} # return ''.join([block for block in self._oem_text if block]) class GroupMembershipStaticRequestHandler(RequestHandler): """Request handler to request static group membership of a module.""" def __init__( self, addr_conn: ModuleConnection, num_tries: int = 3, timeout: float = 1.5, ): """Initialize class instance.""" self.groups: set[LcnAddr] = set() self.groups_known = asyncio.Event() super().__init__(addr_conn, num_tries, timeout) async def async_process_input(self, inp: inputs.Input) -> None: """Process incoming input object. Method to handle incoming commands for this specific request handler. """ if isinstance(inp, inputs.ModStatusGroups): if not inp.dynamic: # static self.groups.update(inp.groups) self.groups_known.set() await self.cancel() async def timeout(self, failed: bool = False) -> None: """Is called on static group membership request timeout.""" if not failed: await self.addr_conn.send_command( False, PckGenerator.request_group_membership_static() ) else: self.groups_known.set() async def request(self) -> set[LcnAddr]: """Request static group membership from a module.""" await self.addr_conn.conn.segment_scan_completed_event.wait() self.groups_known.clear() self.trh.activate() await self.groups_known.wait() return self.groups class GroupMembershipDynamicRequestHandler(RequestHandler): """Request handler to request static group membership of a module.""" def __init__( self, addr_conn: ModuleConnection, num_tries: int = 3, timeout: float = 1.5, ): """Initialize class instance.""" self.groups: set[LcnAddr] = set() self.groups_known = asyncio.Event() super().__init__(addr_conn, num_tries, timeout) async def async_process_input(self, inp: inputs.Input) -> None: """Process incoming input object. Method to handle incoming commands for this specific request handler. """ if isinstance(inp, inputs.ModStatusGroups): if inp.dynamic: # dynamic self.groups.update(inp.groups) self.groups_known.set() await self.cancel() async def timeout(self, failed: bool = False) -> None: """Is called on dynamic group membership request timeout.""" if not failed: await self.addr_conn.send_command( False, PckGenerator.request_group_membership_dynamic() ) else: self.groups_known.set() async def request(self) -> set[LcnAddr]: """Request dynamic group membership from a module.""" await self.addr_conn.conn.segment_scan_completed_event.wait() self.groups_known.clear() self.trh.activate() await self.groups_known.wait() return self.groups class StatusRequestsHandler: """Manages all status requests for variables, software version, ...""" def __init__(self, addr_conn: ModuleConnection): """Construct StatusRequestHandler instance.""" self.addr_conn = addr_conn self.settings = addr_conn.conn.settings self.last_requested_var_without_type_in_response = lcn_defs.Var.UNKNOWN self.last_var_lock = asyncio.Lock() # Output-port request status (0..3) self.request_status_outputs = [] for output_port in range(4): trh = TimeoutRetryHandler( self.task_registry, -1, self.settings["MAX_STATUS_EVENTBASED_VALUEAGE"], ) trh.set_timeout_callback(self.request_status_outputs_timeout, output_port) self.request_status_outputs.append(trh) # Relay request status (all 8) self.request_status_relays = TimeoutRetryHandler( self.task_registry, -1, self.settings["MAX_STATUS_EVENTBASED_VALUEAGE"] ) self.request_status_relays.set_timeout_callback( self.request_status_relays_timeout ) # Binary-sensors request status (all 8) self.request_status_bin_sensors = TimeoutRetryHandler( self.task_registry, -1, self.settings["MAX_STATUS_EVENTBASED_VALUEAGE"] ) self.request_status_bin_sensors.set_timeout_callback( self.request_status_bin_sensors_timeout ) # Variables request status. # Lazy initialization: Will be filled once the firmware version is # known. self.request_status_vars = {} for var in lcn_defs.Var: if var != lcn_defs.Var.UNKNOWN: self.request_status_vars[var] = TimeoutRetryHandler( self.task_registry, -1, self.settings["MAX_STATUS_EVENTBASED_VALUEAGE"], ) self.request_status_vars[var].set_timeout_callback( self.request_status_var_timeout, var=var ) # LEDs and logic-operations request status (all 12+4). self.request_status_leds_and_logic_ops = TimeoutRetryHandler( self.task_registry, -1, self.settings["MAX_STATUS_POLLED_VALUEAGE"] ) self.request_status_leds_and_logic_ops.set_timeout_callback( self.request_status_leds_and_logic_ops_timeout ) # Key lock-states request status (all tables, A-D). self.request_status_locked_keys = TimeoutRetryHandler( self.task_registry, -1, self.settings["MAX_STATUS_POLLED_VALUEAGE"] ) self.request_status_locked_keys.set_timeout_callback( self.request_status_locked_keys_timeout ) @property def task_registry(self) -> TaskRegistry: """Get the task registry.""" return self.addr_conn.task_registry def preprocess_modstatusvar(self, inp: inputs.ModStatusVar) -> inputs.Input: """Fill typeless response with last requested variable type.""" if inp.orig_var == lcn_defs.Var.UNKNOWN: # Response without type (%Msssaaa.wwwww) inp.var = self.last_requested_var_without_type_in_response self.last_requested_var_without_type_in_response = lcn_defs.Var.UNKNOWN if self.last_var_lock.locked(): self.last_var_lock.release() else: # Response with variable type (%Msssaaa.Avvvwww) inp.var = inp.orig_var return inp async def request_status_outputs_timeout( self, failed: bool = False, output_port: int = 0 ) -> None: """Is called on output status request timeout.""" if not failed: await self.addr_conn.send_command( False, PckGenerator.request_output_status(output_port) ) async def request_status_relays_timeout(self, failed: bool = False) -> None: """Is called on relay status request timeout.""" if not failed: await self.addr_conn.send_command( False, PckGenerator.request_relays_status() ) async def request_status_bin_sensors_timeout(self, failed: bool = False) -> None: """Is called on binary sensor status request timeout.""" if not failed: await self.addr_conn.send_command( False, PckGenerator.request_bin_sensors_status() ) async def request_status_var_timeout( self, failed: bool = False, var: lcn_defs.Var | None = None ) -> None: """Is called on variable status request timeout.""" assert var is not None # Detect if we can send immediately or if we have to wait for a # "typeless" response first has_type_in_response = lcn_defs.Var.has_type_in_response( var, self.addr_conn.software_serial ) if not has_type_in_response: # Use the chance to remove a failed "typeless variable" request try: await asyncio.wait_for(self.last_var_lock.acquire(), timeout=3.0) except asyncio.TimeoutError: pass self.last_requested_var_without_type_in_response = var # Send variable request await self.addr_conn.send_command( False, PckGenerator.request_var_status(var, self.addr_conn.software_serial), ) async def request_status_leds_and_logic_ops_timeout( self, failed: bool = False ) -> None: """Is called on leds/logical ops status request timeout.""" if not failed: await self.addr_conn.send_command( False, PckGenerator.request_leds_and_logic_ops() ) async def request_status_locked_keys_timeout(self, failed: bool = False) -> None: """Is called on locked keys status request timeout.""" if not failed: await self.addr_conn.send_command( False, PckGenerator.request_key_lock_status() ) async def activate(self, item: Any) -> None: """Activate status requests for given item.""" await self.addr_conn.conn.segment_scan_completed_event.wait() # handle variables independently if (item in lcn_defs.Var) and (item != lcn_defs.Var.UNKNOWN): # wait until we know the software version await self.addr_conn.serial_known if self.addr_conn.software_serial >= 0x170206: timeout = self.settings["MAX_STATUS_EVENTBASED_VALUEAGE"] else: timeout = self.settings["MAX_STATUS_POLLED_VALUEAGE"] self.request_status_vars[item].set_timeout(timeout) self.request_status_vars[item].activate() elif item in lcn_defs.OutputPort: self.request_status_outputs[item.value].activate() elif item in lcn_defs.RelayPort: self.request_status_relays.activate() elif item in lcn_defs.MotorPort: self.request_status_relays.activate() elif item in lcn_defs.BinSensorPort: self.request_status_bin_sensors.activate() elif item in lcn_defs.LedPort: self.request_status_leds_and_logic_ops.activate() elif item in lcn_defs.Key: self.request_status_locked_keys.activate() async def cancel(self, item: Any) -> None: """Cancel status request for given item.""" # handle variables independently if (item in lcn_defs.Var) and (item != lcn_defs.Var.UNKNOWN): await self.request_status_vars[item].cancel() self.last_requested_var_without_type_in_response = lcn_defs.Var.UNKNOWN elif item in lcn_defs.OutputPort: await self.request_status_outputs[item.value].cancel() elif item in lcn_defs.RelayPort: await self.request_status_relays.cancel() elif item in lcn_defs.MotorPort: await self.request_status_relays.cancel() elif item in lcn_defs.BinSensorPort: await self.request_status_bin_sensors.cancel() elif item in lcn_defs.LedPort: await self.request_status_leds_and_logic_ops.cancel() elif item in lcn_defs.Key: await self.request_status_locked_keys.cancel() async def activate_all(self, activate_s0: bool = False) -> None: """Activate all status requests.""" await self.addr_conn.conn.segment_scan_completed_event.wait() for item in ( list(lcn_defs.OutputPort) + list(lcn_defs.RelayPort) + list(lcn_defs.BinSensorPort) + list(lcn_defs.LedPort) + list(lcn_defs.Key) + list(lcn_defs.Var) ): if isinstance(item, lcn_defs.Var) and item == lcn_defs.Var.UNKNOWN: continue if ( (not activate_s0) and isinstance(item, lcn_defs.Var) and (item in lcn_defs.Var.s0s) # type: ignore ): continue await self.activate(item) async def cancel_all(self) -> None: """Cancel all status requests.""" for item in ( list(lcn_defs.OutputPort) + list(lcn_defs.RelayPort) + list(lcn_defs.BinSensorPort) + list(lcn_defs.LedPort) + list(lcn_defs.Key) + list(lcn_defs.Var) ): if isinstance(item, lcn_defs.Var) and item == lcn_defs.Var.UNKNOWN: continue await self.cancel(item) pypck-0.8.5/pypck/timeout_retry.py000066400000000000000000000074231475005466200173040ustar00rootroot00000000000000"""Base classes for handling reoccurent tasks.""" from __future__ import annotations import asyncio import logging from collections.abc import Awaitable, Callable from typing import Any from pypck.helpers import TaskRegistry, cancel_task _LOGGER = logging.getLogger(__name__) # The default timeout to use for requests. Worst case: Requesting threshold # 4-4 takes at least 1.8s DEFAULT_TIMEOUT = 3.5 class TimeoutRetryHandler: """Manage timeout and retry logic for an LCN request.""" def __init__( self, task_registry: TaskRegistry, num_tries: int = 3, timeout: float = DEFAULT_TIMEOUT, ): """Construct TimeoutRetryHandler.""" self.task_registry = task_registry self.num_tries = num_tries self.timeout = timeout self._timeout_callback: ( Callable[..., None] | Callable[..., Awaitable[None]] | None ) = None self._timeout_args: tuple[Any, ...] = () self._timeout_kwargs: dict[str, Any] = {} self.timeout_loop_task: asyncio.Task[None] | None = None def set_timeout(self, timeout: int) -> None: """Set the timeout in seconds.""" self.timeout = timeout def set_timeout_callback( self, timeout_callback: Any, *timeout_args: Any, **timeout_kwargs: Any ) -> None: """Timeout_callback function is called, if timeout expires. Function has to take one argument: Returns failed state (True if failed) """ self._timeout_callback = timeout_callback self._timeout_args = timeout_args self._timeout_kwargs = timeout_kwargs def activate(self) -> None: """Schedule the next activation.""" self.task_registry.create_task(self.async_activate()) async def async_activate(self) -> None: """Clean start of next timeout_loop.""" if self.is_active(): return self.timeout_loop_task = self.task_registry.create_task(self.timeout_loop()) async def done(self) -> None: """Signal the completion of the TimeoutRetryHandler.""" if self.timeout_loop_task is not None: await self.timeout_loop_task async def cancel(self) -> None: """Must be called when a response (requested or not) is received.""" if self.timeout_loop_task is not None: await cancel_task(self.timeout_loop_task) def is_active(self) -> bool: """Check whether the request logic is active.""" if self.timeout_loop_task is None: return False return not self.timeout_loop_task.done() async def on_timeout(self, failed: bool = False) -> None: """Is called on timeout of TimeoutRetryHandler.""" if self._timeout_callback is not None: if asyncio.iscoroutinefunction(self._timeout_callback): # mypy fails to notice that `asyncio.iscoroutinefunction` # separates await-callable from ordinary callables. await self._timeout_callback( failed, *self._timeout_args, **self._timeout_kwargs ) else: self._timeout_callback( failed, *self._timeout_args, **self._timeout_kwargs ) async def timeout_loop(self) -> None: """Timeout / retry loop.""" if self.timeout_loop_task is None: return tries_left = self.num_tries while (tries_left > 0) or (tries_left == -1): if not self.timeout_loop_task.done(): await self.on_timeout() await asyncio.sleep(self.timeout) if self.num_tries != -1: tries_left -= 1 else: break if not self.timeout_loop_task.done(): await self.on_timeout(failed=True) pypck-0.8.5/pyproject.toml000066400000000000000000000114311475005466200155770ustar00rootroot00000000000000[build-system] requires = ["setuptools>=62.3"] build-backend = "setuptools.build_meta" [project] name = "pypck" license = {text = "MIT"} description = "LCN-PCK library" readme = "README.md" authors = [ {name = "Andre Lengwenus", email = "alengwenus@gmail.com"} ] keywords = ["lcn", "pck"] classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Intended Audience :: Developers", "Development Status :: 5 - Production/Stable", "Operating System :: OS Independent", "Topic :: Home Automation", ] requires-python = ">=3.11" dynamic = ["version"] dependencies = [] [project.urls] "Source Code" = "https://github.com/alengwenus/pypck" "Bug Reports" = "https://github.com/alengwenus/pypck/issues" [tool.setuptools] platforms = ["any"] zip-safe = false include-package-data = true [tool.setuptools.dynamic] version = {file = "VERSION"} [tool.setuptools.packages.find] include = ["pypck*"] [tool.black] target-version = ["py311", "py312", "py313"] [tool.isort] # https://github.com/PyCQA/isort/wiki/isort-Settings profile = "black" # will group `import x` and `from x import` of the same module. force_sort_within_sections = true known_first_party = [ "pypck", "tests", ] forced_separate = [ "tests", ] combine_as_imports = true [tool.pylint.MAIN] py-version = "3.13" ignore = [ "tests", ] # Use a conservative default here; 2 should speed up most setups and not hurt # any too bad. Override on command line as appropriate. jobs = 2 init-hook = """\ from pathlib import Path; \ import sys; \ from pylint.config import find_default_config_files; \ sys.path.append( \ str(Path(next(find_default_config_files())).parent.joinpath('pylint/plugins')) ) \ """ load-plugins = [ "pylint.extensions.code_style", "pylint.extensions.typing", "pylint_per_file_ignores", ] persistent = false extension-pkg-allow-list = [] fail-on = [ "I", ] [tool.pylint.BASIC] class-const-naming-style = "any" good-names = [ "_", "ev", "ex", "fp", "i", "id", "j", "k", "Run", "ip", ] [tool.pylint."MESSAGES CONTROL"] # Reasons disabled: # format - handled by black # locally-disabled - it spams too much # duplicate-code - unavoidable # cyclic-import - doesn't test if both import on load # abstract-class-little-used - prevents from setting right foundation # unused-argument - generic callbacks and setup methods create a lot of warnings # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* # abstract-method - with intro of async there are always methods missing # inconsistent-return-statements - doesn't handle raise # too-many-ancestors - it's too strict. # wrong-import-order - isort guards this # consider-using-f-string - str.format sometimes more readable # --- # Enable once current issues are fixed: # consider-using-namedtuple-or-dataclass (Pylint CodeStyle extension) # consider-using-assignment-expr (Pylint CodeStyle extension) # --- # Temporary for the Python 3.10 update # consider-alternative-union-syntax disable = [ "format", "abstract-method", "cyclic-import", "duplicate-code", "inconsistent-return-statements", "locally-disabled", "not-context-manager", "too-few-public-methods", "too-many-ancestors", "too-many-arguments", "too-many-branches", "too-many-instance-attributes", "too-many-lines", "too-many-locals", "too-many-public-methods", "too-many-return-statements", "too-many-statements", "too-many-boolean-expressions", "unused-argument", "wrong-import-order", "consider-using-f-string", "consider-using-namedtuple-or-dataclass", "consider-using-assignment-expr", "consider-alternative-union-syntax", ] enable = [ #"useless-suppression", # temporarily every now and then to clean them up "use-symbolic-message-instead", ] [tool.pylint.REPORTS] score = false [tool.pylint.TYPECHECK] ignored-classes = [ "_CountingAttr", # for attrs ] mixin-class-rgx = ".*[Mm]ix[Ii]n" [tool.pylint.FORMAT] expected-line-ending-format = "LF" [tool.pylint.EXCEPTIONS] overgeneral-exceptions = [ "builtins.BaseException", "builtins.Exception", ] [tool.pylint.TYPING] runtime-typing = false [tool.pylint.CODE_STYLE] max-line-length-suggestions = 72 [tool.pylint-per-file-ignores] # protected-access: Tests do often test internals a lot # redefined-outer-name: Tests reference fixtures in the test function "/tests/"="protected-access,redefined-outer-name" [tool.pytest.ini_options] testpaths = [ "tests", ] norecursedirs = [ ".git", "testing_config", ] log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s" log_date_format = "%Y-%m-%d %H:%M:%S" asyncio_mode = "auto" pypck-0.8.5/requirements_test.txt000066400000000000000000000002761475005466200172130ustar00rootroot00000000000000-r requirements_test_pre_commit.txt pylint==3.3.2 mypy==1.13.0 pre-commit==4.0.1 pytest==8.3.4 pytest-asyncio==0.25.0 pytest-cov==6.0.0 pytest-timeout==2.3.1 pylint-per-file-ignores==1.3.2 pypck-0.8.5/requirements_test_pre_commit.txt000066400000000000000000000000351475005466200214220ustar00rootroot00000000000000codespell==2.3.0 ruff==0.8.3 pypck-0.8.5/setup.cfg000066400000000000000000000011021475005466200144760ustar00rootroot00000000000000# Setuptools v62.3 doesn't support editable installs with just 'pyproject.toml' (PEP 660). # Keep this file until it does! [metadata] url = https://github.com/alengwenus/pypck [flake8] exclude = .venv,.git,docs,venv,bin,lib,deps,build max-complexity = 25 doctests = True # To work with Black # E501: line too long # W503: Line break occurred before a binary operator # E203: Whitespace before ':' # D202 No blank lines allowed after function docstring # W504 line break after binary operator ignore = E501, W503, E203, D202, W504 noqa-require-code = True pypck-0.8.5/tests/000077500000000000000000000000001475005466200140255ustar00rootroot00000000000000pypck-0.8.5/tests/__init__.py000066400000000000000000000000361475005466200161350ustar00rootroot00000000000000"""Tests for pypck module.""" pypck-0.8.5/tests/conftest.py000066400000000000000000000070031475005466200162240ustar00rootroot00000000000000"""Core testing functionality.""" import asyncio from collections.abc import AsyncGenerator from typing import Any import pytest from pypck.connection import PchkConnectionManager from pypck.lcn_addr import LcnAddr from pypck.module import ModuleConnection from pypck.pck_commands import PckGenerator from .mock_pchk import MockPchkServer HOST = "127.0.0.1" PORT = 4114 USERNAME = "lcn_username" PASSWORD = "lcn_password" class MockPchkConnectionManager(PchkConnectionManager): """Mock the PchkConnectionManager.""" def __init__(self, *args: Any, **kwargs: Any): """Construct mock for PchkConnectionManager.""" self.data_received: list[str] = [] super().__init__(*args, **kwargs) async def process_message(self, message: str) -> None: """Process incoming message.""" await super().process_message(message) self.data_received.append(message) async def received( self, message: str, timeout: int = 5, remove: bool = True ) -> bool: """Return if given message was received.""" async def receive_loop(data: str, remove: bool) -> None: while data not in self.data_received: await asyncio.sleep(0.05) if remove: self.data_received.remove(data) try: await asyncio.wait_for(receive_loop(message, remove), timeout=timeout) return True except asyncio.TimeoutError: return False def encode_pck(pck: str) -> bytes: """Encode the given PCK string as PCK binary string.""" return (pck + PckGenerator.TERMINATION).encode() @pytest.fixture async def pchk_server() -> AsyncGenerator[MockPchkServer, None]: """Create a fake PchkServer and run.""" pchk_server = MockPchkServer( host=HOST, port=PORT, username=USERNAME, password=PASSWORD ) await pchk_server.run() yield pchk_server await pchk_server.stop() @pytest.fixture async def pypck_client() -> AsyncGenerator[PchkConnectionManager, None]: """Create a PchkConnectionManager for testing. Create a PchkConnection Manager for testing. Add a received coroutine method which returns if the specified message was received (and processed). """ pcm = MockPchkConnectionManager( HOST, PORT, USERNAME, PASSWORD, settings={"SK_NUM_TRIES": 0} ) yield pcm await pcm.async_close() assert len(pcm.task_registry.tasks) == 0 @pytest.fixture async def module10( pypck_client: PchkConnectionManager, ) -> AsyncGenerator[ModuleConnection, None]: """Create test module with addr_id 10.""" lcn_addr = LcnAddr(0, 10, False) module = pypck_client.get_module_conn(lcn_addr) yield module await module.cancel_requests() @pytest.fixture async def pchk_server_2() -> AsyncGenerator[MockPchkServer, None]: """Create a fake PchkServer and run.""" pchk_server = MockPchkServer( host=HOST, port=PORT + 1, username=USERNAME, password=PASSWORD ) await pchk_server.run() yield pchk_server await pchk_server.stop() @pytest.fixture async def pypck_client_2() -> AsyncGenerator[PchkConnectionManager, None]: """Create a PchkConnectionManager for testing. Create a PchkConnection Manager for testing. Add a received coroutine method which returns if the specified message was received (and processed). """ pcm = MockPchkConnectionManager( HOST, PORT + 1, USERNAME, PASSWORD, settings={"SK_NUM_TRIES": 0} ) yield pcm await pcm.async_close() assert len(pcm.task_registry.tasks) == 0 pypck-0.8.5/tests/messages/000077500000000000000000000000001475005466200156345ustar00rootroot00000000000000pypck-0.8.5/tests/messages/__init__.py000066400000000000000000000000701475005466200177420ustar00rootroot00000000000000"""Tests for input message parsing for bus messages.""" pypck-0.8.5/tests/messages/test_input_flow.py000066400000000000000000000025261475005466200214400ustar00rootroot00000000000000"""Test the data flow for Input objects.""" from unittest.mock import AsyncMock, patch import pytest from pypck.inputs import Input, ModInput from pypck.lcn_addr import LcnAddr @pytest.mark.asyncio async def test_message_to_input(pypck_client): """Test data flow from message to input.""" inp = Input() message = "dummy_message" pypck_client.async_process_input = AsyncMock() with patch("pypck.inputs.InputParser.parse", return_value=[inp]) as inp_parse: await pypck_client.process_message(message) inp_parse.assert_called_with(message) pypck_client.async_process_input.assert_awaited_with(inp) @pytest.mark.asyncio async def test_physical_to_logical_segment_id(pypck_client): """Test conversion from logical to physical segment id.""" pypck_client.local_seg_id = 20 module = pypck_client.get_address_conn(LcnAddr(20, 7, False)) module.async_process_input = AsyncMock() with patch("tests.conftest.MockPchkConnectionManager.is_ready", result=True): inp = ModInput(LcnAddr(20, 7, False)) await pypck_client.async_process_input(inp) inp = ModInput(LcnAddr(0, 7, False)) await pypck_client.async_process_input(inp) inp = ModInput(LcnAddr(4, 7, False)) await pypck_client.async_process_input(inp) assert module.async_process_input.await_count == 3 pypck-0.8.5/tests/messages/test_output_status.py000066400000000000000000000036301475005466200222120ustar00rootroot00000000000000"""Tests for output status messages.""" from unittest.mock import AsyncMock import pytest from pypck.inputs import InputParser, ModStatusOutput, ModStatusOutputNative # Unit tests def test_input_parser(): """Test parsing of command.""" message = ":M000010A1050" inp = InputParser.parse(message) assert isinstance(inp[0], ModStatusOutput) message = ":M000010O1050" inp = InputParser.parse(message) assert isinstance(inp[0], ModStatusOutputNative) @pytest.mark.parametrize( "pck, expected", [ ("A1000", (0, 0.0)), ("A2050", (1, 50.0)), ("A3075", (2, 75.0)), ("A4100", (3, 100.0)), ], ) def test_parse_message_percent(pck, expected): """Parse output in percent status message.""" message = f":M000010{pck}" inp = InputParser.parse(message)[0] assert isinstance(inp, ModStatusOutput) assert inp.get_output_id() == expected[0] assert inp.get_percent() == expected[1] @pytest.mark.parametrize( "pck, expected", [("O1000", (0, 0)), ("O2050", (1, 50)), ("O3100", (2, 100)), ("O4200", (3, 200))], ) def test_parse_message_native(pck, expected): """Parse output in native units status message.""" message = f":M000010{pck}" inp = InputParser.parse(message)[0] assert isinstance(inp, ModStatusOutputNative) assert inp.get_output_id() == expected[0] assert inp.get_value() == expected[1] # Integration tests @pytest.mark.asyncio async def test_output_status(pchk_server, pypck_client, module10): """Output status command.""" module10.async_process_input = AsyncMock() await pypck_client.async_connect() message = ":M000010A1050" await pchk_server.send_message(message) assert await pypck_client.received(message) assert module10.async_process_input.called inp = module10.async_process_input.call_args[0][0] assert inp.get_output_id() == 0 assert inp.get_percent() == 50.0 pypck-0.8.5/tests/messages/test_send_command_host.py000066400000000000000000000033731475005466200227370ustar00rootroot00000000000000"""Tests for send command host.""" from unittest.mock import AsyncMock import pytest from pypck.inputs import InputParser, ModSendCommandHost # Unit tests def test_input_parser(): """Test parsing of command.""" message = "+M004000010.SKH001002" inp = InputParser.parse(message) assert isinstance(inp[0], ModSendCommandHost) message = "+M004000010.SKH001002003004005006" inp = InputParser.parse(message) assert isinstance(inp[0], ModSendCommandHost) message = "+M004000010.SKH001002003004005006007008009010011012013014" inp = InputParser.parse(message) assert isinstance(inp[0], ModSendCommandHost) @pytest.mark.parametrize( "pck, expected", [ ("SKH001002", (1, 2)), ("SKH001002003004005006", (1, 2, 3, 4, 5, 6)), ( "SKH001002003004005006007008009010011012013014", (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14), ), ("SKH001002003", (1, 2)), ("SKH001002003004", (1, 2)), ], ) def test_parse_message_percent(pck, expected): """Parse output in percent status message.""" message = f"+M004000010.{pck}" inp = InputParser.parse(message)[0] assert isinstance(inp, ModSendCommandHost) assert inp.get_parameters() == expected # Integration tests @pytest.mark.asyncio async def test_send_command_host(pchk_server, pypck_client, module10): """Send command host message.""" module10.async_process_input = AsyncMock() await pypck_client.async_connect() message = "+M004000010.SKH001002" await pchk_server.send_message(message) assert await pypck_client.received(message) assert module10.async_process_input.called inp = module10.async_process_input.call_args[0][0] assert inp.get_parameters() == (1, 2) pypck-0.8.5/tests/mock_pchk.py000066400000000000000000000132741475005466200163440ustar00rootroot00000000000000"""Fake PCHK server used for testing.""" from __future__ import annotations import asyncio HOST = "127.0.0.1" PORT = 4114 USERNAME = "lcn_username" PASSWORD = "lcn_password" READ_TIMEOUT = -1 SOCKET_CLOSED = -2 SEPARATOR = b"\n" async def readuntil_timeout( reader: asyncio.StreamReader, separator: bytes, timeout: float ) -> bytes | int: """Read from socket with timeout.""" try: data = await asyncio.wait_for(reader.readuntil(separator), timeout) data = data.split(separator)[0] data = data.split(b"\r")[0] # remove CR if present return data except asyncio.TimeoutError: return READ_TIMEOUT except asyncio.IncompleteReadError: return SOCKET_CLOSED class MockPchkServer: """Mock PCHK server for integration tests.""" def __init__( self, host: str = HOST, port: int = PORT, username: str = USERNAME, password: str = PASSWORD, ): """Construct PchkServer.""" self.host = host self.port = port self.username = username self.password = password self.separator = SEPARATOR self.license_error = False self.data_received: list[bytes] = [] self.server: asyncio.AbstractServer | None = None self.reader: asyncio.StreamReader | None = None self.writer: asyncio.StreamWriter | None = None async def run(self) -> None: """Start the server.""" self.server = await asyncio.start_server( self.client_connected, host=self.host, port=self.port ) async def stop(self) -> None: """Stop the server and close connection.""" if self.server and self.server.is_serving(): if not (self.writer is None or self.writer.is_closing()): self.writer.close() await self.writer.wait_closed() self.server.close() await self.server.wait_closed() async def client_connected( self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: """Client connected callback.""" # Accept only one connection. if self.reader or self.writer: return self.reader = reader self.writer = writer auth_ok = await self.authentication() if not auth_ok: return await self.main_loop() def set_license_error(self, license_error: bool = False) -> None: """Raise a license error during authentication.""" self.license_error = license_error async def authentication(self) -> bool: """Run authentication procedure.""" assert self.writer is not None assert self.reader is not None self.writer.write(b"LCN-PCK/IP 1.0" + self.separator) await self.writer.drain() # Ask for username self.writer.write(b"Username:" + self.separator) await self.writer.drain() # Read username input data = await readuntil_timeout(self.reader, self.separator, 60) if data in [READ_TIMEOUT, SOCKET_CLOSED]: return False assert isinstance(data, bytes) login_username = data.decode() # Ask for password self.writer.write(b"Password:" + self.separator) await self.writer.drain() # Read password input data = await readuntil_timeout(self.reader, self.separator, 60) if data in [READ_TIMEOUT, SOCKET_CLOSED]: return False assert isinstance(data, bytes) login_password = data.decode() if login_username == self.username and login_password == self.password: self.writer.write(b"OK" + self.separator) await self.writer.drain() else: self.writer.write(b"Authentification failed." + self.separator) await self.writer.drain() return False if self.license_error: self.writer.write(b"$err:(license?)" + self.separator) await self.writer.drain() return False return True async def main_loop(self) -> None: """Query the socket.""" assert self.reader is not None while True: # Read data from socket data = await readuntil_timeout(self.reader, self.separator, 1.0) if data == READ_TIMEOUT: continue if data == SOCKET_CLOSED: break assert isinstance(data, bytes) await self.process_data(data) async def process_data(self, data: bytes) -> None: """Process incoming data.""" assert self.writer is not None self.data_received.append(data) if data == b"!CHD": self.writer.write(b"(dec-mode)" + self.separator) await self.writer.drain() async def send_message(self, message: str) -> None: """Send the given message to the socket.""" assert self.writer is not None self.writer.write(message.encode() + self.separator) await self.writer.drain() async def received( self, message: bytes | str, timeout: int = 5, remove: bool = True ) -> bool: """Return if given message was received.""" assert self.writer is not None async def receive_loop(data: bytes, remove: bool) -> None: while data not in self.data_received: await asyncio.sleep(0.05) if remove: self.data_received.remove(data) if isinstance(message, str): data = message.encode() else: data = message try: await asyncio.wait_for(receive_loop(data, remove), timeout=timeout) return True except asyncio.TimeoutError: return False pypck-0.8.5/tests/test_commands.py000066400000000000000000000371171475005466200172500ustar00rootroot00000000000000"""Tests for command generation directed at bus modules and groups.""" import pytest from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import ( BeepSound, KeyLockStateModifier, LedStatus, MotorReverseTime, MotorStateModifier, OutputPort, OutputPortDimMode, OutputPortStatusMode, RelayPort, RelayStateModifier, RelVarRef, SendKeyCommand, TimeUnit, Var, ) from pypck.pck_commands import PckGenerator NEW_VAR_SW_AGE = 0x170206 COMMANDS = { # Host commands **{ f"^ping{counter:d}": (PckGenerator.ping, counter) for counter in (1, 10, 100, 1000, 10000) }, "!CHD": (PckGenerator.set_dec_mode,), **{ f"!OM{dim_mode.value:d}{status_mode.value:s}": ( PckGenerator.set_operation_mode, dim_mode, status_mode, ) for dim_mode in OutputPortDimMode for status_mode in OutputPortStatusMode }, # Command address header **{ f">{addr_type:s}{seg_id:03d}{addr_id:03d}{separator:s}": ( PckGenerator.generate_address_header, LcnAddr(seg_id, addr_id, addr_type == "G"), 0, separator == "!", ) for seg_id in (0, 5, 10, 100) for addr_id in (5, 10, 100) for addr_type in ("G", "M") for separator in ("!", ".") }, ">M000021.": ( PckGenerator.generate_address_header, LcnAddr(7, 21, False), 7, False, ), # Other module commands "LEER": (PckGenerator.empty,), **{ f"PIN{count:03d}": (PckGenerator.beep, BeepSound.NORMAL, count) for count in range(1, 16) }, **{ f"PIS{count:03d}": (PckGenerator.beep, BeepSound.SPECIAL, count) for count in range(1, 16) }, # General status commands "SK": (PckGenerator.segment_coupler_scan,), "SN": (PckGenerator.request_serial,), **{f"NMN{block+1}": (PckGenerator.request_name, block) for block in range(2)}, **{f"NMK{block+1}": (PckGenerator.request_comment, block) for block in range(3)}, **{f"NMO{block+1}": (PckGenerator.request_oem_text, block) for block in range(4)}, "GP": (PckGenerator.request_group_membership_static,), "GD": (PckGenerator.request_group_membership_dynamic,), # Output, relay, binsensors, ... status commands "SMA1": (PckGenerator.request_output_status, 0), "SMA2": (PckGenerator.request_output_status, 1), "SMA3": (PckGenerator.request_output_status, 2), "SMA4": (PckGenerator.request_output_status, 3), "SMR": (PckGenerator.request_relays_status,), "SMB": (PckGenerator.request_bin_sensors_status,), "SMT": (PckGenerator.request_leds_and_logic_ops,), "STX": (PckGenerator.request_key_lock_status,), # Variable status (new commands) **{ f"MWT{Var.to_var_id(var)+1:03d}": ( PckGenerator.request_var_status, var, NEW_VAR_SW_AGE, ) for var in Var.variables # type: ignore }, **{ f"MWS{Var.to_set_point_id(var)+1:03d}": ( PckGenerator.request_var_status, var, NEW_VAR_SW_AGE, ) for var in Var.set_points # type: ignore }, **{ f"MWC{Var.to_s0_id(var)+1:03d}": ( PckGenerator.request_var_status, var, NEW_VAR_SW_AGE, ) for var in Var.s0s # type: ignore }, **{ f"SE{Var.to_thrs_register_id(var)+1:03d}": ( PckGenerator.request_var_status, var, NEW_VAR_SW_AGE, ) for reg in Var.thresholds # type: ignore for var in reg }, # Variable status (legacy commands) "MWV": (PckGenerator.request_var_status, Var.TVAR, NEW_VAR_SW_AGE - 1), "MWTA": (PckGenerator.request_var_status, Var.R1VAR, NEW_VAR_SW_AGE - 1), "MWTB": (PckGenerator.request_var_status, Var.R2VAR, NEW_VAR_SW_AGE - 1), "MWSA": (PckGenerator.request_var_status, Var.R1VARSETPOINT, NEW_VAR_SW_AGE - 1), "MWSB": (PckGenerator.request_var_status, Var.R2VARSETPOINT, NEW_VAR_SW_AGE - 1), **{ "SL1": (PckGenerator.request_var_status, var, NEW_VAR_SW_AGE - 1) for var in Var.thresholds[0] # type: ignore }, # Output manipulation **{ f"A{output+1:d}DI050123": (PckGenerator.dim_output, output, 50.0, 123) for output in range(4) }, **{ f"O{output+1:d}DI101123": (PckGenerator.dim_output, output, 50.5, 123) for output in range(4) }, "OY100100100100123": (PckGenerator.dim_all_outputs, 50.0, 123, 0x180501), "OY000000000000123": (PckGenerator.dim_all_outputs, 0.0, 123, 0x180501), "OY200200200200123": (PckGenerator.dim_all_outputs, 100.0, 123, 0x180501), "AA123": (PckGenerator.dim_all_outputs, 0.0, 123, 0x180500), "AE123": (PckGenerator.dim_all_outputs, 100.0, 123, 0x180500), "AH050": (PckGenerator.dim_all_outputs, 50.0, 123, 0x180500), **{ f"A{output+1:d}AD050": (PckGenerator.rel_output, output, 50.0) for output in range(4) }, **{ f"A{output+1:d}SB050": (PckGenerator.rel_output, output, -50.0) for output in range(4) }, **{ f"O{output+1:d}AD101": (PckGenerator.rel_output, output, 50.5) for output in range(4) }, **{ f"O{output+1:d}SB101": (PckGenerator.rel_output, output, -50.5) for output in range(4) }, **{ f"A{output+1:d}TA123": (PckGenerator.toggle_output, output, 123) for output in range(4) }, "AU123": (PckGenerator.toggle_all_outputs, 123), # Relay state manipulation "R80-1U1-U0": ( PckGenerator.control_relays, [ RelayStateModifier.OFF, RelayStateModifier.NOCHANGE, RelayStateModifier.ON, RelayStateModifier.TOGGLE, RelayStateModifier.ON, RelayStateModifier.NOCHANGE, RelayStateModifier.TOGGLE, RelayStateModifier.OFF, ], ), "R8T03210011100": ( PckGenerator.control_relays_timer, 30 * 32, [ RelayStateModifier.ON, RelayStateModifier.OFF, RelayStateModifier.OFF, RelayStateModifier.ON, RelayStateModifier.ON, RelayStateModifier.ON, RelayStateModifier.OFF, RelayStateModifier.OFF, ], ), "R810110---": ( PckGenerator.control_motors_relays, [ MotorStateModifier.UP, MotorStateModifier.DOWN, MotorStateModifier.STOP, MotorStateModifier.NOCHANGE, ], ), "R8U--UUU--": ( PckGenerator.control_motors_relays, [ MotorStateModifier.TOGGLEONOFF, MotorStateModifier.TOGGLEDIR, MotorStateModifier.CYCLE, MotorStateModifier.NOCHANGE, ], ), "X2001228000": ( PckGenerator.control_motors_outputs, MotorStateModifier.UP, MotorReverseTime.RT70, ), "A1DI100008": ( PckGenerator.control_motors_outputs, MotorStateModifier.UP, MotorReverseTime.RT600, ), "A1DI100011": ( PckGenerator.control_motors_outputs, MotorStateModifier.UP, MotorReverseTime.RT1200, ), "X2001000228": ( PckGenerator.control_motors_outputs, MotorStateModifier.DOWN, MotorReverseTime.RT70, ), "A2DI100008": ( PckGenerator.control_motors_outputs, MotorStateModifier.DOWN, MotorReverseTime.RT600, ), "A2DI100011": ( PckGenerator.control_motors_outputs, MotorStateModifier.DOWN, MotorReverseTime.RT1200, ), "AY000000": ( PckGenerator.control_motors_outputs, MotorStateModifier.STOP, ), "JE": ( PckGenerator.control_motors_outputs, MotorStateModifier.CYCLE, ), # Variable manipulation **{ f"X2{var.value | 0x40:03d}016225": (PckGenerator.update_status_var, var, 4321) for var in Var.variables # type: ignore }, "X2030044129": (PckGenerator.var_abs, Var.R1VARSETPOINT, 4201), "X2030108129": (PckGenerator.var_abs, Var.R2VARSETPOINT, 4201), "X2030032000": (PckGenerator.var_reset, Var.R1VARSETPOINT, 0x170206), "X2030096000": (PckGenerator.var_reset, Var.R2VARSETPOINT, 0x170206), "ZS30000": (PckGenerator.var_reset, Var.TVAR, 0x170205), **{ f"Z-{var.value + 1:03d}4090": (PckGenerator.var_reset, var, 0x170206) for var in Var.variables # type: ignore }, "ZA23423": (PckGenerator.var_rel, Var.TVAR, RelVarRef.CURRENT, 23423, 0x170205), "ZS23423": (PckGenerator.var_rel, Var.TVAR, RelVarRef.CURRENT, -23423, 0x170205), **{ f"Z-{var.value + 1:03d}3000": ( PckGenerator.var_rel, var, RelVarRef.CURRENT, -3000, 0x170206, ) for var in Var.variables # type: ignore if var != Var.TVAR }, **{ f"RE{('A','B')[nvar]}S{('A','P')[nref]}-500": ( PckGenerator.var_rel, var, ref, -500, sw_age, ) for nvar, var in enumerate(Var.set_points) # type: ignore for nref, ref in enumerate(RelVarRef) for sw_age in (0x170206, 0x170205) }, **{ f"RE{('A','B')[nvar]}S{('A','P')[nref]}+500": ( PckGenerator.var_rel, var, ref, 500, sw_age, ) for nvar, var in enumerate(Var.set_points) # type: ignore for nref, ref in enumerate(RelVarRef) for sw_age in (0x170206, 0x170205) }, **{ f"SS{('R','E')[nref]}0500SR{r+1}{i+1}": ( PckGenerator.var_rel, Var.thresholds[r][i], # type: ignore ref, -500, 0x170206, ) for r in range(4) for i in range(4) for nref, ref in enumerate(RelVarRef) }, **{ f"SS{('R','E')[nref]}0500AR{r+1}{i+1}": ( PckGenerator.var_rel, Var.thresholds[r][i], # type: ignore ref, 500, 0x170206, ) for r in range(4) for i in range(4) for nref, ref in enumerate(RelVarRef) }, **{ f"SS{('R','E')[nref]}0500S{1<<(4-i):05b}": ( PckGenerator.var_rel, Var.thresholds[0][i], # type: ignore ref, -500, 0x170205, ) for i in range(5) for nref, ref in enumerate(RelVarRef) }, **{ f"SS{('R','E')[nref]}0500A{1<<(4-i):05b}": ( PckGenerator.var_rel, Var.thresholds[0][i], # type: ignore ref, 500, 0x170205, ) for i in range(5) for nref, ref in enumerate(RelVarRef) }, # Led manipulation **{ f"LA{led+1:03d}{state.value}": (PckGenerator.control_led, led, state) for led in range(12) for state in LedStatus }, # Send keys **{ f"TS{acmd.value}{bcmd.value}{ccmd.value}10011100": ( PckGenerator.send_keys, [acmd, bcmd, ccmd, SendKeyCommand.DONTSEND], [True, False, False, True, True, True, False, False], ) for acmd in SendKeyCommand for bcmd in SendKeyCommand for ccmd in SendKeyCommand }, **{ f"TS---{dcmd.value}10011100": ( PckGenerator.send_keys, [ SendKeyCommand.DONTSEND, SendKeyCommand.DONTSEND, SendKeyCommand.DONTSEND, dcmd, ], [True, False, False, True, True, True, False, False], ) for dcmd in SendKeyCommand if dcmd != SendKeyCommand.DONTSEND }, **{ f"TV{('A','B','C','D')[table]}040{unit.value}11001110": ( PckGenerator.send_keys_hit_deferred, table, 40, unit, [True, True, False, False, True, True, True, False], ) for table in range(4) for unit in TimeUnit }, # Lock keys **{ f"TX{('A','B','C','D')[table]}10U--01U": ( PckGenerator.lock_keys, table, [ KeyLockStateModifier.ON, KeyLockStateModifier.OFF, KeyLockStateModifier.TOGGLE, KeyLockStateModifier.NOCHANGE, KeyLockStateModifier.NOCHANGE, KeyLockStateModifier.OFF, KeyLockStateModifier.ON, KeyLockStateModifier.TOGGLE, ], ) for table in range(4) }, **{ f"TXZA040{unit.value}11001110": ( PckGenerator.lock_keys_tab_a_temporary, 40, unit, [True, True, False, False, True, True, True, False], ) for unit in TimeUnit }, # Lock regulator **{ f"RE{('A','B')[reg]:s}XS": (PckGenerator.lock_regulator, reg, True, -1) for reg in range(2) }, **{ f"RE{('A','B')[reg]:s}XA": (PckGenerator.lock_regulator, reg, False, -1) for reg in range(2) }, **{ f"X2030{0x40*reg + 0x07:03d}{2*value:03d}": ( PckGenerator.lock_regulator, reg, True, 0x120301, value, ) for reg in range(2) for value in (0, 50, 100) }, **{ f"RE{('A','B')[reg]:s}XS": (PckGenerator.lock_regulator, reg, True, 0x120301) for reg in range(2) }, **{ f"RE{('A','B')[reg]:s}XA": (PckGenerator.lock_regulator, reg, False, 0x120301) for reg in range(2) }, # scenes "SZR003007": (PckGenerator.request_status_scene, 3, 7), "SZD003007200100101050": ( PckGenerator.store_scene_outputs_direct, 3, 7, (100, 50.5), (100, 50), ), "SZD003007200100101050021000199107": ( PckGenerator.store_scene_outputs_direct, 3, 7, (100, 50.5, 10.5, 99.5), (100, 50, 0, 107), ), "SZW004": (PckGenerator.change_scene_register, 4), "SZA7001": (PckGenerator.activate_scene_output, 1, OutputPort), "SZA7001133": (PckGenerator.activate_scene_output, 1, OutputPort, 133), "SZS7002": (PckGenerator.store_scene_output, 2, OutputPort), "SZS7002133": (PckGenerator.store_scene_output, 2, OutputPort, 133), "SZA7005": (PckGenerator.activate_scene_output, 5, OutputPort), "SZA7005133": (PckGenerator.activate_scene_output, 5, OutputPort, 133), "SZS7008": (PckGenerator.store_scene_output, 8, OutputPort), "SZS7008133": (PckGenerator.store_scene_output, 8, OutputPort, 133), "SZA000810001110": ( PckGenerator.activate_scene_relay, 8, ( RelayPort.RELAY1, RelayPort.RELAY5, RelayPort.RELAY6, RelayPort.RELAY7, ), ), "SZS000810001110": ( PckGenerator.store_scene_relay, 8, ( RelayPort.RELAY1, RelayPort.RELAY5, RelayPort.RELAY6, RelayPort.RELAY7, ), ), # dynamic text **{ f"GTDT{row+1:d}{part+1:d}asdfasdfasdf".encode(): ( PckGenerator.dyn_text_part, row, part, b"asdfasdfasdf", ) for row in range(4) for part in range(5) }, b"GTDT45\xff\xfe\x80\x34\xdd\xcc\xaa\xbf\x00\xac": ( PckGenerator.dyn_text_part, 3, 4, b"\xff\xfe\x80\x34\xdd\xcc\xaa\xbf\x00\xac", ), } @pytest.mark.parametrize("expected, command", COMMANDS.items()) def test_command_generation_single_mod_noack(expected, command): """Test if InputMod parses message correctly.""" assert expected == command[0](*command[1:]) pypck-0.8.5/tests/test_connection.py000066400000000000000000000321041475005466200175750ustar00rootroot00000000000000"""Connection tests.""" import asyncio import json from unittest.mock import Mock, call import pytest from pypck.connection import ( PchkAuthenticationError, PchkConnectionFailedError, PchkConnectionRefusedError, PchkLicenseError, ) from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import LcnEvent from pypck.module import ModuleConnection @pytest.mark.asyncio async def test_close_without_connect(pypck_client): """Test closing of PchkConnectionManager without connecting.""" await pypck_client.async_close() @pytest.mark.asyncio async def test_authenticate(pchk_server, pypck_client): """Test authentication procedure.""" await pypck_client.async_connect() assert pypck_client.is_ready() @pytest.mark.asyncio async def test_port_error(pchk_server, pypck_client): """Test wrong port.""" pypck_client.port = 55555 with pytest.raises(PchkConnectionRefusedError): await pypck_client.async_connect() @pytest.mark.asyncio async def test_authentication_error(pchk_server, pypck_client): """Test wrong login credentials.""" pypck_client.password = "wrong_password" with pytest.raises(PchkAuthenticationError): await pypck_client.async_connect() @pytest.mark.asyncio async def test_license_error(pchk_server, pypck_client): """Test license error.""" pchk_server.set_license_error(True) with pytest.raises(PchkLicenseError): await pypck_client.async_connect() @pytest.mark.asyncio async def test_timeout_error(pchk_server, pypck_client): """Test timeout when connecting.""" with pytest.raises(PchkConnectionFailedError): await pypck_client.async_connect(timeout=0) @pytest.mark.asyncio async def test_lcn_connected(pchk_server, pypck_client): """Test lcn disconnected event.""" event_callback = Mock() pypck_client.register_for_events(event_callback) await pypck_client.async_connect() await pchk_server.send_message("$io:#LCN:connected") await pypck_client.received("$io:#LCN:connected") event_callback.assert_has_calls( [ call(LcnEvent.BUS_CONNECTION_STATUS_CHANGED), call(LcnEvent.BUS_CONNECTED), ] ) @pytest.mark.asyncio async def test_lcn_disconnected(pchk_server, pypck_client): """Test lcn disconnected event.""" event_callback = Mock() pypck_client.register_for_events(event_callback) await pypck_client.async_connect() await pchk_server.send_message("$io:#LCN:disconnected") await pypck_client.received("$io:#LCN:disconnected") event_callback.assert_has_calls( [call(LcnEvent.BUS_CONNECTION_STATUS_CHANGED), call(LcnEvent.BUS_DISCONNECTED)] ) @pytest.mark.asyncio async def test_connection_lost(pchk_server, pypck_client): """Test pchk server connection close.""" event_callback = Mock() pypck_client.register_for_events(event_callback) await pypck_client.async_connect() await pchk_server.stop() # ensure that pypck_client is about to be closed await pypck_client.wait_closed() event_callback.assert_has_calls([call(LcnEvent.CONNECTION_LOST)]) @pytest.mark.asyncio async def test_multiple_connections( pchk_server, pypck_client, pchk_server_2, pypck_client_2 ): """Test that two independent connections can coexists.""" await pypck_client_2.async_connect() event_callback = Mock() pypck_client.register_for_events(event_callback) await pypck_client.async_connect() await pchk_server.stop() await pypck_client.wait_closed() event_callback.assert_has_calls([call(LcnEvent.CONNECTION_LOST)]) assert len(pypck_client.task_registry.tasks) == 0 assert len(pypck_client_2.task_registry.tasks) > 0 @pytest.mark.asyncio async def test_segment_coupler_search(pchk_server, pypck_client): """Test segment coupler search.""" await pypck_client.async_connect() await pypck_client.scan_segment_couplers(3, 0) assert await pchk_server.received(">G003003.SK") assert await pchk_server.received(">G003003.SK") assert await pchk_server.received(">G003003.SK") assert pypck_client.is_ready() @pytest.mark.asyncio async def test_segment_coupler_response(pchk_server, pypck_client): """Test segment coupler response.""" await pypck_client.async_connect() assert pypck_client.local_seg_id == 0 await pchk_server.send_message("=M000005.SK020") await pchk_server.send_message("=M021021.SK021") await pchk_server.send_message("=M022010.SK022") assert await pypck_client.received("=M000005.SK020") assert await pypck_client.received("=M021021.SK021") assert await pypck_client.received("=M022010.SK022") assert pypck_client.local_seg_id == 20 assert set(pypck_client.segment_coupler_ids) == {20, 21, 22} @pytest.mark.asyncio async def test_module_scan(pchk_server, pypck_client): """Test module scan.""" await pypck_client.async_connect() await pypck_client.scan_modules(3, 0) assert await pchk_server.received(">G000003!LEER") assert await pchk_server.received(">G000003!LEER") assert await pchk_server.received(">G000003!LEER") @pytest.mark.asyncio async def test_module_sn_response(pchk_server, pypck_client): """Test module scan.""" await pypck_client.async_connect() module = pypck_client.get_address_conn(LcnAddr(0, 7, False)) message = "=M000007.SN1AB20A123401FW190B11HW015" await pchk_server.send_message(message) assert await pypck_client.received(message) assert await module.serial_known assert module.hardware_serial == 0x1AB20A1234 assert module.manu == 1 assert module.software_serial == 0x190B11 assert module.hardware_type.value == 15 @pytest.mark.asyncio async def test_send_command_to_server(pchk_server, pypck_client): """Test sending a command to the PCHK server.""" await pypck_client.async_connect() message = ">M000007.PIN003" await pypck_client.send_command(message) assert await pchk_server.received(message) @pytest.mark.asyncio async def test_ping(pchk_server, pypck_client): """Test if pings are send.""" await pypck_client.async_connect() assert await pchk_server.received("^ping0") @pytest.mark.asyncio async def test_add_address_connections(pypck_client): """Test if new address connections are added on request.""" lcn_addr = LcnAddr(0, 10, False) assert lcn_addr not in pypck_client.address_conns addr_conn = pypck_client.get_address_conn(lcn_addr) assert isinstance(addr_conn, ModuleConnection) assert lcn_addr in pypck_client.address_conns @pytest.mark.asyncio async def test_add_address_connections_by_message(pchk_server, pypck_client): """Test if new address connections are added by received message.""" await pypck_client.async_connect() lcn_addr = LcnAddr(0, 10, False) assert lcn_addr not in pypck_client.address_conns message = ":M000010A1050" await pchk_server.send_message(message) assert await pypck_client.received(message) assert lcn_addr in pypck_client.address_conns @pytest.mark.asyncio async def test_groups_static_membership_discovery(pchk_server, pypck_client): """Test module scan.""" await pypck_client.async_connect() module = pypck_client.get_address_conn(LcnAddr(0, 10, False)) task = asyncio.create_task(module.request_static_groups()) assert await pchk_server.received(">M000010.GP") await pchk_server.send_message("=M000010.GP012011200051") assert await task == { LcnAddr(0, 11, True), LcnAddr(0, 200, True), LcnAddr(0, 51, True), } @pytest.mark.asyncio async def test_groups_dynamic_membership_discovery(pchk_server, pypck_client): """Test module scan.""" await pypck_client.async_connect() module = pypck_client.get_address_conn(LcnAddr(0, 10, False)) task = asyncio.create_task(module.request_dynamic_groups()) assert await pchk_server.received(">M000010.GD") await pchk_server.send_message("=M000010.GD008011200051") assert await task == { LcnAddr(0, 11, True), LcnAddr(0, 200, True), LcnAddr(0, 51, True), } @pytest.mark.asyncio async def test_groups_membership_discovery(pchk_server, pypck_client): """Test module scan.""" await pypck_client.async_connect() module = pypck_client.get_address_conn(LcnAddr(0, 10, False)) task = asyncio.create_task(module.request_groups()) assert await pchk_server.received(">M000010.GP") await pchk_server.send_message("=M000010.GP012011200051") assert await pchk_server.received(">M000010.GD") await pchk_server.send_message("=M000010.GD008015100052") assert await task == { LcnAddr(0, 11, True), LcnAddr(0, 200, True), LcnAddr(0, 51, True), LcnAddr(0, 15, True), LcnAddr(0, 100, True), LcnAddr(0, 52, True), } @pytest.mark.asyncio async def test_multiple_serial_requests(pchk_server, pypck_client): """Test module scan.""" await pypck_client.async_connect() pypck_client.get_address_conn(LcnAddr(0, 10, False)) pypck_client.get_address_conn(LcnAddr(0, 11, False)) pypck_client.get_address_conn(LcnAddr(0, 12, False)) assert await pchk_server.received(">M000010.SN") assert await pchk_server.received(">M000011.SN") assert await pchk_server.received(">M000012.SN") message = "=M000010.SN1AB20A123401FW190B11HW015" await pchk_server.send_message(message) assert await pypck_client.received(message) await pypck_client.async_close() @pytest.mark.asyncio async def test_dump_modules_no_segement_couplers(pchk_server, pypck_client): """Test module information dumping.""" await pypck_client.async_connect() for msg in ( "=M000007.SN1AB20A123401FW190B11HW015", "=M000008.SN1BB20A123401FW1A0B11HW015", "=M000007.GP012011200051", "=M000008.GP012011220051", "=M000007.GD008015100052", "=M000008.GD008015120052", ): await pchk_server.send_message(msg) assert await pypck_client.received(msg) dump = pypck_client.dump_modules() json.dumps(dump) assert dump == { "0": { "7": { "segment": 0, "address": 7, "is_local_segment": True, "serials": { "hardware_serial": "1AB20A1234", "manu": "01", "software_serial": "190B11", "hardware_type": "15", "hardware_name": "LCN-SH-Plus", }, "name": "", "comment": "", "oem_text": ["", "", "", ""], "groups": {"static": [11, 51, 200], "dynamic": [15, 52, 100]}, }, "8": { "segment": 0, "address": 8, "is_local_segment": True, "serials": { "hardware_serial": "1BB20A1234", "manu": "01", "software_serial": "1A0B11", "hardware_type": "15", "hardware_name": "LCN-SH-Plus", }, "name": "", "comment": "", "oem_text": ["", "", "", ""], "groups": {"static": [11, 51, 220], "dynamic": [15, 52, 120]}, }, } } @pytest.mark.asyncio async def test_dump_modules_multi_segment(pchk_server, pypck_client): """Test module information dumping.""" await pypck_client.async_connect() # Populate the bus topology information for msg in ( "=M000007.SK020", "=M022008.SK022", "=M000007.SN1AB20A123401FW190B11HW015", "=M022008.SN1BB20A123401FW1A0B11HW015", "=M000007.GP012011200051", "=M022008.GP012011220051", "=M000007.GD008015100052", "=M022008.GD008015120052", ): await pchk_server.send_message(msg) assert await pypck_client.received(msg) dump = pypck_client.dump_modules() json.dumps(dump) assert dump == { "20": { "7": { "segment": 20, "address": 7, "is_local_segment": True, "serials": { "hardware_serial": "1AB20A1234", "manu": "01", "software_serial": "190B11", "hardware_type": "15", "hardware_name": "LCN-SH-Plus", }, "name": "", "comment": "", "oem_text": ["", "", "", ""], "groups": {"static": [11, 51, 200], "dynamic": [15, 52, 100]}, }, }, "22": { "8": { "segment": 22, "address": 8, "is_local_segment": False, "serials": { "hardware_serial": "1BB20A1234", "manu": "01", "software_serial": "1A0B11", "hardware_type": "15", "hardware_name": "LCN-SH-Plus", }, "name": "", "comment": "", "oem_text": ["", "", "", ""], "groups": {"static": [11, 51, 220], "dynamic": [15, 52, 120]}, }, }, } pypck-0.8.5/tests/test_dyn_text.py000066400000000000000000000044371475005466200173040ustar00rootroot00000000000000"""Module connection tests.""" from unittest.mock import patch import pytest from pypck.lcn_addr import LcnAddr from pypck.module import ModuleConnection TEST_VECTORS = { # empty "": (b"", b"", b"", b"", b""), # pure ascii **{"a" * n: (b"a" * n, b"", b"", b"", b"") for n in (1, 7, 11, 12)}, **{"a" * (12 + n): (b"a" * 12, b"a" * n, b"", b"", b"") for n in (1, 7, 11, 12)}, **{ "a" * (48 + n): (b"a" * 12, b"a" * 12, b"a" * 12, b"a" * 12, b"a" * n) for n in (1, 7, 11, 12) }, # only two-byte UTF-8 **{"ü" * n: (b"\xc3\xbc" * n, b"", b"", b"", b"") for n in (1, 5, 6)}, **{ "ü" * (6 + n): (b"\xc3\xbc" * 6, b"\xc3\xbc" * n, b"", b"", b"") for n in (1, 5, 6) }, **{ "ü" * (24 + n): ( b"\xc3\xbc" * 6, b"\xc3\xbc" * 6, b"\xc3\xbc" * 6, b"\xc3\xbc" * 6, b"\xc3\xbc" * n, ) for n in (1, 5, 6) }, # only three-byte utf-8 **{"\u20ac" * n: (b"\xe2\x82\xac" * n, b"", b"", b"", b"") for n in (1, 4)}, **{ "\u20ac" * (4 + n): (b"\xe2\x82\xac" * 4, b"\xe2\x82\xac" * n, b"", b"", b"") for n in (1, 4) }, **{ "\u20ac" * (16 + n): ( b"\xe2\x82\xac" * 4, b"\xe2\x82\xac" * 4, b"\xe2\x82\xac" * 4, b"\xe2\x82\xac" * 4, b"\xe2\x82\xac" * n, ) for n in (1, 4) }, # boundary-crossing utf-8 "12345678123\u00fc4567": (b"12345678123\xc3", b"\xbc4567", b"", b"", b""), "12345678123\u20ac4567": (b"12345678123\xe2", b"\x82\xac4567", b"", b"", b""), "1234567812\u20ac34567": (b"1234567812\xe2\x82", b"\xac34567", b"", b"", b""), } @pytest.mark.asyncio @pytest.mark.parametrize("text, parts", TEST_VECTORS.items()) async def test_dyn_text(pypck_client, text, parts): """dyn_text.""" # await pypck_client.async_connect() module = pypck_client.get_address_conn(LcnAddr(0, 10, False)) with patch.object(ModuleConnection, "send_command") as send_command: await module.dyn_text(3, text) send_command.assert_awaited() await_args = (call.args for call in send_command.await_args_list) _, commands = zip(*await_args) for i, part in enumerate(parts): assert f"GTDT4{i+1:d}".encode() + part in commands pypck-0.8.5/tests/test_messages.py000066400000000000000000000220771475005466200172550ustar00rootroot00000000000000"""Tests for input message parsing for bus messages.""" import pytest from pypck.inputs import ( InputParser, ModAck, ModNameComment, ModSendCommandHost, ModSendKeysHost, ModSk, ModSn, ModStatusAccessControl, ModStatusBinSensors, ModStatusGroups, ModStatusKeyLocks, ModStatusLedsAndLogicOps, ModStatusOutput, ModStatusOutputNative, ModStatusRelays, ModStatusSceneOutputs, ModStatusVar, ) from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import ( AccessControlPeriphery, BatteryStatus, HardwareType, KeyAction, LedStatus, LogicOpStatus, OutputPort, SendKeyCommand, Var, VarValue, ) MESSAGES = { # Ack "-M000010!": [(ModAck, -1)], "-M000010005": [(ModAck, 5)], # SK "=M000010.SK007": [(ModSk, 7)], # SN "=M000010.SN1AB20A123401FW190B11HW015": [ ( ModSn, 0x1AB20A1234, 0x1, 0x190B11, HardwareType.SH_PLUS, ) ], "=M000010.SN1234567890AFFW190011HW011": [ ( ModSn, 0x1234567890, 0xAF, 0x190011, HardwareType.UPP, ) ], "=M000010.SN1234567890vMFW190011HW011": [ ( ModSn, 0x1234567890, 0xFF, 0x190011, HardwareType.UPP, ) ], # Name "=M000010.N1EG HWR Hau": [(ModNameComment, "N", 0, "EG HWR Hau")], "=M000010.N2EG HWR Hau": [(ModNameComment, "N", 1, "EG HWR Hau")], # Comment "=M000010.K1EG HWR Hau": [(ModNameComment, "K", 0, "EG HWR Hau")], "=M000010.K2EG HWR Hau": [(ModNameComment, "K", 1, "EG HWR Hau")], "=M000010.K3EG HWR Hau": [(ModNameComment, "K", 2, "EG HWR Hau")], # Oem "=M000010.O1EG HWR Hau": [(ModNameComment, "O", 0, "EG HWR Hau")], "=M000010.O2EG HWR Hau": [(ModNameComment, "O", 1, "EG HWR Hau")], "=M000010.O3EG HWR Hau": [(ModNameComment, "O", 2, "EG HWR Hau")], "=M000010.O4EG HWR Hau": [(ModNameComment, "O", 3, "EG HWR Hau")], # Groups "=M000010.GP012005040": [ ( ModStatusGroups, False, 12, [LcnAddr(0, 5, True), LcnAddr(0, 40, True)], ) ], "=M000010.GD008005040": [ ( ModStatusGroups, True, 8, [LcnAddr(0, 5, True), LcnAddr(0, 40, True)], ) ], "=M000010.GD010005040030020010100200150099201": [ ( ModStatusGroups, True, 10, [ LcnAddr(0, 5, True), LcnAddr(0, 40, True), LcnAddr(0, 30, True), LcnAddr(0, 20, True), LcnAddr(0, 10, True), LcnAddr(0, 100, True), LcnAddr(0, 200, True), LcnAddr(0, 150, True), LcnAddr(0, 99, True), LcnAddr(0, 201, True), ], ) ], # Status Output ":M000010A1050": [(ModStatusOutput, OutputPort.OUTPUT1.value, 50.0)], # Status Output Native ":M000010O1050": [(ModStatusOutputNative, OutputPort.OUTPUT1.value, 50)], # Status Relays ":M000010Rx204": [ ( ModStatusRelays, [False, False, True, True, False, False, True, True], ) ], # Status BinSensors ":M000010Bx204": [ ( ModStatusBinSensors, [False, False, True, True, False, False, True, True], ) ], # Status Var "%M000010.A00301200": [(ModStatusVar, Var.VAR3, VarValue(1200))], "%M000010.01200": [(ModStatusVar, Var.UNKNOWN, VarValue(1200))], "%M000010.S101200": [(ModStatusVar, Var.R1VARSETPOINT, VarValue(1200))], "%M000010.T1100050": [(ModStatusVar, Var.THRS1, VarValue(50))], "%M000010.T3400050": [(ModStatusVar, Var.THRS3_4, VarValue(50))], "=M000010.S1111112222233333444445555512345": [ (ModStatusVar, Var.THRS1, VarValue(11111)), (ModStatusVar, Var.THRS2, VarValue(22222)), (ModStatusVar, Var.THRS3, VarValue(33333)), (ModStatusVar, Var.THRS4, VarValue(44444)), (ModStatusVar, Var.THRS5, VarValue(55555)), ], # Status Leds and LogicOps "=M000010.TLAEBFAAAAAAAANTVN": [ ( ModStatusLedsAndLogicOps, [ LedStatus.OFF, LedStatus.ON, LedStatus.BLINK, LedStatus.FLICKER, LedStatus.OFF, LedStatus.OFF, LedStatus.OFF, LedStatus.OFF, LedStatus.OFF, LedStatus.OFF, LedStatus.OFF, LedStatus.OFF, ], [ LogicOpStatus.NONE, LogicOpStatus.SOME, LogicOpStatus.ALL, LogicOpStatus.NONE, ], ) ], # Status Key Locks "=M000010.TX255000063204": [ ( ModStatusKeyLocks, [ [True, True, True, True, True, True, True, True], [False, False, False, False, False, False, False, False], [True, True, True, True, True, True, False, False], [False, False, True, True, False, False, True, True], ], ) ], # Status Access Control "=M000010.ZI026043060013002": [ ( ModStatusAccessControl, AccessControlPeriphery.TRANSMITTER, "1a2b3c", 1, 2, KeyAction.MAKE, BatteryStatus.FULL, ) ], "=M000010.ZI026043060013011": [ ( ModStatusAccessControl, AccessControlPeriphery.TRANSMITTER, "1a2b3c", 1, 2, KeyAction.HIT, BatteryStatus.WEAK, ) ], "=M000010.ZT026043060": [ ( ModStatusAccessControl, AccessControlPeriphery.TRANSPONDER, "1a2b3c", ) ], "=M000010.ZF026043060": [ ( ModStatusAccessControl, AccessControlPeriphery.FINGERPRINT, "1a2b3c", ) ], "=M000010.ZC026043060": [ ( ModStatusAccessControl, AccessControlPeriphery.CODELOCK, "1a2b3c", ) ], # Status scene outputs "=M000010.SZ003025150075100140000033200": [ ( ModStatusSceneOutputs, 3, [25, 75, 140, 33], [150, 100, 0, 200], ) ], # SKH "+M004000010.SKH000001": [(ModSendCommandHost, (0, 1))], "+M004000010.SKH000001002003004005": [ ( ModSendCommandHost, tuple(i for i in range(6)), ) ], "+M004000010.SKH000001002003004005006007008009010011012013": [ ( ModSendCommandHost, tuple(i for i in range(14)), ) ], # SKH with partially invalid data "+M004000010.SKH000001002": [(ModSendCommandHost, (0, 1))], "+M004000010.SKH000001002003": [(ModSendCommandHost, (0, 1))], "+M004000010.SKH000001002003004005006": [ ( ModSendCommandHost, tuple(i for i in range(6)), ) ], # SKH (new header) "$M000010.SKH000001": [(ModSendCommandHost, (0, 1))], "$M000010.SKH000001002003004005": [ ( ModSendCommandHost, tuple(i for i in range(6)), ) ], "$M000010.SKH000001002003004005006007008009010011012013": [ ( ModSendCommandHost, tuple(i for i in range(14)), ) ], # SKH (new header) with partially invalid data "$M000010.SKH000001002": [(ModSendCommandHost, (0, 1))], "$M000010.SKH000001002003": [(ModSendCommandHost, (0, 1))], "$M000010.SKH000001002003004005006": [ ( ModSendCommandHost, tuple(i for i in range(6)), ) ], # STH "+M004000010.STH000000": [ ( ModSendKeysHost, [SendKeyCommand.DONTSEND] * 3, [False] * 8, ) ], "+M004000010.STH057078": [ ( ModSendKeysHost, [SendKeyCommand.HIT, SendKeyCommand.MAKE, SendKeyCommand.BREAK], [False, True, True, True, False, False, True, False], ) ], # STH "$M000010.STH000000": [ ( ModSendKeysHost, [SendKeyCommand.DONTSEND] * 3, [False] * 8, ) ], "$M000010.STH057078": [ ( ModSendKeysHost, [SendKeyCommand.HIT, SendKeyCommand.MAKE, SendKeyCommand.BREAK], [False, True, True, True, False, False, True, False], ) ], } @pytest.mark.parametrize("message, expected", MESSAGES.items()) def test_message_parsing_mod_inputs(message, expected): """Test if InputMod parses message correctly.""" inputs = InputParser.parse(message) assert len(inputs) == len(expected) for idx, inp in enumerate(inputs): exp = (expected[idx][0])(LcnAddr(0, 10, False), *expected[idx][1:]) assert type(inp) is type(exp) # pylint: disable=unidiomatic-typecheck assert vars(inp) == vars(exp) pypck-0.8.5/tests/test_vars.py000066400000000000000000000055311475005466200164150ustar00rootroot00000000000000"""Tests for variable value and unit handling.""" import math import pytest from pypck.lcn_defs import VarUnit, VarValue VARIABLE_TEST_VALUES = ( 0, 48, 49, 50, 51, 52, 100, 198, 199, 200, 201, 202, 205, 1000, 1023, 4095, 65535, ) UNITS_LOSSLESS_ROUNDTRIP = ( VarUnit.NATIVE, VarUnit.CELSIUS, VarUnit.KELVIN, VarUnit.FAHRENHEIT, VarUnit.LUX_I, VarUnit.METERPERSECOND, VarUnit.PERCENT, VarUnit.VOLT, VarUnit.AMPERE, VarUnit.DEGREE, VarUnit.PPM, # VarUnit.LUX_T, ) ROUNDTRIP_TEST_VECTORS = ( *( (unit, value, value) for value in VARIABLE_TEST_VALUES for is_abs in (True, False) for unit in UNITS_LOSSLESS_ROUNDTRIP ), ) CALIBRATION_TEST_VECTORS = ( *( (VarUnit.CELSIUS, native, value) for native, value in ((0, -100), (1000, 0), (2000, 100)) ), *( (VarUnit.KELVIN, native, value) for native, value in ( (0, -100 + 273.15), (1000, 0 + 273.15), (2000, 100 + 273.15), ) ), *( (VarUnit.FAHRENHEIT, native, value) for native, value in ( (0, -100 * 1.8 + 32), (1000, 0 * 1.8 + 32), (2000, 100 * 1.8 + 32), ) ), *( (VarUnit.LUX_I, native, value) for native, value in ( (0, math.exp(0)), (10, math.exp(0.1)), (100, math.exp(1)), (1000, math.exp(10)), ) ), *( (VarUnit.METERPERSECOND, native, value) for native, value in ((0, 0), (10, 1), (100, 10), (1000, 100)) ), *( (VarUnit.PERCENT, native, value) for native, value in ((0, 0), (1, 1), (100, 100)) ), *( (VarUnit.VOLT, native, value) for native, value in ((0, 0), (400, 1), (4000, 10)) ), *( (VarUnit.AMPERE, native, value) for native, value in ((0, 0), (100, 0.001), (4000, 0.04)) ), *( (VarUnit.DEGREE, native, value) for native, value in ((0, -100), (1000, 0), (2000, 100), (4000, 300)) ), *( (VarUnit.PPM, native, value) for native, value in ((0, 0), (1, 1), (100, 100), (1000, 1000)) ), # VarUnit.LUX_T, ) @pytest.mark.parametrize("unit, native, expected", ROUNDTRIP_TEST_VECTORS) def test_roundtrip(unit, native, expected): """Test that variable conversion roundtrips.""" assert ( expected == VarValue.from_var_unit( VarValue.to_var_unit(VarValue.from_native(native), unit), unit, True ).to_native() ) @pytest.mark.parametrize("unit, native, value", CALIBRATION_TEST_VECTORS) def test_calibration(unit, native, value): """Test proper calibration of variable conversion.""" assert value == VarValue.to_var_unit(VarValue.from_native(native), unit)