pax_global_header 0000666 0000000 0000000 00000000064 15023331221 0014502 g ustar 00root root 0000000 0000000 52 comment=90e7681325a1973235dd86cfc3becb531e2eee69
pypck-0.8.8/ 0000775 0000000 0000000 00000000000 15023331221 0012645 5 ustar 00root root 0000000 0000000 pypck-0.8.8/.github/ 0000775 0000000 0000000 00000000000 15023331221 0014205 5 ustar 00root root 0000000 0000000 pypck-0.8.8/.github/workflows/ 0000775 0000000 0000000 00000000000 15023331221 0016242 5 ustar 00root root 0000000 0000000 pypck-0.8.8/.github/workflows/ReleaseActions.yaml 0000664 0000000 0000000 00000001460 15023331221 0022030 0 ustar 00root root 0000000 0000000 name: "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.8/.github/workflows/ci.yaml 0000664 0000000 0000000 00000020426 15023331221 0017525 0 ustar 00root root 0000000 0000000 name: 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.2.3
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.2.3
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.2.3
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.2.3
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.2.3
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.2.3
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.2.3
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.2.3
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.8/.gitignore 0000664 0000000 0000000 00000000621 15023331221 0014634 0 ustar 00root root 0000000 0000000 # 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.8/.pre-commit-config.yaml 0000664 0000000 0000000 00000001771 15023331221 0017134 0 ustar 00root root 0000000 0000000 repos:
- 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.8/LICENSE 0000664 0000000 0000000 00000001777 15023331221 0013666 0 ustar 00root root 0000000 0000000 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
pypck-0.8.8/README.md 0000664 0000000 0000000 00000011375 15023331221 0014133 0 ustar 00root root 0000000 0000000 # pypck - Asynchronous LCN-PCK library written in Python



