pax_global_header 0000666 0000000 0000000 00000000064 15063173236 0014520 g ustar 00root root 0000000 0000000 52 comment=3c6bd5bf09d0983fb02dbd099bab1f24ce384c4d
pypck-0.9.2/ 0000775 0000000 0000000 00000000000 15063173236 0012656 5 ustar 00root root 0000000 0000000 pypck-0.9.2/.github/ 0000775 0000000 0000000 00000000000 15063173236 0014216 5 ustar 00root root 0000000 0000000 pypck-0.9.2/.github/workflows/ 0000775 0000000 0000000 00000000000 15063173236 0016253 5 ustar 00root root 0000000 0000000 pypck-0.9.2/.github/workflows/ReleaseActions.yaml 0000664 0000000 0000000 00000001460 15063173236 0022041 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.9.2/.github/workflows/ci.yaml 0000664 0000000 0000000 00000020434 15063173236 0017535 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 tests
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.9.2/.gitignore 0000664 0000000 0000000 00000000621 15063173236 0014645 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.9.2/.pre-commit-config.yaml 0000664 0000000 0000000 00000001771 15063173236 0017145 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.9.2/LICENSE 0000664 0000000 0000000 00000001777 15063173236 0013677 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.9.2/README.md 0000664 0000000 0000000 00000003617 15063173236 0014144 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-0.9.2/VERSION 0000664 0000000 0000000 00000000007 15063173236 0013723 0 ustar 00root root 0000000 0000000 0.dev0
pypck-0.9.2/docs/ 0000775 0000000 0000000 00000000000 15063173236 0013606 5 ustar 00root root 0000000 0000000 pypck-0.9.2/docs/Makefile 0000664 0000000 0000000 00000001136 15063173236 0015247 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.9.2/docs/make.bat 0000664 0000000 0000000 00000001411 15063173236 0015210 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.9.2/docs/source/ 0000775 0000000 0000000 00000000000 15063173236 0015106 5 ustar 00root root 0000000 0000000 pypck-0.9.2/docs/source/api/ 0000775 0000000 0000000 00000000000 15063173236 0015657 5 ustar 00root root 0000000 0000000 pypck-0.9.2/docs/source/api/connection.rst 0000664 0000000 0000000 00000000255 15063173236 0020552 0 ustar 00root root 0000000 0000000 :mod:`pypck.connection`
-----------------------
.. automodule:: pypck.connection
.. autoclass:: PchkConnection
:members:
.. autoclass:: PchkConnectionManager
:members:
pypck-0.9.2/docs/source/api/inputs.rst 0000664 0000000 0000000 00000000117 15063173236 0017732 0 ustar 00root root 0000000 0000000 :mod:`pypck.input`
------------------
.. automodule:: pypck.inputs
:members:
pypck-0.9.2/docs/source/api/lcn_addr.rst 0000664 0000000 0000000 00000000157 15063173236 0020162 0 ustar 00root root 0000000 0000000 :mod:`pypck.lcn_addr`
---------------------
.. automodule:: pypck.lcn_addr
.. autoclass:: LcnAddr
:members:
pypck-0.9.2/docs/source/api/lcn_defs.rst 0000664 0000000 0000000 00000000155 15063173236 0020167 0 ustar 00root root 0000000 0000000 :mod:`pypck.lcn_defs`
---------------------
.. automodule:: pypck.lcn_defs
:members:
:inherited-members:
pypck-0.9.2/docs/source/api/module.rst 0000664 0000000 0000000 00000000120 15063173236 0017667 0 ustar 00root root 0000000 0000000 :mod:`pypck.module`
-------------------
.. automodule:: pypck.module
:members: pypck-0.9.2/docs/source/api/pck_commands.rst 0000664 0000000 0000000 00000000142 15063173236 0021044 0 ustar 00root root 0000000 0000000 :mod:`pypck.pck_commands`
-------------------------
.. automodule:: pypck.pck_commands
:members: pypck-0.9.2/docs/source/api/timeout_retry.rst 0000664 0000000 0000000 00000000145 15063173236 0021324 0 ustar 00root root 0000000 0000000 :mod:`pypck.timeout_retry`
--------------------------
.. automodule:: pypck.timeout_retry
:members: pypck-0.9.2/docs/source/conf.py 0000664 0000000 0000000 00000011617 15063173236 0016413 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.9.2/docs/source/index.rst 0000664 0000000 0000000 00000000703 15063173236 0016747 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.9.2/mypy.ini 0000664 0000000 0000000 00000001242 15063173236 0014354 0 ustar 00root root 0000000 0000000 [mypy]
python_version = 3.13
platform = linux
show_error_codes = true
follow_imports = normal
local_partial_types = true
strict = true
no_implicit_optional = true
warn_incomplete_stub = true
warn_redundant_casts = true
warn_unused_configs = true
warn_unused_ignores = true
enable_error_code = ignore-without-code, redundant-self, truthy-iterable
disable_error_code = annotation-unchecked, import-not-found, import-untyped
extra_checks = false
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
pypck-0.9.2/pypck/ 0000775 0000000 0000000 00000000000 15063173236 0014004 5 ustar 00root root 0000000 0000000 pypck-0.9.2/pypck/__init__.py 0000664 0000000 0000000 00000000425 15063173236 0016116 0 ustar 00root root 0000000 0000000 """Init file for pypck."""
from pypck import (
connection,
helpers,
inputs,
lcn_addr,
lcn_defs,
module,
pck_commands,
)
__all__ = [
"connection",
"inputs",
"helpers",
"lcn_addr",
"lcn_defs",
"module",
"pck_commands",
]
pypck-0.9.2/pypck/connection.py 0000664 0000000 0000000 00000056437 15063173236 0016534 0 ustar 00root root 0000000 0000000 """Connection classes for pypck."""
from __future__ import annotations
import asyncio
import logging
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 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 = asyncio.get_running_loop().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
loop = asyncio.get_running_loop()
_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 = loop.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
loop = asyncio.get_running_loop()
try:
_LOGGER.debug("Write data loop started")
while not self.writer.is_closing():
data = await self.buffer.get()
while (loop.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 = loop.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 exc
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."""
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 = asyncio.get_running_loop().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) -> 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"]
)
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) -> ModuleConnection | GroupConnection:
"""Create and/or return a connection to the given module or group."""
if addr.is_group:
return self.get_group_conn(addr)
return self.get_module_conn(addr)
# Other
async 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] = await 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()
# 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.9.2/pypck/helpers.py 0000664 0000000 0000000 00000002400 15063173236 0016014 0 ustar 00root root 0000000 0000000 """Helper functions for pypck."""
import asyncio
from collections.abc import Coroutine
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: Coroutine[Any, Any, Any]) -> "asyncio.Task[None]":
"""Create a task and store a reference in the task registry."""
task: asyncio.Task[Any] = asyncio.create_task(coro)
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.9.2/pypck/inputs.py 0000664 0000000 0000000 00000131404 15063173236 0015703 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.9.2/pypck/lcn_addr.py 0000664 0000000 0000000 00000003632 15063173236 0016130 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.9.2/pypck/lcn_defs.py 0000664 0000000 0000000 00000123542 15063173236 0016142 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
@classmethod
def variables(cls) -> list[Var]:
"""Return a list of all variable types."""
return [
cls.VAR1ORTVAR,
cls.VAR2ORR1VAR,
cls.VAR3ORR2VAR,
cls.VAR4,
cls.VAR5,
cls.VAR6,
cls.VAR7,
cls.VAR8,
cls.VAR9,
cls.VAR10,
cls.VAR11,
cls.VAR12,
]
@classmethod
def variables_new(cls) -> list[Var]:
"""Return a list of all new variable types (firmware >=0x170206)."""
return cls.variables()
@classmethod
def variables_old(cls) -> list[Var]:
"""Return a list of all variable types (firmware <0x170206)."""
return cls.variables()[:3]
@classmethod
def set_points(cls) -> list[Var]:
"""Return a list of all set-point variable types."""
return [cls.R1VARSETPOINT, cls.R2VARSETPOINT]
@classmethod
def thresholds(cls) -> list[list[Var]]:
"""Return a list of all threshold variable types."""
return [
[cls.THRS1, cls.THRS2, cls.THRS3, cls.THRS4, cls.THRS5],
[cls.THRS2_1, cls.THRS2_2, cls.THRS2_3, cls.THRS2_4],
[cls.THRS3_1, cls.THRS3_2, cls.THRS3_3, cls.THRS3_4],
[cls.THRS4_1, cls.THRS4_2, cls.THRS4_3, cls.THRS4_4],
]
@classmethod
def thresholds_new(cls) -> list[list[Var]]:
"""Return a list of all threshold variable types (firmware >=0x170206)."""
return [cls.thresholds()[0][:4], *cls.thresholds()[1:]]
@classmethod
def thresholds_old(cls) -> list[list[Var]]:
"""Return a list of all old threshold variable types (firmware <0x170206)."""
return [cls.thresholds()[0]]
@classmethod
def s0s(cls) -> list[Var]:
"""Return a list of all S0-input variable types."""
return [cls.S0INPUT1, cls.S0INPUT2, cls.S0INPUT3, cls.S0INPUT4]
@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())):
raise ValueError("Bad var_id.")
return Var.variables()[var_id]
@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())):
raise ValueError("Bad set_point_id.")
return Var.set_points()[set_point_id]
@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()))
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]
@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())):
raise ValueError("Bad s0_id.")
return Var.s0s()[s0_id]
@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)
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
"MAX_RESPONSE_AGE": 60, # Age in seconds after which stored responses are purged
"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
"PING_MODULE_TIMEOUT": 60, # The delay before sending a ping to a module
}
pypck-0.9.2/pypck/module.py 0000664 0000000 0000000 00000130125 15063173236 0015645 0 ustar 00root root 0000000 0000000 """Module and group classes."""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
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
if TYPE_CHECKING:
from pypck.connection import PchkConnectionManager
_LOGGER = logging.getLogger(__name__)
@dataclass
class Serials:
"""Data class for module serials."""
hardware_serial: int
manu: int
software_serial: int
hardware_type: lcn_defs.HardwareType
@dataclass(unsafe_hash=True)
class StatusRequest:
"""Data class for status requests."""
type: type[inputs.Input] # Type of the input expected as response
parameters: frozenset[tuple[str, Any]] # {(parameter_name, parameter_value)}
timestamp: float = field(
compare=False
) # timestamp the response was received; -1=no timestamp
response: asyncio.Future[inputs.Input] = field(
compare=False
) # Future to hold the response input object
class StatusRequester:
"""Handling of status requests."""
def __init__(
self,
device_connection: ModuleConnection,
) -> None:
"""Initialize the context."""
self.device_connection = device_connection
self.last_requests: set[StatusRequest] = set()
self.unregister_inputs = self.device_connection.register_for_inputs(
self.input_callback
)
self.max_response_age = self.device_connection.conn.settings["MAX_RESPONSE_AGE"]
# asyncio.get_running_loop().create_task(self.prune_loop())
async def prune_loop(self) -> None:
"""Periodically prune old status requests."""
while True:
await asyncio.sleep(self.max_response_age)
self.prune_status_requests()
def prune_status_requests(self) -> None:
"""Prune old status requests."""
entries_to_remove = {
request
for request in self.last_requests
if asyncio.get_running_loop().time() - request.timestamp
> self.max_response_age
}
for entry in entries_to_remove:
entry.response.cancel()
self.last_requests.difference_update(entries_to_remove)
def get_status_requests(
self,
request_type: type[inputs.Input],
parameters: frozenset[tuple[str, Any]] | None = None,
max_age: int = 0,
) -> list[StatusRequest]:
"""Get the status requests for the given type and parameters."""
if parameters is None:
parameters = frozenset()
loop = asyncio.get_running_loop()
results = [
request
for request in self.last_requests
if request.type == request_type
and parameters.issubset(request.parameters)
and (
(request.timestamp == -1)
or (max_age == -1)
or (loop.time() - request.timestamp < max_age)
)
]
results.sort(key=lambda request: request.timestamp, reverse=True)
return results
def input_callback(self, inp: inputs.Input) -> None:
"""Handle incoming inputs and set the result for the corresponding requests."""
requests = [
request
for request in self.get_status_requests(type(inp))
if all(
getattr(inp, parameter_name) == parameter_value
for parameter_name, parameter_value in request.parameters
)
]
for request in requests:
if request.response.done() or request.response.cancelled():
continue
request.timestamp = asyncio.get_running_loop().time()
request.response.set_result(inp)
async def request(
self,
response_type: type[inputs.Input],
request_pck: str,
request_acknowledge: bool = False,
max_age: int = 0, # -1: no age limit / infinite age
**request_kwargs: Any,
) -> inputs.Input | None:
"""Execute a status request and wait for the response."""
parameters = frozenset(request_kwargs.items())
# check if we already have a received response for the current request
if requests := self.get_status_requests(response_type, parameters, max_age):
try:
async with asyncio.timeout(
self.device_connection.conn.settings["DEFAULT_TIMEOUT"]
):
return await requests[0].response
except asyncio.TimeoutError:
return None
except asyncio.CancelledError:
return None
# no stored request or forced request: set up a new request
request = StatusRequest(
response_type,
frozenset(request_kwargs.items()),
-1,
asyncio.get_running_loop().create_future(),
)
self.last_requests.discard(request)
self.last_requests.add(request)
result = None
# send the request up to NUM_TRIES and wait for response future completion
for _ in range(self.device_connection.conn.settings["NUM_TRIES"]):
await self.device_connection.send_command(request_acknowledge, request_pck)
try:
async with asyncio.timeout(
self.device_connection.conn.settings["DEFAULT_TIMEOUT"]
):
# Need to shield the future. Otherwise it would get cancelled.
result = await asyncio.shield(request.response)
break
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
break
# if we got no results, remove the request from the set
if result is None:
request.response.cancel()
self.last_requests.discard(request)
return result
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,
wants_ack: bool = False,
) -> None:
"""Construct AbstractConnection instance."""
self.conn = conn
self.addr = addr
self.wants_ack = wants_ack
self.serials = Serials(-1, -1, -1, lcn_defs.HardwareType.UNKNOWN)
self._serials_known = asyncio.Event()
@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
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) -> 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
"""
await self._serials_known.wait()
return await self.send_command(
self.wants_ack,
PckGenerator.dim_all_outputs(percent, ramp, self.serials.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, to_memory: bool = False
) -> 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
:param bool to_memory: If True, the dimming status is stored
: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, to_memory)
)
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 = -1,
) -> 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 == -1:
await self._serials_known.wait()
software_serial = self.serials.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 = -1) -> 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 == -1:
await self._serials_known.wait()
software_serial = self.serials.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 = -1,
) -> 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 == -1:
await self._serials_known.wait()
software_serial = self.serials.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.serials.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[lcn_defs.KeyLockStateModifier],
) -> 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,
):
"""Construct GroupConnection instance."""
assert addr.is_group
super().__init__(conn, addr, wants_ack=False)
self._serials_known.set()
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 = -1,
) -> 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 = -1,
) -> 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
class ModuleConnection(AbstractConnection):
"""Organizes communication with a specific module or group."""
def __init__(
self,
conn: PchkConnectionManager,
addr: LcnAddr,
has_s0_enabled: bool = False,
wants_ack: bool = True,
):
"""Construct ModuleConnection instance."""
assert not addr.is_group
super().__init__(conn, addr, wants_ack=wants_ack)
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()
# StatusRequester
self.status_requester = StatusRequester(self)
self.task_registry.create_task(self.request_module_properties())
async def request_module_properties(self) -> None:
"""Request module properties (serials)."""
self.serials = await self.request_serials()
self._serials_known.set()
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)
async def serials_known(self) -> None:
"""Wait until the serials of this module are known."""
await self._serials_known.wait()
# ##
# ## 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)
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
for input_callback in self.input_callbacks:
input_callback(inp)
async 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.serials.hardware_serial:10X}",
"manu": f"{self.serials.manu:02X}",
"software_serial": f"{self.serials.software_serial:06X}",
"hardware_type": f"{self.serials.hardware_type.value:d}",
"hardware_name": self.serials.hardware_type.description,
},
"name": await self.request_name(),
"comment": await self.request_comment(),
"oem_text": await self.request_oem_text(),
"groups": {
"static": sorted(
addr.addr_id
for addr in await self.request_group_memberships(dynamic=False)
),
"dynamic": sorted(
addr.addr_id
for addr in await self.request_group_memberships(dynamic=True)
),
},
}
# Request status methods
async def request_status_output(
self, output_port: lcn_defs.OutputPort, max_age: int = 0
) -> inputs.ModStatusOutput | None:
"""Request the status of an output port from a module."""
result = await self.status_requester.request(
response_type=inputs.ModStatusOutput,
request_pck=PckGenerator.request_output_status(output_id=output_port.value),
max_age=max_age,
output_id=output_port.value,
)
return cast(inputs.ModStatusOutput, result)
async def request_status_relays(
self, max_age: int = 0
) -> inputs.ModStatusRelays | None:
"""Request the status of relays from a module."""
result = await self.status_requester.request(
response_type=inputs.ModStatusRelays,
request_pck=PckGenerator.request_relays_status(),
max_age=max_age,
)
return cast(inputs.ModStatusRelays, result)
async def request_status_motor_position(
self,
motor: lcn_defs.MotorPort,
positioning_mode: lcn_defs.MotorPositioningMode,
max_age: int = 0,
) -> inputs.ModStatusMotorPositionBS4 | None:
"""Request the status of motor positions from a module."""
if motor not in (
lcn_defs.MotorPort.MOTOR1,
lcn_defs.MotorPort.MOTOR2,
lcn_defs.MotorPort.MOTOR3,
lcn_defs.MotorPort.MOTOR4,
):
_LOGGER.debug(
"Only MOTOR1 to MOTOR4 are supported for motor position requests."
)
return None
if positioning_mode != lcn_defs.MotorPositioningMode.BS4:
_LOGGER.debug("Only BS4 mode is supported for motor position requests.")
return None
result = await self.status_requester.request(
response_type=inputs.ModStatusMotorPositionBS4,
request_pck=PckGenerator.request_motor_position_status(motor.value // 2),
max_age=max_age,
motor=motor.value,
)
return cast(inputs.ModStatusMotorPositionBS4, result)
async def request_status_binary_sensors(
self, max_age: int = 0
) -> inputs.ModStatusBinSensors | None:
"""Request the status of binary sensors from a module."""
result = await self.status_requester.request(
response_type=inputs.ModStatusBinSensors,
request_pck=PckGenerator.request_bin_sensors_status(),
max_age=max_age,
)
return cast(inputs.ModStatusBinSensors, result)
async def request_status_variable(
self,
variable: lcn_defs.Var,
max_age: int = 0,
) -> inputs.ModStatusVar | None:
"""Request the status of a variable from a module."""
# do not use buffered response for old modules
# (variable response is typeless)
if self.serials.software_serial < 0x170206:
max_age = 0
result = await self.status_requester.request(
response_type=inputs.ModStatusVar,
request_pck=PckGenerator.request_var_status(
variable, self.serials.software_serial
),
max_age=max_age,
var=variable,
)
result = cast(inputs.ModStatusVar, result)
if result:
if result.orig_var == lcn_defs.Var.UNKNOWN:
# Response without type (%Msssaaa.wwwww)
result.var = variable
return result
async def request_status_led_and_logic_ops(
self, max_age: int = 0
) -> inputs.ModStatusLedsAndLogicOps | None:
"""Request the status of LEDs and logic operations from a module."""
result = await self.status_requester.request(
response_type=inputs.ModStatusLedsAndLogicOps,
request_pck=PckGenerator.request_leds_and_logic_ops(),
max_age=max_age,
)
return cast(inputs.ModStatusLedsAndLogicOps, result)
async def request_status_locked_keys(
self, max_age: int = 0
) -> inputs.ModStatusKeyLocks | None:
"""Request the status of locked keys from a module."""
result = await self.status_requester.request(
response_type=inputs.ModStatusKeyLocks,
request_pck=PckGenerator.request_key_lock_status(),
max_age=max_age,
)
return cast(inputs.ModStatusKeyLocks, result)
# Request module properties
async def request_serials(self, max_age: int = 0) -> Serials:
"""Request module serials."""
result = cast(
inputs.ModSn | None,
await self.status_requester.request(
response_type=inputs.ModSn,
request_pck=PckGenerator.request_serial(),
max_age=max_age,
),
)
if result is None:
return Serials(-1, -1, -1, lcn_defs.HardwareType.UNKNOWN)
return Serials(
result.hardware_serial,
result.manu,
result.software_serial,
result.hardware_type,
)
async def request_name(self, max_age: int = 0) -> str | None:
"""Request module name."""
coros = [
self.status_requester.request(
response_type=inputs.ModNameComment,
request_pck=PckGenerator.request_name(block_id),
max_age=max_age,
command="N",
block_id=block_id,
)
for block_id in [0, 1]
]
coro_results = [await coro for coro in coros]
if not all(coro_results):
return None
results = cast(list[inputs.ModNameComment], coro_results)
name = "".join([result.text for result in results if result])
return name
async def request_comment(self, max_age: int = 0) -> str | None:
"""Request module name."""
coros = [
self.status_requester.request(
response_type=inputs.ModNameComment,
request_pck=PckGenerator.request_comment(block_id),
max_age=max_age,
command="K",
block_id=block_id,
)
for block_id in [0, 1, 2]
]
coro_results = [await coro for coro in coros]
if not all(coro_results):
return None
results = cast(list[inputs.ModNameComment], coro_results)
name = "".join([result.text for result in results if result])
return name
async def request_oem_text(self, max_age: int = 0) -> str | None:
"""Request module name."""
coros = [
self.status_requester.request(
response_type=inputs.ModNameComment,
request_pck=PckGenerator.request_oem_text(block_id),
max_age=max_age,
command="O",
block_id=block_id,
)
for block_id in [0, 1, 2, 3]
]
coro_results = [await coro for coro in coros]
if not all(coro_results):
return None
results = cast(list[inputs.ModNameComment], coro_results)
name = "".join([result.text for result in results if result])
return name
async def request_group_memberships(
self, dynamic: bool = False, max_age: int = 0
) -> set[LcnAddr]:
"""Request module static/dynamic group memberships."""
result = await self.status_requester.request(
response_type=inputs.ModStatusGroups,
request_pck=(
PckGenerator.request_group_membership_dynamic()
if dynamic
else PckGenerator.request_group_membership_static()
),
max_age=max_age,
dynamic=dynamic,
)
return set(cast(inputs.ModStatusGroups, result).groups)
pypck-0.9.2/pypck/pck_commands.py 0000664 0000000 0000000 00000142432 15063173236 0017022 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, to_memory: bool = False) -> 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
:param bool to_memory: If True, the dimming status is stored
: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}{'MT' if to_memory else '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[lcn_defs.KeyLockStateModifier],
) -> 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 == lcn_defs.KeyLockStateModifier.ON 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.9.2/pyproject.toml 0000664 0000000 0000000 00000011431 15063173236 0015572 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.9.2/requirements_test.txt 0000664 0000000 0000000 00000000276 15063173236 0017206 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.9.2/requirements_test_pre_commit.txt 0000664 0000000 0000000 00000000035 15063173236 0021415 0 ustar 00root root 0000000 0000000 codespell==2.3.0
ruff==0.8.3
pypck-0.9.2/setup.cfg 0000664 0000000 0000000 00000001102 15063173236 0014471 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.9.2/tests/ 0000775 0000000 0000000 00000000000 15063173236 0014020 5 ustar 00root root 0000000 0000000 pypck-0.9.2/tests/__init__.py 0000664 0000000 0000000 00000000036 15063173236 0016130 0 ustar 00root root 0000000 0000000 """Tests for pypck module."""
pypck-0.9.2/tests/conftest.py 0000664 0000000 0000000 00000006075 15063173236 0016227 0 ustar 00root root 0000000 0000000 """Core testing functionality."""
import asyncio
from typing import Any, cast
from unittest.mock import AsyncMock, patch
import pytest
from pypck.connection import PchkConnectionManager
from pypck.lcn_addr import LcnAddr
from pypck.module import GroupConnection, ModuleConnection
from pypck.pck_commands import PckGenerator
import pypck
HOST = "127.0.0.1"
PORT = 4114
USERNAME = "lcn"
PASSWORD = "lcn"
async def wait_until_called(
mock: AsyncMock,
*expected_args: Any,
timeout: float = 1.0,
**expected_kwargs: Any,
) -> None:
"""Wait that AsyncMock gets called with given arguments."""
event = asyncio.Event()
async def side_effect(*args: Any, **kwargs: Any) -> None:
"""Set the event when the mock is called."""
if (len(expected_args) == 0 or args == expected_args) and (
len(expected_kwargs) == 0 or kwargs == expected_kwargs
):
event.set()
mock.side_effect = side_effect
await asyncio.wait_for(event.wait(), timeout=timeout)
class MockModuleConnection(ModuleConnection):
"""Fake a LCN module connection."""
send_command = AsyncMock(return_value=True)
class MockGroupConnection(GroupConnection):
"""Fake a LCN group connection."""
send_command = AsyncMock(return_value=True)
class MockPchkConnectionManager(PchkConnectionManager):
"""Fake connection handler."""
is_lcn_connected: Any
async def async_connect(self, timeout: float = 30) -> None:
"""Mock establishing a connection to PCHK."""
self.authentication_completed_future.set_result(True)
self.license_error_future.set_result(True)
self.segment_scan_completed_event.set()
async def async_close(self) -> None:
"""Mock closing a connection to PCHK."""
@patch.object(pypck.connection, "ModuleConnection", MockModuleConnection)
def get_module_conn(self, addr: LcnAddr) -> ModuleConnection:
"""Get LCN module connection."""
return super().get_module_conn(addr)
@patch.object(pypck.connection, "GroupConnection", MockGroupConnection)
def get_group_conn(self, addr: LcnAddr) -> GroupConnection:
"""Get LCN group connection."""
return super().get_group_conn(addr)
scan_modules = AsyncMock()
send_command = AsyncMock()
def encode_pck(pck: str) -> bytes:
"""Encode the given PCK string as PCK binary string."""
return (pck + PckGenerator.TERMINATION).encode()
@pytest.fixture
async def pypck_client() -> MockPchkConnectionManager:
"""Create a mock PCHK connection manager."""
return MockPchkConnectionManager(HOST, PORT, USERNAME, PASSWORD)
@pytest.fixture
async def module10(
pypck_client: MockPchkConnectionManager,
) -> MockModuleConnection:
"""Create test module with addr_id 10."""
lcn_addr = LcnAddr(0, 10, False)
with patch.object(MockModuleConnection, "request_module_properties"):
module = cast(MockModuleConnection, pypck_client.get_module_conn(lcn_addr))
await wait_until_called(cast(AsyncMock, module.request_module_properties))
module.send_command.reset_mock()
return module
pypck-0.9.2/tests/test_commands.py 0000664 0000000 0000000 00000041741 15063173236 0017241 0 ustar 00root root 0000000 0000000 """Tests for command generation directed at bus modules and groups."""
from typing import Any
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: dict[str | bytes, Any] = {
# 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()
},
**{
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()
},
**{
f"MWC{Var.to_s0_id(var) + 1:03d}": (
PckGenerator.request_var_status,
var,
NEW_VAR_SW_AGE,
)
for var in Var.s0s()
},
**{
f"SE{Var.to_thrs_register_id(var) + 1:03d}": (
PckGenerator.request_var_status,
var,
NEW_VAR_SW_AGE,
)
for reg in Var.thresholds()
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]
},
# 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)
},
**{
f"A{output + 1:d}MT123": (PckGenerator.toggle_output, output, 123, True)
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()
},
"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()
},
"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()
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())
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())
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],
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],
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],
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],
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,
[
KeyLockStateModifier.ON,
KeyLockStateModifier.ON,
KeyLockStateModifier.OFF,
KeyLockStateModifier.OFF,
KeyLockStateModifier.ON,
KeyLockStateModifier.ON,
KeyLockStateModifier.ON,
KeyLockStateModifier.OFF,
],
)
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: str, command: tuple[Any, ...]
) -> None:
"""Test if InputMod parses message correctly."""
assert expected == command[0](*command[1:])
pypck-0.9.2/tests/test_connection.py 0000664 0000000 0000000 00000013656 15063173236 0017603 0 ustar 00root root 0000000 0000000 """Connection tests."""
import asyncio
from unittest.mock import AsyncMock, Mock, call, patch
import pytest
from pypck import inputs
from pypck.connection import (
PchkAuthenticationError,
PchkConnectionFailedError,
PchkConnectionManager,
PchkConnectionRefusedError,
PchkLicenseError,
)
from pypck.lcn_addr import LcnAddr
from pypck.lcn_defs import LcnEvent
from pypck.pck_commands import PckGenerator
from .conftest import HOST, PASSWORD, PORT, USERNAME, MockPchkConnectionManager
async def test_close_without_connect(pypck_client: MockPchkConnectionManager) -> None:
"""Test closing of PchkConnectionManager without connecting."""
await pypck_client.async_close()
@patch.object(PchkConnectionManager, "open_connection")
@patch.object(PchkConnectionManager, "scan_segment_couplers")
async def test_async_connect(
mock_scan_segment_couplers: AsyncMock,
mock_open_connection: AsyncMock,
) -> None:
"""Test successful connection."""
pypck_client = PchkConnectionManager(HOST, PORT, USERNAME, PASSWORD)
connect_task = asyncio.create_task(pypck_client.async_connect())
await asyncio.sleep(0)
pypck_client.license_error_future.set_result(True)
pypck_client.authentication_completed_future.set_result(True)
pypck_client.segment_scan_completed_event.set()
await connect_task
mock_scan_segment_couplers.assert_awaited()
mock_open_connection.assert_awaited()
assert pypck_client.is_ready()
@patch.object(PchkConnectionManager, "ping")
@patch.object(PchkConnectionManager, "open_connection")
@patch.object(PchkConnectionManager, "scan_segment_couplers")
@patch.object(PchkConnectionManager, "send_command")
async def test_successful_connection_procedure(
mock_send_command: AsyncMock,
mock_scan_segment_couplers: AsyncMock,
mock_open_connection: AsyncMock,
mock_ping: AsyncMock,
) -> None:
"""Test successful connection procedure."""
pypck_client = PchkConnectionManager(HOST, PORT, USERNAME, PASSWORD)
connect_task = asyncio.create_task(pypck_client.async_connect())
await asyncio.sleep(0)
await pypck_client.async_process_input(inputs.AuthUsername())
mock_send_command.assert_awaited_with(USERNAME, to_host=True)
await pypck_client.async_process_input(inputs.AuthPassword())
mock_send_command.assert_awaited_with(PASSWORD, to_host=True)
await pypck_client.async_process_input(inputs.AuthOk())
mock_send_command.assert_awaited_with(PckGenerator.set_dec_mode(), to_host=True)
assert pypck_client.authentication_completed_future.result()
await pypck_client.async_process_input(inputs.DecModeSet())
mock_send_command.assert_awaited_with(
PckGenerator.set_operation_mode(
pypck_client.dim_mode, pypck_client.status_mode
),
to_host=True,
)
assert pypck_client.license_error_future.result()
await connect_task
mock_open_connection.assert_awaited()
mock_scan_segment_couplers.assert_awaited()
mock_ping.assert_awaited()
@pytest.mark.parametrize("side_effect", [ConnectionRefusedError, OSError])
async def test_connection_error(side_effect: ConnectionRefusedError | OSError) -> None:
"""Test connection error."""
with (
patch.object(PchkConnectionManager, "open_connection", side_effect=side_effect),
pytest.raises(PchkConnectionRefusedError),
):
pypck_client = PchkConnectionManager(HOST, PORT, USERNAME, PASSWORD)
await pypck_client.async_connect()
@patch.object(PchkConnectionManager, "open_connection")
async def test_authentication_error(mock_open_connection: AsyncMock) -> None:
"""Test wrong login credentials."""
pypck_client = PchkConnectionManager(HOST, PORT, USERNAME, PASSWORD)
connect_task = asyncio.create_task(pypck_client.async_connect())
await asyncio.sleep(0)
await pypck_client.async_process_input(inputs.AuthFailed())
with (
pytest.raises(PchkAuthenticationError),
):
await connect_task
@patch.object(PchkConnectionManager, "open_connection")
async def test_license_error(mock_open_connection: AsyncMock) -> None:
"""Test wrong login credentials."""
pypck_client = PchkConnectionManager(HOST, PORT, USERNAME, PASSWORD)
connect_task = asyncio.create_task(pypck_client.async_connect())
await asyncio.sleep(0)
await pypck_client.async_process_input(inputs.LicenseError())
with (
pytest.raises(PchkLicenseError),
):
await connect_task
@patch.object(PchkConnectionManager, "open_connection")
async def test_timeout_error(mock_open_connection: AsyncMock) -> None:
"""Test timeout when connecting."""
with pytest.raises(PchkConnectionFailedError):
pypck_client = PchkConnectionManager(HOST, PORT, USERNAME, PASSWORD)
await pypck_client.async_connect(timeout=0)
async def test_lcn_connected(pypck_client: MockPchkConnectionManager) -> None:
"""Test lcn connected events."""
event_callback = Mock()
pypck_client.register_for_events(event_callback)
await pypck_client.async_connect()
# bus disconnected
await pypck_client.async_process_input(inputs.LcnConnState(is_lcn_connected=False))
assert not pypck_client.is_lcn_connected
event_callback.assert_has_calls(
(call(LcnEvent.BUS_CONNECTION_STATUS_CHANGED), call(LcnEvent.BUS_DISCONNECTED))
)
# bus connected
await pypck_client.async_process_input(inputs.LcnConnState(is_lcn_connected=True))
assert pypck_client.is_lcn_connected
event_callback.assert_has_calls(
(call(LcnEvent.BUS_CONNECTION_STATUS_CHANGED), call(LcnEvent.BUS_CONNECTED))
)
async def test_new_module_on_input(
pypck_client: MockPchkConnectionManager,
) -> None:
"""Test new module detection on serial input."""
await pypck_client.async_connect()
address = LcnAddr(0, 7, False)
assert address not in pypck_client.address_conns.keys()
await pypck_client.async_process_input(inputs.ModAck(address, 0))
assert address in pypck_client.address_conns.keys()
pypck-0.9.2/tests/test_dyn_text.py 0000664 0000000 0000000 00000004466 15063173236 0017301 0 ustar 00root root 0000000 0000000 """Module connection tests."""
import pytest
from pypck.lcn_addr import LcnAddr
from .conftest import MockPchkConnectionManager
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.parametrize("text, parts", TEST_VECTORS.items())
async def test_dyn_text(
pypck_client: MockPchkConnectionManager,
text: str,
parts: tuple[bytes, bytes, bytes, bytes, bytes],
) -> None:
"""Tests for dynamic text."""
module = pypck_client.get_address_conn(LcnAddr(0, 10, False))
await module.dyn_text(3, text)
module.send_command.assert_awaited() # type: ignore[attr-defined]
await_args = (call.args for call in module.send_command.await_args_list) # type: ignore[attr-defined]
_, commands = zip(*await_args)
for i, part in enumerate(parts):
assert f"GTDT4{i + 1:d}".encode() + part in commands
pypck-0.9.2/tests/test_input.py 0000664 0000000 0000000 00000003132 15063173236 0016567 0 ustar 00root root 0000000 0000000 """Test the data flow for Input objects."""
from unittest.mock import patch
from pypck.inputs import Input, ModInput
from pypck.lcn_addr import LcnAddr
from pypck.module import ModuleConnection
from .conftest import MockPchkConnectionManager
async def test_message_to_input(pypck_client: MockPchkConnectionManager) -> None:
"""Test data flow from message to input."""
inp = Input()
message = "dummy_message"
with patch.object(
pypck_client, "async_process_input"
) as pypck_client_process_input:
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_process_input.assert_awaited_with(inp)
async def test_physical_to_logical_segment_id(
pypck_client: MockPchkConnectionManager,
) -> None:
"""Test conversion from logical to physical segment id."""
pypck_client.local_seg_id = 20
module = pypck_client.get_address_conn(LcnAddr(20, 7, False))
assert isinstance(module, ModuleConnection)
with (
patch("tests.conftest.MockPchkConnectionManager.is_ready", return_value=True),
patch.object(module, "async_process_input") as module_process_input,
):
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_process_input.await_count == 3
pypck-0.9.2/tests/test_messages.py 0000664 0000000 0000000 00000023343 15063173236 0017245 0 ustar 00root root 0000000 0000000 """Tests for input message parsing for bus messages."""
from typing import Any
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: str,
expected: list[tuple[Any, ...]],
) -> None:
"""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.9.2/tests/test_module.py 0000664 0000000 0000000 00000022264 15063173236 0016724 0 ustar 00root root 0000000 0000000 """Tests for module."""
import asyncio
from itertools import chain
import pytest
from pypck import inputs, lcn_defs
from pypck.lcn_addr import LcnAddr
from pypck.module import Serials
from pypck.pck_commands import PckGenerator
from .conftest import MockModuleConnection, wait_until_called
RELAY_STATES = [True, False, True, False, True, False, True, False]
BINARY_SENSOR_STATES = [True, False, True, False, True, False, True, False]
LED_STATES = [
lcn_defs.LedStatus.ON,
lcn_defs.LedStatus.OFF,
lcn_defs.LedStatus.BLINK,
lcn_defs.LedStatus.FLICKER,
lcn_defs.LedStatus.ON,
lcn_defs.LedStatus.OFF,
lcn_defs.LedStatus.BLINK,
lcn_defs.LedStatus.FLICKER,
lcn_defs.LedStatus.ON,
lcn_defs.LedStatus.OFF,
lcn_defs.LedStatus.BLINK,
lcn_defs.LedStatus.FLICKER,
]
LOGIC_OPS_STATES = [
lcn_defs.LogicOpStatus.ALL,
lcn_defs.LogicOpStatus.NONE,
lcn_defs.LogicOpStatus.SOME,
lcn_defs.LogicOpStatus.NONE,
]
LOCKED_KEY_STATES = [
[True, False, True, False, True, False, True, False],
[False, True, False, True, False, True, False, True],
[True, True, True, True, False, False, False, False],
[False, False, False, False, True, True, True, True],
]
RANDOM_NAME = "IC77J3jmk5326OQl4zWpuENm"
RANDOM_COMMENT = "29nCynSxzn0mrJ6kt99zsl88azVaCAFv79sh"
RANDOM_OEM_TEXT = "8Zmt98YjYY6ksAGNIdxNOLSOjgJpOd1SWFVLaAGpsW5BPbJJ"
#
# Status requests
#
@pytest.mark.parametrize(
"output_port",
[
lcn_defs.OutputPort.OUTPUT1,
lcn_defs.OutputPort.OUTPUT2,
lcn_defs.OutputPort.OUTPUT3,
lcn_defs.OutputPort.OUTPUT4,
],
)
async def test_request_status_output(
module10: MockModuleConnection, output_port: lcn_defs.OutputPort
) -> None:
"""Test requesting the output status of a module."""
request_task = asyncio.create_task(module10.request_status_output(output_port))
await wait_until_called(module10.send_command)
await module10.async_process_input(
inputs.ModStatusOutput(module10.addr, output_port.value, 50.0)
)
result = await request_task
assert isinstance(result, inputs.ModStatusOutput)
assert result.physical_source_addr == module10.addr
assert result.output_id == output_port.value
assert result.percent == 50.0
async def test_request_status_relays(module10: MockModuleConnection) -> None:
"""Test requesting the relays status of a module."""
request_task = asyncio.create_task(module10.request_status_relays())
await wait_until_called(module10.send_command)
await module10.async_process_input(
inputs.ModStatusRelays(module10.addr, RELAY_STATES)
)
result = await request_task
assert isinstance(result, inputs.ModStatusRelays)
assert result.physical_source_addr == module10.addr
assert result.states == RELAY_STATES
@pytest.mark.parametrize(
"motor",
[
lcn_defs.MotorPort.MOTOR1,
lcn_defs.MotorPort.MOTOR2,
lcn_defs.MotorPort.MOTOR3,
lcn_defs.MotorPort.MOTOR4,
],
)
async def test_request_status_motor_position(
module10: MockModuleConnection, motor: lcn_defs.MotorPort
) -> None:
"""Test requesting the motors status of a module."""
request_task = asyncio.create_task(
module10.request_status_motor_position(motor, lcn_defs.MotorPositioningMode.BS4)
)
await wait_until_called(module10.send_command)
await module10.async_process_input(
inputs.ModStatusMotorPositionBS4(module10.addr, motor.value, 50.0)
)
result = await request_task
assert isinstance(result, inputs.ModStatusMotorPositionBS4)
assert result.physical_source_addr == module10.addr
assert result.motor == motor.value
assert result.position == 50.0
async def test_request_status_binary_sensors(module10: MockModuleConnection) -> None:
"""Test requesting the binary sensors status of a module."""
request_task = asyncio.create_task(module10.request_status_binary_sensors())
await wait_until_called(module10.send_command)
await module10.async_process_input(
inputs.ModStatusBinSensors(module10.addr, BINARY_SENSOR_STATES)
)
result = await request_task
assert isinstance(result, inputs.ModStatusBinSensors)
assert result.physical_source_addr == module10.addr
assert result.states == BINARY_SENSOR_STATES
@pytest.mark.parametrize(
"variable, software_serial",
[
*[
(variable, 0x170206)
for variable in lcn_defs.Var.variables_new()
+ lcn_defs.Var.set_points()
+ list(chain(*lcn_defs.Var.thresholds_new()))
+ lcn_defs.Var.s0s()
],
*[
(variable, 0x170000)
for variable in lcn_defs.Var.variables_old()
+ lcn_defs.Var.set_points()
+ list(chain(*lcn_defs.Var.thresholds_old()))
],
],
)
async def test_request_status_variable(
module10: MockModuleConnection, variable: lcn_defs.Var, software_serial: int
) -> None:
"""Test requesting the variable status of a module."""
module10.serials.software_serial = software_serial
request_task = asyncio.create_task(module10.request_status_variable(variable))
await wait_until_called(module10.send_command)
await module10.async_process_input(
inputs.ModStatusVar(module10.addr, variable, lcn_defs.VarValue.from_native(50))
)
result = await request_task
assert isinstance(result, inputs.ModStatusVar)
assert result.physical_source_addr == module10.addr
assert result.var == variable
assert result.value == lcn_defs.VarValue.from_native(50)
async def test_request_status_led_and_logic_ops(module10: MockModuleConnection) -> None:
"""Test requesting the LED and logic operations status of a module."""
request_task = asyncio.create_task(module10.request_status_led_and_logic_ops())
await wait_until_called(module10.send_command)
await module10.async_process_input(
inputs.ModStatusLedsAndLogicOps(module10.addr, LED_STATES, LOGIC_OPS_STATES)
)
result = await request_task
assert isinstance(result, inputs.ModStatusLedsAndLogicOps)
assert result.physical_source_addr == module10.addr
assert result.states_led == LED_STATES
assert result.states_logic_ops == LOGIC_OPS_STATES
async def test_request_status_locked_keys(module10: MockModuleConnection) -> None:
"""Test requesting the locker keys status of a module."""
request_task = asyncio.create_task(module10.request_status_locked_keys())
await wait_until_called(module10.send_command)
await module10.async_process_input(
inputs.ModStatusKeyLocks(module10.addr, LOCKED_KEY_STATES)
)
result = await request_task
assert isinstance(result, inputs.ModStatusKeyLocks)
assert result.physical_source_addr == module10.addr
assert result.states == LOCKED_KEY_STATES
async def test_request_serials(module10: MockModuleConnection) -> None:
"""Test requesting serials of a module."""
request_task = asyncio.create_task(module10.request_serials())
await wait_until_called(module10.send_command, False, PckGenerator.request_serial())
await module10.async_process_input(
inputs.ModSn(
module10.addr,
hardware_serial=0x1A20A1234,
manu=0x1,
software_serial=0x190B11,
hardware_type=lcn_defs.HardwareType.SH_PLUS,
)
)
result = await request_task
assert isinstance(result, Serials)
assert result.hardware_serial == 0x1A20A1234
assert result.manu == 0x1
assert result.software_serial == 0x190B11
assert result.hardware_type == lcn_defs.HardwareType.SH_PLUS
@pytest.mark.parametrize(
"command, blocks, text",
[
("N", 2, RANDOM_NAME),
("K", 3, RANDOM_COMMENT),
("O", 4, RANDOM_OEM_TEXT),
],
)
async def test_request_name(
command: str, blocks: int, text: str, module10: MockModuleConnection
) -> None:
"""Test requesting the name, comment or oem_text of a module."""
match command:
case "N":
request_task = asyncio.create_task(module10.request_name())
case "K":
request_task = asyncio.create_task(module10.request_comment())
case "O":
request_task = asyncio.create_task(module10.request_oem_text())
for idx in range(blocks):
await wait_until_called(module10.send_command)
await module10.async_process_input(
inputs.ModNameComment(
module10.addr,
command=command,
block_id=idx,
text=text[idx * 12 : (idx + 1) * 12],
)
)
result = await request_task
assert isinstance(result, str)
assert result == text
@pytest.mark.parametrize("dynamic", [True, False])
async def test_request_group_memberships(
dynamic: bool, module10: MockModuleConnection
) -> None:
"""Test requesting group memberships of a module."""
addresses = [LcnAddr(0, 7 + id, False) for id in range(3)]
request_task = asyncio.create_task(module10.request_group_memberships(dynamic))
await wait_until_called(module10.send_command)
await module10.async_process_input(
inputs.ModStatusGroups(module10.addr, dynamic, 12, addresses)
)
result = await request_task
assert isinstance(result, set)
assert result == set(addresses)
pypck-0.9.2/tests/test_status_requester.py 0000664 0000000 0000000 00000006451 15063173236 0021061 0 ustar 00root root 0000000 0000000 """Test the status requester of a module connection."""
import asyncio
from unittest.mock import call
from pypck import inputs
from pypck.module import StatusRequest
from pypck.pck_commands import PckGenerator
from .conftest import MockModuleConnection
RELAY_STATES = [True, False, True, False, True, False, True, False]
async def test_request_status(module10: MockModuleConnection) -> None:
"""Test requesting the status of a module."""
request_task = asyncio.create_task(
module10.status_requester.request(
response_type=inputs.ModStatusRelays,
request_pck=PckGenerator.request_relays_status(),
max_age=0,
)
)
await asyncio.sleep(0)
module10.send_command.assert_awaited_with(
False, PckGenerator.request_relays_status()
)
await module10.async_process_input(
inputs.ModStatusRelays(module10.addr, RELAY_STATES)
)
result = await request_task
assert isinstance(result, inputs.ModStatusRelays)
assert result.physical_source_addr == module10.addr
assert result.states == RELAY_STATES
async def test_request_status_stored(module10: MockModuleConnection) -> None:
"""Test requesting the status of a module with stored status request."""
status_request = StatusRequest(
type=inputs.ModStatusRelays,
parameters=frozenset(),
timestamp=asyncio.get_running_loop().time(),
response=asyncio.get_running_loop().create_future(),
)
status_request.response.set_result(
inputs.ModStatusRelays(module10.addr, RELAY_STATES)
)
module10.status_requester.last_requests.add(status_request)
result = await module10.status_requester.request(
response_type=inputs.ModStatusRelays,
request_pck=PckGenerator.request_relays_status(),
max_age=10,
)
assert isinstance(result, inputs.ModStatusRelays)
assert result.physical_source_addr == module10.addr
assert result.states == RELAY_STATES
assert (
call(False, PckGenerator.request_relays_status())
not in module10.send_command.await_args_list
)
async def test_request_status_expired(module10: MockModuleConnection) -> None:
"""Test requesting the status of a module with stored status request but max_age expired."""
states = [False] * 8
status_request = StatusRequest(
type=inputs.ModStatusRelays,
parameters=frozenset(),
timestamp=asyncio.get_running_loop().time() - 10,
response=asyncio.get_running_loop().create_future(),
)
status_request.response.set_result(inputs.ModStatusRelays(module10.addr, states))
module10.status_requester.last_requests.add(status_request)
request_task = asyncio.create_task(
module10.status_requester.request(
response_type=inputs.ModStatusRelays,
request_pck=PckGenerator.request_relays_status(),
max_age=5,
)
)
await asyncio.sleep(0)
module10.send_command.assert_awaited_with(
False, PckGenerator.request_relays_status()
)
await module10.async_process_input(
inputs.ModStatusRelays(module10.addr, RELAY_STATES)
)
result = await request_task
assert isinstance(result, inputs.ModStatusRelays)
assert result.physical_source_addr == module10.addr
assert result.states == RELAY_STATES
pypck-0.9.2/tests/test_vars.py 0000664 0000000 0000000 00000005636 15063173236 0016416 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: VarUnit, native: int, expected: VarValue) -> None:
"""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: VarUnit, native: int, value: int | float) -> None:
"""Test proper calibration of variable conversion."""
assert value == VarValue.to_var_unit(VarValue.from_native(native), unit)