[](https://pypi.org/project/pypck/)
[](https://github.com/pre-commit/pre-commit)
## 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.8/VERSION 0000664 0000000 0000000 00000000007 15023331221 0013712 0 ustar 00root root 0000000 0000000 0.dev0
pypck-0.8.8/docs/ 0000775 0000000 0000000 00000000000 15023331221 0013575 5 ustar 00root root 0000000 0000000 pypck-0.8.8/docs/Makefile 0000664 0000000 0000000 00000001136 15023331221 0015236 0 ustar 00root root 0000000 0000000 # 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.8/docs/make.bat 0000664 0000000 0000000 00000001411 15023331221 0015177 0 ustar 00root root 0000000 0000000 @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.8/docs/source/ 0000775 0000000 0000000 00000000000 15023331221 0015075 5 ustar 00root root 0000000 0000000 pypck-0.8.8/docs/source/api/ 0000775 0000000 0000000 00000000000 15023331221 0015646 5 ustar 00root root 0000000 0000000 pypck-0.8.8/docs/source/api/connection.rst 0000664 0000000 0000000 00000000255 15023331221 0020541 0 ustar 00root root 0000000 0000000 :mod:`pypck.connection`
-----------------------
.. automodule:: pypck.connection
.. autoclass:: PchkConnection
:members:
.. autoclass:: PchkConnectionManager
:members:
pypck-0.8.8/docs/source/api/inputs.rst 0000664 0000000 0000000 00000000117 15023331221 0017721 0 ustar 00root root 0000000 0000000 :mod:`pypck.input`
------------------
.. automodule:: pypck.inputs
:members:
pypck-0.8.8/docs/source/api/lcn_addr.rst 0000664 0000000 0000000 00000000157 15023331221 0020151 0 ustar 00root root 0000000 0000000 :mod:`pypck.lcn_addr`
---------------------
.. automodule:: pypck.lcn_addr
.. autoclass:: LcnAddr
:members:
pypck-0.8.8/docs/source/api/lcn_defs.rst 0000664 0000000 0000000 00000000155 15023331221 0020156 0 ustar 00root root 0000000 0000000 :mod:`pypck.lcn_defs`
---------------------
.. automodule:: pypck.lcn_defs
:members:
:inherited-members:
pypck-0.8.8/docs/source/api/module.rst 0000664 0000000 0000000 00000000120 15023331221 0017656 0 ustar 00root root 0000000 0000000 :mod:`pypck.module`
-------------------
.. automodule:: pypck.module
:members: pypck-0.8.8/docs/source/api/pck_commands.rst 0000664 0000000 0000000 00000000142 15023331221 0021033 0 ustar 00root root 0000000 0000000 :mod:`pypck.pck_commands`
-------------------------
.. automodule:: pypck.pck_commands
:members: pypck-0.8.8/docs/source/api/timeout_retry.rst 0000664 0000000 0000000 00000000145 15023331221 0021313 0 ustar 00root root 0000000 0000000 :mod:`pypck.timeout_retry`
--------------------------
.. automodule:: pypck.timeout_retry
:members: pypck-0.8.8/docs/source/conf.py 0000664 0000000 0000000 00000011617 15023331221 0016402 0 ustar 00root root 0000000 0000000 #
# 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.8/docs/source/index.rst 0000664 0000000 0000000 00000000703 15023331221 0016736 0 ustar 00root root 0000000 0000000 .. 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.8/pypck/ 0000775 0000000 0000000 00000000000 15023331221 0013773 5 ustar 00root root 0000000 0000000 pypck-0.8.8/pypck/__init__.py 0000664 0000000 0000000 00000000475 15023331221 0016112 0 ustar 00root root 0000000 0000000 """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.8/pypck/connection.py 0000664 0000000 0000000 00000057552 15023331221 0016522 0 ustar 00root root 0000000 0000000 """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.8/pypck/helpers.py 0000664 0000000 0000000 00000002363 15023331221 0016013 0 ustar 00root root 0000000 0000000 """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.8/pypck/inputs.py 0000664 0000000 0000000 00000131404 15023331221 0015672 0 ustar 00root root 0000000 0000000 """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.warning(
"Unconventional manufacturer code for module (S%d, M%d): %s. Defaulting to 0x%02X",
addr.seg_id,
addr.addr_id,
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.
Includes helper functions for motor states based on LCN wiring.
"""
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]
def get_motor_onoff_relay(self, motor_id: int) -> int:
"""Get the motor on/off relay id."""
if 0 > motor_id > 3:
raise ValueError("Motor id must be in range 0..3")
return motor_id * 2
def get_motor_updown_relay(self, motor_id: int) -> int:
"""Get the motor up/down relay id."""
if 0 > motor_id > 3:
raise ValueError("Motor id must be in range 0..3")
return motor_id * 2 + 1
def motor_is_on(self, motor_id: int) -> bool:
"""Check if a motor is on."""
return self.states[self.get_motor_onoff_relay(motor_id)]
def is_opening(self, motor_id: int) -> bool:
"""Check if a motor is opening."""
if self.motor_is_on(motor_id):
return not self.states[self.get_motor_updown_relay(motor_id)]
return False
def is_closing(self, motor_id: int) -> bool:
"""Check if a motor is closing."""
if self.motor_is_on(motor_id):
return self.states[self.get_motor_updown_relay(motor_id)]
return False
def is_assumed_closed(self, motor_id: int) -> bool:
"""Check if a motor is closed.
The closed state is assumed if the motor direction is down and the motor is switched off."
"""
if not self.motor_is_on(motor_id):
return self.states[self.get_motor_updown_relay(motor_id)]
return False
@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 ModStatusMotorPositionBS4(ModInput):
"""Status of motor positions (if BS4 connected) received from an LCN module.
Position and limit is in percent. 0%: cover closed, 100%: cover open.
"""
def __init__(
self,
physical_source_addr: LcnAddr,
motor: int,
position: float,
limit: float | None = None,
time_down: int | None = None,
time_up: int | None = None,
):
"""Construct ModInput object."""
super().__init__(physical_source_addr)
self.motor = motor
self.position = position
self.limit = limit
self.time_down = time_down
self.time_up = time_up
@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_MOTOR_POSITION_BS4.match(data)
if matcher:
motor_status_inputs: list[Input] = []
addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id")))
for idx in (1, 2):
motor = matcher.group(f"motor{idx}_id")
position = matcher.group(f"position{idx}")
limit = matcher.group(f"limit{idx}")
time_down = matcher.group(f"time_down{idx}")
time_up = matcher.group(f"time_up{idx}")
motor_status_inputs.append(
ModStatusMotorPositionBS4(
addr,
int(motor) - 1,
(200 - int(position)) / 2,
None if limit == "?" else (200 - int(limit)) / 2,
None if time_down == "?" else int(time_down),
None if time_up == "?" else int(time_up),
)
)
return motor_status_inputs
return None
class ModStatusMotorPositionModule(ModInput):
"""Status of motor positions received from an LCN module.
Position is in percent. 0%: cover closed, 100%: cover open.
"""
def __init__(
self,
physical_source_addr: LcnAddr,
motor: int,
position: float,
):
"""Construct ModInput object."""
super().__init__(physical_source_addr)
self.motor = motor
self.position = position
@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_MOTOR_POSITION_MODULE.match(data)
if matcher:
addr = LcnAddr(int(matcher.group("seg_id")), int(matcher.group("mod_id")))
motor = matcher.group("motor_id")
position = matcher.group("position")
return [
ModStatusMotorPositionModule(
addr,
int(motor) - 1,
100 - float(position),
)
]
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,
ModStatusMotorPositionBS4,
ModStatusMotorPositionModule,
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.8/pypck/lcn_addr.py 0000664 0000000 0000000 00000003632 15023331221 0016117 0 ustar 00root root 0000000 0000000 """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.8/pypck/lcn_defs.py 0000664 0000000 0000000 00000121415 15023331221 0016126 0 ustar 00root root 0000000 0000000 """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.
: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)
def motor_position_time_to_native_value(time_msec: int) -> int:
"""Convert time to native LCN time value.
Scales the given time value in milliseconds to a two-byte value.
:param int time_msec: Duration of timer in milliseconds (1001..65535000)
:returns: The duration in native LCN units
:rtype: int
"""
if not 1001 <= time_msec <= 65535000:
raise ValueError("Time has to be in range 1001..65535000ms")
value = 0xFFFF * 1000 / time_msec
return int(value)
def native_value_to_motor_position_time(value: int) -> int:
"""Convert native LCN value to time.
Scales the given two-byte value (1..65535) to a time value in milliseconds.
:param int value: Duration of timer in native LCN units
:returns: The duration in milliseconds
:rtype: int
"""
if not 1 <= value <= 0xFFFF:
raise ValueError("Value has to be in range 1..65535")
time_msec = 0xFFFF * 1000 / value
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 = "RT70" # 70ms
RT600 = "RT600" # 600ms
RT1200 = "RT1200" # 1200ms
class MotorPositioningMode(Enum):
"""Motor positioning mode used in LCN commands."""
NONE = "NONE"
BS4 = "BS4"
MODULE = "MODULE"
# EMULATED = "EMULATED"
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.8/pypck/module.py 0000664 0000000 0000000 00000112715 15023331221 0015641 0 ustar 00root root 0000000 0000000 """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_motor_relays(
self,
motor_id: int,
state: lcn_defs.MotorStateModifier,
mode: lcn_defs.MotorPositioningMode = lcn_defs.MotorPositioningMode.NONE,
) -> bool:
"""Send a command to control motors via relays.
:param int motor_id: The motor id 0..3
:param MotorStateModifier state: The modifier for the
:param MotorPositioningMode mode: The motor positioning mode (ooptional)
:returns: True if command was sent successfully, False otherwise
:rtype: bool
"""
return await self.send_command(
self.wants_ack, PckGenerator.control_motor_relays(motor_id, state, mode)
)
async def control_motor_relays_position(
self,
motor_id: int,
position: float,
mode: lcn_defs.MotorPositioningMode,
) -> bool:
"""Control motor position via relays and BS4.
:param int motor_id: The motor port of the LCN module
:param float position: The position to set in percentage (0..100)
:param MotorPositioningMode mode: The motor positioning mode
:returns: True if command was sent successfully, False otherwise
:rtype: bool
"""
return await self.send_command(
self.wants_ack,
PckGenerator.control_motor_relays_position(motor_id, position, mode),
)
async def control_motor_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_motor_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, option: 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, option: Any = None
) -> None:
"""Activate a specific TimeoutRetryHandler for status requests."""
self.task_registry.create_task(
self.status_requests_handler.activate(item, option)
)
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.8/pypck/pck_commands.py 0000664 0000000 0000000 00000142112 15023331221 0017004 0 ustar 00root root 0000000 0000000 """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})"
)
# Pattern to parse motor position BS4 status messages.
PATTERN_STATUS_MOTOR_POSITION_BS4 = re.compile(
r"=M(?P\d{3})(?P\d{3})\."
r"RM(?P[1-4])(?P[0-9]{3})(?P[0-9]{3}|\?)"
r"(?P[0-9]{5}|\?)(?P[0-9]{5}|\?)"
r"RM(?P[1-4])(?P[0-9]{3})(?P[0-9]{3}|\?)"
r"(?P[0-9]{5}|\?)(?P[0-9]{5}|\?)"
)
# Pattern to parse motor position module status messages.
PATTERN_STATUS_MOTOR_POSITION_MODULE = re.compile(
r":M(?P\d{3})(?P\d{3})"
r"P(?P[1-4])(?P[0-9]{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_motor_relays(
motor_id: int,
state: lcn_defs.MotorStateModifier,
mode: lcn_defs.MotorPositioningMode = lcn_defs.MotorPositioningMode.NONE,
) -> str:
"""Generate a command to control motors via relays.
:param int motor_id: The motor id 0..3
:param MotorStateModifier state: The modifier for the
motor state
:return: The PCK command (without address header) as text
:rtype: str
"""
if 0 > motor_id > 3:
raise ValueError("Invalid motor id")
if mode not in lcn_defs.MotorPositioningMode:
raise ValueError("Wrong motor position mode")
if mode == lcn_defs.MotorPositioningMode.BS4:
new_motor_id = [1, 2, 5, 6][motor_id]
if state == lcn_defs.MotorStateModifier.DOWN:
# AU=window open / cover down
action = "AU"
elif state == lcn_defs.MotorStateModifier.UP:
# ZU=window close / cover up
action = "ZU"
elif state == lcn_defs.MotorStateModifier.STOP:
action = "ST"
else:
raise ValueError("Invalid motor state for BS4 mode")
return f"R8M{new_motor_id}{action}"
# lcn_defs.MotorPositioningMode.NONE
# lcn_defs.MotorPositioningMode.MODULE
if state == lcn_defs.MotorStateModifier.UP:
port_onoff = lcn_defs.RelayStateModifier.ON
port_updown = lcn_defs.RelayStateModifier.OFF
elif state == lcn_defs.MotorStateModifier.DOWN:
port_onoff = lcn_defs.RelayStateModifier.ON
port_updown = lcn_defs.RelayStateModifier.ON
elif state == lcn_defs.MotorStateModifier.STOP:
port_onoff = lcn_defs.RelayStateModifier.OFF
port_updown = lcn_defs.RelayStateModifier.NOCHANGE
elif state == lcn_defs.MotorStateModifier.TOGGLEONOFF:
port_onoff = lcn_defs.RelayStateModifier.TOGGLE
port_updown = lcn_defs.RelayStateModifier.NOCHANGE
elif state == lcn_defs.MotorStateModifier.TOGGLEDIR:
port_onoff = lcn_defs.RelayStateModifier.NOCHANGE
port_updown = lcn_defs.RelayStateModifier.TOGGLE
elif state == lcn_defs.MotorStateModifier.CYCLE:
port_onoff = lcn_defs.RelayStateModifier.TOGGLE
port_updown = lcn_defs.RelayStateModifier.TOGGLE
elif state == lcn_defs.MotorStateModifier.NOCHANGE:
port_onoff = lcn_defs.RelayStateModifier.NOCHANGE
port_updown = lcn_defs.RelayStateModifier.NOCHANGE
else:
raise ValueError("Invalid motor state")
states = [lcn_defs.RelayStateModifier.NOCHANGE] * 8
states[motor_id * 2] = port_onoff
states[motor_id * 2 + 1] = port_updown
return "R8" + "".join([state.value for state in states])
@staticmethod
def control_motor_relays_position(
motor_id: int, position: float, mode: lcn_defs.MotorPositioningMode
) -> str:
"""Control motor position via relays and BS4 or module.
:param int motor_id: The motor port of the LCN module
:param float position: The position to set in percentage (0..100)
(0: closed cover, 100: open cover)
:param MotorPositioningMode mode: The motor positioning mode
:return: The PCK command (without address header) as text
:rtype: str
"""
if mode not in (
lcn_defs.MotorPositioningMode.BS4,
lcn_defs.MotorPositioningMode.MODULE,
):
raise ValueError("Wrong motor positioning mode")
if 0 > motor_id > 3:
raise ValueError("Invalid motor")
if mode == lcn_defs.MotorPositioningMode.BS4:
new_motor_id = [1, 2, 5, 6][motor_id]
action = f"GP{int(200 - 2 * position):03d}"
return f"R8M{new_motor_id}{action}"
elif mode == lcn_defs.MotorPositioningMode.MODULE:
new_motor_id = 1 << motor_id
return f"JH{100 - position:03d}{new_motor_id:03d}"
return ""
@staticmethod
def request_motor_position_status(motor_pair: int) -> str:
"""Generate a motor position status request for BS4.
:param int motor_pair: Motor pair 0: 1, 2; 1: 3, 4
:return: The PCK command (without address header) as text
:rtype: str
"""
if motor_pair not in [0, 1]:
raise ValueError("Invalid motor_pair.")
return f"R8M{7 if motor_pair else 3}P{motor_pair + 1}"
@staticmethod
def control_motor_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 = f"Z{'A' if value >= 0 else 'S'}{abs(value)}"
else:
# New command for variable 1-12 (compatible with all modules,
# since LCN-PCHK 2.8)
pck = f"Z{'+' if value >= 0 else '-'}{var_id + 1:03d}{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.8/pypck/request_handlers.py 0000664 0000000 0000000 00000061206 15023331221 0017722 0 ustar 00root root 0000000 0000000 """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
)
# Motor positions request status (1, 2 and 3, 4)
self.request_status_motor_positions = []
for motor_pair in range(2):
trh = TimeoutRetryHandler(
self.task_registry, -1, self.settings["MAX_STATUS_POLLED_VALUEAGE"]
)
trh.set_timeout_callback(
self.request_status_motor_positions_timeout, motor_pair
)
self.request_status_motor_positions.append(trh)
# 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_motor_positions_timeout(
self, failed: bool = False, motor_pair: int = 0
) -> None:
"""Is called on motor position status request timeout."""
if not failed:
await self.addr_conn.send_command(
False, PckGenerator.request_motor_position_status(motor_pair)
)
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, option: Any = None) -> 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()
if option == lcn_defs.MotorPositioningMode.BS4:
self.request_status_motor_positions[item.value // 2].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()
await self.request_status_motor_positions[item.value // 2].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.8/pypck/timeout_retry.py 0000664 0000000 0000000 00000007423 15023331221 0017266 0 ustar 00root root 0000000 0000000 """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.8/pyproject.toml 0000664 0000000 0000000 00000011431 15023331221 0015561 0 ustar 00root root 0000000 0000000 [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.8/requirements_test.txt 0000664 0000000 0000000 00000000276 15023331221 0017175 0 ustar 00root root 0000000 0000000 -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.8/requirements_test_pre_commit.txt 0000664 0000000 0000000 00000000035 15023331221 0021404 0 ustar 00root root 0000000 0000000 codespell==2.3.0
ruff==0.8.3
pypck-0.8.8/setup.cfg 0000664 0000000 0000000 00000001102 15023331221 0014460 0 ustar 00root root 0000000 0000000 # 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.8/tests/ 0000775 0000000 0000000 00000000000 15023331221 0014007 5 ustar 00root root 0000000 0000000 pypck-0.8.8/tests/__init__.py 0000664 0000000 0000000 00000000036 15023331221 0016117 0 ustar 00root root 0000000 0000000 """Tests for pypck module."""
pypck-0.8.8/tests/conftest.py 0000664 0000000 0000000 00000007003 15023331221 0016206 0 ustar 00root root 0000000 0000000 """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.8/tests/messages/ 0000775 0000000 0000000 00000000000 15023331221 0015616 5 ustar 00root root 0000000 0000000 pypck-0.8.8/tests/messages/__init__.py 0000664 0000000 0000000 00000000070 15023331221 0017724 0 ustar 00root root 0000000 0000000 """Tests for input message parsing for bus messages."""
pypck-0.8.8/tests/messages/test_input_flow.py 0000664 0000000 0000000 00000002526 15023331221 0021422 0 ustar 00root root 0000000 0000000 """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.8/tests/messages/test_output_status.py 0000664 0000000 0000000 00000003630 15023331221 0022174 0 ustar 00root root 0000000 0000000 """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.8/tests/messages/test_send_command_host.py 0000664 0000000 0000000 00000003373 15023331221 0022721 0 ustar 00root root 0000000 0000000 """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.8/tests/mock_pchk.py 0000664 0000000 0000000 00000013274 15023331221 0016326 0 ustar 00root root 0000000 0000000 """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.8/tests/test_commands.py 0000664 0000000 0000000 00000041254 15023331221 0017227 0 ustar 00root root 0000000 0000000 """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,
MotorPositioningMode,
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,
],
),
# Motor state manipulation
"R8--10----": (
PckGenerator.control_motor_relays,
1,
MotorStateModifier.UP,
),
"R8-----U--": (
PckGenerator.control_motor_relays,
2,
MotorStateModifier.TOGGLEDIR,
),
"R8UU------": (
PckGenerator.control_motor_relays,
0,
MotorStateModifier.CYCLE,
),
"R8M1ZU": (
PckGenerator.control_motor_relays,
0,
MotorStateModifier.UP,
MotorPositioningMode.BS4,
),
"R8M2AU": (
PckGenerator.control_motor_relays,
1,
MotorStateModifier.DOWN,
MotorPositioningMode.BS4,
),
"R8M5ST": (
PckGenerator.control_motor_relays,
2,
MotorStateModifier.STOP,
MotorPositioningMode.BS4,
),
"R8M1GP200": (
PckGenerator.control_motor_relays_position,
0,
0.0,
MotorPositioningMode.BS4,
),
"R8M6GP100": (
PckGenerator.control_motor_relays_position,
3,
50.0,
MotorPositioningMode.BS4,
),
"R8M3P1": (
PckGenerator.request_motor_position_status,
0,
),
"R8M7P2": (
PckGenerator.request_motor_position_status,
1,
),
"JH050001": (
PckGenerator.control_motor_relays_position,
0,
50,
MotorPositioningMode.MODULE,
),
"JH030004": (
PckGenerator.control_motor_relays_position,
2,
70,
MotorPositioningMode.MODULE,
),
"X2001228000": (
PckGenerator.control_motor_outputs,
MotorStateModifier.UP,
MotorReverseTime.RT70,
),
"A1DI100008": (
PckGenerator.control_motor_outputs,
MotorStateModifier.UP,
MotorReverseTime.RT600,
),
"A1DI100011": (
PckGenerator.control_motor_outputs,
MotorStateModifier.UP,
MotorReverseTime.RT1200,
),
"X2001000228": (
PckGenerator.control_motor_outputs,
MotorStateModifier.DOWN,
MotorReverseTime.RT70,
),
"A2DI100008": (
PckGenerator.control_motor_outputs,
MotorStateModifier.DOWN,
MotorReverseTime.RT600,
),
"A2DI100011": (
PckGenerator.control_motor_outputs,
MotorStateModifier.DOWN,
MotorReverseTime.RT1200,
),
"AY000000": (
PckGenerator.control_motor_outputs,
MotorStateModifier.STOP,
),
"JE": (
PckGenerator.control_motor_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.8/tests/test_connection.py 0000664 0000000 0000000 00000032104 15023331221 0017557 0 ustar 00root root 0000000 0000000 """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.8/tests/test_dyn_text.py 0000664 0000000 0000000 00000004437 15023331221 0017266 0 ustar 00root root 0000000 0000000 """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.8/tests/test_messages.py 0000664 0000000 0000000 00000023234 15023331221 0017233 0 ustar 00root root 0000000 0000000 """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,
ModStatusMotorPositionBS4,
ModStatusMotorPositionModule,
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],
)
],
# Status motor position via BS4
"=M000010.RM1100?1234567890RM2200200??": [
(
ModStatusMotorPositionBS4,
0,
50,
None,
12345,
67890,
),
(
ModStatusMotorPositionBS4,
1,
0,
0,
None,
None,
),
],
# Status motor position via module
":M000010P1070": [
(
ModStatusMotorPositionModule,
0,
30,
)
],
# 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.8/tests/test_vars.py 0000664 0000000 0000000 00000005531 15023331221 0016377 0 ustar 00root root 0000000 0000000 """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)