pax_global_header00006660000000000000000000000064147553076260014531gustar00rootroot0000000000000052 comment=2af570ea5336aef8fa5dbb5f4a0a965f684e3c4b xknx-3.6.0/000077500000000000000000000000001475530762600125275ustar00rootroot00000000000000xknx-3.6.0/.codecov.yml000066400000000000000000000006221475530762600147520ustar00rootroot00000000000000comment: # this is a top-level key layout: "reach, diff, flags, files" behavior: default require_changes: false # if true: only post the comment if coverage changes require_base: no # [yes :: must have a base report to post] require_head: yes # [yes :: must have a head report to post] branches: # branch names that can post comment - "main"xknx-3.6.0/.coveragerc000066400000000000000000000007421475530762600146530ustar00rootroot00000000000000[run] source = xknx relative_files = True omit = [report] precision = 2 # Regexes for lines to exclude from consideration exclude_lines = # Don't complain about type checking imports: if TYPE_CHECKING: # Don't complain if tests don't hit defensive assertion code: raise NotImplementedError # Don't complain about logs logger.debug logger.info logger.warning logger.error # Don't complain about missing debug-only code: def __repr__ xknx-3.6.0/.github/000077500000000000000000000000001475530762600140675ustar00rootroot00000000000000xknx-3.6.0/.github/FUNDING.yml000066400000000000000000000000431475530762600157010ustar00rootroot00000000000000github: XKNX open_collective: xknx xknx-3.6.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001475530762600162525ustar00rootroot00000000000000xknx-3.6.0/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000014721475530762600207500ustar00rootroot00000000000000--- name: XKNX Library bug report about: Report a bug in our library title: '' labels: '' assignees: '' --- **Description of problem:** - [ ] using xknx standalone - [ ] using Home-Assistant knx integration **Version information:** - xknx / Home-Assistant release with the issue: - last working xknx / Home-Assistant release (if known): **KNX installation:** **Problem-relevant `configuration.yaml` entries (fill out even if it seems unimportant):** **Diagnostic data of the config entry (only when Home Assistant is used)** **Traceback (if applicable):** xknx-3.6.0/.github/dependabot.yml000066400000000000000000000003251475530762600167170ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: pip directory: "/" schedule: interval: monthly open-pull-requests-limit: 10 - package-ecosystem: github-actions directory: "/" schedule: interval: monthlyxknx-3.6.0/.github/pull_request_template.md000066400000000000000000000020451475530762600210310ustar00rootroot00000000000000 ## Description Fixes # (issue) ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Code quality improvements to existing code or addition of tests ## Checklist - [ ] The documentation has been adjusted accordingly - [ ] Tests have been added that prove the fix is effective or that the feature works - [ ] The changes are documented in the changelog (docs/changelog.md)xknx-3.6.0/.github/workflows/000077500000000000000000000000001475530762600161245ustar00rootroot00000000000000xknx-3.6.0/.github/workflows/ci.yml000066400000000000000000000037301475530762600172450ustar00rootroot00000000000000name: CI on: push: branches: - main pull_request: branches: - "**" # run on all branches jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: "pip" cache-dependency-path: | requirements/production.txt requirements/testing.txt .pre-commit-config.yaml - name: Install dependencies run: | pip install -r requirements/testing.txt - name: CI run: | tox - name: Upload coverage artifact uses: actions/upload-artifact@v4 with: name: coverage-${{ matrix.python-version }} path: .coverage include-hidden-files: true coverage: name: Process test coverage runs-on: ubuntu-latest needs: ["build"] strategy: matrix: python-version: ["3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: "pip" cache-dependency-path: | requirements/production.txt requirements/testing.txt .pre-commit-config.yaml - name: Install dependencies run: | pip install -r requirements/testing.txt - name: Download all coverage artifacts uses: actions/download-artifact@v4 - name: Create coverage report run: | coverage combine coverage-*/.coverage coverage report --fail-under=94 coverage xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} xknx-3.6.0/.github/workflows/pre-commit-update.yml000066400000000000000000000024161475530762600222060ustar00rootroot00000000000000name: Pre-commit auto-update on: schedule: - cron: "0 8 15 * *" # on demand workflow_dispatch: jobs: auto-update: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.x" cache: "pip" cache-dependency-path: | requirements/production.txt requirements/testing.txt .pre-commit-config.yaml - name: Install dependencies # needed for the mypy hook run: | pip install -r requirements/testing.txt - name: Pre-commit auto-update env: # new branch is created in create-pull-request step SKIP: no-commit-to-branch run: | pre-commit autoupdate - uses: peter-evans/create-pull-request@v7 with: token: ${{ secrets.GITHUB_TOKEN }} branch: update/pre-commit-hooks title: Update pre-commit hooks commit-message: "update pre-commit hooks" labels: | dependencies body: | Update versions of pre-commit hooks to latest version. Run tests locally to check for conflicts since PRs from GitHub Actions don't trigger workflows. xknx-3.6.0/.github/workflows/pythonpublish.yml000066400000000000000000000015201475530762600215550ustar00rootroot00000000000000# This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: types: [published] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install build twine - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | python -m build twine upload dist/* xknx-3.6.0/.github/workflows/stale.yml000066400000000000000000000034111475530762600177560ustar00rootroot00000000000000name: Stale # yamllint disable-line rule:truthy on: schedule: - cron: "0 9 * * 5" workflow_dispatch: jobs: stale: runs-on: ubuntu-latest steps: # The 90 day stale policy # Used for: # - Issues & PRs # - No PRs marked as no-stale # - No issues marked as no-stale or help-wanted - name: 90 days stale issues & PRs policy uses: actions/stale@v9 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 90 days-before-close: 7 operations-per-run: 150 remove-stale-when-updated: true stale-issue-label: "stale" exempt-issue-labels: "no-stale,💡 feature request,🙋‍♂️ help wanted,🏗 in progress,👩‍💻 needs testing,🐜 bug" stale-issue-message: > This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Please make sure to update to the latest version of xknx (or Home Assistant) and check if that solves the issue. Let us know if that works for you by adding a comment 👍 This issue has now been marked as stale and will be closed if no further activity occurs. Thank you for your contributions. stale-pr-label: "stale" exempt-pr-labels: "no-stale,🙋‍♂️ help wanted,🏗 in progress,👩‍💻 needs testing,🐜 bug" stale-pr-message: > There hasn't been any activity on this pull request recently. This pull request has been automatically marked as stale because of that and will be closed if no further activity occurs within 7 days. Thank you for your contributions. xknx-3.6.0/.gitignore000066400000000000000000000003361475530762600145210ustar00rootroot00000000000000*.pyc docs/_site/ docs/vendor/ htmlcov build/ dist/ xknx.egg-info/ .cache .ruff_cache .tox .DS_Store .coverage coverage.xml venv .idea/ .vscode/ .sass-cache/ .pytest_cache/ .mypy_cache/ .python-version *.iml *.log main.py xknx-3.6.0/.pre-commit-config.yaml000066400000000000000000000044261475530762600170160ustar00rootroot00000000000000--- repos: - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: - id: codespell additional_dependencies: - tomli exclude_types: [csv, json] - repo: https://github.com/charliermarsh/ruff-pre-commit rev: 'v0.9.6' hooks: - id: ruff # in CI it is directly run by tox to allow dependency upgrade checks stages: [pre-commit] args: [ --fix, --exit-non-zero-on-fix ] - id: ruff-format files: ^((xknx|test|examples|docs)/.+)?[^/]+\.py$ # in CI it is directly run by tox to allow dependency upgrade checks stages: [pre-commit] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: check-executables-have-shebangs stages: [manual] - id: check-json - id: no-commit-to-branch args: - --branch=main - id: trailing-whitespace - repo: https://github.com/cdce8p/python-typing-update rev: v0.7.0 hooks: # Run `pre-commit run --hook-stage manual python-typing-update --all-files` manually # from time to time to update python typing syntax. # Will require manual work, before submitting changes! - id: python-typing-update stages: [manual] args: - --py310-plus - --force - --keep-updates files: ^(xknx)/.+\.py$ - repo: local hooks: # Run mypy through our wrapper script in order to get the possible # pyenv and/or virtualenv activated; it may not have been e.g. if # committing from a GUI tool that was not launched from an activated # shell. - id: mypy name: mypy entry: script/run-in-env.sh mypy language: script types: [python] require_serial: true files: ^xknx/.+\.py$ - repo: local hooks: - id: pylint-xknx name: pylint-xknx entry: pylint xknx examples language: python types: [python] pass_filenames: true require_serial: true files: ^(xknx/|examples/) - id: pylint-test name: pylint-test entry: pylint --disable=protected-access,abstract-class-instantiated language: python types: [python] pass_filenames: true require_serial: true files: ^test/ xknx-3.6.0/LICENSE000066400000000000000000000017771475530762600135500ustar00rootroot00000000000000Permission 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. xknx-3.6.0/MANIFEST.in000066400000000000000000000000701475530762600142620ustar00rootroot00000000000000include README.md include LICENSE include xknx/py.typed xknx-3.6.0/README.md000066400000000000000000000052251475530762600140120ustar00rootroot00000000000000# XKNX - An asynchronous KNX library written in Python ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/xknx?logo=python) [![codecov](https://codecov.io/gh/XKNX/xknx/branch/main/graph/badge.svg?token=irWbIygS84)](https://codecov.io/gh/XKNX/xknx) [![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/) [![Pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=f8b424)](https://github.com/pre-commit/pre-commit) [![HA integration usage](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fanalytics.home-assistant.io%2Fcurrent_data.json&query=%24.integrations.knx&logo=home-assistant&label=integration%20usage&color=41BDF5&cacheSeconds=21600)](https://www.home-assistant.io/integrations/knx/) [![Discord](https://img.shields.io/discord/338619021215924227?color=7289da&label=Discord&logo=discord&logoColor=7289da)](https://discord.gg/bkZe9m4zvw) ## Documentation See documentation at: [https://xknx.io/](https://xknx.io/) ## Help We need your help for testing and improving XKNX. For questions, feature requests, bug reports either open an [issue](https://github.com/XKNX/xknx/issues), join the [XKNX chat on Discord](https://discord.gg/EuAQDXU) or write an [email](mailto:xknx@xknx.io). ## Development You will need at least Python 3.10 in order to use XKNX. Setting up your local environment: 1. Install requirements: `pip install -r requirements/testing.txt` 2. Install pre-commit hook: `pre-commit install` ## Testing To run all tests, linters, formatters and type checker call `tox` Running only unit tests is possible with `pytest` Running specific unit tests can be invoked by: `pytest -vv test/management_tests/procedures_test.py -k test_nm_individual_address_serial_number_write_fail` ## Home-Assistant XKNX is the underlying library for the KNX integration in [Home Assistant](https://home-assistant.io/). ## Example ```python """Example for switching a light on and off.""" import asyncio from xknx import XKNX from xknx.devices import Light async def main(): """Connect to KNX/IP bus, switch on light, wait 2 seconds and switch it off again.""" async with XKNX() as xknx: light = Light( xknx, name='TestLight', group_address_switch='1/0/9', ) xknx.devices.async_add(light) await light.set_on() await asyncio.sleep(2) await light.set_off() asyncio.run(main()) ``` ## Attributions Many thanks to [Weinzierl Engineering GmbH](https://weinzierl.de) and [MDT technologies GmbH](https://www.mdt.de) for providing us each an IP Secure Router to support testing and development of xknx. xknx-3.6.0/changelog.md000077700000000000000000000000001475530762600201742docs/changelog.mdustar00rootroot00000000000000xknx-3.6.0/docs/000077500000000000000000000000001475530762600134575ustar00rootroot00000000000000xknx-3.6.0/docs/.bundle/000077500000000000000000000000001475530762600150065ustar00rootroot00000000000000xknx-3.6.0/docs/.bundle/config000066400000000000000000000000411475530762600161710ustar00rootroot00000000000000--- BUNDLE_PATH: "vendor/bundle" xknx-3.6.0/docs/.gitignore000066400000000000000000000000151475530762600154430ustar00rootroot00000000000000.jekyll-cachexknx-3.6.0/docs/.ruby-version000066400000000000000000000000051475530762600161170ustar00rootroot000000000000002.7.0xknx-3.6.0/docs/Gemfile000066400000000000000000000001321475530762600147460ustar00rootroot00000000000000gem "jekyll" gem "just-the-docs" gem "kramdown-parser-gfm" source 'https://rubygems.org' xknx-3.6.0/docs/Gemfile.lock000066400000000000000000000033451475530762600157060ustar00rootroot00000000000000GEM remote: https://rubygems.org/ specs: addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) colorator (1.1.0) concurrent-ruby (1.1.7) em-websocket (0.5.1) eventmachine (>= 0.12.9) http_parser.rb (~> 0.6.0) eventmachine (1.2.7) ffi (1.13.1) forwardable-extended (2.6.0) http_parser.rb (0.6.0) i18n (1.8.5) concurrent-ruby (~> 1.0) jekyll (4.1.1) addressable (~> 2.4) colorator (~> 1.0) em-websocket (~> 0.5) i18n (~> 1.0) jekyll-sass-converter (~> 2.0) jekyll-watch (~> 2.0) kramdown (~> 2.1) kramdown-parser-gfm (~> 1.0) liquid (~> 4.0) mercenary (~> 0.4.0) pathutil (~> 0.9) rouge (~> 3.0) safe_yaml (~> 1.0) terminal-table (~> 1.8) jekyll-sass-converter (2.1.0) sassc (> 2.0.1, < 3.0) jekyll-seo-tag (2.6.1) jekyll (>= 3.3, < 5.0) jekyll-watch (2.2.1) listen (~> 3.0) just-the-docs (0.3.1) jekyll (>= 3.8.5) jekyll-seo-tag (~> 2.0) rake (>= 12.3.1, < 13.1.0) kramdown (2.3.1) rexml kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) liquid (4.0.3) listen (3.2.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) mercenary (0.4.0) pathutil (0.16.2) forwardable-extended (~> 2.6) public_suffix (4.0.6) rake (13.0.1) rb-fsevent (0.10.4) rb-inotify (0.10.1) ffi (~> 1.0) rexml (3.3.9) rouge (3.22.0) safe_yaml (1.0.5) sassc (2.4.0) ffi (~> 1.9) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) unicode-display_width (1.7.0) PLATFORMS ruby DEPENDENCIES jekyll just-the-docs kramdown-parser-gfm BUNDLED WITH 2.1.4 xknx-3.6.0/docs/Makefile000066400000000000000000000006451475530762600151240ustar00rootroot00000000000000all: @echo "Jekyll website for xknx.io" @echo "" @echo "Preparations:" @echo "" @echo "bundle install" @echo "" @echo "Available targets" @echo "" @echo "build - build website" @echo "" @echo "serve - start webservice on localhost: http://localhost:4000/" @echo "" @echo "clean" install: bundle install build: bundle exec jekyll build serve: bundle exec jekyll serve clean: bundle exec jekyll clean xknx-3.6.0/docs/README.md000066400000000000000000000003311475530762600147330ustar00rootroot00000000000000# XKNX website This is the source for the [XKNX website](http://xknx.io). # Site preview Run ```bash bundle exec jekyll build bundle exec jekyll serve ``` and open [http://127.0.0.1:4000](http://127.0.0.1:4000) xknx-3.6.0/docs/_config.yml000066400000000000000000000025421475530762600156110ustar00rootroot00000000000000title: XKNX description: A KNX library written in Python meta: title: XKNX - A Python KNX library keywords: KNX,KNX/IP,EIB,Home Automation,Home Assistant description: > XKNX is a KNX library written in Python. It lets you to control KNX devices like Lights, Shutters, Covers, Switches, Outlets, Thermostats via python scripts. Driver for the Home Assistant KNX integration. robots: index,follow,noarchive,noodp google-site-verification: Z_FsKIe3iX8aXRXZG8NUgqOr3d41c5k9cBGccgKOvwM theme: "just-the-docs" show_downloads: true xknx: zip_url: "https://github.com/XKNX/xknx/archive/main.zip" tar_url: "https://github.com/XKNX/xknx/archive/main.tar.gz" repository_url: "http://github.com/XKNX/xknx" logo: "/assets/img/xknx_logo_inverted.png" # Enable or disable the site search # Supports true (default) or false search_enabled: true aux_links: "XKNX on GitHub": - "//github.com/xknx/xknx" color_scheme: dark # Footer "Edit this page on GitHub" link text gh_edit_link: true # show or hide edit this page link gh_edit_link_text: "Edit this page on GitHub." gh_edit_repository: "https://github.com/XKNX/xknx" # the github URL for your repo gh_edit_branch: "main" # the branch that your docs is served from gh_edit_view_mode: "edit" # "tree" or "edit" if you want the user to jump into the editor immediately xknx-3.6.0/docs/_layouts/000077500000000000000000000000001475530762600153165ustar00rootroot00000000000000xknx-3.6.0/docs/_layouts/default.html000066400000000000000000000175071475530762600176420ustar00rootroot00000000000000--- layout: table_wrappers --- {% include head.html %} Link Search Menu Expand Document
{% if site.search_enabled != false %} {% endif %} {% if site.aux_links %} {% endif %}
{% unless page.url == "/" %} {% if page.parent %} {% endif %} {% endunless %}
{% if site.heading_anchors != false %} {% include vendor/anchor_headings.html html=content beforeHeading="true" anchorBody="" anchorClass="anchor-heading" %} {% else %} {{ content }} {% endif %} {% if page.has_children == true and page.has_toc != false %}

Table of contents

    {%- assign children_list = pages_list | where: "parent", page.title | where: "grand_parent", page.parent -%} {% for child in children_list %}
  • {{ child.title }}{% if child.summary %} - {{ child.summary }}{% endif %}
  • {% endfor %}
{% endif %} {% if site.footer_content != nil or site.last_edit_timestamp or site.gh_edit_link %}
{% if site.back_to_top %}

{{ site.back_to_top_text }}

{% endif %} {% if site.footer_content != nil %}

{{ site.footer_content }}

{% endif %} {% if site.last_edit_timestamp or site.gh_edit_link %}
{% if site.last_edit_timestamp and site.last_edit_time_format and page.last_modified_date %}

Page last modified: {{ page.last_modified_date | date: site.last_edit_time_format }}.

{% endif %} {% if site.gh_edit_link and site.gh_edit_link_text and site.gh_edit_repository and site.gh_edit_branch and site.gh_edit_view_mode %}

{{ site.gh_edit_link_text }}

{% endif %}
{% endif %}
{% endif %}
{% if site.search_enabled != false %} {% if site.search.button %} {% endif %}
{% endif %}
xknx-3.6.0/docs/_layouts/table_wrappers.html000066400000000000000000000003211475530762600212120ustar00rootroot00000000000000--- layout: vendor/compress --- {% assign content_ = content | replace: '', '' %} {{ content_ }}xknx-3.6.0/docs/_layouts/vendor/000077500000000000000000000000001475530762600166135ustar00rootroot00000000000000xknx-3.6.0/docs/_layouts/vendor/compress.html000066400000000000000000000106701475530762600213400ustar00rootroot00000000000000--- # Jekyll layout that compresses HTML # v3.1.0 # http://jch.penibelst.de/ # © 2014–2015 Anatol Broder # MIT License --- {% capture _LINE_FEED %} {% endcapture %}{% if site.compress_html.ignore.envs contains jekyll.environment or site.compress_html.ignore.envs == "all" %}{{ content }}{% else %}{% capture _content %}{{ content }}{% endcapture %}{% assign _profile = site.compress_html.profile %}{% if site.compress_html.endings == "all" %}{% assign _endings = "html head body li dt dd optgroup option colgroup caption thead tbody tfoot tr td th" | split: " " %}{% else %}{% assign _endings = site.compress_html.endings %}{% endif %}{% for _element in _endings %}{% capture _end %}{% endcapture %}{% assign _content = _content | remove: _end %}{% endfor %}{% if _profile and _endings %}{% assign _profile_endings = _content | size | plus: 1 %}{% endif %}{% for _element in site.compress_html.startings %}{% capture _start %}<{{ _element }}>{% endcapture %}{% assign _content = _content | remove: _start %}{% endfor %}{% if _profile and site.compress_html.startings %}{% assign _profile_startings = _content | size | plus: 1 %}{% endif %}{% if site.compress_html.comments == "all" %}{% assign _comments = "" | split: " " %}{% else %}{% assign _comments = site.compress_html.comments %}{% endif %}{% if _comments.size == 2 %}{% capture _comment_befores %}.{{ _content }}{% endcapture %}{% assign _comment_befores = _comment_befores | split: _comments.first %}{% for _comment_before in _comment_befores %}{% if forloop.first %}{% continue %}{% endif %}{% capture _comment_outside %}{% if _carry %}{{ _comments.first }}{% endif %}{{ _comment_before }}{% endcapture %}{% capture _comment %}{% unless _carry %}{{ _comments.first }}{% endunless %}{{ _comment_outside | split: _comments.last | first }}{% if _comment_outside contains _comments.last %}{{ _comments.last }}{% assign _carry = false %}{% else %}{% assign _carry = true %}{% endif %}{% endcapture %}{% assign _content = _content | remove_first: _comment %}{% endfor %}{% if _profile %}{% assign _profile_comments = _content | size | plus: 1 %}{% endif %}{% endif %}{% assign _pre_befores = _content | split: "" %}{% assign _pres_after = "" %}{% if _pres.size != 0 %}{% if site.compress_html.blanklines %}{% assign _lines = _pres.last | split: _LINE_FEED %}{% capture _pres_after %}{% for _line in _lines %}{% assign _trimmed = _line | split: " " | join: " " %}{% if _trimmed != empty or forloop.last %}{% unless forloop.first %}{{ _LINE_FEED }}{% endunless %}{{ _line }}{% endif %}{% endfor %}{% endcapture %}{% else %}{% assign _pres_after = _pres.last | split: " " | join: " " %}{% endif %}{% endif %}{% capture _content %}{{ _content }}{% if _pre_before contains "" %}{% endif %}{% unless _pre_before contains "" and _pres.size == 1 %}{{ _pres_after }}{% endunless %}{% endcapture %}{% endfor %}{% if _profile %}{% assign _profile_collapse = _content | size | plus: 1 %}{% endif %}{% if site.compress_html.clippings == "all" %}{% assign _clippings = "html head title base link meta style body article section nav aside h1 h2 h3 h4 h5 h6 hgroup header footer address p hr blockquote ol ul li dl dt dd figure figcaption main div table caption colgroup col tbody thead tfoot tr td th" | split: " " %}{% else %}{% assign _clippings = site.compress_html.clippings %}{% endif %}{% for _element in _clippings %}{% assign _edges = " ;; ;" | replace: "e", _element | split: ";" %}{% assign _content = _content | replace: _edges[0], _edges[1] | replace: _edges[2], _edges[3] | replace: _edges[4], _edges[5] %}{% endfor %}{% if _profile and _clippings %}{% assign _profile_clippings = _content | size | plus: 1 %}{% endif %}{{ _content }}{% if _profile %}
Step Bytes
raw {{ content | size }}{% if _profile_endings %}
endings {{ _profile_endings }}{% endif %}{% if _profile_startings %}
startings {{ _profile_startings }}{% endif %}{% if _profile_comments %}
comments {{ _profile_comments }}{% endif %}{% if _profile_collapse %}
collapse {{ _profile_collapse }}{% endif %}{% if _profile_clippings %}
clippings {{ _profile_clippings }}{% endif %}
{% endif %}{% endif %}xknx-3.6.0/docs/assets/000077500000000000000000000000001475530762600147615ustar00rootroot00000000000000xknx-3.6.0/docs/assets/css/000077500000000000000000000000001475530762600155515ustar00rootroot00000000000000xknx-3.6.0/docs/assets/css/style.css000066400000000000000000000005211475530762600174210ustar00rootroot00000000000000body, .side-bar, .search-input, .main-header { background-color: #2f2e31; } .site-footer { display: none; } a { color: #23a7e2; } .main-content .anchor-heading svg { color: #23a7e2; } .highlight .na { color: #93a1a1; } pre.highlight, figure.highlight { background-color: #44444a; } code { background-color: inherit; }xknx-3.6.0/docs/assets/img/000077500000000000000000000000001475530762600155355ustar00rootroot00000000000000xknx-3.6.0/docs/assets/img/xknx_logo.png000066400000000000000000000374641475530762600202710ustar00rootroot00000000000000PNG  IHDRNb| pHYsodtEXtSoftwarewww.inkscape.org< IDATxw]U{bH R Ay (G3ODQ =@ ("#tJHB-s=e5{~c1ㆱooq*2 XX x;WMZK72['Z5Θ5  < \ L3jnCNVoe%_eZrBt56> < xV&k>Gn{Ȫ1:p09Gv91 8 2b0p`Cbp1_!/r8y1fV۱r|n:x2Z-eF781 x`*q9yɃc6.dl9*+"pyiwY:8Q LA~'Z'm7٩8+Wzۮ)67}c&[Z%JyN>"~|ƴyC\_Sp۝J*_,ʝC{Zc Rb"}7r6DRCʝGDę'(߹b'>]Hqd[FX޵ ~cgҌfb8*͎(˗Q%D/ɃXb R`r#F?]AӸ;|xTʎuHNio&9QTU}o9TQ7`;\$+>(n6n%Hw(,|jT6=9uS"< H$q(V"')O62xH4o⯟19buU+bZy#C3qdU.dOy_E;pv}',oʬD0+& ~Ii/{>׍#v8$y!̃!728 ]Ebwnx}1#^k$ŽX<bkC;+u0E5in~ G^|!cHZ"a}mpwVkhEbk88L4vw7F :N=~Txbᵝj22uCj7odX>n `x}'mv2nnt![Spj puC5c\SL, A_2nwnN}NZln`uʰB~]v9#qs!ȳxKO,7 `R+ߛ3x(}[׿(+'b?`}MъĬM8P'/UCQ~LSO7c/j78pu#SzߑBqMF e.kݐ~ Ӻ%`06޺!8˺Ⱥjǔ/ Iթb'y~9⦭T v`?k7);*R85䋏 ȳSM>x9جq$а |~uJW{Y졬U7N3e=k6`(hph)8.د,PϽTh\lg³. Y9 y^/IzR5N -nZ|N@PÒwFkk 427EЪ Ϸ/Í΍St!Iy)[R5NxγU 7N3R+u`0RCz`@ ލ4;g5)4Ð,A^EWn48(<'!YB) ɘ n#(O:9, glShp^ YčSZ.~F/[_6R7Nzng3`k'Tر+.$u0H+w ϭ,T7N09^)K"Z8!Z$[m ܛ@:>N>L$b)kS, ܃)q }-6n:'! (X< Y9a-" omZ| -sNqgM)xm<<<7G:q!H.Wt:~>es꽝 qbpNbSFrpdˆro8oIqد<֕tYϓZY88fZ.~~`ܤpPژ 쩨8 ЊIo`~WŖ~~d5epfN 4SY|^Ykjqe4p6^AvTw8n,k'c:-zn,+7Y8:ڸX\Qw)qI7Nq < TY b?Rgq ]3p]N?[tp/[a?kBd˴ndp}7N'Habb?e-s"hVtp!Yum \,JٌS@;).~޹l$pjNԊ_Uӗ2|OYkpI'PEe)˳*ɢjv-Y)`= +}F ZwRnH7N3\4E NqX=Fa?xXVWn笇?HW&nڧ a=kc$'[RJgwIrV΃HS$4#7N19V 8  |bRrd;(k-:n3h=`?M"qFk\JU7Ni1teqVR+a] ,C7NYd_<4EW \(^8ǡgq2Aҩz%ٚREКa3l_)k- nsz&+q)Xkk'i49|ZYkC:nap:s6S]e,_omSbp2dP֚GcY|IYk9q ȃVRK'a({pm\8 ֻqJM)9%&I;;?Ej9 1Zˈ'|6CR;z`F"ٟNp>b;šZFHO1 H^! E|댤]x<=hT8GZX]%6YoQ8TW{a< ^-!&cQlV~^!6&'/DlKmG:ƩcWt&Elƿg}c.%f8)'=/4DsV 8ʑ)}QYkZS ~ya]TW[D38\B|~0eNzRp*P50vYųHM'>'Ձ#6oeǍSLn6BgZkkj*Y7Gj86h@lZɅ-q*&^;(.)Ժ _GҀF`P4Yݾq3^CǚTFl[m<,b{ˆb\{fk5ep;-'_*kbfܺнJQvjؒq }q506 7Ne8xXF¾dke .CƦ~ ٔ tV2!soO~F˂bAlk+(KC,TZZ~*LEо}f1YWj%)q\fQRI8-fh.:n^gq֘L$)k-5C[@y oA:B~8n2(_EO#6^8TjR:}πle-R(vuc ֕[Yf1(fSe(ƩۗQ`7N堛tv +Ws,+:lA딱_kR&&𔁞F<IQ H(\FƩ<~')3Mpʭ,P֚7˱ѕ[yb6 Y<WAE2N;a}9oU85-,7x_pľ߲WYke9 ڝɉY@nҟT8MDeXپq;68nsm@8T7Ncw9}<I|fg8EYk~)7}w:Bэgo\>sҧi0p}vƭTrr ,5^žrγ =xYtj̋^/1 n!o8HY+xSER*]y3lo}㗊:H8M&7~60t H>B7N,=nsx:0ZQ#kΖ)bo鐒qB:g3-S2~q VoEkŢl L6d7ܸcͣ!rq^3>D:@l*|IDAT-^}CCԋ[%:ET99D-ٷ"u!Xy}Aa446>@=U7Nx9oT7N܌:vqr1x{W/pǸ"gJJ?h1YD@KVDqZaM8GR08ЃlMtR#hHPїU4NÑűuCh.¯[10[\^Ieq,y )| :Yǚ_6oWo,|qll?qf1NXl{ jpfb._tS(|^}~N˱zN=1rC rQP'Y#hs@@[icq>Izb=W뭹ݣNpT>l]]čSFȳ=*B7AkmȳSB&$am ܖ@p8|cІf 7N ;v#fuI.$ed [Y[ h8qIA{ /[N8 L7p%vkZl;qr'rГ9Kgo$bu:=1[ɇmZ/PnpKbїnMy{p!T2ਜ49ٕt{ H6qλI8lܠ]sx2Ʃy% 7NfEd='w89]ED9b 5'On(hL`~H:Hkim lф'}8QحJ2 {M@^8j/ 5R(0 IɥnF:v!U-Gk&lR.nZۯzvqT]֊:'3|A_LjnR'K@l+Z8DY)s6I 7N3xujmvqTMޒŁZ>a6Ӏc?I+]XAK"'}#^g**nc#v0KtTA&"_y)8OYk_Rji=H 2Il;~!7'?b@nj3 L-"n0lډ8U KKYvE9>w:-.0aR"h8li}ѠğƩ}[oL)ÍSu ܃=$Ӂu֥jěrf </8J:6Pnp=\EmnAplG\WnCiE5VǷ4VӰ@}KQVfvw8nt85v/7NcIzp-u&Qy*kU6N[ΗBqϊnv;1 ,T֚Α udQ3 A\Wn驺qXtmXbQpdؕpT,6B7;P,plG:ѕ,)AA7N|^(W7NvXy;@8Z;KW&RZQYkaXO}f`ܤ@:Enc na/D?VkO4*ƩNWUqxF@r,+L$"h*k-+n2sBɖ}wpq*g`;(k-で<,+7]a?C]P$7N2/no!)}79C7֕&"r6"hƩ>]H=%9]/ qqbB)m6#LnRZD~\8ZYkrJ:*V 5Sc&ߟB) a̢kɍSH:c'a?>๧dXtj ѕ[hq-=^uÍS:,$AZKմǍS 7/jb0p5ZrY Ihy[JWn)t^485Ƕ^(}8VؕϻqJ ˴tqc؏WFʵtn|XWn81PeeS|tmzK8u4#7NqY|UYkc+ArmgyzƸqjUG 嶎: yrY^u>5|_Ykt>N:Ceeŋ7N3's^xp&K`SJLW߸ h ƾSp%%ƃș9gQ8H䓿ܮ^(y8F%eYL|M7N ܋j-5_ǀԙ\}bߙxXBWn%)i=O\7N3-s^xp6n NtiQ = ѰDu Ý:J^SZSǍwa?8u,k(墨qڃtXY(E2Z}NS2,[We؏@nM7N~׋W-7N+دi]`6cQ%p?/+kmI Ex^KR+VՂn y^/^(׍Sq9gT8ž}H.)*܁Yx]NI:EЮo8alQ*uT\FNicYZNqzq*Ʃq>Zٖt)k](VGȤVm$pdZz(?Ʃ' 7Nc(xi%l6F~C8S eaOSl:;/Q #{G"hʸqJAz |SO nJy5J8-<}`1]2< vϝ:^93)6OdmRM7NL~??~< % NYxP*f20s^PI(pI2锛ϷH$*Hm%ySXh=ū"Ʃ: ~e0Ncߏ_t͒wFu@r0CS~eqeڸq*#˱u`==ƩZ,|^'iW0:`6s/6nKҨ |~uJD*EBcjƩ ~/"u5zFX^h݇y6pɧIß,hTkS\< eq*.]6ZLjj25RE6NcIʬAٯTQ1!q*>rm|EStNq&دQT<}MSO7c/Q 45ɗˏh US9^}U`1M9Ʃ,<:ɢicn.,8 !3D7ae1!;֍8{ú!} m+Rp D#$+C H m\p0aݐ>ai-wQp}'gI`ź ŝ>yMV"hחo+bM!3Ios?`VU$èTH= |N1XlxFfx IzuCB)(yO: QEy ح]cpQS'3=ȱgpQS-AGUĔ#g G.L[ӀqQUz=IqdY91E$5y`x2A[H­2ro̫NǷv;|v l87^Bo {Gnt8n|  @"pZc.q|"nsc;<Gϐv"$Niq=!n8|"OK"۹Ѝrlj.Ei,13$Mt~e`b̗'L`mwt2^@y5`(K4S&;}M/@Pl \(4@/p &8V ;ٶMee{:;P"2q3p<ܓr)]΃k 4ܖެ x>^* svM. *JU콋XJ(* ֈ w #`Q#6b-nJA)g{Yk/}f3})$I$A;; %p%``cl/ ?LUcHFd`#CU[%͆$;7y38lbp|u㪟 7ݪpJ́k{ 7d~gï_2lwQ$IJI$I/*6]:*UexIҥ$)/u Ck0h 3UnUwvcp|u1?>|KIҚ$2pwG38\5Ƨ= 3 K$I%8P)I$iY (waJRNN45HLJrA];ی/|`L-M$< ?πSc?V%I&ɦ{ 8 xp$I$I$i%*s8IN$5Ia}|0y+O|HN$ IdGIn}'&yZ/I$i&b$7\366~m[B}H5IU\;ï fpCw/T+/M$ < ؤ9gm~ x}U#I x:pƔW'e⣀18_:~ Թ1HQ77TկcI4QU_cJ)4 pcxl|)^8 a|&apmw$M$3*usŝN)k[FUI ^ < X9gcV׼ޠsN21:pzUh )IPk{0Ac>ǁc$7L$?Y0|kw%IKe|,Ʌ+pqN[]4WTG$ -_%yrK;əEձIZIKd$4376,ܢ5VZ /I~B?o_LrHt%IKCC|{5~mId"v$&y kra3ꘒ$i$mxIKM!$WKo?s?JR$&9m,T\Irps{I~=%I@;O$ j$& 38GINJrC$WUIII$IAkhIH$?o})/I+H45[,.$oNs#-$wJeuvC_gIӺiI~ Q%,?7 $M$FYI~md$o$$w~}$I$Y/oY\ϓ<'ݯ2J=(/Z_N{u^$i0&ٹI^dK Vy4&I 9Y{$~=sͷ^\u$&37լܤ5%ɦI^O?Zkuzt^$i;w%IeX~ %x=0M'OZU?$IW,f 'ӲOgVq!R$Yx;IUuVw$- n9ww˘8$nYf?^U$]$6nYFUU+Iƕ/?tH+.ݲ_ޗ~ܧ*Q^-cUIfI^ lݲ/`KLpq ~"Iw>x NsRyHS'F '!4NI|Qw2z Jw-+gUI$=cy}!Ҩf[pUuAw$IH96!-cy`Z@18{U_!$ͻ$W2F/tHkd_nܱ~"IGSV«ioác:V/C$IdG[mw!$y.B8Ъew$I.-[w#L(} G`hw$I*N Nsry7q,9.-$1a> ܣ"IK vJw znU;B)ɵ2_?skE7"Ilg[v4>p[4er&+K'ɝSͻ[ꭋͫ lS`k&ٯ;Dy N0%#I6$!8L&w!IK'Ó<;BgG5ْHR naJsRCK3>&$7$i%y gq{%!$͓M'vCaiA[~'y1>ɔxpU"IҬK mnYaSUl7Ssʤ;UU};DF5`z;Ȩ~3C4< 8cܦ"I$טUhB`:;DJppkg$i% X`yª:loy27/h س>"IҬJ-aʝSxxU]X`)e`תKw$MCGFٕ; "I"ɿ@n{:%)]-M~3ڏC$IUIv>zJUnoi${o\Rt85 C$IEI6x(9Ypr!n<;BHUIބvS]Յg~OP:#$i^$yS^qo{%;L pZ$I۩W'y(߸*\ Rf[WկC$I;oOnSC4l| ءevU;D.-{tLwOth$9xmwǔxLU;Bf5[&M!?I`wat''I'ɖ][&Y-++\rx'8Ly%Y;DBHG%٩;Ds8L!Id|%!-Ó"oHrIqaʵYxqwO0^ #$IIa007]m}$p۱%͆/뎐$i$y %p7ph%%)$뎐$$pip]͜[wGL l"Ih#ų;Iv.nt"Uw˄yځ$IZꎘ0b\Lr{I.OOIpx <;BsySl]i0\ '4g&JwfC퀃;.ۻ#$iF=Fw9;@s婸њptuH4͒<uCzyq >lf;VC$I6->K_?ׯth%#\s!WnB*Wk֒%y))vpU;BfI727wGh w6*ݗW!$M$7ظe}MU]ppy+T>)fC$W0$iaދÔkpDwA8L9!i~%-i\'xGKdVZW'ew$͊${0b<;@s8L6; $I#J5) V TWzr͈"Iy9p)$uGhv 1C ^I8غem<;BSဃKprC$iF<;`J=2f]Iv1%;Bi1a;عeJd%i'F]=tGH4 wÁkxGlGI;Dp p)ʹuLg$yhw$IS f4-Y3L1ĕ*!'.?I@G3Y^XwHInwtJp@Ir{vGL1?kY ϓ߽c V$M$tI6/\z۬\LypyvIt)IVI͞ \{uw /HZvI*+mGxo#!IS>SnB4Ҹ=cC$W$i%)5_ֆ[|P_vH4)|Ss4ر~ّd3UIi8V[fM$픖Ï[TկC$i$YZw˔۫>ّdxcR|l$M_nbNU.yS.ն ٠;D j\uvGh:qXIbB)8L6FN d+V5~. Iqr?S.՝WuGH4))j-3%T%IJrᱚ36fO++.:페a!ISk`͜fēx$Iok~ۖ3{H$i%&YwGh6 o14V-[>'Vʝ4uZY~'ٽ;B-f lِfwI,IkIӃ+~u݁zfkx^4\ 8ؠeܯ;@3f󹜮W"5O7n#Z(oX~' ?kJ΁X8ܿ;`Ƭ!$uXpdwnjن* T:7~28۹;D4k`1ŋ˫╤1Hr+s[U[Kk[ww̉&Y;D&Y tw5.'%qI\Ip2~w 8PܶV'ٸ;D&_kvĕ.[3+V+?+.s#$i`6éZm;f퀷uGHRl`*-3lܪ9f 8zx$I3-c;fFxMcwؾ;@J!`9#U+$w ڡ;@3a7`v`'tGHBމs~vI6^]nO뎐$i9% xuwYSwE^zEw+opLwH҄rr|8ܲ;`6ɝ#$IZNIs#$IZ$c̒9 n ;B+ ;xC$IS%#ո5ZU;BҢxEw$IҐr' ܯ;Bx p *= JI$u<!F){[4dOI $ix.w2MrI$U+@LrI..-Bw$%$`JZuhA> i,59P$oW 'yH41bGyIUMӒl"I@U{/BMT՟#$IKO3[`莐4'$٨;D$ͯ:Ew%Ijd+`51M9P9yvޗdI%x&IK1սnѢ>)i68;B$=N{/쎐$i8,p]™TNGtGH[e|;@$Y-nѢ i자$iyNw<'~w$p]ƙuWh@u2 I.+ɶ }w,}[\;B$ͭw2 xZ$I+n87V_]5z{tGHKvh$IZgvhIUUgwGHZ'$ٸ;D$ͥvh6NKrI|IrsZs/?_m!dXZ-Zs/wGHIr+?iObJF#$I\<.p4vOnw$i>$*p*M?/JGk[$tH+{vGrgU;B$ $;mnђsU};BҲ;(Ɂ$iĻ+IK~`]3.vGHf[Oxw$IH@4[i]<;BҊySvGH]^#$i9أ;BWOk>#$I)́uwhN$I`$`;V&$uH?%uw$i6%y$B?>ÁʪXY!#$I%US[t\U鎐$i%9x#IU #$o$I󣪾OwІ)I"I-Ivݡ:a8Cy3NH]w$i6$Y1-Z+$Ijd$o^ݢz~U;BR$I4W<;dIlH-ݎ6nZX-Z''q[1I8أ;BkIBq%ˬ Iސ$in=Iv``_[\/ Wwin[H(Ɂ;4'$ͣ$nXLڐ@wHWU?NHF3^|\/. bEsXHrHw$i:%x[wFݰ$5HrS-'WvGH!I@]oLrItJ^.1PYU޲EZ#ܥ;B4]\8ؠzS1 IDATE#תJw$I&N EsUutw_'tGHWU_>ݡ?"IȆ\0KP g+ӣ%Z81ɵC$I!Ɇ )nH.^!IҼIrCÔn}phJҚ:-#$I\xs|%٨;D4`K._"V$tHvGhdGU#$I'In`[4vgT_C$M l"If[U} pq m7?ئE#;κ/iJw^7NR!ɕA7H’lݢ?UImGݞg$I+Ye.kb="ɓ#$Ikx.(/Ǘc8PYUOƧɔN; XU?$i^$9n5{~ ܫ"i*8;B$Ͷ-ȫZ$IZCtGhdg$/VxrUiY$}#$I%Ɏ -٧l.IV@Cw,prwrDݺ#$I{-l]$$M${wwhA^RUyyr*>;-UqI"I I6b6-ٯIf]JrXn:{/uH:KrI4+"$tH&Ckn>Z`pgo9$[tHz%)(-Y!$ͺ$ݢesUISk{=֒$Iˢ~ܰM=N$% pw+PIU8u[_IL{#4=Ӻ#$IuI6Nݢe=`IS3#$IlGvwhA4%i gnݢxTU`m8]UhQZY;B#݁vwhA><;BYdK}[,ص_wqxݻ#$I{/Z%ww$ fizp8V YIQLow$ie%xNwF`;DYd[n)Zw_vH)'$Zw$I]Uu!pæ*I"IZYI28rw$iy%)(AU +$-$> ݢeztU"ixNw$ImU-` jw==IdK-oꬅWnI9=vjIy/ЂN쎐$i% p:MwqUIs$w$INЂx~w$i  ݢ]<߸$cs{K#$I#֧i#$IeI>lݢ;x@U;D\Y!$iyAuGHˁ{wGhA]UZo\ʲG/k=3C#$Iz{XVַm[$IZ&IN6nس;D\*$IYUx4w%Qw$iZ3ɭ#$IdK+nݢػ$iV%9x#p2ܾ>"i`(IeSU~ݢm dIx$)ōE# ōX҅:RG+j#$tH&*-مUIfQu+=^U$9I!If[U}x0pAwF3>W47_ lݢFg/ARCU/;cҊXdIҒOwUIf3q[,>ܡ~"ICc"If[U}x~wK.p"p|AUuRh,[_U3XZ1w쎐$-Nwڬ^!I,J)A`-c{UC$RO^w$Iy/94C#$IZU'@ciE<)uGH&MnȾ PU$i$*)`-C$rW$IlxnQ[4JrI$y-1Uq=*|~̏$펐$&V V:ܤE# wU"IҬIi)ϨCIZg$y@w$ImUu.l#$tHFۺ; _7@eU xq>)I"IbINݢ]_U$i$!ppݟVՑ!4Vڹ;D$Ͷ_!-َ$uHX2tp'c3*28p5$tHЫ;wGhAVU鎐$i$#7Ξ{VC$iO~w$ImUu:-WvGH.p`<`xUy)M-]qZIXI!I,Hrk-3;V՗C$AI쎐$I x\wۺ#$i%8 rwFv.OUjx*#t<Tw$͢$3u.dp-*8KrwFv.OU;DiP {:;D&C]w$I}U`ol+o"I.u,nNwF]~bRY-f "I*Inݢ`C$IfIIV- Wճ*14ސ$iUWt pZ$il`q#gw!Um Nk:\8>Sܒ8fާ:;Bid}[4vfp[C$iJldI4j54=<;BQUq [4 U׻C`B*# ; 뎐i; !I4K)A`oUI!4ev;B$͍Ђ4^tyAU};"3P9t8pBwI뎐i:+N*WҖ$i\4pݪIR%yBw$I}Uu!?lp\WX%y -)K#.n**Athd3ɍC$i% X \E#wU;Didg3ͻ[4v_vouHҔ{u[vGHWUn6V'٢;D&]f0ˤMĘJ#lS$WIdp,p,!$M$7b0Ly!46NNUw$I}Um$]gӀͻ[4{ o(7P PU? ߜdIP/9Nꎐ$iZ%8Fwd>U&4>;N*$iUi; ^!Ih8p^`BaUCd"*߀gwwhA ;B&MЂ !IҴJafO!4!IƋ# K_w$M#{uGhAYU<;P PUG2^iI!I"01=PUnw"I"$9 uhZUx$I$uGHWU |E#+In"I"ÀЂWUꎸ"=P9x Z7'uw$uK%pywF[~UIQCwvh`#C$i/ɕC$I쫪?{n6NxQ ̀wwhAxLwL@p ݢm*!%*X`BUC$I6I*+ܳ n8y_w$͑$=U$->?pAwFpUJ5ظE#5wU6?P PU?f""IMyVUKw$Ifxӻ[4v?vvH7I4cs; w^!I ݢTL'ydw$5xGw)UQM@%@U -<;BVJ{մ p@U;DidKࣸ*,p!>'$Zw$I_Ђ%ɭ#$i$9x|wzswBL@g# oNjI j`UuNw$I$ՁO1ؙ@T.UIWMNw$I}Uu.-نII"I-ɮۺ; PS7PYU?~{p&+Gδ S4g1 DJ +Ɣ>,g2͕R*ky^8v ns^-ۺNR߱;D֔$gwnخvovH4K$pMK/$I˻#$IbXVpmwvg`UC$iMYp~wv t 5sUu #0s/̀l"Ik[GuGhVՇ#$I%K+>AGêjߪ;F+$i1Tǀtwhoꎐ5!yowhl;WL^8U5W.ջPvwhwW1$͒$>ܶEu:;DZ xw;tHPUGݡAOIZ;BWUwGc;4S<;B&%;;4ȧ(H4@݁th~lWU$viI$I Ǻ#4ȱI!Iӻ;41U5Ӈ2fzr tGh7'ٺ;Bn$gwhl;U5!$͊$nD] <"I$i1TյNL\P+WKpdw8j*z`G[-:YI"I+ɺJ:`IfAJr(p4s@ͷU'C$II!ICU]}̎̀x!*`߀K2f\K-ۭUI66 Uuqw$I`i_thآ"IZmk&ټ;D$- IZI6nݢ] PU? NJR!4˧wwhc]$͂ ݢyy%I nS $Ik\U ͎<;BVqwGhT?tGL TTi;4K#$i\IC|xQw$I {|9xRU;D41[鎐$I G#4[<;Bƕ ` rdU1Is5P`}$Oꎐ$ɝUz-wUumw$I.fݢ; xjU]"Il!Iouhlg'kw$&I C|xYwĤ@eU|Ec[ 8%=C$Ip[-$7[zwuhn[UTUc$IkD\$-X\ݢXf!,= XEc6[U2is7P PU.LrI5~wٯ>!IҴKr?cthvuHָ[g$q$IZU9A#$WI1pp `qwȚ0U/ހ70̎{'%.%ͦ$O o#$IvIh-ˀVIҲy(pDw$IZUu* {tGH/KR[4VC֔\twhWvGHJ;4vGH4lpS|𨪺;D^dIPblA$O쎐_*` y??K &ٱ;B8X9E6[U]"I4KTl`M֗-ꟻC$ImNHrI`-ZI"IIe4PqpXwĚ6U`F/4 8>}C$-$nخwH4͒hFGWwC$I6V&Є$IZUu-VIn"iq-<]#_Cִ~.n6MrI'In[U_$iZ%$ogtMcĪiw$i*<xcw$IZU`oF5 NL@-[4+B TT׀][4LvwgU;Bid=T-IT/ɞ$iqTy;4+#$-$knnv/w, ^!iq$8C-phw$I*Fyn-UbEZޞT$i9 xOw9,k? PK^A^dI/ɽY̿U Ԓ$ I6ݡA !i~$YC ;Bi䏁nD}ز"IY1ZIw$IZ(VvGh%٥;B\y EߒU $vGH}I TwUIM=/[WՏC$I3;#$IXz/<;DK rJU;T.y.4;II+ɦ*F74h6ئ~"IҴIpNMC$I⨪1ڼqmwv{$uH>In1;XUtL#*pUwv;$>':r@U};Biǀ{th>lUU"IZh'&I8 ;4I_lx~逃~*UYYwtGHI椪z{w$I"ɣLr ĪYw$I6$iTۀwvwhg'yfwr, U};b9PTջ;4ȞI^!_- XIKnD]UuHKސ$i f1I;BR$/ݡA뎘vT#4<;BR$nؾTUtH4 n>ؠ9E:n쎑$X8=mC$IX:l3-ۺ$w'c7twh#fc끝ovhlk&{wd<\9K\U"I4Hr0pNw&`zcw$I;'%I8`;vI6lϯgwܚ4*TU?Vn6V%٨;DҲ;xhwٿ;BnI*ɛû[4Q3C$IÓ#$Ib !iy- RܶEc0叺CfT災lxp'ő` rTUUw$IݒE}`hw$͈/w$yDw$IZ,Un $lݢ:xtU;DfQU}؛Z*z4 $IMU]h /[4MUK[X$͙$Pw'Cw$IZ,Uu5+#$MҀ }[4T՗CfCe7QUCl ![5"F/$IZXI pM3I'Uu=3pIwpZ׻I2௻#4Iw8 ء;BVUgwG:*'0 gIvꎐA[U"IR${2z qw&UW!4`ߒ !I=whlk1:sI/v+;4NPU|Ec+$4\s[vhlW04;D.IݢȪz_w$ͳ$O莐$I.~ݢ87-C$ Ɍfk4{ n"*'`JAٱ1paI3$I'n<fH[2l ئUu]w$3ڈ^b!$iT˻[4 ܺ;DAɌ~i6|FGT!UuH~ݢm䷺C$INݢ^U_$i%p1M ~URNIt[ݜ"pFC$Ibvf]I- :nݢ] _t+*נEc3JJ#4˫$-$>A5UIWUiPF79K$-xewy,I#Ib4p=SU};d9PUǀ;4V#$A3t֬!IrK(C7Ϗˀ?C$IZU}xYwxsIvpFw9(nx uwyQU};b9P (௺;4<;BZdITwy`/WaJM7n|x!IIt{ }wB{uGHŲNi?vhlCEd/` :;b8P|;B-V"JrkF/C6n~_tHh%-/[UտtH~ 2Ɇ!$iTU[4 sܶ;DZDIݡA> <;bQ8PLthlg%i$YPݺ[4ʗW`x`Ṁ S{w$i|Uu9E<xcw$IZ gVҢH.cwv,/*YU}`5^u,-$OݡA]UGuGH\N̟EuRWwHV_U !ޞ>$iT_uwh'yzw A# $d$uGH,Ƀwtwh!IIp>kw&&Kꀪ;F4!6V&Yw$IZH $i%y. T9hwykGuGH(f6n~lWUWuHl \ !6btH}IxDЛ5{'yNw4'Awٯ>Uu* GwGH,ÀtwhOtGH)!6-gt3wC$Iˣv~"%IR1:4I!Ͳ$/ o#@49x_w9"#Y_Ec>SU]"IҚdO`pMUI򪪯vikwGHSUnLri%cK#4@唨=othlk&Gw4KlܾEcؾ"IҚ$Fm5v\LL\8-N{$iT+[4$г4@4h|vC4@ Yr+F_n"͐A[U$iMJRIފEêj_DIvG >O$IꟀguwh쎐fE thlWۺ]ihTW2zp$"M$/ o#$IZ!-gWա!PU;?n!ISU@fI^!Mwhl/tsr Uչ;4+#i1A$IIVOnخ@T7049~$ia $O쎐+$x]U@3=$tGH(]3uS4S4ϒXݢx\U;D4$鎐$Inݢ !4Jx4k?ZTNrwƶpjvH$m[4UI֔$ќDw$iEsl"IOU!_thl&yw4M 8f׀]5bUsF_.n6V-t#-$ܿEc OU};D5%݀vhbز"I~Uu+ksĥgH$I˪؋ѻ͆G$XܢEc9ˍbTN*{N%ώ{g$q_W;wGhWՙ$)Kk%? ܣE1UI쨪{i p@w$IZLU 8C<xyw-Zivhl7W՗CsrTvwhuGH<?fWuGH$[-wOuHfOU8C!û#$Iz% I;BjjUUߛ@x-;B<.R$uv| إY4l5pMQKk[%IZ].'Mw$IZC< xkwtS$?{fiUI&)ݢpXU=n쎑$- Ӗ6H$-:8C8>Mt Hwn:*DU C<'3#ՑA;;4gtGH4IIpMĵSI2`4  !Is oKH7 rRU;B@|y.pqw9&uGHC$5 Ywv)$iR<̓+I~YU}xYwxy'tGHTUW;?n6I[!IݡA><;B@뀝nV&q=fBuvhl;VշC$I$0Zns&U7!$oatCNMyw$IZLUo | XdiInݢ]}5!*LU]uv8?Ɇ! <;B>!I$$$3N UI~ /)pҡ_IeWU^ݡA;BMl vwvSUGw&Ps>Aޜd% 8 XEcر$JrKn|8XQUWvH4&S;D'kw$IZLUu3ٱ.L IDAT2C_d+ 1%ZscUu=m$wn::`$i%=`MUUIfstG[g$Y;D$-ٱp~ C$$̎vkC8P9RF7x5*F!p U I[:dt1M s!$TUuۈ|$iqUէgvwh莐{nݢ]h!Z\Uy`/ -TwWO Tջ#$I>I-+/C$I"$;tGHUU'vwh$ٿ;Bkix!-iUZ\Uu 8;B)V1k>;B*nDx\U;DI; I!I tGh,=:APUwGhy8PX^ uwy}'uGh$3n XQUvHtS$y2,7nD|xXU};D5h?b`!6V&Yw$IZh 'yRw!NUuVwz8P $ٱ;B%] 3ݢ}ح$iu,6عEࠪ:n쎑$KU]O[$Ijd-Z)I˳fa@OݢpBD, 3\ܦEcض~"IHrk"q-kݫIAU}؋с7$iqUeϺ[4[&ywC{'|,*,6.9EcX!mI 8щ}͆VC$IZI|آ9Eq9:;DiRUGwwcܷ;B$- 7%NN-ۍ1};DU~Ϻ;4ctGh6%ٖ@fuGH:l|cw&UIUU] \" ;B$-C #4ȡIvz'UM* 'ٵ;B%ɽo,*KUy$i$pMħGVտuH4껸qZ$iqUՍ[4_ڼ#-  8;Ba -~{HwfCn~vH4Ts [4wH4+n8C#$I⪪+˻[4Msܲ;D!]nT-?{-LI*`G-ۆ9Ind-4>-ۍUIJrp"ns&8`ߌ$iWwG K%IZT׀]͎Lvw[MtQwF;DŁJ/U`7ms`U_VZO#4+=$ O΃Uӫ߈$;t{ꎐ$IC<xuwWuuhl7{T7C4}ԯTU [ IVwwhsû#$Ib[4ϪCC$IuUu D4 vN_w$IZx /Mkw֛#4ȋo#4ԯUUo $ٷ;B%YS*!$+̀·_U78I4!U!FDI!I;}vhlHOIpҳ3IUuqwƶ!tH`䟀guGh9PSU] l|Ec8/owӀwhl7{T7C$IWjNd|ز>"I<=y481[]$IR+p}wƶ9pFuC+mmڰEcmU]@~>pMwv{$w՛wGho#$IWnD|0vH4Ϫ|l!I[U}8C?\ݡV7UGhresծO$5;Xݢ q?#I̩SeQu$I{^V>ϯ7Ww # 5@Grvf:B#3 ,֮nQcvC$Ij"3w> Wݢ q6D,$iDD XfFu$I_ 5|ju&GfxuZ 2@Zo~ZݢƦWd_Uh2s:53"~R"IRy(p mKC"0$I*n^!I[D)Ufu+3\ Qݢ~V[TkK`&ijdNuj-Ị#G#Iܡ:B$ pDuZy Nf|4x%"`au,̍C$Ixf_;_V(@VYD\KuZ'3Z/_ݡVnT!Id߭Q3I""V9:D$À#9M':B@Vۀk#{2J`5 `x:Duk-7E$Iw[Ďq$iEbzEM̗̿WwGUGj-jl*/VՓkZݢvUH"dK+[4!("]!7ϰ8)3_Q!I[D?'2sVݢ+"Tjr-jlC`af>:D\j專IV$37whb\ 6"~_"IsK;cfIT*" C<puV]f ,6nQcEįC}TjB5-:De[;ʿD$HfnlYݢ q60;"$I:B<43VHGݱOfQ3Ё.IFuF08C Sv2ejK42s;z|EuV[s#XV#Iڋ{}-%I$Iۀ/TG33jX`rZD\^@&#ə5Ӫ["bqu$I%3w> :BŁJM'sV)yFuV,3Xݢv_WHX2s_`!vuV۽̈:D$M8CLiu$Io[`5!03W-+ ªn!-TjE^~_ݢf{{|u/3 ^!I0ꎭ2ә"U;ɥgMƽ;6.i!cj.[uZWUGHht-ZmK7F1!$NDؗǝuDf$I*UݡVf'VG |`5 '"nxpR&"P+/]1n2sg_R%IC)3\lRݢ`Hu$Is;ܪ:B$ށ-WVGwdct5j嘈*5" rTf_1.'H]Du_Dx$id'VhxeD|:D$ #TG\:D$H@5qs]Jq5pfuƋpjFXE$2so6wm?^KH?TmqO$ UҾ#vǺ5QuȨgP+7;(C4^Ԥ݀_U2I!*3[Te>_!$=RfJj5[/CD|:D$ RC2sIz`iu 43V|XEqR%"*nQc 3su:rlD[u$IG덂Yu$Ini=z7l]!IWwWVG̜\l^ݢƖ{DC4dS8Cl:bdn۪;Bܨ$ ̜NnфxMD]"I:xb]`Afz$IgWG2#}K#[# _TTDkuZ9,3P1*2E@T}#«$IC!3.Xݢ q6D,$IK-$IJQݢV.̿/phuZ8">P@aת#ʹCuDet`VD:D$\أE-"XV#I'"V2sI uu[ :3TuM<0_WhxiD\_"IFGD,Z[戮$iHDj3#:\`rhD|:Bz*1ofZj|%IC"3_܀_֎EM!$iDį9$i|:B|03_TyhuHTj|:B?3_Z12scJkһqu$I9 GOC$I芈/'Vws29$I+5623~%2s{jóJ X EMͫCUfNYݢ#!$e~-Zm^$IcTb-`AfW"InQcۏǐ 5[OYPuhTj(7gU' 3s!u.CuZ9,"$)3Ev_K ^";I4"Fje;#(3עw [niyЊoWwg㏂&3PݡV΋B"I*DuVKs#-:F$'fg!$I1Cje_'<#!8P󁳪;ʜ<:bXd pDu$ie4|[!"N$I+"8Cϭ$I{ |iuỊ̇;{#b^u"T |:B 5[ρ="$Ie2s`m3#I$z.kWg!$Ii?nQcӁ2sjrjs18PK}۪[`~fnQR%3TݢvWHWfn|SFK#$IP~X"6.̨$I_;U' 珥 Vn%!8PN3[\:UGC"Ow-Zm?^ߨ$Iz _"v$IP+>RQ!3ץwF-jۜpRY>6n_ZgxEgל#$I+3nUݢu_!$I7GUw32$IP;ޙԟ=8KoWHM9PNP+'TGL|9pjuZptu$i|eLIuVgEįC$IV$".Ӂ2ӓu$IҰ8 _j|Mu$z0:B #6T3273w Vn%!/7 E=!$I :Bcxˏ$INAu -C-3g'Vwk#TD2`5ܦ:dP2s]``fDY"IO9 0EtX\"ITDKd[kWGH$Do[\:dP2s+"s;hiuԖШ Uݢf 3sꐉz|Y-j,"!񔙇ӻOݵxsDY#IVD C;TGH$D- [3'g `!5v0+"Q'9PΊ[mb̩!z_;WTG~c IDATHSf }]IVwiܸ:D$ ">ZݡVvH9xFu[̉oUgNk;+wVGL|pbuZI d0pZuVoWDU!$I10?H$ISjenfT1N^WV9"sC@F)rlfY2s+ 8Vz'Iy$iRe-ZmG !$I%"w-bGI$XSݡV^QTf [ݡV>R!IK]v&0;"$I  0t/N$Izjܳ:3WWGGg#@FND ꎷf#V&3\DuIXz|uVKGGQKHq?NZeʒ$i(';!̭CV?3pDuZgUGH́J^V>[UG<\ XE5"$I0'":s$IDE9b 0?37$IYݢ̵CKfn|C| 8:B*5EK5CTGq[u$I{"I!p$!pyfN$IہKS6P$؟XEUH@FVDX\ݢƞ ̭xxCuZ9&">S!I:#!$I "{,S퀓#$I8C)3wXPc S:D*5"z 3s}je~DY!I:`Vu$I0-ߒ$IC'7C| 3׭xXf>8CޟőFyAjl lVƾ\!I:Nqu$I0O\WH$=!׫#&ӫ["iԸG5"`NuDf>_;G}!$S> wVH$ /WG 3sI$x؝^meuK#؍#@BD<~ZݢNi _tb`OO$I-]WH$ XnN$IzXnPuPSi9Pw;nv^UgU뫵#"$3$I@7ֽ-ܭ:B$aPcs2sp}5 KD:D,TjD7C;;2ϩȮ8?">X!I:c ƈ8:D$")`^&$Iҟs;T؊SWVɛ#kdrXHc'"Tw-OU$I9">Z"Iqs# `Af]"Io\F^O,X5 U{ggl@?_P#o(X@`͂u`u$I$I꺈XEl Y!IX ~ZݢZ{dP{7GUGHX%[RdS&y&y=#"~V"I:G #IQK[!Y.I\qxF~gfLb1*5~ ̊C TjlEo݁[BӘ3OzZeo#$IR']DZ"I4j"Kܺ:B$aUӁgOz{'q=wKD:D@ZD'/-Z&qV9qAu$I ?=I$Iq*. 3ש$IzXOZ)HПƖ{q5pfuV襙9cnY!I:"qOu$I(n/N<8:B$Q |:B+4)x`XK쌈:B@s,pmuHf>vhUHw6D,$I;`/mUGH$=,"л-zL'M:뾇ٵq0pR"b)z=&iunI4ԖF$I4I"k6,m#$IEL~ӏ}3sRE-'aMZ5FTGH`u$I; XX!,!$Io~=&c|2޽̈:DTJaYT3#I g$IJA[ĖG#$I)"[ݡcfNvkh$oD\" *GПY?_]S!I:3sIqw[^$Ir4o3[;I]I@|'WUGl6gZƃzVXZ"I:eܽ:D$i\EMQ|Nu$I"bp[u_d|f|V"0rRZ4ߩnџl>_ wVHNZ<3T"I4"\ 3׫$IzXD E4>>[}U˪Ca@"zWUݢ? ܠWs 7WHN UH$7TG-VGH$=RD|8ޠ.`fD:DVTJ+{^9<ftj]:B$3̜V"I4n"^`6p_uTGH$=ROoOFcpRZ,>[-VGHspUf]"I4nCϭ$Iz# v|>[R3WG}`߈XV"IF3sIqVw53sIED ;-8:B*:@du$I:m;KIu$I::Bl \-IG~{:ÁJc=#l5pEfN$I#mu$I8-b'I$̜n#npLuTJ+Y!`/p|{pbu$IyO;TH$8CWGH$s}zT3uҰsRZļp}vNY$im\WH$pu\UH:DVTJiMu||.8$I68/3O$I 'Z$id"`R{| (31 V?sA=_d3zM$Ic̜织$I䈈ۀ}n'g$i<[TO,"Z53#a@(+pBuw|rI46̵C$IAD,ΩӀqu$I Пe1pP{Hz*ĢQ?>kWGH1BfnT"I4&R!6gI42s?  * zW?:D&TJ}p ^uk2> kh՜ϫ$IcTH${n;UGHє>Zݡ4{p4@ آEi_ CL.̧VHѐӁ4#"~= \<4 hռ 8:BTjen۪;B_{&iEV{#$IXY.3$I_O.tI4A:B+td,wOZZeGe#jTjesn }jDҪ9,3$IceM|Su$I(%lIm=C;Rĵ܏~ڟƖ[j`b`d-2Yi}ȫ7%I$ UH$XZ"WWGHnVwh|k׻ :'2I!R*52sp%yuVI^s$ 2/C$I9:3N!ITw)ܴ:D$uKfn \E GDNb&DLM3sWGuh U;We$Im`afzڽ$IgI  K ֭P{/ά*8PUwE#Nz'jm[!Ic8:@ܰ:D$iE2`-b;I;TGK{Zංu?gl@JfnGNpr vC#$IZbmܐTH$0ߩQKu$InypPuY VpD,NX[Ù鐴Ɗ1p_UGDjI-".v/Nl |53:D$iEė;Dd!$i8e{;%qk뫹i8Pk #w4 O$ XT\Iu*:Bl\kTHߣ[ Q|Bdʓ+i9Pqq5eꈈ>5$Nu$I_^E>̜]"I4j""ۋSN$I#3Wn8ccK/VG&yy(58#0?vɳVGH<]`;Vh`.7WH$-tTܭ:B$ 󀿫Pc?]p05KC#AsR#' v{"bh ">2'3$iy"KS48Sfi1$I$"W!eV$VOnrhD:agq@FVfn\ LnQcNxXGWwI'"~58]GdIs:Bd!$Ff8C|0">U'_PcӁyu4(Tj$eZU[ؽ+{nPcSEu$I{Gnz?,S"I4*El Y!I&_fn\1qw -E=sR_UG#s ~6>VH<x0Eܨ:D$iTD],qHfS!I&O߻^`vD sDgQTjd^PwUGLD|5ĽI'"2"Nhp\TH$ 8C[WGH﹝OoNݐAqKugWݡV̷UGH́J|9pjuZptuDSqjeIV$"vnl |53]"I4*їTwu^'IXx0:B #Z8 _j|Mu4̀ˀi)jv`XR?P+s3sIVkWh`BWH$7Cx&pvu$I̜ XݡVNh#"һ-jl*0?3& .بE ̌;Cڊe-j,yMu$I+_^8E>K~I ;%qPfW!I&^fn\3&]}z-i+"~ ]ݢ6ez!D?v |Y-j,"!*"vnQc3~u$I+7~ufM<:D$iߡR!fIFKfnQc"{q ?u3p}fnZ"Ieq^9= dz!$ܐFnQcfFȞF6Q%pyfz:ǁJuѻUG#Aہ%)jnS̜V"IRq6/PuI;fuhu$Ij?4xZu[ U ZD\79uͫ:ȁJuJf [ݡV>R1Y"s1je$5#x *_Q"IUl<:B$5r&Lu$:XPV=#6Tgd@[""C&SDWݡVޚVGHTD|Uufp?4I$)=c:+3[!I[fQݡVGY?{p5)* !p ^u03"CWGs3$5^WufM|[u$IRWE"&pu~I4d29;ʷ#*DĽL58`QfnT"5@^fNOnQcK_BQ]|ju$IMEoVh`833%3=_$i |:Bl \{$I%3 |XEUTہKSf9:DZ*^]VTGTUWf!$5^\[ݢz !$I]macNP3׾z ff?y=;B$̽k:{wv#}owtKw;4S##ŁJMz rz7wGL$YI+NnD|6$I5sEUU펐$Iam͎|;bZ$98CEU!6TjjUC;4tGL$ݡA^TUY$͔$7th"I4k|эH 8"IRVU^`d IDATݡANI)/U7qRS6.6nخ'UwȔz pqw9!II*yg1{$pYUݻ;D$iMTUC$IZ;4e8{\\ݢm|"*5ujp6* 7;$vwȴJr#mpNUݭ;D XU;wH$͒$^!ctK!$- %LwĬHr< zAwԨGҬ4;;4sjID {w&f}̪zyw$IҌ9;Bl^UvHU>I;b;BWUO莐 Uug\V-IQ}wysU=;B5߁_n8tH$͂$݁v'vGHXU〣;4%(ɍN[4e6߭;D*5j-ۍI;dV% Vwƶp^Uݫ;D5GOuhNe!$I nM-bEUm!IbSU[+ ,i6|XYrF]5;6. C$*5 Ww7%.ɕr-fʪڸ;D5gӀs[4Q{MC$IfAOuwuW]C$IZ,ݢ] l!.ɧtwh鎐TاC#_wG,I TwTҔ$ͪ$3MVǪʗ$Iy#;BlQUvH4e!$I.I{iN<X!I" ` rT3#ARUO@ZTՖJ`{VHpwyCU=;BHFon"I4\hw?펐$iVUV"(M[4s^!ZԂ 1;dJfwƶ"IHr2=pms&g SUIi3!{>A$ PUnؾ *ɕn!Uwv{`eUmǁJ- p" wuG,vI~ <ywvkMw$Ik#*-MV\UvH$M$uwgU!$M}%_Sw~ݢXUUTMr'͎N.vϺ[4M=9@.IyIvNnD=XUݩ;D$iJwx$pdw$IӤn|phv.ϻC$_`4#n^rM0MaWΖJrnwF| Wώ{gVպ!$$7/nD="I4\EXUwGH4 N|3;V%ΔHr!pDw-nTjbf*X>u|xMw x}w$I!I%Y腟{WCC$I܍>ww$M7 &PwÀ#4aUCw/*5s/3N?ckIH> Tծ$͗$;uhb>UUO$IFI!6Ω C$I2 8;BSݢAvhqrMn \ܦEc<ϻCtEc zxw$I%3MV.!$ISj_1$u0Zn틌UwnYϺ[4M !Z|Լu3thlݒ[w~$?n6ί;vH4_|x)3-$Iwt;1:^V$-=MwhlW1ZnvI ݢݗuC8PYI>$.[4-U~w$I%ɗ U~Eਪ:v$I}h}AwxWUA$Tz[4|;DIQ/;4VZ\(ySUˁC;4J b6k!I|J1e)jYw$I4Ir6pBw؄FC$IZoAJ & ڥ;BUTF[L4#Iuh$oݡA?$i>%Ϗth |6$I2!I$Usvwh'9;BPSwĪzxw*֪j3F7nخ'ew~g#4qU#$IOsϔ[aǪN!$I"uϻ[>Ugw$IPUݡA>;Bk.ɵ[4 !}TjTzy[4$?nn\PUw$i>% x>pxw&eUuIi7;!I|-  i6'@fX7uhl[+jYwfZ[oAJ#4?Ec3pnUݪ;DV7hr|jn3$I$wtw}<ݴ;D07tpF`$?H1 86*ƪ$oJr hAH%9ц[41[wH$MK##$I' %TwW'vwhj.*F涑8 4[xaw&#wrYU/$i<Ew&f`UU"I4 \"v?$imTվ ;4Ȼxcz  r\U=;BɁJ VUvhl lW! V#$IP\ݢY8$II TwxGU=;B5QUݡA.$awƶkwfwnnvNM)whlˀ!$MBݢ pTU[Uc$IK^Gww s!$ QUwctUGInd%#p}wvgܪUwf;4q#4KpH6HgUQw$I[~|w&jCx$IKݫ#}vGH4 ͆mpHr)p`wy4*5z1 rrtGha%g $MJˁ'ݢ[$IR&`'SU!IҘ <;B$g#pc<^@RUݡA.D%9 ΚT_tGH4)InD=XUݩ;D$ܡ7wTû#$Im^INP o'vGh68Pߩ ݢ!!ju#4QUI&% n1-?.vH$uJqVUYw$IC| 8;B}h+[4eUuM?*[Uthl&awz%)nN{wH4)I*@uhb\\U$Ijv8pQw8"Iү{0eInQ$Vk[4́ jM7*<;BIn6>XU$i <9E9ɪI.IVlIKUmݢ] ,OM$ C<xOwemI_=p,y$-IN\ݢلщݺC$I$ pQ$}z-[{%|wKvwhݫM/*u)QNIVo ꎐ$iҒ\<T>pZU"I%'ú;2̪r$ۡNIz9oWo@8 XEc.KO8y 7䯪Ϻ#$I$|EȪ:|!I72S0.Ut Q^Inf߻[4uӫ!>~SU+OgΎk퓸GUՌEc[8;DIKUQ[4Q3x;D$i%)`/;)gnP$-Fvhl_v~$Wˁ_vhl+jM*͝< xXwVI;D!Ռ~ݢm!!$MZO>ݢHUݺ;D$i}dEQU$-U)p7q5<ϺC4 Y͆w~uv oJrVwfK1z[ݢ8<(IZ^N> 8EcUuI3b=ڢ;DU:͎J͖$+#;4v+#4=U4u_vGh6%YݡA莐$i!$xww&ꏁOW}C$IX!N $i rX#4^ |;BQU;-JUu?lInL+ ک;B$/S=!$I )InOƍ mgͬpDwfWnW[4u3MrrMmwv5U_n;DH`/M'OC$I;-bEUm!IZ|)h6|s[ƒU?n6VVmCˁ%'P-]UI¨  SީEc!!I.^ݡA@"QU LiM$3kZU$KJ8Xݜɹ=pQU=;D$i|8Clz?1K>#4ȋcw&CQU/pro ;BZI{fA$uKr,pcw&fc`UU"I4!߃wGH&v $9;BZK o?srU+[4˓"̓CtGh7U3#$It/[41ﭪ!$I-MN-bڳ;B4U w#F`-2༪gw֎36ݢ]li>$Y <fwƶpZUݧ;DnI><| pXU[U$IJE_U쎐$ͯ3u[4>f^+[4+j9?&̶w &i>%)5nw%>XU$[ݢ8g`$IҢ;ƌ>n"IU Cwv utH)twh?5@匪uwh7'9;B$_\ IDATEc}T75II<|w&j{*$IE~ WUNݢO&![;4nUuPw֌C3 C| xew4II>g6k#$Is%>hw&I%UuId5;KUyw$iر;Bi>AgtGh8*gLU8X9EKCp v莐$i$x6pVw&~!$I%n!IZ3U5J͎e[ZԒ 7[4uӫ>!ƁRU;thl['IwN?rwĪz`w$I >Dݢ'piU=;D$i$$ClSU$ SUN9Y5`A3iKr5v*?->̈ >F44 'ɿvH )rg-ۦʪmw$I I%98sU3C$I-fP펐$one%pj`ywH )WY/*gk#4드!uHu``~Unw$I"ɱ^L؜$II Ns`ڷ;BU:thlݒ[w!Sivl ;BqrTӁú;4*Z-5kUw$I$ɩxުZ"I4E!I^f_&k̖ërUulak.I'NŜA^YUtGH4M\<qw&&aU$I3-gC;ĭjI-+;4#nIVnjWC`UզJ6-$?]1-$KY[4Q/έ C$I[}cP-*! ۈfÿϝ)-yIQ+!Rs1Ec[ $XEcې;vH4M|xMrUFI4 " !I6nخb!4Iu`[TgۊϺ#4kMw4|CM-ۖYU^w$I$ɏ'nD= "I6nSZ\"vGH`n -s{w4-C< x]wnS^ݡA.!M$^ݡA !IҴ(4Mԃ U޿;D$im$"ppwXYUw$f$;Br #?9P9ethl_7w}"[;4ȁUww$I&[4Q.tH$$uwT߀$IUݡANKiͬkwपzXw;*HUm6nخNi r|U=;Bi$/̀V3C$Iҋ-PgtGHRTUݡAxaw4+\l EcX&@唨uӁ{whl7'fw4K\l Ec@Uݥ;DiH`oMU!$IkjN[UIZJjs`Qw6I|vH`|_=KΪC4@8 rHtGH([4;VխC$IFINvnĬPU+C$IT/wwsjIZ jp6pF`$fQJ)qr T˺;4I!Ͳ$vwh$iZ%x2pew&&aU$III!NL4YAOi%y3pRwڻ;BT _^!-IݡAWU/쎐$iZ%4_^ WUtH$}/vG'vGHbVU/1[NNi~ cw99P٨6.6nخ'Uw|;B<)IonDm|n"I4Tk[Ċڪ;Bz  riHr=pEwƶKwR@eZ ܽEc>ɷC$ɍ^Ec[]Uw$iZ%!MݢzpIUݵ;D$i$_ۘ:`*I ܪEccC$Ec3pnUT9P>ivb?mk[4́UUeI~$?Mw&A*"I4T;Unw$-Uaqm$/ nYn@ez5$yWw%< P=$M$ݢpUb$IIZ$AKi1KN|O=k^\U*VUݡA.!/iA$y? W$M$73MfGY!$IC$yw8;BfYUݡAMix pqw98P[4;&;DZB^}wysU=;Bi^ nl \XU$I"7=nYN-C$iUՓ;4%!RF`'-2[wR@ wnخK!R&`[-ۺUuI]=\ݢYxOU$I"*b3ପZ"Idn,`$7tHKIˁmk[4߰;dpr<;B3R*`9m3`eUm"IҴKrLRq1 pXU|"IfAQ4+Lݢ] l!R ;4ÀtG,_UuOwy{$_kf̓S*!$M$<p#pܭ$ISo ]+[Umw$Mo'n ;?uHKYYܪڿ;b)prªqId%pTwY;BYK*ݢHUݦ;D$iI8"IS$guGH/ o'uG,vTNPUm uhl';Du( zVw$I w5h"I4$evYU~w$MzW"5F|Ec[ѭHY8cwv-}wH |Ec[8;DYJ`+#-WC$IjHI6Uu?l`In̽^Ec XYUw,VTN@U8xxw'#$OI~ l Ec58$i I~yM=K!$Iܶ˻[āU}w$MڔMnخܒ4e|!s3jgTN+]#4QI쎐%*-=!I>X9nY6>ZU$I]\<pTv.Im[4JIYc;4ryVU[GtwhC#$nI.Cl ;BY8,fVC$I~$^!6Ω C$ ` rx#$#4XUw$i$MKQ$M$W7uwU#$iTn~hY JU`O)ܟG;tGh%`i%6+pswvZ;DYsn;Þ"$I,'bڨ;D&mn04>-j`$\ݡA^8Pj/;4*u^Wwwh7tGH4k| x<-?"I[{vzIZoA^  ڪڱ;b9PS,GKQY䐪ڵ;BYr G[4Q.{ I4u>Ugw$MJUmݡAVGwGHs{_nXUe^5;~l!ϯ=Sw9;BYxdےѦvH$ݒ$W7t4ߪ!C4hQuH?I~ݢmw**@UܷEc[ +!_kwhlW՝C$I5Invݢ hUYw$I-I1627بEc X!_o;7whlάuCfk3#4ȡI>!ir|Xg1\;DYx@U;D?wܤey㷀F5Ƽ5F11 &*PA Q|AQX5. Q򢠨(tvc >Sn9>v.I`/Gajc8^-j`$?4>I>C#xUw4rrDUx^wFx]wKox}w$I*ɛ݁U)W˺C$I~‘{P۱;BGwGh$IIK@UcwĴqrU( -i 7u$́$ݡ<Û$$G1ةMꐪ^$I(I.VWu!I뫪vHޓ-͞׺[4着&ބRUݎN7n.';DҒrwF֪ڼ;Bi voEw UYw$Iҵ%pIZUPS#$-$WiwT6ݢ]cF9J O[4MwC$IVI< QwjUuIkKr(I;vhh?^4gXݢXYU7T`;}hz<7Ǻ#$Ir.=54M _U0$i=%9x(pZwQVݻC$I~QUݢV$qw>I> [UwHd-p/pO[4J&6XoHvgwGtʪz p`wF)fGtwh$/ꎐ$iV$+nXdUm"I~#$IKI펐4S;B#9;TV=cS4OL;Dy)Uew$I"5 _yywjSI&*`GIT/H>!i,{~wptUݻ;\TVf [4+m;DI  mwH4+T4h|6^U$I+9n $͘p,g;. >IҢJr!ECna**[4HI+% ..nn"6!$͒$Wvhëʓ;$IR$$-%uwv !IZU xp m stH}IV3];b@eU-tGHI>wI ǵth>]U$I'j`IҢW#4|;B\y _j>f8pqCw^ |;B#yQU莐$i$64 |"IO;14ժj.wr^!i$YOw x_Us1PYUNnݢ],OrQwouhh?$i$9 x8pjw~ϺC$IIi萤UU^n $iI%X\ݢXYUY 3?PYUˀ<t(`$4\ lݢ8"IҬIr]V!$i.Dw$i4Uu;C[4_[%Uw{ >U5C@%/[vGh$$9;B <Xݜ86$i$wuhn |$Ie]vH0P^-5I$'3[qʪxqwFrI$^ݡ<8;BYd5OY;6eH!$i$9 8KҴ8x|wFO 4I8;B#yIUGnо 욤C$ڒ8C#yNU!I,JRI^< ceovHeWU;HNI=owhh?'n~ lW!O;BYnUڸ;D$͕W3x#I@Ua7'wGH Ir)%p~wv 䪺Cw8@eU-EC[IZ$Wuhh7>XUw$iV%y/\7ͺC$\ IDATI|XإfIo;'7n.';D&kS4{37P M|;B%ɏ-]&!$ͪ$ Ofguű$I`+#$IGI.|EcuUB[Ih|8C&īH^4>G1!=Ú$vGh$WՎ$ͣ$[nXm $I3#$SU-ݡ쎐id 3#Uuƪ%[whh˓\"I*I{_n;Dy)xR¬xkU#I_ g-ԡ`s#cMv]x$IZI.\ݢXYUieuvh$k8-I(/[4̀!$ͣ$e3|?ڟbC$IK3;xꖤRUcͻ[4 lntYw$M$g;iGUQgDAyiwGHҬHC  Vl"IҼJrpewjw]7$I/ɧWwwHRYXv^U+C$I{5prw$-p mCsC$i^$wFuKK6PYUZӢ8:#$i$"~fNo$i%nXm "IWnNr &KP#;4gW՞KfK2PYUc)K#$i^%y;~vNݫ$ͻ$W+V6VUc$ItJr!#n QU{i$>>OZU^7@eU 8 ٸKI.9 sɿUգ#$IwI* v^ݣ8bh;I%2IZ_UpMɩ4HR$W28-MV]Fc\},pq*`$tHҼK XEC8$i$y3ﺚ]'Ty%I:X!I7nKrUw$ͻ$?]j>ޡ-4M}!IHs`+ NI>З$i2$y/xՖg!$i$)`/-4)A`4i\ l0#II#Momv2XJ L6;B$ $7y-T=C$IIr6K2OtGH+ۀú;4VՓcMSqXG&V0UZtT$II xpfwUI4}s =|;D4$ݡqʪCX`yC8 xbkC$Ic U;B$]_s>ߜڡ;D$M$W;wH]U $ܐJfZ5.nЖ﫪_ZU8ͨo6[%;D4^I vwIt}I.abxU=;D$M$g28U#;t. $i%%S]`5=nXU/ =PYU0)`$HҜHr)[4['WC$I% x{w* $I*[;$͖-p2LIH]`7AqਪzNr*_l7jZlxDxGU$IAIpg7H wGhQIl6xox܇Du*wXh"ɏC$I!*`;j$M$ݢE`yw$I\I.vn{`y͖$-$2ءoՖTr4}|;B4[\!-QvGH,+\ݢEIw$I\I CRcQW28)IlIr*vU N[$uGHfSoO:m IBnѢ^KKu;X!͎ZNIlJnQw ;PE_Eٖ njr=;@$m$Ej_^$I!I Aw>loXxF"I8 X߁Ct~ lI\x>CjIaE J$U-=ݿZ} xAw$i%Yݢ`pJ`$?$͇$0xfwnm$͈$dY-wHɗ4yC91I|Hs`kݠTզˀܤzrvGHK E7诺$IIf`w`Us6c#$IKr(Ѳd*!S;t6 wnI!IOI< ]}$IZxm0X$I4ӻ#$-uz kY$IKn;t ]ϧ#$I-Wwwz I_OnI4\ y=$莐$ͽex7i~duw$Iux&IҌJUa-ZowGH{c˺#$IJr 3]ǽ,[%9;D$$k]u7~6펐$I#Ӻ[^6s,XIDAT莐$I#ƣnܩC=`I%X`fLWOJ!$][[26$i| l|EA$i jYu; `3I&¬ؓ̎uW$9;B `GUf$i\ l x`iw$I.IV04[;;%InHvw͖E$/$ݘ$^!7IB'wh$ I‚'wwHZt˝ $K?*$IU &$I"I%y.i2VU鎐$I'1a;BuIgt̹-VwW̱KI.$iI Vsʼ[;`h g3-%sṤyL?!t`υg$M[تe|=;'Nw$IHr)[Wt̑˻$͖$ߗip-f׫Z+xgx }.LIF{˯E\|;Bl`G!7!|@4 ;BY7eݺ;@KkЊL`W-M577q KwH>| xywǜ؁^!I҆H)sʁJmP/x$HeQ9-Z+wT.>$9xKwh,|;B JsWˀtW̙ӁݒP48c<ɪM5P/ Β*Nn 8gshE~#g_񄁥vt#$IP e{n3?^|b*JIfӀvG̑vh]0'.4 <]8I҂E;v9?|t <;BŒR`+\@YIS+m[Pr:~+iI$%X] OfUϻ#$i$!+g=.MrAwt$*+I)InN[pKI>!I8$9xpuwp16HkwwƒLwv7ܵp~;`Ɯp$Z|8cN=[lI!'vw̉.Nq{_7tGH4NIQ$vGHRSqRrw׻f4%^|;b\|;B3s{%I;II\&IkIyU}EA)Vh Hp:" Zq@Y$dD)$! Ox$BBr=]w&+ۿ Qc|WC`JŒwThI1"2V~q.U* jIV{[ "K$XPeM#'sqWhМLSXmтsexA"""ZϫCt|!bh9:@DCI x1pbuwZu(qp_upBut&9FXh~;AOρCDDDE45k1y`3 *%tynrBDD"z]t%eviGDtlC$j)HGDN>tv=GqFDDDWJ 'ECe)!vAY:j1zzGIJjDV5'k_T2'UzE1A){ЌE~"1> :D͡9)2 !T# l& 1lE3_:ˈ )ivuee{w[Y:ntIU:֫ACED=/TfۛgX%Wu.SY:bY~G~#i&a]SL1!'V舣RL$z BIG/qJ1e9fr1TDDD:+!)A48:GGpuհ :KG|Ŕ]/6t"#""&YZl6Nh5pFuT 'kVgy3$er~p,˪CDDItTg%eQ [Ů6t{uL`:JuT҂:Tv]%{W)#""&GNv{ă>bI?.1~bʈk﫳t2z V3RL_vVg#RL /gs])Iob]\1%,@JqH::DDDDIpu|:DDDLU(pKlrHuxuQkguou+_έBޘ7S#{9Z I-/XPٳ7=iCDDDI2w4:H Ct/TI7/N{:DЛV#tNuQ%+wstg%Z"Gox7 ]ڧzk1u{'Uh )"I_]_"Y lbʈt>0:K/k1%LCloQL}:HDDĸ13Y,॒GorKڹ:DD0!㻃$`le2t*Mu9PCDD#ۏ.֫"-%[$WN62$3$:HDDĸfr"c"0J{34ŔGW7kZY85)Mܞ lPn- ;Y Ltwuk_t_uqe{CU{$}:DC^ .Ku A%A"""ƍWҌ7V2`I)24F `SFDDԐt|,t$SF EBw;2"UX-i:S2&Duˁq/"%Ŕ$] 愶qw@)$v2@bʈ~ :ˀ,}2Ŕ0֝^̚cU%U$""bI 8:K->&iI9%Fi*gTgiF;"b$+ 4)gVn3~:GY4n.~:GcCD<I $ ͜(9LҟDDD3I[TgY%>YN6ΙM%:HDDDY4]0]nMgD, I;tew'iZu6-|m \Te4;cH$\+{ʈh IQdҥ3:7b,}0؏0Es(xvqҦTP z`+;fIJ:LDDD,$ih]u)8@ҩA"&BtIq.$Y]WDDIEYX`E'C4^A""bb^/4:HDH:F+hGҼ0)= /tTlIx,4sl`{/dO="+l`{FkTͲ[g{_?ްGDIJ?eVu۫{K]~?g%U;Xmv_JUmM`{7)""l?/j3!׬>`{^PL3qDĄ~Ӌ_'BV__fv5mDDLl^>ov6JCImoZ}}"""bjlklmme{k4in +cׅrlh{ke{_}Wx۟#blofabsӹ;""ZDm,}E_vst \Kj}1'Mf)Ϭ}K3l?F O۾97l?zDDDD^NwmgݜJ5+ϵ}훆t!/s3="""nzOpsmc{ggF!O%7EtU_׈QfQXQn6gGKW_Lj7{vTb{?ƐG>ss;lVGDDلIۗaa75~0ϒ 771v<}p:p p?1#""3 )}xyt`p}x܈N/AY&w$_$"b ͘-.>hOLwT3K:LDDWQ8A_>*iFujn:ollЇ888]>!=Wzݏ""clol;甾-4nm#""""" ]c"""""llؑfYIqɒ,(` ca6?sU K382kvpto~ RDD^x {CwӼ/,ivDDDDDTFDDDD^xi q}pY'x> YՀ-w Ͽ)5f{-#W7yLrE` `14i g[?>FmC5""bztGqVDӀuzעGD3z42^b;iO^$-o4Ϡ4߯\hNX}B}lMSIENDB`xknx-3.6.0/docs/assets/img/xknx_logo_inverted_bg.png000066400000000000000000005302711475530762600226330ustar00rootroot00000000000000PNG  IHDRw kg pHYs.#.#x?vtEXtSoftwarewww.inkscape.org< IDATxA !<!QP]3 G;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;#@`;ܱ A-A0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rG! IDATA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGعcAփ[  rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`;s@`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w QA IDAT`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# wb[bo`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# wb[bo w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w6Io IDAT`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0#޽f]}SsIB)u`]U7l:Q.Q7Q@2SA7ԱC1UIEL5NEi*ӁR-qr mv~Z^r@qG ;$wH @ #@qG ;$wH @ #@qG ;$wH @ #@qG ;$wH @ #@qG ;$wH @ #@qG ;$wH @ #@qG ;$wH @ #@qG ;$wH @ #@qG ;$wH @ #@qG ;$wH @ #@qG ;$wH @ #@qG ;$wH @ #@qG ;$wH @ #@qG ;$wH @ #@qG ;$wH @ #@qG ;$wH @ #@qG ;$wH @ #@qG ;$wH @ #@qG mAAAAt1z G}vx@ѳgܹst-""vO@֭[t)zZ?ֈ;@D}1bĈ(..GQQQ?]vǺubݺu~X~}[/ڵk[/:v{(((ˋ;vĮ]>luuuYO6W^QRR|0:wknn۷ǎ;⭷ފ7x#V^6lhg@{CO>߾'7|شiSQ[[QSS/bֶ3-ɉG@kׯ_;6JJJ$Y[n+W/O?ttFSN9% {nٲ%֬Yk֬_=.]c޽ږ;vl92N;2dH9ׯ%Kij>O%۷ouoQSScժUn>w:Ϗ#Gã[n-r݆Xre}zlذ!9c=6fΜsNd=ck׮n)|hn,ڇ]ƍ7^{mt99d׮]qwƭ}QshA<Ǝ}\seee1jԨ())}s-rݦxW矏{.}ؽ{w\h;:u~zqQZZC:ȵm+VKƢEbڵ-r]'qGk1~(//!Cʙ^TVV?j d#///_ƍ~޽{瞋 DUUUl߾ ޽{'N㎘9s(?+2wuEN.]&MUVe=s9qwGr@jjj.%Kd=D:t+2nL. IDAT޽{sۖ-[kӧO(//o}[1|Vyݶmb…1XlsЎDIII\|Q^^GydrX`A,X {V9CҨQbԩ1f̘L/ӟb֬YhѢػwof;գGwyg+"aeܸq1mڴ8䓳ojjjb֬YchÎ:꨸cqGd=chѢcYρӧO3fDnnns>(~=/999quŏСCs(//kf=Iqqq,Z(?˗\7oz aС&b֬Y\pAYOWRR3f̈:+ӛ,M6ٳEe=8@ݻw/}oyя~˖-z w0hР뮻bYOjjjbʔ)SOe=yyy1yjllyŴib֭Yρѻw飯N;-)*.صkWSZx衇⬳zJ[%\g>?֭s=7d=}PVVW__^zi,Z()"~{L0ᐊ:7_=@s9Ĝ9s0)Uuuu\uU;d=;Юqq-ĤI"///9l…q5Ļロ392̙w\Sٖ-[nyVڴxg8)jqƶm۲pP 4(~E~"bԩ1{쬧'0aB<mb1vXbES/ʢ]_ڻwo\ved=Xǎcԩq7Fݳϖ-[W^y7C 9sDiiiSΝ;o3gƞ={@+w6lXTVVƀr@/ʬs1k֬ꪫ"'''9:֭[mРAOGaaaS2z=zt_>)I'O>duQYOiqsO\}"ROf_8cɒ%YOu_bҤIqf=[2ӧOYfy}v[t)9c?>jjj@+wɉ)SĬY;z(&MYO_~#Ĉ#lӦMq饗SO=gw\,^]Ǻu뢤$6lؐuc=YO9hΝW\qEݻ7)ӦM[n%.S>}^¬^W۶mz @8G.]d={cĉԔcwzFf8S&)DėXbEt-)n޽qƓO>:tw}wL<9)-jÆ QVV/BSw'GUUU{/p޼yqWDCCCS8h7zOGQ۶mc*Uo[թS:̩SԡC>|:^ڸqhR*[ 4U uE۶mS||uJسgﯓ'OZJ:( u];(IIIIڿnBҬYvZW~WLLu aѩS'Z:ɜ9s\}&O:!oT^^^> 4ЦMr;%X S~~~H;JR۷J*)QbEkvm߾]~u !_~z];(I]v՞={TjU@)IʴE _Z5kִN)u{gQ\9޽[ժUN :w8`!iٌ}B jڴrrrSTۧ-[Z:׫>}/om@ȸ۵qF[jժO>ә3gs#:ZիWׁTvm2ߪk׮z뭷Spӧ+++:رC `ACzSӧռysONDDDhݺu:tuӧOuzS5vX댠s%u҅G(c5~;EFFZq);;:PLLvڥ[˗/kȐ!ϷNzm۶),,:L>|X:u7|c(a;ʗ/7xCM6N1qqo^~u 5dmڴI^:5fsIvʗ/?JNNNqcǎ***N[[nN ZnӧO[B댠駟y?a!j߾}!7ӧOYf:zu BXxxo߮=zXpz={XZ7tݫrYxգG;w:PB)48^XXvÎTreKJLLN:wuօ찣$5J>uBSO=ŰcڶmqYgJLL޽{v/5ҥK3b+Wի3Z5aÆeiҤI ;X-_:!nʕ!;(Iڲe5jd+_aGIx3%#:… 5k,댠puU.\N5u!F%]|YݺuӞ={SnFی/Tu ?ꫯA)A+==]u\hٲe4iu#̙3G>uZrtQUZ:%zڵkuBСCyf댠릛nǭSp8u ĉUbE ǘ3g/]A1cz댠4c 3:GМ9s3RZZx @ H*ѱcG<#>slR߭Sp[oU/?/P t -ܢݻw[g8UV-={:@9r֬Ypy'5}t M4l0 zG4w\ p ׫Gv)r뭷j׮]pZjWrSֹsDNqF)A… t5Z v$+22:G_^xjժLL2:*W̐SӦM?ϰU4iUf4` G{ս{w pnݺ1x&NhtR **JRu ;w2xZtu1G;u[g8B6m|r #>>^۷oW SaԩJMM΀թSGzpI&Y'AGO<,Y"ۓVTT&L`~ GzQrru wiH?ODҥ2223VZEi\cSnx8wO 腇kƌ2n8F\ƍfaaaT;V>:57ouZg!zu}Y¨QUx<ۭ3\bŊQXXu 8Z ӟ:Ñ|>n6 ٳSNzꩧ3p [opٸAoĈ]u,YD]t =ݻu=ZժU΀K:uk Dh֭5juk$''s~WM6^uko^ ,G޽í3O> pnIruL4IwquAm>}utQ:t\%<7x^0mٲEuԱN (Og϶pHs=pQFa TXQwV^S\g p~Y'Ό3kiٲjԨa<[|:vh@Pj֬V^mXu*1ֵkW5n:ñ*UBXT6l^:űƍH D]vJLLb5jо}Ԯ];Wѣ^nBx.yGJIIN|޽uy<?΀TVM{pp1 ឵k׳gOACq/ڈ#iӦZ~=U|bŊڱcS-!!Az΀ y|رuJKKӁx9G)\6mjIKKS3\)!!A/"""SQZhʕ+[g8^=2v|>u%%%i튎N (+??akx4|p U`A+**Jph|ڸqRSSS\!np5o:õZjeZl})99:nf8Lv\UVZhu8 KKF֭2-͛7׳>k@PXd:wl vu*0էO/_:5qzZhzazRBBu\iӦp-F4nݺi޽JLLN :uN0TMZgch:V5kZg%zp 1rHM2:Sƍĉ3\#--R@; h:U|>6oެTL1B{uDFFk׮p JW˖-rѿm߾])!#--:ðsV 3$)97tu\%oҥu&ڴi˗[gNϞ= Ν;['NBBvܩ[PnFZ:ÕxP%zYgp)Sh˖-N ))))΀CĨI&!!..N7o3 **J 6p QR8[´eթS:2|EFFZl<; (VZu 4TZU۷oWLLu+0 JJZZu륤X'pYfiҥS080댐q7ꩧwS _u\ <<\۷pJ*PʕNLDFFj۶m^u+mVp5#A+##Cs̱ԅk˖-Yuk))):.lz\-ϧUVi…)!q+eo„ 9ru-͕,^ЪU+KQӦMsXPVX֭[[gVTTڴicJM6NpyiKcǎxt 7XgᢣUJ cLՈ͛uwX886VX Zg@Ph%oС9sujԩ;vuq¸#R \hڵjҤu b̘10auFHHKKNժUK:jժeabccsN 4:bcFr唗:;Dop͸_6{1:RѡCeeeYgYw@Pb ljǎTu %m۶ZruFa\+FFխ8HRR߯t|qGc+;M4˭3 TV:ujԨaz|zڴi6lh@JNNVaa"""SBn,; +Wё27YP"WBEFFZ &blX'puj֬u NCplek̘1;vuJ^llu~ !#>>^|\#&&F[nUbbuJz<8 :UTa2ֽ{w-X:kB%%%YUZ',I&z7Tn]'OZ'!η30 !:::uZql5jH7n#<gxC 8eɒ%j޼u IDAT7+..N/*Wl_bccuV%$$X˗/['2:gϞNy:tO:շo_͙3:<8:_|um۶z)/ݺu#c 6mhժU(..NPu 5jA) ;wNPj֬uX9RV-*"":^6nܨTYw@P3j(M4:¼^rrrԸqc^ڵӁTfM\%W^1cua >۷ŋ3!?zi±8 J=,YΝ;[gBջwo |? {KwVBBu {ァ[g( ,Pǎ3@np4i @0`> |Yw@P:puGXX󕒒b1}Ճ>hqBvnݪ\KZ'(%aaaSRRu ANX|9#2׬Y3_^:߃{)Yw@P:x𠊊3=*UB!eaÆZnzK/^aڴiZníSp Xg(EIIIɑN!O?'|b<ըQ:",}:zu ; (mܸQ)u;ZJ3f̰NA /OXg(#JOO!yzg3.SOs\aA+77:WpB:R-R=3#׿귿u!/22Ryyy?~u JܹsuI ejÆ Vu !;vٳg3#F)SXg\jԨQ4iu~ą }v @w@zw[gGx^mܸQ)1b^ \MT\\l@HKHHО={Կ>@k֬PƪVM6YqiŋK.iӦVZe+/: p ڶmS.ѢE ^:~X []wݫ[\|Y&LХKSԩ2333@͵N)77W)V i+z<8j=.\`+hܸ6l SkSjUmݺU)Cwޱ dթSGWӦMSP~k߾} ͞=[ݻw!bǎ:vu 11Q۷oWrS\]wu)/Rjߕk?ddd衇8ؿ$Yu h"BV˖-uAխ[:%2333zQrru ϟO?m?4mTׯN83<:Xg˗/ٳg3WqG,[g8pu.]N:Yg_uV BRzzݫ*UX={VF҅ S+^PDDu @eEFFZOSN3)fҲer<b\믿Vffu[|) %&&ZOϬ3׀;=+ )==] ,(m۶MիWN^|E+ ϧ+Wj…)(oFbA,!!A+VS n:^AGk֬Q˖-S৏>H˗/\#1k͞=:9s bR+WTV33g{ dDDDhӦM뮻SP |M[gϞN7t-Zd\HSL˗Sৌ ͝;:f̘aÆYgO~[pUV)//:~x3 ku7Zk֭N8a┗hb.]҈#7XOںuS/RJ)ӧ5l0]p:PBw#۷O}+|)(aEEE:~5tP]t:W5k֮]0JOOW^^u kܸV^m\ӧ?n?EEE@UTNܹS˗NN:oN uZg mڴѪU3(..NPu C>ǣ,-\P:%رcܹo D >\cǎ.KVZ*,,TDDu x^mܸQ)˗5b{)Rĸ#\cZzu0j(M8:P ^rrrԸqcXƍӻkki͚5>}u J{ァ6m)B3u,\P={@Ν;wZgJpI&i%KԹsg @ ?zm<˳ʕ+;vhر)(ڷoo)BPTTo\nZvu0ydq6`^"0۶mc=f(;U.^O?NÕ@ ۷|A W_3p+j׮]ѣu JAaaz'NXaի3@8q:d,_\;vf͚ix<)|ѣG:Pw|ٳgSJ*P111)k԰aC[Iȑ#2dSpjժ_WvSP .]Aܹs)k„ Ν;}رc)SxxTF 5+էO#@ba=5}t U|Qjju te >\u ԪU+۷O5kִNA pn6effZ\/_:ܛo:hӦVZeJK,Q.]33gW^1j{k?WϞ=3Zhza ੧R.]3~jӦ c;;aW_:su ;w*>>:p͚5A9|Fb\gԩZ~íSP>mVu -++Kڵ.wE Hxx^xU\:_|>}ٳ)SJTXXrY~@xxTzuŋ2dY q79BFaa3 =C\լY:~***҈#tQ\gռySP o߮.])pUj֬uX{wt]wYg M6X3<;Zg SNoa0Ƹ#B<^z:3eҥԩup}闿u\q)(8pΞ=kפgϞ1cu6l/~ `+@2eƏouy@`!ȑ#=z._l?իWOyyy|):mٲE)_|>}ٳ)F:u~5k:%̙3իST$%%)''s}߿Μ9c?%$$hǎ*_u hJ*)ӹsԷo_;v:Dw@ھ}~a  ==]>u(m۶MիWN.^O?N5Zh*55:%쫯Rzz^z%(U7|fϞmBÇ5j([OiiiZn^5xfl:\u .6i:\/%%E7o|':t.]dR;h***NUXXpd(<<:~:v  .X fr۬_^={ԩSSLNiBSԿ8q:~Վ;Trep}VT> IDAT:~:{ ) Ɲwvޭx:С3UPPDܹs۷;f|>XB .NA)Xtƌ/Zٳg{ 9rDC ᅋM6Yx<]VM4N5vXSAqG?deeiԩS5~x pǣlh:8q:dEDDhӦM0au JXQQ&NiӦ:UNNS@xW5|  ==]=uμy4p@ Wnnuw˸qo[g ˖-S֭35~ ::Xx֮]kjΝ\+VPHHu |h>|JKKsBXx5kf͛7գG>}:^ UNN4h`./*::ZW\N1/;8puhٲ,YbRDD TF xP|u ~r)##Csaܺ}aJjՔpN:xݾ}:^WfNHHrssոqcxD:ru 18rJ͟?:$%%iĈPkjҤu TZZ$mVu  g?uiiiڹsu={:wlڶmHy5p@ @¸#?^vu W_}U5j԰N3-[ XwرC۷N;z衇_Z@ZrrrTJԫW/8q:^ Qnn7nlfի@-f͚)++K.:\ڵpc|Gwݫx:>a=oS 4o\K,ٳэ7SoEUZ:\hhrrrԠAxH{W_}e`wۭ3fXg=zhܸqPBCCvi/(99YGN<ڹs6lh[lCIIIׯuנA3@-p@@Zp3SO=;vXg* gkf&Mx (3sUǎ3ȑ#[gwym۶Mw}u |hҤIz'U\\lo/~a+sZg85:LZZ|I 8EYg*(充TogYK.Kj޼u qr!:^{`+77Waaa)ٳgׯ[7\.&Ny)(`*gϪcǎz׬SD^4x`  .\X]v:^PAAj֬i>.ۭuZK7oT=tI@[mܸQ?uڵ&Ol>Ure_^ 4NԻwo8q:ŋk„ )?\QQQzSٳժU+  >CXK͚5SVVrp-[֭[[g4۷:x: ؤIkFD M6p`ذaڱcu~#,,L֭ӀScڵk>:0nu]) @kp &&F=uLzzcΝe˖Ygy<O}u rVZY6|p:,ZH/oԪUK[nU||u |l֭zG_[hԨVX!eرci& 80a%$$Xg֥KM2:ڵKF;իSpnթS::hƌp`Ϟ=>|u~^zھ}ScYYYzTXXhw[PfJKKZK^x[wk*88:^:~TTTd ;wȱcԧOXK5R~~*Ud}j/>}Z }u ~qڵkZli>}[sj߾u˗Sjժ@w}u 8gXڵS7n(..NΝN;h֭`:t蠌 p$<<\n[uԱNn޼=zԩS)VZi޽jҤu |D֘1cxsShh֮]X(3Vbb".HYF)5˥L)Q~{YkZgÇoj: :T/tIo֭kyh"G?RVV\.u &Mdt)SXg&L 80m4[:w@:̟?_mڴi̘1JLL΀/2333 =z͛UF Ѕ ԥKYnݺ)==:ɓ'+'':O>/瞳΀[l `qG ܸqCqqq믭Sʕ+kjРu S]v?omۦѣG[g\U\:>_}ڽ{u [o)**J'NNzi\L_|>}pɒiذn*Ud)((HVRӦMS%ǣ}?NqG͟?_K.΀6o<[7o޴N0atbȬY|r 8Xg4j( 8aM<:ĸ#`Hzɓ'SP)22:@ѣƍgx M0:r֭[:>xÇ:`(((HV=cȐ!CZg (**:@kѢrSO>Dx<)'#gΜQ||nݺe/ծ][Ru t}q>|X*))N={m۶)[n)))I3f̰Nu՚5kb͛7գG>}:^ UNN4h` }NPժUSो/*::ZW\Na0;hpe˖z嗭3F)Raabccu%ʭ͛k׮]Ojt~hݺu)rcǎ8qu NR||n߾m/իWOyyy N@BBBƍJNN֑#GS_w\R ,΀1buիWI&)RiitAʭ6mhǎh:}:w;vXʩ?dϞ=zg3@۶mp@={:wlӵyf !r駟ۭ3@FF}Q siӦM[ݻwUvmնm[)rrijԨu  .d,Ϥ(--:@^ZfͲb(?:^ իդIXrrFa֯_SZgPnv+<<:>;衇җ_~iwuUR%@ҴsN 80{lu:@ֶm[￯Zgb('Ο?X]~:^PAAjԨajٲ%İ~\:riZbBCCSC6l?[H6m /XgRTT^zĉ)RHHrssոqcPz0xwqG(%e]UJx… յk׬S(w\.2224g;U0W\\nܸaC#FPϞ=3@9{bbbG}fVjUHhhrrrԠAxH:vu @9v5sL 8УG7:@x4i$ 6L9?ro L߿_ ΀-ZE>`EEEYgÇ߶+;И1ckYgI&)..:@0w\u:9RuN*UqF:>T\\hĉ) VZVXXu  ͛g4rH @ZZ `?:1Co#GXK.K˗/WͭST <:ZJs̱܉Ж-[ԭ[7իWe˖Y*|P3f̰fĈz뭷3i Ҿ}{͚5:ݻW rŋ޽._l/U^]Uu ?Ԯ];-^: @o})3gΨcǎڼyu 6l3@)..VBB=j/iժUjڴu ?԰aCRJ)ӧ[nY@9v!:^{`~~UXXu tY)+͚5;C)GꡇSŋլY3 @.\X]v:^PAAj֬ivnݺ)͛7ճgOb =c*,,N*%%:cjӦMp`„ JHHPuESL΀wVzzu zuyx)<<\n[uԱNPuA3f̰΀{~; ʕ4+ NM>]}Uqqu @-\P?Ϭ3@)--URR}ԯ_? ` 4qD 8ХKM2:@90~x%$$Xgӧkݺu x<߿Y뗿uCGVbbu={2333(UM6)>>:>t1W_ u0'OVNNuHOOW>}33-[4n8 |qGOݸqCqqq믭Sʕ+kÆ jРu ]vɓ3m۔n@P^=ڵKSCPTT>l?UreeggF) |{SeԺuk 5k,q\_|ݻJJJS)V~/T\\n߾m/կ__ NP6ml[KǏWbbS0;we˖)m۶C:yu RӦMd `^h}7)Rxxn֭k EDD@5kִN K.Ys;~n9ruh׮/^lT^]ׯWZS7n(66ÚHz{n5m:>j*=裺ru <Hի b_|>}piذn*Ud W^7l?xԯ_?});K/KZgT <:r͛[KG}S0שS'ڵKsu |h޼yJMMUQQu K*++:^|Ej:7|S>uСfΜi L:U=u0ai*4 bȐ!ڵku;w:vh4i3)Smm޼Y5kִNx<5JÇWiiuC/,,Ln[wuu 03g˭3aOZg4j( 8aM<:;qG(**R^tIx)44Tn[)7:0aux r)[nO>9su k׮gϞ*,,N x5Ҋ+rS@2d}] 8pBEEEYgZh,C)55U:;qG9su-xvW*USPfʹrJO?T*))Nѣ|rXG.]]*;;:ާ~Zg@R5|p `n޼8})RhhrrrԠA>6UVN.^ݻʕ+)q;;c?ӲeK|$""BjԨa/*66V.]NҬY4m4ЩSԩS'ܹ:*uiɒ%[gW_gϞ}u T^=),,:(77W7NJKK#GXP&w*+Wjp 99Y3|OAAAZz4ib/*))Iu f*U5k𝤂նm[})PuakӦMZ6nܨDo:諯N ͛ݻ._l~)++KAA …  3))):tuWZZuXff͚e@m6*..V||;f/kjҤu ))I#G΀ׯה)S30s]wi֭wխ[7]t:*#G(%%E:%uMF(--M;w΀sQΝ3|m۶Ւ%K3￯XgPw*+66Vׯ_N"""TPP5jXpe˖$3|x F8QFڳgڵkgz饗7nX@@(((K/dI'OVTTu0EEEիN8a/(77W7N@z0xwqG{x%e kV~~Tb/]pAݻwו+WS0LwOSѤISO:ȑ#g대իW:g*&&?NcǎY`qG n͜9:Ccǎ/*//O)RIIuQLiF;w lrm=8qu "%&&)^zZfS@ٿ dZh+Wr",X( 8O߶ @3f^{5 8?QqqqsQN3Q[g`??[ov)W*&&FWNW_)55U:%uܙKsW^yEͳ΀qqq9ruСC5` 8 Zg`qG @귿9b/\.-_\͛7NjȐ!p`ժU={u&qJ*)3gΨcǎ W@9?Y3f̰΀ &K. 1B۷o΀ӦMSnݬ3۷׋/hݫAYg`qG \xQݻw˗Sի+??_jղN_kN/΀9HXÇ׊+j9z~1vXڵ:#iժU{S@)..V||>sx;6i:ѰaCRJ)ӧ[nY`qG :tH*--N^egg+88:+77Waaa)ٳgׯ[P\.O9s((WD*}]v:ru (..V^t딀Wn]YF!!!) \pAvu W_}U5kִN )<<\n[uֵNnݺ={ɓ) 7jpk׮z3׫A)RQQz:2%K(==:>TPPΝ;ܹs)̙3JJJRIIuJر&Md|y<xYfXpZnm׿:r@8q31cԻwo ͟?_mڴ΀Æ ӎ;3(SUTƍտׯ[۷sN9*:::|M>:瞳ڨQOXgyiҥ+;o߾裏S%˥˗UV)@@zꩧOZg+VhzǬSCӧOW~T\\lGmٲ:#}cƎ?p`„ R.]4uT 8{n5:rqG ]zU:u .ۭ:uXC1cuػw~YgPׯ~[:tNhРA3fu R=:yuJ뮻n:UT:R%%%)ҷu P"##f[KǏWlln߾m@ø#;>}:^jԨ֭[ 4jH ӧO+>>^nݲN'?Ѯ]:>r5_N|G_T\\lڴi^x:+W(66V/_NUծ]:})ҍ7sYP.1@[nرc3?3fXg^xxnԩc/ݼyS=zЩSS(3[޽{:>ruUSΝ;5~x H1bziÇRx)22Rk׮Uppu P\.effN<{:rqG ]:<߿uPa}{UV)p`Сڷoue[our1WҞ={S>2m4mܸ:#}[OƍS@*((ФI3@.] /Xg㕐`OuYgP1@Yܬgϟ_@4zh%&&Zgٳg+33:2^{MիWNoSvtay<WǎN xjRvv¬S@z畓cէO B3֭[5n8 =7n(..NΝN*W 6A)@ҥKM<:l۶MC*77a 7#.5mT999 N?>}:21zh͟?+nݺʕ+);h߾}=zu$ 6L ]zUoSpnխ[:jԨ׫f͚)RaazK.Yx{n9:kN-^յ~zժU:^qbccu9`-ZHӦMN͛7O***N9sv[g@ҢEԬY3  .3 6VJS5kN<맏?:#襗^ҥK3Oubcc5j( 8qFM<:ø#JHHɓ'SPeggG?u Wbbb4n8 8ohq?sNu:>rEuE999)C~~[g@bԐ!CZg ***:+-Zʕ+rSC)%%E)Μ9xݺu:^~7J*)_h֬V\ O?T*))NԮ]_:>r)uػEľUF[-ݨ:CD,ث2ZahmU/ Dr*2-]wԾ$wߗyᑓs%qr^ڵku Xd}] zu%nk׮]S'Tƍ)BdnJ,)ۭҥK[ t EGG͛)QjՔP ӆ Tvm(>>^GN `1')))ZhuרQ3B+$$Dk֬Q:uSࣟc9:4nܘ _0gj:7N[l 1g#FЎ;3̙3նm[ P1c>k, n(mٲEe˖NnmV/_NRT3 [n۷uR-˭3@BBlJ/jRSS5{l |X;v:> իUNP޽Fm{=VzRzz"##S'SΝc(֮]$ HZ`6lhk. 80w\=@ҨQ#-]:߿'8ryEGGƍ)Q i&)S:(4h˗[gCW^z)6ƍӊ+f?zp:t+WXp[\.͞=[ӧOzqMC)8.ԩ ̙3glg47NcǎYPd0WIMMٳ3@ǎ?:0tժU:>*((P=tQnbŊ)55U/u ڵkСRSSSѣ\SHiРA H}0`uhРRRRAm…j޼u1bva@¸#_mܸqڼyuD-ZǭSEH^^v[5kjժUr\) +66V~u |իWN:)q=vXb)љ3g\$ܲÇ+!!A:>~zZUjՔ~PTTnܸa=CڵkxIffZj:P8qBzN z۷׈#3@pႢuu|RٲeS۪xJOOW*US\uQNNb_dffjԩpӔ)S3&""B7nTխSࣼ})ʕ+裏Z~5l0:ZJK,z!ۭ%JX 4`%&&ZH~~usY5j(99Y!!~ lL0AuHLLTlluW>x 8{n3:eիԩ.]dEFF*##C+WNYf5kuسg^z% nJNNVxxu 늊˭SAɓ֭ S^v4vX <wCYG?]X~}/^]V)7oZwwGQ\\H͚5vZYf͚r*Vu |ti*77:q\JLLܹs«EզM) 7ް΀Su H]rE:u˗SRJiӦMXu pKJ*,UT:>VLLΝ;g@b۶m8quhݺf͚ejPʕSࣜuQ_e߄jٲe4iu رczꩧg4i$m۶:#腆j͚5Vu R_xSZjiڵ N~˥+V~)U>}o> n3fhݺup`Ĉ۷uOI}Q80d}MDDԯ_?ƍoN@xԣG.J(VTyM6ipM66mukܹu9s&m`qG 7 ';vvjΝ$ \rm?3ٳ)gϪ{ϷN zZĉ3@ӟ 6XgcǪ[n#:tkfmۦ?%VيѹsSŋkƍSiFӦM΀~ƌcu]ڱc5kf?IIIQvtU~Ν;hI{9 ~p)RRR{1'|^uwS^*U0;. 0sn+fN~)51;cz꥗^z:q\Zrիgtd߾})3<?PUTN[nZxu \pA]v͛7S^˖-5yd >M0:4kLg϶~믿:$&&*==:Ǹ#;jȐ!O3yԲeK $&&*66:Z~u~ѩS'*Su ŋjӦ6l`'0SH?^QQQ ͚5KV΀CU~3cZgLM::qGwXnn:vSNYGZ~jԨaH4qD 8uVkŋ[?zJvN̙3Gn:#\.\R{u bg}f-Zf͚YgGyD)))r\)Ç cĸ#gΜQΝkUZU*Qu \ݺuqoF]tQAAu lܸqZ`EYf:|u o߾oS^n:+V:ٳ)Qxx6lؠի[ UPAn[%KN.^ʕ+)q={hpaÆZluX啕2eXGW^Utt.]d- ŋ5}tլY3|<uu!댠W\9_^) P6mڤ2eXkРRRRrSCgϞ$Zji׮]jԨu ̙͛3Sc]8s-{1͞=:<ɓ)QxxUV-qժUSff"##S#ޱc@~:Y)np7u |X;v: ;(4RSS5{l 8ЩS'M0:EPxxUV-@=zѣGSZlݻwz)ӧOEںuu fVX+WZg@Ғ%KTn] ľ 0:4h@)))\b…j޼u9rva~ qi˖-p`ʔ)j߾uzKO?u;v,Cڲeʖ-k?8t7n[`nРAX*UJiii*Qu bϟobbb/[gn:Z @UVM銈N~EEEƍ)8r4k,M>].:~Ν;+;;:B)--M/΀E顇A󊎎׭S+++KeʔNA*^UJ(//O;w֩SS;(2335uT 8NSL@ ׆ Tzu(//O]tocaaaJJJѣS^W'OV~o@6rH۷:#,YRn[KNA쫯Sn]($`pnzǭ3A{n Oj&MRzzu?~tbh"5k: 6L;w%KjӦMݻu ??_ Pbbu !77W111puJ{tR nfΜi4qD ѣGW^p`zw3#BO>:xu |rrJ5j:bСׯuXj,Ybc*T֭[նm[˗[P?^zZnݺo߾ M0AuHLLTlluij>7x:޽[G WSNtu |)ۭʕ+[k֬fϞm٣^z:nرCO=u ~P˖-yfRVVΝkI ,PÆ 3@x<޽:dtbzSP{Zv¬S'N(::Z7o޴N‘#GԥKXG5k{Gn[ŊNN>Ν;+77:G|AݻW?u oU͵o>رc[gŋ+--MeʔNAʕ+ԩ._l*UJʕ+gBTRRJSlܹs)0 `lݺU'N΀[̙33PEFF*##C+WNrrrԱcG:u:Gx /Q5S}4i#GXյkW)AN:Z|ur_xSOiii NA!r[G^W}nnʌ3n: 80rH:OI{180d}8>PJS۶mS֭uY'O2SHiРA mڴISL΀mڴѴiӬ3Pȼ꫊΀3gڵk30 x^GN .Ov:t[?${n3:4iDK,΀RJiӦMXu |;w:jҥ4iu ''G]v~AݻwWAAuJ#FXg wߩ[n|> 5k֔VbŬSp(55U=u |zջwo8p:ڼy󔔔dzXgr\Zrׯotd߾})$""BׯWS.\P6mn@ٱc&OlI3fPӦM3@4~x 8ЬY3͚5:w믿:L<(w O>:̟?_-[2i$Zgӧk\rڶmbbbS}6mݻw[MzZv*UdܬYj* 80l0:wHttƎk233?:&;x999ԩN:ekQu n(p`֭Zjھ}7on?͛)5ǣx?~:%ըQC))) u_`kϬ3 OZg6{G"e>|X={N o{(N>Ν;+77:>Z233UD &uUrr27|.]:_TvmڵK 4N|Gj޼Nc5:4nX˖-nŞ IDAT΀jժuxWyf p0>|vaf͚ӧ]vp`ҤIʲuI7oV2eSp^ƎÇXO>:vuJЫZ֬YP.\˗[g 4:~ /hذap ##Cg϶w Wzzjժe_ZjTddu |ĻK'N=(߯_|:WΝu1p1H[f|M 8ЩS'M0:iÆ Uu |TPPx=z:XbJMMK/d?ƍk)K:|uFЫRRSSf_|Xg hphj޼u1bon 0;vlbL?ܹsժU+ 8E@aWT)eee)..:~w^hB'NNڵkӍ7S^-4e ?u޽Fm 4H/uHII… 3yG:zu |TիW: !!A΀k֬ќ9s3V;w瞳NlܸQ<Ο?oonI򊢢34j(m߾:̘1Cڵ/hڴz- 8w^8r; /^Tttr*Wu ƍkٲep`$j{򗿨QF) (&&F)y%''[g=˥+W{NA.??_;wַ~khժSu 5jvXb)љ3g\`qGAСCٳ^u |tkݺu NQZ5+"":>:u ~Vz{n)E^W'OСCxsm2p@}WA|Z~=/OϢ_n/_^YYY*Su ŋ+##CUTNSNYc;*7nԩS3~;M<:KxxTzu(//O111:vu ?qڹs'/|_)6V\\^jx ꫯnݺJIIQHG E΀ Ү]3@!7AgҤIȰ΀&LP.]3-Z͛[gÇkΝ룏>RŊSp]:()):!|^|E H9r3v5k, 8'Zg࿍=Z{΀;c ׫޽{)ʕ+ըQ#딠7tP:$''knEFFZ9sFO?lbuiҥAr)))Ik׶N[gDZgg}Vouؽ{Fm W*::Z.]N"##vUR%딠լY3͞=:ٳG g >\VRXXu nEڷou 02|p}A\rB{:tu |rb իW:%h{Zv-ߙ'N(&&F7o޴N7|.]:>Y֭[as222TX1ܹrssS.K3gܹsrsp>S5iDGNrss˗/[GyDfͲЕ+WԩS'>#ҥKSrSNRi&. يٳgS@!ø#uVpu֚9suFPTFFTbcǎ:uu ,,Lƌc?غu}Y;w:y^딠7x`[g믿VϞ=xSOiii N .KIIIzᇭS@߾}[gBqGAoZnu9rc4cYg!CO?,YR6ms\j*=zu (D6mڤyYg@Ғ%KTn] effjʔ)pM6:uuFxWgfΜkZgBqGAO>ڷou Xpx "oر֭uxd?(_nݪmZf̘޽{+??:Bcƌ_W댠WT)D)2e6l`ƍ]Zgy:tФI3m4a P1sYGŋƍuw[Ymڴ믿nvڥ1cXgnܹSO=u nQAAW^y:byyyڵ~G딠߶ 8`\.XB>uJ(%%E!! ǎSnTPP` 1;~gu]ڰa"""SZjiڵ N?~ |P{?l[t uQK,NWϞ=xS^~ԳgO ]vM:t`:pu ;o+)):K (\.V\[G?$ٷou uUR%ܢcǎ驧lĉZg%Kvt)ڶmƏo5kYfYgӦM?o&O 6Xg#!CO>΀W˖-3ޤIkOuYg?ecoj޼k ӧO[_˖-$͚5Kk׮΀Æ S~3^ttƍg233?:gSN:uu |׫F)+**Juغu+?3@2tP(<<:?Tfܲ~A*((N z]ve}n,X'|:#`=#JIIN>={X¸#OVΝkUZU*QuJ[£@7ߨK. # ˥D͛7Eինm[]r:۷oԩS3 ijذugZGڸqWnp*T ۭ%KZG/^Tte`8Qž={4` 8аaC-]:#/_^YYY*[u |tUEGGҥK)(44TK.դISSϞ=g)Sh֭AxJKK@P(8qBѺyu |TZ5mذA)#,,L]u |xԣG9r: $''kpG1buF@ իUNw:xu кuԿ"׫1chx<9x|x "}o>u@DDq1o޼믏N:Ψ{Rg^x!L&u PYŋGiiiX"u Yj26t)Ξ{'N)dW^qZavy~)l> :u_RꫯYf(x 6SF-Rڵk,N!K 63g)N֭cѴi)d~|)@g#@8SRg1i$K I-26d)diɒ%ѥKXti xc]vIZ`A /N P&… Sv!&L)P+,Z(zWNB{ ӣM6SҺuG[oNr4yRg]_:VhРA̘1#vi)d:zof \NGVZNa=ѡCxRlɒ%ѳgXfMꔂ׹s`lfS@{oy矧N1dȐDUW] 8qbtM3A^ .HܠA,'cyll;ݻw曩SRqqqn:%~g:L<9O?=***I&S@7xct=V\:+cǎ3g(x 4SF˖-SDDcܹ3UW]GqDdo:<qiɒ%ѥKXtiԼy󨬬-"uJw}oOAyIHnСq7GqeL&.8쳣:u7 0 ^{M61qDjuE=⭷JBcɱ.Nqo}{ѨQ)diѢEѽ{Xzu+ +D#ɤN!KkL:5JJJRԘ:fΜ7NB/^bŊ)3fL=:u h͚5ѷo1bDolٲ(+++WN)xGqD :4u@Dwf˗/OBr˘3gNlfSjL&M"Zj:,]6OC;lYfW\:vaq嗧Ψ 6ӧvm:,]6w .L@jԨQL2%: _1'ON^|8sSgF>8u@DD >X?'Fqqa\[㗿e rpgļyRgTa%ȳK/4***RgaÆEYYYꌼHA9xGSgP5kw_3u hѢEqƟ)Ycĉ3 ^IIIL<9f)QQQ\sM rp1%\:#.8Sgn)@=f#FdO N!KEEE1~O~:%o<8Sg;n*~8R+8xgSlРA/(x[ɓ'GIIIHAFݻwO7o+LAϟ~ `#YlY:,5k,hٲeꔍCquץ ?xzꩩ3(Pm۶oNa=SѾ}x7S˗Giii,[,uJ;蠃b3"":oFTTTwygS6mƔ)SASһݺu5k֤N96_=zUUUSR۶mcԩ;ѨQ)d?=zիSPv}7o^뮩S@w_tA'N `ÇC=4u@DD,Y$t_|EԼy{c-HHWVnݺ:(;ld=P >d GXoC ?>u9;vl?>u_~QQQM6MSO=5.)yjժ(++/"uJرc92uW.1cF r0t8Rg.]Ĉ#Rg~8 :(0;ʕ+[n'N!KM4YfŶn:%grHwKAϟC I@:sb„ Ѱa)l˗DZ~{z7c3.(9?K/N!KEEEqw)9u]cĉQ\*^]pիWTUUN 7Jy;q֭KBf1cF4j(uJ~Ɣ)S$u Yzw4֬Y:RTTcƌ믿>R>1cƌSg뮻m۶S""/.]ħ~:,5m4***?Aꔬ5o<*++c7OBd#@=#q _;6uFV7os̉-ZN!K+Vc=6>)-": p8⩧JP?xSg-2MV>| IDATo .>}DUUU;ƴiӢASWqqqL:5vm)d)D~⥗^J(jرcc3i}Mn}SgL&'tRĕW^:+Æ KA:5jTu%đG:92@ySO rK?N{NAꪘ:uj 8㣏>J̻L&: <8JKKSg|ꫯ)S C C=4uƷ:K/MAfϞ#GL8jȪU4-Z:,mQ^^7N {G;6u9xK.IA&M#<2.]: ?qצ(xEEE1~iR|O{.uY*..'[o:ZlSL)d_}FuuuYP(--իWN!K{W\|ũ38?DӦMS_=zUUUS:nڵkSÆ y(x[lETVVzN +Vc=6>)duq7믿>ndiɒ%qƲeRXP8SSg.(vu_9WU lٲ(--?oN[oMA7n7xcꌈhݺu5*uYd21`X`AYzu+ƍ:ZhQ;R38#:+Ϗ??u9;vl4i$uF4j(n(**JB.xRg|rGD>7o^ t!đG:#F[ne 4bĈ9sf YdIr!1})#<W\qE "[nǩ3rM7ŝwޙ:,qYgΈSO=5ڵk:,M:5_c#@"k׮=z{w:,92~OL6̚5+F:C>ԩ%9NQ5kӧOM6$u WN;4!CDf͒oҤI :4|r /'L&u XG}]t+VN! ?O6ذaѸqdޫwڂ b}_|1u @R]]}> uJO~cǎMkFYYYSB˖-NK6SN.|x(--˗N{SOMA d[o H2,Y$>XtixGc{/u @G^bݺuS 'OEEcթS\5 4 /璻uE=⭷J/YP s=1f̘dCN8!%rS]]{7|3u PGTVVG_|E:.,u1nܸ$PO<rJ кu8cj|Gmڴn1wrGZbȐ! '|r+**L֏\p ѣGXjUz+ٳg(xniTVVFS|eĉqM7 ,@]`#@-QUU}z+u ߣ_~ѸqשS)//뮻.uPd2sΉ9F&O<1~)G?Q~3΋{,u56om#8~|8SRg|/j>,:wK.MwjSN56[n56Sgu5kO>1bĈ)Ғ%KgϞf͚) U֮]ݺuZ8vZc=())ynѢEѽ{Xzue#@-+D#ɤN;}56먣YnQZZ+VHr_~et%SkO=T\tE3n!k_w{˗/Ow59ܭ]6GXP ͚5+|>:>g?Ym6sX?k׮ݻ… S܇~;vHPHQ4iӧO7bqƥj'|2:vNlٲ(+++WN)x=z3<3u\tE[_Wy_N3je˖Eiii|Sj2厵?zj =zt|駩3 ֋/~ "b̘1Ѿ}_>}o:&ޕ;rFiiiY&u @,w^ӧOTWWN/~j>nݺիSu3⤓NJPn喘4iRꌂװaØ:ujh"u W,Y]ve˖N;3ʕ+k׮NX/;c3_vy缞.|rz(--?0u PG4h 1bD6hРxSgv!&LEEES`۷.2[nelVy;M7m&o~ >l f#@qWƴiRgOzhڴio۶mfyO꘢bرQ\)|QVV+VHR:ww^ ={v5*uK>ߙ{_\s51a„č:"ĉ'I3{G̟??vu)gժUѳgXtiꔂwGE]:k.\|TUUN)x;oL& /RrG:믏{,uFƦMls3<:ڶmbKPpx8p` "bԨQq~8n/4iΤIbƌ36*C9$:t:Z*og^:og>;v@=V[C=GuT3}Sg<=@9rdt9u--x=>kRgln!^zit5uwީgqEǎSg3ό3gFӦMS?ƌ:ǤIbK]ưaRgO~}|rI':`YPGt%.>,⋼p¼M6lӧO6mڤN @׮]7(o/Y$>O4i[NXonrmYTTTHR =3y=>e>~{ @1: ;.RC1:JJJ{v)u s=L&3}ټOڴiѸq)rGZ8ˣ]vS}SO=o߾q9 Ď;_cOP?ѣSg뮻m۶Szk?hg~JJJ{mۦN &lf͊SN9%u @W]]}> uJg}bѩ3zEq3L&͛9͋a;6:t: ';B{g?>R-xo駟}e˖1{tMS$n6mjG}z|s=7JKKSgD֭24i:os?'OFb̙ۧNȚL-ҲZnΜ952*|O'NQCG Rk=X1"uF+**N;:6lӦM,5=ܵn:fϞlIXP4h f̘j-jժx衇jlٳkl맴4:(0 3gFӦMSkOSꌂ[Deen<0uYfͪY+nY3fLtA33gΌ/͙3'>+sΩ3s1#<-ZHPoUWWG޽wIRsϸkSgu 'vZ Nj/>l{W'y}Sg|/j}Yg:,q5:o͚51iҤI,zk֬IR }I1_z뭩3BO55\GqD d#@-_*ndW_y;#2L%7͛73gfm:aݺuznϷh |D-ڵKmڴhܸqˣN6-.]ZsMIIIL4)vi)rGĶz9sf4i$u Yk,Y|bΜ95>ܵk.O%%%Sk-[~x|Sȓv!ϟp@z뮋Yf1}dMR\&M"z)dnK {q7\rעE}p,wHaÆ1}hӦM;ĤI1bDŒ.,u|ã>:t?u y[?ݻwOP/e28餓⭷JRcRgܸqb}IAVZ]w]^{m,[,|׮]0aBN7n\p3ҨQb͚5?s1gΜd%\eee3^zСCkSȓƍԩSNKP/}QZZ+WLRN:߿ H61nܸdM׮]cذa3rGD LA,X'NLÆ kצ EEE1~c=R7۱~)IIIIr-1z) /^xa -s=SgW_}u dɒZ,kO>$uY9rdt9uXSgL&guVX`Sgf͚Ŝ9se˖S>8CHB :4hРAzgܸqq=(xM6ӧGSĎ;SNL?N}Y 2$uY*..}S|rG;Deee4j(u Ycܹ32bĈxSgmƔ)S\VZ|ys11~) 'Ѵi)駟Jꌂ(n@-дiӨ:{q3R~wk޼yTVV[l: ",wQM6{7Zj:,}q+VĠA"ɤN!Kobѩ3_Zn] 80.)Q.]bܹ.ld_~eŊ+R;.:H({SUV'թSdbРA|)dG?Qs=Q\$75ov~ŧ~:|3fL rplj':L&#Fs9V]eկ~_bvHPKqg "n 6,z:/W_}똣:*F:rGr ':\q3_?x rp7/n(++UVN!OڵkOht1u @r?:.qwjЮӦM)d뿢_~dR|A/:,?iYgm۶ɓ'HR;ѳgXn]oGYYYh~{# IDATFNo#D>HBlCEYYYzcժUQVVfQz-УG83Sg5yQYYoy_ƱK,I-[;w:dM7/~N yԬY3gNl2u YZret->)Y{G .HAoSgw?D_OB4n8cРAS7x#N9DĘ1c}3<*..mRL& ^z)uJ.\|TUUN!K;cL2%4h:(@;IQQQy睱{N!KsgIcSgO?Ej FǎߏNIII7.FEEEsiӦŭޚ:5l0N-ZHȑ#sΩ3ȑ#cƌ3rǿ |p\}թ3d#@ >)sqy6vY\Ǽkѯ_N2LtION!ƍ}'uP@,wvuט8qb{[WѳgϨJ>ѣG^:u Yjݺu̞=;6m:Ӛ5kO>qWN!>;wn?HP;q 'D&IRF:tHl[mU̙3'6l)diҥѵk/Rl+WFn?NB4if͊m6u P ,؈6l{c7OB-[矧Nh8SSg;n2L :4=ܨNC>;:Λ3gN;6uFkРAL2%Zl:%%%1ydϭիW+S6w}7uk֬IBf9sf4n8u P,wH<ڵk:,e20`@,X uFwwǭޚ:7>cFcڵSȓw9͛?RyC 3 ^6mbԩQ\PW]s5qᇧ Æ ?36\:oo(I~8ꨣRg.,fΜ:#o>xRg뮻.~_s=qGIJeR'lM̛7/=)uڵk㏏O?4uJ;㢋.J>}SgʸSg͍7RgN8!N=@=g#FPZZC IAf͚oSgڵkgϞ{N!K 43f;:?9~_:N9?)oVpsϥN!Okc; >|xNQZnQRR:=֭ݻ[o:,Z(wWNB,rGԠA9sfNSRuuu;|)5n1f̘䠴4:hѢرc;&L 6LP'UUUE޽?LR:uÇO|o1<΋sΨqO<.1{Wvm3zrG4f̘8蠃Rg!Că>:#B篋ܹs ڗ_~G}tL:5u yԯ_xySꤏ>(zUUUS K 'pBzꩩ3IoLĉcܸq3A>}b3zrGзo8묳RgRg$UUU{7|3u Y*..mR@֬Yzk6u y&?GVRIs΍#G(x1iҤnRd[o5u9xg-㌈s=7}k?f͚izSҌ3{Ygjժ)##C)@R5[oL2)ҙ3gԵkWXWtt;f/kڵ[u (%wneeelٲ)vu СCׯCK~~JNNVP?տYG~رCM6N;ӧNv?N5JVRhhu ߸xz >>^QQQze˖UffUf/ݼyS:{u߸xt˗/[K*TPff*WlO+##Cwuu nݺɓ)~ɓ3@4k, ~K.Wzzu |(66V*Vh7>S;:#\. RRR6mj O?:>|XSaau T^=)88:Xd5kfF{ZgYfiݺup`̘10`uHnnzKZڷo[2… vZ대Wreefflٲ)J'NT=39srJ 3fXg;jԩ0igz73рo>8-h?~<u|??g7NCÇ3^ƍ5g J| &XgeddXghS;Сf͚evܩcZg 999ҹsSp_^wuu Xbb┟ocwN ׮]SLLnܸaX Jz)--M)^z/:^r\JJJRӦMS`:u(55U!!!)vu-Rٳ's=ܣ 6(,,:_lʕr݌הbիW׶mOX/BtR5h:WBmذA+WN].]N)5]?:^*[233UjUw*_9GrrrsY:~ƎkZjyYgp[6mڤG}¥X啕޽{[_]Vx˗WzzVPPRRR԰aCxh/SJcǎW^*((N^X,wI.K+VPFS%ǣ8۷:zWb 80|p <:۲w^iF?u |$,,Lk֬QBBu _1b+대jiӦsp`JOO(l٢:o^g϶% ^PLLuHLLTZZuF7rH}'p`ѢEjӦuСCjٲ>sҔ)S4|q|_~"##uU딀7p@:'ZglM::ԛ={RSS3ѣgJ0Nx2euزe&Mdn޼nݺԩS)RhhUV-n˩SԮ];ر:>4j(+<<:Do4x` HZtz! F rJ\.x믿V߾}UXXhRy< 8PN/Vͭ3@ Ÿ#V~}%''+(ϥoULL Sӧ\xzRٲeS-/^c= PTTyUX:DKKK˭3^xxUBJ*UhӦM|#W\˗SFNNt9x)<<\7nT͚5S@ mebŊTJS५W*22R.]N 8wСC3@ӦMl2 n[nnz^{:>裏jΝmP3^z֚5kTn]xP{W_}ep?.ۭ[nYK5j[o2eXqG)((H)))jذu xZ+W2gQFYgp 4l0?:>ԨQ#رCճN(n޼=z)gϞ[Gʕ+,u @5fڵ:#=z׭3(W,q)҅ vuuUnnu릓'OZKZnP0 `0n.^.]ʕ+);}l6{Q۶mu HHH/_)//O={d޽~i L1n:̙3Vnnu ĸ-eddN #GXXv^y 8v5n8 բE })˥)Sh ;q'cΝ-[Zg`f…j۶uxgGYgسg.&Mhٲes]vp`ܸqڼyu{NuxթS' ̩SԶm[ON=Pxxu @;h/44Tiiiկ~e@߿ bV^ XgHNN֒%K3@ll3!z}ѨQ3@JJ̙c/(66VGN ZPd.^\ou |(22R7oVJSJI&iǎvZr\.u Ŧe˖z׬3#ڶmu3gx `qGZӦMl2 8p 4:\pAru TbEeff2(Unܸnݺ_NkN;wTZSJ|̙3)szg3(5jPFFʔ)c/9sF]vUNNu h;v:^ ڵkUn]`qGVՕeZKϟ֍7SСCԯ_?y<x~JNNVP?J 2DSNN=ڱcׯoP9sF{VAAuJ5ky |*<<\7nT͚5Sॼ<NC?UTQffʕ+gJJOOWZSॼRNڵK?u @Giƌ/$$DZu >xb5o:9R;v/t 4:4nXWNňqGҢEԦM 8m۶Yg6M._l;AYӧN\~u|۷ S+-- (wP*,YD͚5΀#FΝ;3cfҺu31c4` |… zǵyfPnr)< cǎY<[&LP=39sdҌ33@ǎ5uT C;{ 7ް@1[sյkW-ڶm;w{N0wESnݲN x&MRǎ3pcǎ6mu4a eddXg'*&&:Z4k, 8sN3:(''Gn[ΝNÕ:5uTo[ٳG5N0'hܸq/((HW֯kR^=)88:^իW/[x<)Ҋ+v @)Ÿ#UN*$$:^:~nnݺebvq.ڵk+33Saaa)QBBFBH͚5}v=#)^}U_:#UVM N_P6lؠʕ+[K׮]SDD~gW*22R.]Nʗ/lUZ:1%9EEEܹs)0uV;:<Ú7ou>pB͛);S|S 0@_uFkѢOn?kתaÆ)_Xȷ~XKuQZZP0\.XB5Nvd߾})0ꫯ7ߴ΀ÇנA3׫SNru |L2JKKӐ!CSL]zU111ɱN x=vj?4m4u:L>]0eM4:o^1L4I111p`JMM@ 1b}'p`jݺu>GѩSS#zK/Y_ѣG[g<˥$w})8quԩS3PB$&&*--:<3EqG~%""B p`˖-z3PܼySݺuc8ɏ*==]jղN =#oSCƍӊ+b`fٲeJNNxwyf$AZr\.u _o߾*,,NA x}Yŋy0oԯ__ Ӧ8vzꥂ0OVttrssSॻ[YYY*[u >u1jJ{N)##m@@>|+대׼ys%&&Zg*U(;;[+VN\"ۭ˗/[QTTΝ;g/kƍYu M܀*TLUT:^v"""tyPwСC3@ӦMl2 |С?Zv?PUVN0qu)/>>^QQQ5kN ջwoƺO?~\n[nݲNjԨzKaaa)60 RJJ6lh/y< 0@_~u J+W2g飧~:~vP-}vծ]:5h HJJJR3_֓O>iy;(vܩcZgVZi60{չsg 8 ~Z۷o΀ڵkgݺuK={u |aÆڽ{z!ZbuFPUlY@yꩧ4zh 8D  7ް΀CՐ!C3/ĸ#vkܸqp ++K3f̰΀S=t x)$$DS9ǣq)>>^95k꣏>R֭SL9R}uFkܸΝk M4˭3}X9R{΀ .T۶m3/#qJNNN>~1z~'uU7nܰNTL+W:b1|Oyyy);S[lQݭS͛7+WXaÆ)66:W,q)҅ vuu\uM'ONBCCn:ժU:8ĸ#0sEuE/_N4d 8C1 (k֬ѓO>M)VLjС)ȑ#4hu$-]T 4b {S|EGGᄈN:s挺w\xzf?ø#'$$Do֭k/*66VGN[f͛gn{9 ֭[վ}{={:>K꥗^N(vZhuF+_q̂ Զm[ 8꣏>΀۽{7.&Mhٲe8sգ>jƏ͛7[g;v}] 80sLu:b?Y-[Է~k7nbPy޽:#=Zpu߿jV^ XgXj,Ybbccoĸ#O>5juX~̙cR@:zu 5h:bwߩM6ڿu |waPlԳgO?:%ũRe˖z׬35d 2ڶmu3gx M6ղeˬ3Էo_y<2.\P.]txbŊڰa*Ud@9sڶm{:>ԥK}ᇪZu @ԿK.C=d(jԨ )S:^駟ԵkWXStt;f/kڵ[u 7wP"T^]YYY*[u tynݸq:ԡC@gׯd p\vMJKKN>cծ]:ؼۚ;wuF Wzz*Vhcڸqj֬i/)&&F?u J)|*U(33SʕN7 Uzzjժe/+::ZǎNA)a͜9:DDDhʔ)[nwޚ3gu |Aڳg?:L0A;wx˭3~lѢEj޼u9r>c rРA3@ƍzj\.O0ܢEԦM 8m۶Yg @L##??_zҹsS^=4x` 3%KvFIII"? &XgqG eD?ŋ[g wꫯS%˥7|S>u n߿S#+W￯b?W^*((N xWӦM3~}z嗭3]4zh qwߵ΀ӧOWΝ3?#3Zҫj'L\zUtu T|ymڴIUVN%''SNzu |L2JIIíS֭[5sL대u֩RJ)?p*55U!!!)҉'[nY ꩧґ#GSॠ aÆ)0D͚50x̙3Vnnu 7|={:^Sk˖-j߾Ν;g ŋ5|\.KHHЖ-[3|vaHfΜuYgcǪ8zZnXwyG*TNgϪwϷN xsU˖-3%Є ԣG 80g%%%Yg,͘1:tQ wP}Y 0:,\Poux<0`o.]ʰ/ `>}Zmڴі-[SC=nݪjժY)BCC_W)״iӬ3| &Xgǔ)SayXg1uA/uعsƌc?rrrvu9x)<<\뮻S0qUuE)f͚izS|j̙ζxk֪UrS%@zn:[Kz|x<S%˥+VQF)<:u(55U!!!)?(ݺu:?e'?Svmeff*,,:ݻ,Yb[vء&MXр[N:gP233Urexڵk?lCW^Udd.]d//_^٪Zu qG>a(={:nݪ{: 8xbn:SV֍7S#aaaJIIј1cS|f޼y̴xjRjjSŨ[nz3@vv3GfyYgw3;vԔ)S3-[4qD 7o[n:uu tժU:S6mң>:>r/k (}<:%o^Ǐ hժUr\)_}:p(..NN/Vfͬ38׺ul/;vLzRAAu >}ZݻwWnnu tw+++Ke˖N޽{զM)QFiʕ N(r.]VNNuJ6m:t`*U(;;[+VN^*ۭ˗/[HNNt9x)<<\7nT͚5S(;(r*TPff*Ud/]vM:u p[vڥCZgMjٲe;tZh?:>ԧOm޼Y*TN(rƌcf\ ֚5kXKݻ+?~\n[nݲNj֬zKaaa)  aÆ)Q\\KH\@?ӧO=;}ڵk;vX:t蠭[ꮻN(rK,њ5k3^յvZ[|`z'3?~:(;wرc3@V4| ԋ/Ν;[gSꭷ޲O?۷[gW^yEڵŋc)##:>ԬY3޽[oSܰat!대^'OzJ:(ryyyѣN8a/(##Cu rssճgOk)_6mjP]ݸq:%M4I;v&Mhp/+c#Gj޽p`…jӦuDƍ,e/>|XSaau ?v%r?RJeff\r)+((аa4~xw߭۷'N(R_~ dzj׿Nܦի+++Kwqu tn]~:7o*22R'ONBCCZjYP11.^]|:>LC ΀=c/S~~u ||R޽STJJ3^jՔP/u{Nս{w=z:ӧO{͵NWlƂ!ܖ`nݺ)RaabccZb͚57oupz3(1HXX֬Y@>|8`Zhӧ[g~ m۶pgՇ~hݻw࢟iҤ-[f@Ÿ#2w\u:?^7oرc?:̜9S:uң>[G\.5|q7oTLL\b{9u:P5tP 8zj-X:(VVҥK3@ll3(8 ӧ?XgkΜ9@+((SO=GZKAAAJIIQ S(1٣mĉ)QFiժU N(~ d\.|Mw})/lRuؿ bm۶Yg9sل IDAT'a/ҴiS-[:|ׯ<u `… ҥ\b/UXQ6lPJS(1<-Z/NwUŊSDzz.]jN[Naaa)FP2eSय़~I]vUNNu `"//O:vu kתnݺ)*;pzRٲeSv)C+99YAAߜ:uJm۶Ν;SC۷ׇ~ի[ѣGk߾}YfJLL ڸqj֬i/)&&F?u `ogSnܸa/URE*Wu 8tժU:^WwY%† 4k, 8ɓ'[gP\xQ=֯_o~ݻw^z)-77WQQQpuJWTTuXh7onF?:(8[gƍkr\) ;pdѢEjӦu=z># Dyo[gɓ'{(7oT=|r}ݧ]ve˖)ׯ<uJKJJR3'>>^΀K.w֮]sZgH?:RqG^6llh" ),,T޽W_YK.Ko޽\8$B*E6JJc̘bsΕ5uVf0ӌ1)QCjQ,Rɡ|sz|wK~o׿qG ̙33;~ٳgեK:u:Zx ]{)+n[)))4hʬs%UTQAAc=Z|uFkժOnԨQ#XB!!!)Ç"\*++Sbb x(((HYYYj޼u >qG~zUBxѣڵ.]dkW||JKKSƍk Nܙ3gUXXh/ ѫ_D:~uJ0`zaRJU:uSBEFF)@vIEDD̙3)PjՔ5jXw”zYCŊw}gu)99:/l@;)˥d)(?w:tH={TYYuJ;wn6 H.K/wm~i}GOػwvSf͚iʕ<e75w\{p`ڲeuS^z%\:9R3(}=:tu hVXXu e[vRSS3^ժUʕ+[@yggOŋ[g>%??_'N΀:tPJJu>qG?iիu={^}U nGwNcgM6ڷou (::ZojԨap^xE5kuG}T&L΀ƌcdZg^WڵSjjuغun+""BǏN뮳N\:xõm6x?-[A)Lݻwב#GS^޽?:­ު+W*88::xu릒'nKu R *XP.8qB>z-xQ˖-uV5k:;vL *--N xwa~Zj5\c;wG+ٳҩSSࡪU@^{u >qG r}Oaa[~aÆ 5ju3fXgPn?^;wkf/jܸmۦpk/,,L٪^u eff귿u GpqGriѢEjٲu ݻ>c +JOO΀SO=e@URR}2jժN,&Momn-\:OZg'*;;:+ׯ /`ڵkɓ'[gw?ƎX 80e-_:Kԇ~h̙6mXgPnnh*++΁TREW֟'ʔ:%ũ_~W"##[guX %Zbu6lze@Ǹ#IRly=s*,,Tdd>l*;;[ 6N\5kbbbTXXh/ … b؉'"딀:mݦ \.xh߾}Siiu nz)p`ܹ{3(wf͚iʕ N+%$$p#G(&&F.]Nz^Z*UN\?3gXK\.5k,qD;vhرbŊV5SլYS^u ܹsǭSm6 ::u]Z`uƍᄈN 4H¬S6mVZe6m%KrYO VffnfxL ڳgu kEEE:_rrrTB%,5o:rݻ>3 ̟?@ӣG=3{wV6m~xQ.]vZv>ÿK딀6LSLQǎ3رcf lݺU#G΀9su@8q:ud&L =3ڴiu1c~a ʽJھ}u 衇֭[հaC:uJqqqtuJ6mí3$&&jذap //O@@JKKӢE3Էo_ 1cXgիWk„ @*..V\\[x($$D999馛S(~Go^k׮NhB[no~}1buF Պ+Tvm wq.\hvڥ={v[kРAffϞmZgP0VZiɒ%r\)?O%%%:hǎSΝuxvS*US(Ο?HeeeY5j>@S<6{leffZgnA} ~AݺuUPPʕ+[C'NPTTΟ?oBuE}u <l5l:rqG ԪU+sIEDD)$}'ׯu5CEEE޽&Ol/U֯_N:Yx?ix 1:ʭP\R7pu ;Ԃ 3(wcYgg}VoubĈz3/'x:$[K5|Xxd2duF QVVԩcJRRo.]4 EFF͛gz@ԩS3R%&&)PPPtmY3-[;̙3)˥d-ZH!!!9^SFFuFkذ/_`(Zn Xg;w_~~Ɛ!Ci& 80;3$>O?URRnu q EEE)Pյj*ըQ:akNu wQJS~р駟ZgvgsSNN*Vh;vL;wŋSbEGG뫯NMZ`qGխ[W38ڵK=z`Շ4kL Oex?V֭:^{Ok׶NY/^TllΜ9cƏ[g0A)Pqqbccu! .XCjR^^Tb n~,44Tjذu }Xgyi….CffOn4f :?5`^1K,ٳ3\2%$$hϞ=)RzzZhaO9w"""b xQNYC Ν;3C=d jyM6:l۶MC+=Zk׮΀'NTN3wPxxfΜiv'Ξ=(:u:Z tZSY}]u:']tI:}uJ{աC FiŊ N>YJKK/: RVV7onUø#gׯUP::zbbbt%W}RxI&Rppu >vkȑ:tnubŊZbo8={ؿ[5jd^SR%N:)Paa"##uaWɓ'3gXCժUS^^jԨaU#G”[CŊաCS\A֭SJJuh߾^~e |RZZT\\l/ ּy󔚚j :#ժUK+VPhhu \q.KSO?>:w^%%%1C5k+W" 0s{΀Ԗ-[3xImFnݺYg.]\gϞN=Z NF>:#8qu\qcƌQ||u>}/^l 5i$ 8СC%''[gu;~bذaիu={^}U ^vէO޽:{'j׮~xQ^J*YbuMǏN x#GTddu\1>^|E 8a3:7Np`رw@v4yd 8hܹs2 Snn:|G}֭[/NEDDhƍkS÷~$Y4˥t5i:~[oU+WTppu riѢEjٲu ݻ>c f̘t 80h =Ss'Tvvu ^zzնm[T:ru GM0Ap`ĉ| /Xgv)55:+qGG=󊍍΀SN˭38p>C 80gi:u%%$$hܹ)kFׯ;(w;DZ|Lddy 8n:?:TX: >\zbA:tPJJuxwZg0VXXH>|: Uvv6lh*--5fnxIŊX/7nĉ3>nSFF\.u 1 8ۭ޽{u ;w^ ӬY3\R)W_}.$9rD111***NzbŊ)ɓ'W^*))NkܹJMMe+&Lкu3>f͚*((PխSࡳg***JONP\xQSࡰ0~)\;>ZjS5Ss)""BǏNPl۶MC ΀.\hPTT.\`/=z/^IRYYz:P+33S7|u >|H *X1 eeey)V޽gY(ϟ Xg={jРA7xC<#~.))Iyyy\u $~P׮]U\\l(L;Zgcj͚5ʡ{O#F΀9su8q:ud&L 3<͛7[gW^yE?u>oڶmo:^OjƍSu $iǎz3Pbb f򔚚jKKKӢE3Էo_ ~ХK3:^Z&LP+66VNBBBs IDATn:w^OSEw6mڤFYHL| @9rwh…p`׮]ٳnu rnРA3ٳնm[ .@9תU+-]T.:ڷoTVVf;vLpu D΀~222_ 8uڷoxQzyfo:@'OT\\SBCC&MXCJLLԁS#G(&&F.]N֭իWRJ)8Ƹ#PN+33SM6Nʔ{ZA˖-̙33@ttFi_t钺u[U7xCqqq)裏4j( 4=p`z3۷࢏뮻` c(M{:,##|L=4d a(wﮡCZgQ~3kӦMp`Сuc(gN.O?T={NN8(?:Y TF ŋյkW]x:^4x`Uhhu `ΝSll,= @ԫWO999Xu }:Z tZWN<+77:^tkСu PoG5j\ZCVLLSR%&&/N͛[_1WrrrTBxѣܹ.^h ۷Oqqq*--N4i,[W  Z=okYg~J*)77WuԱN Ç[ 'OTDDΜ9cUVMyyyQu qGHXXU~}xX:tu n:Xg륗^?~u B ȑ#S@zgsN er\JOOwm~i}G޽{$m5kL+VE@ø#`dΜ93e lҤIζ΀FRnݬ3;n[)))4hʬs%.KSLQZZ8B)0f[g3fhX~~&Md{1%''[gp00l0:̙3GuvէO޽:{/͙3G*,,N LmڴѾ}SEso߮[n:bN˥ŋ뷿u fΜ9jӦu~ĉzGoZ֭_=u  oMfDFFjرp`ݺuJII_/kʕp`իu 1xYc}]=sBEFF)PhhՠAչsgk)UjJHHNӖ-[3mݦ \.xh߾}Siiu "ۭ^zu ;w^ @bf͚iŊ NǏ[c۶mСC3߯ ZgnRRR4x`YK\.RSS C;zUZZj+((H曭Sࡲ2%$$hϞ=)_x!7pVZ *X / RVV7onn[}u \y1czAYgf͚ZlY{iҤIL;Zgcj͚5p{=9:W^/xթS' 8/*'':~AipW^?l?ӧO[oz) ?^uD >:ZJ͜9S-΀O?k ;WX.]Zg?:bСC)PHHrrrtM7Y6nܨ6mᄈN=#ںu4h`\YYwÇ[@;pB 8k.Cn:A?΀gV۶m3qG jժ.]*e۷O={TYYu \1ǎSDD.^hծ][yyyRu ~ojӦo/jٲl٢[o:^ *))No+WNN8(?:BEFF2CBCC ZRV-F|̙3gԥK>}:O>D΀~222*ꫯ۷[4im۶u)mڴIƍ_]M4NJKKXwuU.]N֭իWRJ)?Ǹ#p+33SM6Nʔ{Z,]Tiiip ::Z#F j߾֮]k/]yu:T^:VZZz! 80b5۷oW3]wݥ Xg0m4=cpӛoi^7|p.T=+""B)*U@}N~vW^:xu $ 0:,[L3gδ_[g=zh?Ƹ#+u]C΀yyy2eu\JLLԁSࡠ -[Lru Dӟ4ydxQHH^}UX?vIũ:F֭` 8'_~p gȐ!ӧu?,X`2335c 8ѣG[gCW~3ҥK5k, (ʔ={XC.KjѢu ?V֭:^ԢE mݺU͚5N~h׮]6lux@SN΀۶mӐ!C35j֮]k&M'x:'w̙33Ν;ٳgӧO[CUVUAAj׮m@@/զMܹ:^Ըqcm۶M)͟?_K.ШQ#)44::rbbbTTTdFiiw/: RVV7on;WrrrTbExѣܹ.^hξ}RxI&Z|S(ǎӃ>uYjժ'N~hڳguk*URnnԩc*22RNrĉЙ3gSի+//O5j԰N8)??_׷NCY@n:?:o^/uܹs˭SEUTիSOY?sy… )P.\.Nԇ~h޽{$m5kLK,QPn̙: 8P[lroĉζ΀FRnݬ38EEEJLLԩSSEZ`RRRS0f[g3f(==:ʽ|1JNN0_?Y{΀sիj>vO>ڽ{u Xh ֨Q4tPYK\.5{lq \9YYY aGՋ/h6lؠѣG[g7nx 8 /(66:8} viʔ)p>Пg )ΝSDD?nUTISHiiibxJ*Y?2wZ?LXn]v @q|k_|0bh sL\2;TQQ?x)@.]Ċ+SRmv1rqS# V\lٲhmQFŮ@16lS]];vEecF3VZu]֘1c?8;taK/{gv P&yI&qwB իWL6-;d͜93&;:7wCP/4i$?SOI&eg믿> 3Έ_WhM4)>xwSh@p@7.debq-dg0`@t!;z~Ç(yGK.$;z8#⪫w6t钝A=\wu6![o֭[gg@5k֬hݺuwv _z?>Zj~ūYUTTE]A==:. qUWŰaò3s9'z왝@0?y 0 ;z3fL\pe:***b)Q-b{d@GE֭駟NNOGS2vСC,Y$;`&MdPG3f̈N(B!z'ONnя~@ 0o}+h֬Yv u4{ڵklذ!;̟??:v֭Nvmxe˖)hZ*ڶmÆ Nm1lذӧOv P>8餓P(d4v)Fo}v urʨe˖e5kDeee|)і[n>`|_Nwn⡇w1;:ZjUk./^PƏ}S IDATΠ:G5m4w}S֭[L>=;l͙3GmK^{#G-";"fFiӦqwP(D^bڴi)eӣG8Ӳ3Q+ ѯ_߿ ӟy)@߿7.;A\s5qGggP_|q<eoرqfgPrH 4(;"fF/__Çh4N?x3n!ڴigϞ~'<@lV)@ ۰aCt5/^Iu=뗝A=wx?Av P.x駳3-Z<{wv uTSSݻwYfe4Z ,;ڵkSv-V[m@1@ӬY{b}Njkk[n1}FoȐ!q7fgP:ts9';z+:蠘:ujv _j. :;Q)SD>}3;gqF<_mڴw I:uj# Bv gҥQYYWNvixGbvN"bڵѵk׸۲Sh@͚5?qWg%+'|2;s1bĈhٲev uph׮]|)G.]bܹ)Qo~)$3@|%[oB (~|'hcB(555&B;;͛g%6wsNL[neFj* aqYgegPO{n<ÕW^{lv ;P~ӟmݖA=L<'@ZreTVV˳Sm6FKv 7^~8Ccܹ)4?^~8SR(W^{e׿9rdl)т SNnݺꩦ&?5kVv uԴiӸ{nv qG#F-[fPG .ǚ5kSf̘=zh/5kӧAzv hwg}6=,[,*++=ORmVƗ꨺:***b)|AK.mƊ+S>F;Cv qGΖ[n?p|_N֯_;wyeF]vYvpG~ ̟??;7n\v hwz*:wSƹ瞛4RM4;#-;z8bĉlz+N:( )ѷM: X# @ٹ[?qvp /&rWó3;/v횝O>$8xSh@-[{7N=r-1t :󣪪*;zwqGvC=W^yevЮ]K3L;PV>8䓳3[o5&T(W^1mڴI&?)=;;k׮.]߹5k,n喸ꫣI&9@o~oVvЈuQqWdgPcǎ??;MK.G}4;z⋣sl(~x\s5Z*ڵk/Njx_rv wjjjO>ѿwygh";(VΝ;ǧ~4_ 6,5kB͞=;bÆ )lbѭ[>}zv u>?;f=h޼yv u4w8bݺu)4ٳgG׮],!_-";~zfЀNX|yv ?qL0!w7ЀcǎÕW^Ç`3=zt\zGW]uUv ĸ#%mt%;z08-=zkn:; ?|r!1o޼>/bx)@Ν;NJ+S2RQQ]tQv` q+cذa9={wd?dgPcƌgg::t-NZhÇ=#; ofj*~n/buQ)@{ww@ww}w4i$;:1cFTUUEMMMv YP={ɓS[o5~egw$}ߌaÆEfͲS{/t6lN ܹsSN~hvaÆEͳS0gΜhݺu+)4m6Fw\v P v[vPn5jTl)ˣ"-[@5kDNbɒ%)і[n#Fv);Mȸ#%y1taSUVEEEE,]4;d/B7;z8C/ŋ=Xv e˖1lذN\߾}^Jح_vuT[[|)$=+;Mȸ#%+>8;:* ѫW6mZv E[oggP]tQg?իWG-s-ZÇGvS"vСn_ȉ'|vpǣ>@;vlwyCeee;;Mĸ#%#s=7;z~ÇȜy1~ iӦqw;555ѻw0`@v E1bĈh۶mv P̙'tR ?!;zx⪫ 4( A= 4( 6-"nhmR1jԨK3(Bk׮ʘ7o^v u{_|B]vY7jkksh [lE ><~dEGn!;(!7|sl믿nԻw8qbvu[I&)l$(o}[ь3G$L .ǚ5kS>}Ŀ˿dgup7FN:;[?{Wv P?x饗3о}/~A-]4*++c)ꨨgPGZ;fg;Pvm߿vurʨ˗gP&O}Π5kI&)@929v-FlMv P֯_]vŋgEl-k6;:ݻǬYS(r ,N:ڵkS믿^g.~ ꠶6b)!CM7ݔAj****3:zgM6Ge@< piܹѣGNԙg_vutƓO>@?~|uY^{}`#wqggPG^xa<~3;b `ȑlذ!:u^v u(Zn?Oc„ )4믿>oegE/cfgE{կ~5;:2eJ';ug?A|K_=zdgwm_*;:xףGQ(S(QK.Xzuv upf'dɒ8#'Nl6q]wEfͲS"USSݻw dEI&?Π.\۷O?4;~ҥK̝;7;:ׯ_4m/@)rwv';쐝XdITTT`M:5ze,{챱{ggzh߾}s=)4>8> -\0u555)@~p@vcݺuѱcGc\l F:;ϱ~ ]f'9jjj{gP& s4m4:w|ׯN8! BK+_JvPĞ{ x_<7n\vebҤI_:;:N 0@k⠃s/F@ st%; B\veѷoߨan /W\4r-Z >]w~{vefС1hР >GEEElVԓqGV.]I&C o1;2T[[ݺuӧgO??;7xc#֭[&t)~헝smΜ9)@#<2ve )@:s'~EvdնmI&E޽3(c+V;Ɗ+S'~_f'{mƪUSDZh_|qvP.]UUU~ m޼yѡCXvmv e&?xS'ڵk@=w(l2~gg.\Q]]@{뭷N>CVM੧;,-Z&ҥKW_~9. A֭ ѡC裏S(sK,c=Ǹ#Ec-X~}t9͛@#1jԨ0`@vUVѴǎP^{8wNab-W^@ k6w9 >i'Nx뭷⤓NB?@=8e@QjݺuvO^x!;F/Çgg|';D{hݺuL<9;MSN͛ggEP('~v hO4hPq42=P\uU|C9$;z4oo v[ <8;FP(D^7Npe…ѦM;vlv i6mdg%O>.]ڵkS=4v83h.xDz3\}';3~8묳3hVZm۶ŋgw\@YbEs1q}e=DL4)= `3pO̙3'bÆ )4Rѵkט>}zv fv`{g'7ϟ:uue͞=;vpcqiݺuѽ{kSm۶NJ7sOv 5kDee[nʕqqŲe˲SJqG.;cvU]]11c\g}R(΋}Fmmmv_7SN9%~ [< B3&O1s̨j}N;PtcƯ4iRv?{3=3;h`7xc|ɱ~C=4;(!VΝ;ǧ~4-Zė ꪫaÆegcq饗fgW;clԑqG6l_]1t zeH8HwsL\2;ziӦE߾}3~n=zt\r%]yk8aE0v߿v|СC,Z(;jd̘1qB=w;;ʕ+2-[ի]vxF5@0@i֬YvBj*/PR^z8쳳3Mc i֬Yq衇ԩSS]w5f (YWlѹsXjUv oy Zmmmtp.%cѵkW N֢E H IDATȸ#Egʕ VP^zŴiӲS^n;쐝fjsqJp?7C=W^yev|! ;#;2P:;Pt-[(=: _s1o޼FA6AP֭[]v믿>;bN`3N(;W:+&OAD^:;f͚XvmvF[oI'B!;N;-&N XrevuddɒFߎ.]DMMMv l>(:vhd3s2ywRPs9'."CEj-N`3oѥKX|yvJ^ =kҥ_҇(yѱcXpavJl2e˖h\2:t eW^޽{gg4*3fN< ?~կ~6lNZ*;ozF^3=Mlwy';Ѩݻ{wܹs2֮]hxPZ;PyFnb)I 2$nFõkѢE … F;JsEfʕ l&>i_>/^PT|Aޒ͟??;sƓO>o߾o@=w(9Ly\tE裏fg@ׯ_<$l> ;Q;wnvЈ<#ѦMhEdժU l&N(+͋ s93%Kfyy :4  no=;QpPZ;Px섲7bĈꫳ3lذ!:wgN){X-5kdg=CO6q8CB˖-`3ʜ9s룪ʘw;mxj;;ԙg/RvFsPZ;P섲GϞ=P(d@ZxqTTTիSּyΠ ;wnvF3Ldx뭷⠃Sf4js5܈t޴ |sƉ'yf{63… auE;6)SD>}3ʒǸ#Ek esΉ'|2;R\xOdg3fLvehNz뭷b@#vڵk~)δiӲ،֭[ofvFxWކ sGe4 /rveMo:thp e穧N;PF֭(C -ZMMMTUUӳSƋ/sΠLL81;EMMMr)ѿFgN`3$;l}m&O ,(iB!{ S[[ݻw?0;#}z?>;'&NE_={FPN)Y{%ʸ#E:n쌒ugK/E/ÇggBΠ =#aÆ쌲裏:<qEVbܹ)egɒ%裏fgP(ѣ3Jĉ |A-N:SK/x4zAş (J#F*;d.[o5-[Qr?3h իW)%GwA,]4ƍQvy5}8蠃_N)+sO]6;$?xvBIs q&L\rIvFYy衇(S$;\} #Gf'UV3<P'Ϗ6mS]wݕ@zʸT[[v@8p`$|l#| 8Ӣ6; ٳgL<9;Jʌ3*jjjSZPO?=֯_F0@ɸ(jzjL4);Ju]wuWvFQ+b47|s 쌒7nܸ2eJvRSSrJ?;$=/fgP}xw3J{O>dv@Y8qb%k1q W_}5쌢vUWŰaò3$=:.쌢6tx3H"ɓ''͛7N): fg@I{ꩧ裏w=;qI'EMMMv rH>)%Ϗ7x#;`KGM4) 555ѭ[X`Av Ee˖qQGeg . ^} +wRrnvĄ Om6;뗝Afmb֬Y|%;͜938Yvm&Mo)%g?<;FM6OGfͲS̙3'?I,[,;J޶nǏ)EP(q_StM1r쌢1{쨪r6> *++cݺu)EO7f7eʔ:thvF: ;e>:/_R̙^zivEfqWgg ЀV\;w5kd۷ovг>(+WJÎZ*ڵk/N)_aG2$" P_;SL<9odZbEj*MeSN?83hsϘ1cFl)%瞋6mdg4OW씢S(裏ѣGgPZlN^)Ek„ q!DJֻw۳3O>4R͛7gy&Zn&***GNsG?-ZNI+D֭c)l""xǣK.6dXn]TTTĄ S,ѢE8CSL0!:u iVX~xrIذaCt),X .\#G>:vu윢r5I3ŋN)Jk׮mƢES^{-o~R>СCTWWgHƨQm۶/});'EP>}ߟeދw}7;hҤIvNYfQGPf;/XjUvfW[[|<)P.ӦMc=6>:{쌒qɓ3ٳ?iK)EcرqfgP ?xvFQK7hTN9x뭷3Rmmmp lٲ%KQG|AvJ />3<3;#sL|G)lbM"{,ZlٜuYqM7eg@ЬY6lXt!;eyUV`=3N;svJQ{; Ϊ>%(*Pa-EAFHŭVqh[kcRGGZ"ԅ*PpZZDA (Hd1 I4 !྇vJ***K/UVР^:N9yʊS?]tQ|iԊ~saGHŋO>H;Vlݺ5.Xvm)"5v͋/8?sjT~~~ :4.\v 4X+ħ~]tQ4j(5w1bDիڵ~rD뮻'L; U_c߾}}ԽOOJKKcoB=hѢ8sSNif/@FΝNIM~~~wy}S*..ٳgSOM;Fŷ:uj)Р[v1bD4m4oDž^iPˎG`mڴ):묘3gN)5fժU1`7G+"ݛvJ8p@{qGiii9v'Gy$ #?7xcTVVR cذatҴS***/͛7~8OvOEEE5*rrrNIEqqq\r% u ?xǢ:m۶ ci1gΜ8b֭iԘ'x"=K;:`'|Fk&JJJ8'O|v?̙3'w ,H;%8ETUU2ƌsM;%unK;2}7eeeiԊ۷ǠA{) bСvJzN;e߾}^)u .x뭷NC;cȑQPPvN"gώ38#VXv ?\2zSLI;%;vl|Ѐw5cƌׯ_,[,öq4hPL81ߟvň#[owҕ_:zz9p+'x"L2%nᆨL;?) [nM;F[.9Xvm)E>4hPyɓ ;vS\pA\2Bϟz^x!Ö]vY\y'%%%1a„1bD奝s̙={{.qGj֭u]i|¸3Έ7|3sTWWԩSG1s̨J;sѿۢ0*++oiԩ{b„ g@Zy8묳biԈ> ip͍??SjMUUUs=1qĴSw .^>p{1`xwNDvcƌz1xǣ{1wܴsC0ѣG,2eJY&#&Lcǎc=6Ֆ͛7c=ӧOT[82wjܸqq7e]M4WZӦM3gFIII ԭO>9w~z[\\>l<3|:;ָq~&qo IDATMJFRTT<@h<QZZv u-~ׯ_)_؛oǏ7.GӧOvnذ!?~ػwo ԭ֭[رc㦛n3<ݿ̛7/MK,jw8餓bذa1|ʪ!XdI,Z(.\۶mGӧOdggGvvv߿Ƈsss_ (++C}ҡCW_}uddd8p O޽;JNqEƍ92fΜ'N?8VZ}?Yfi^{-nXn])Ԡƍ->`i&CgϞ8qbG4m4E~_~ѣGԩStI~X~}lذ!V^oF8p"333={=zDnݢk׮tɁbǎe˖ɉ+Wʕ+c˖-uPK߾}hԨQ92Ν?O܄P˺v<@=:׉nj3bM׮]㗿e >~Oǃ>f2pk׮]L4)ƏDox衇b޽i%###tr^zEΝC~86lpX|yA=Pl29ݻwtg2_]]v튭[ƻ+WaÆz;pZl;w-[Ffffh"""8 R.꣌8M6ѶmhӦMi&JJJ,JJJ on4Sc„ 1nܸCQMÞ={b&rsshPN?w^{mo߾͍YfԩScuv.|Q_W{^tMѼysZfM<1k֬L;:r)c„ Ѷm۴sϏSƔ)S:aj֬Yvi[͛Gf͢(0 T:m۶oi&***FyyyکchڵkW]uU\ve1hРhܸq=EEEp˜7o^KQVVj@C״i>|x=: ڵ3v/r̜93/_UUU5~ԶSO=53fL3xc,]4 [1c_FyCyyy,^8{3gNyG48'xb92 g}vo߾Ϭw}7VXϏKk\_FFFy晑g}vt=:utXiӦXvmkꫯƍk^+!CD>}yvƍcK/ŋ~:?p8cjxc1gΜطo_@aNYg]t;Fǎ#33gŶm⣏>ɉ~;֬YP@]hѢEt5:t[̌O$>ؽ{wl޼9v5yѷo0`@;:w_O>딖Ɩ-[b˖-qXbE둗WK4i}^zE.]{8TTTĶmb˖-yx뭷bŊiӦZ2qzj4k,ڶm͛7VZzaaaTVVƾ}bcǎػwoGVZEΝe˖ѶmhѢEl24io߾(--عsgڵ+db-ZDZsL{ѶmhҤITVVFaaag}GEeeeH;>3qG;$`  wH#@0qG;$`  wH#@0qG;$`  wH#@0qG;$`  wH#@0qG;$`  wH#@0qG;$`  wH#@0qG;$`  wH#@0qG;$`  wH#@0qG;$`  wH#@0qG;$`  wH#@0qG;$`  wH#@0qG;$`  wH#@0qG;$`  wH#@0qG;$`  wH#@0qG;$`  wH#@0qG;$`  wH#@0qG;$`  w;w,0z{ $# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0i IDAT# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0 vX` H0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA@ܱ A-A0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rG IDATA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGعcAփ[  rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`;s@`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`;Lp IDAT rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# ws@`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# wj[bo w`; rGA0# wJIDAT`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0# w`; rGA0Q=IENDB`xknx-3.6.0/docs/assets/img/xknx_logo_inverted_bg2.png000066400000000000000000005412571475530762600227230ustar00rootroot00000000000000PNG  IHDR pHYs.#.#x?vtEXtSoftwarewww.inkscape.org< IDATx !{$Vlyfa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fa6 fpعA $ (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (. IDATLF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 @܁ {|Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&ds2H0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql O IDAT&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LFعA $`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Q@܁ {|l&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ͽ IDATQl&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! v@`= (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 {w\uy{$mX8*iGŪt@ԥhU\F)Z-*êqÅ)(XѲR!0?^繮̜@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@Fxm IDAT06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2y,ԩSt)ڶmѺuhݺuGeeeر#***66lc(\.:u;w6mD˖-0ڶmuuue˖زeK|3555Ƞ֭[GAAA_z}m۶ŧ~|ATWW`blt-};,wݻw]>k۶mf͚wߍ_~9^~x뭷Y]t=zDǎc6mDV l[nذaC{Sg^~EiiiyQRRr.XjUoƟXzu#5-Z^zEwQRR%%%Q\\Kڵkc;K/˗/F*`_{8N~ENxW⩧ŋF`_֩S8cq1D=uֻtF]]]Y&|Xti,]4^u/bРA1bĈ8gϞzڵkcɒ%hѢXdITUU5}]vguVr)q1DVNj/-ŋ֭[.-FvRVbذa1bĈ8 iOyyy,Z(ϟoFѣG3&<8厊xcܹOGMMM{}ƨQbĈqA%i ƬYbIHo#GƨQbѬY&oG}4,X=׈|-FЁ;wGͣM6}ذaC[.{ҥKEv(5kuuuQQQձq2u.U:~^xa^xᅸ;6Hc̘1qyE߾}7M|Mz7SOI&Eiii/y7n{=(.. &Ą }s3gΌ9h۶mt-ڷoyyyѮ]رcoܸ1֭[;vH\ mZh:u(**hѢEDDl޼9jjjbƍƦMQF{ӧO|߈ovoo[o˖-˗Aű~x~ѭ[h֬NcձjժX|y,_3f0_l2?ˣk׮I[v͋n)˓@cr1jԨ2eJ3uWZjU\}ձhѢoYԦMkbܸq;] |Ir-1}t_QG }a%%%;=\SS~ӟ_h۶mwqQZZ~x3JJJo6mWx饗_+WFmmm#clt1C>}D˖-XbE,Y$x|`֬Y8cذa1hР8#>,Xpakl˖-z(~ؼys(вe=zt92;/Ǽyb駟6ٽ@cƌ#G6֭[Gٳgǒ%KG;N8ᄸ;K .]\pAN#yv=ܸqc\q1k,kj*nƘ0aBh"unSƴiӢ6u_Cǎcƌq駧N->.شiShyyyqgѣ㤓N&<Νs΍VcqgGϞ=xꩧb޼yCEMMM (nСCwyqGǎTWWSN5ҿ4iRzꩩSbʕq]wٳc۶ms4o<~__\.uNqNHYf1eʔ2e^5zlٲ=zt|ᇩS`3x8ꨣR4{~Ń>sz>|xkSƙg'ON@uuu 7/r|[ߊ/}N|G1u(++9Рt ,NiTbرaÆ)MC1w8餓R 6ď~xSN93b֬YQTT:Amذ!F?|vرcc鑟:l۶-&LeeeSA%\'Nۧx'믏+VvS^믏SN9eM6mzkTUU f͚ѣ[n<0uWڱcG̘1#&OsԥK6mZ3&uk_˖-K 4/^ũSĻ{/u @߿w}ѹs)_K}}}tMqFmmmr\zq%Ni4$fϞ:"]tQFsw%\ awi%%%SR}}}̝;7&N~i`'k.Oyyysڵkcflw{o}ѩSvɚ5k/ŋNB~~~\yqWF˖-S?~\tEq9۾ŋcKҤ6lvZKS駟w_Ni0/#GFUUU9sf{S]}}}L81n)r\q1a„),.K[n1s8S쒍7ɓ{1 {\.~tM{>sq￟:QC..(n(((H̙ƍ?;^x)ˆ͋)ITVVưa駟N(Ǝw}wNip?| 2$lْ:"" ϟgqF&uer㢋.Jd̙sN֦N]2t(++ۧNm))**>|xݶe˖?~|̛7/u {!cAw]zꩩS[ogyfM!CĬY]vS;cĉcǎ9S:묘7o^&vŶmOe˖NhPW_}uL:5r\Fꫯ>K>yhѢ8SR4;vlN_L6-Mn8="ž0ň[.FK`~78CR49s㣲2u {cf<ѵk) 2>xGR>--]vYOƈ#<odɒ(((HGo:kr_*&N:I\27oN>쮻ǧH. >h"bpLk&N:=X+uJK/4O:yƍӧGS4+W'qFE߾}G)6ƍeeeS`Լy{sMh<Ğ#_|1ڷo:enݺ(--5k֤Nm͛73g9眓:I-]4N>ضm[ASLo1uFr[n_:`vGˣUVS>;,X:C=4xѣGFs7UW]&M/Sg4?8N>XreFcΜ9QXX:QǤI[nIVZ<?R4{/n8 VXݻwOGzW_~Q]]:`n:z8餓R$hѢ>|x֦Na2jԨ;wnr){wy'z[lIOСCѵk)UUUEW_M_ҧOxGCS̙3cޣ@j֬YL>=Ə:mܸ1~SˋSG6tXpaNit\.N8ᄨe˖}BAAA<q 'Ni۷38#z表H_X`A{챩3X:uxgRvO< JLϞ=#???})#JJJ){8CLO*++{-Zc֬YQSS:""W^FvR4^z!-J\.eee1v)Me˖qgƳ>֭K(;'Fՠa߬?,&uT (X@+ U5*n|@PC 3`LҘWIĘͥ.0_qFsι5\z>#W^)P***bҥ1bĈ}qʕ+w΁8s /h;/bv @#y8S 4(~O~J\UUUk;;w}w]<3)eeԨQ1sfsQSS6lNݻw<#ѩSjpuԩS3v-ZĘ1c_=;fYwcǎ))n'pB]6;J֜9sbڴiiQG7oN~hݺuvJQ_[)U>}[V3;x뭷⠃2vu5UW]l1hР~P:t>km۶8ꨣN#xc=SҜ1w (YguV?svF_1hР/@3Te]vzlF#"*++{g@I6mZYFD|q=DPNL ׿nhvuWWvaqFRcǎq}EE}~qgg4kv IDATZ+VDSu]TTT… *;2բEXvmYFD̞=;F%iĈqwfgu]fj*;f/T(bѢE뮻fkӦM]6t钝%eذaq-dg4 Gs=7;2u'QtL{Wv6lX|[ߊN:e4KzhL4);5k,e ^{ŋ1qf61;2u7ǀ3UTTŋo߾)PRjkk{tx`̚5+;f;ڥ^7pCvF}/<S+~ܹsvJ?Av eEsVZƍ_N=Ni?ƾJv %_r<Eeʔ)qwdgEń 3W_}5kؼysv edĈq_3/bg@k׮]<ѧOfSNe˖egЌTdh 뮻.;8p`̙3';^۶mcFB˖-cŊQSSB2eN:(;wĒ%K 6@c曳3άYC(IOqEK.1mڴ HcņFB^bҥQYYE".]jhc,X z왝@3bl(+1o޼Ni&O'NU(bѢEѷofiK/4;2Ѳe˸袋3ZPSfgDP:fϞmN<3(G}7;h"VZ;sv @ə6m}pG֭3(f͊N:eg4KG}ttMPԮ9rdvFԱcGPĉCh͛C tWĸq3ӧ>쓝AڵkvF;餓[n@ ƌ3SNP+2;1eʔ섢=c, n:N>쌢N;oqS\x1~ (JG.,;Y7n\s14FѩS3Xre3;Í`5@˖-oΠ wy %E1q LD]]]uY)Ek̘1ggP<8ó3ڈ# /('|rt1;g{Yo Ā3/,YZnhٲev̀Ql\veѹs쌢еkXn]d@Q+VDeeevJQ>|xqAAQ2:,/n;\ǎcÆ 1jԨ씢V(sΠ&k4hPv@Ip(gs7d:3_~EUVvѣGv N:E]]]i&;(|%;f(P:ug}vvFQ߿,\0;vE]]]t!;\ve O?=;39 t-{8óSJ¸quVZرc3JBuuu,_}dÇAA QFeg;.;({glܸ18씒Ѿ}=ztvEj6={Ʋeˢ2;h N8!jkk3cܹЬ͚5+9쌢{Ɖ'@2c@kݺuwyEkqGfg@tȑ#3%\@ :â{% #|iӦkSJi@2T^ziv@1bDvBQիW~ӧg'I&Yg駟ӦM(ZA`l(yGw9;hUUUիc=Nfe̘1^F;wΠ>/{aÆţ>]tN)IGqDj*;"SYYim;kcذaEM6qGdg;.;ӿ߿vFQ?~ 2$;<0eg~ŀ3Hdl(yzjvBԩSE6mSYׯ_,^8 BvJQqegPbP q<ڵN)Y555qaegPd p",YݺuN(* 2 ^C)C?.\QDSkJo>|8SRP ^{B!;l.zꨮNhcMMh=Nt!Q*++cҥѻw!wV-[f?vy J֐!CJZmmm,^8**ju-֭[555)%{{ggP an{vPv=XuQ)ek׮  U;ޡ_}v@ne'4AEeeevFԩSY&ڴiUVbݺuѣG씒UQQ 7f'QFŌ33`+VDϞ=SJk7>;F3JQիW|;߉gve'P$ h6-4t?/^B!;ǀ3Jk7dl(Y e+qegv1o޼2dHvFYh}7xg}Sc4B!o{쑝mZ۷wh ;Ƙ1cb]1a„쌲 <JB!-Z}N&5iҤ8qbvFpգG섲ХK 2$xu]Sc4TݳN;˗/-Zd4+]vN(9y9@jǙ9sf92;СCo(ʓQ$N;ggm7ԩSv 4mݖQVg\6mBMMMvP$=رcv 棏>NHu! [N(9|^{N(d({GX"Sʆdl(I;wN(;{#pJB=bŊQ]]RV y183@q3?!;"3?ԩScر͆k˦3cǎ~СCv |.m۶uy>u1*++3% 69 7pCv|fZkFݳSN(r2v gL:5n4SFi(ÿ˿Dmmmv@`lhӦ} jkkcŊ(ZB!-Z}N);B!ڵkfl(I;vN([]tQ?>;> Ā3ʒx>VZe'C$'9sܹsP(d 6oޜ@|x̘1#;>/<ƍQ P~%m۶ emѿ h .`v֭[GeeevEe˖ e8ձx6mZv ◿evEE I~bΜ9|65=<q/6@qѣG<1p?qv9m6V\[N/ xw>;6lXx𱮽9rdv<@16 _W  Đ!C3+bر7( |;g۶mz(;hrJ|+_G}jvr3=zt\~믿^v;Q$[ofg'ձrٳgv DDcƌ 6lX<#ѹs駟@;ʜ{˂ b%KDPNO\'c@z3]vuEMMMv e6VX)^z);+N=x]v)|<@vЄZj+WgeUVvѣGv eSNQWWmڴNϸv(OF<;пX`AveCnݺСCv 4_w^,^8S cŊ@{32nӣGX|+VD޽S FʓQd= ||+1eʔ PEEE{Q[[xꩧP(ꫯo=**ԬX=CggI'g}vvP6mڔ@c@zgwcTUUիc=NL3&O'ظqcggRSSuuuqYge9oovԲeXret!;(3oV J޲e˲q>쓝@838[nVvGƣ>]tN[1y nsO L,_<;5kVuQAmݖA8J޽zvcǎ~СCv E}-;_WP?x})4ɓ'Ƃ27bĈ⋳32 >EUUUZ*z@޽{\2Zh§xgG QNJDmmm,^8**ܾڵk[.jjjSh[o5;ɓc͚5ѪUP]]],[,;hfΜʄ{šSNQWWmڴNHj*֮]=zNf͚$6Px3hQFŌ33(Bձrٳgv |'| (KW_}u̟?a%楗^:+;h&bʕѭ[ X"^~ _~x( )!A7q0FꫯwߝA]y1f̘ ̼ybȐ!4M7ݔe2,Xy8Sf[nq}Eeeev P>裘={vv 4f̘K3(2ӦM &dg@zk|$36 +2x P(=}NHL4)&NA}ߎ|0;J-I&eĶmzj?N:*. ,X } 믏#GfgP$rKv <͛@3`l(oF\wu4P۶mcѹsܹs3h-[ĴiӲ3m68餓S.3fꫯ>:;(q[l??;KF>}Shz˗/hԩdg ?qv ԫWXlzW-Zd@~{쓝vp]w矟#8"bbyx IDAT :+;F8c]'OΠ&O?ϳ3hFe뮋G}4;FX`A 0 ;f /4D]dشiSvO<={Na;7o^}Q__/<9 ĭ^:-ZA#\q1nܸ Ç5\A#,Y$ c@Yڶm[yod@ZիWG.]SH6lذ馛3hG}Կ {l|[ߊ;fk;(UTT}_S7u P(ĢEbN YmmmX"*++ShgOO4CF+ĸqb˖-)4uuuѢE+-[Ŧ"/Ƕm۲SqQWW555)4-[gW_}uv P:u˗/wخy=zt[)4P۶mΝ;g]vQWW:tN~3fLl޼9;f(P}Ѹ⋳3hAŜ9s3HжmX~\{cǎ^{-;JԩS;w\u])@ 9CJ?'tRlݺ5;իW,_2TQQK.>}d@1a„xgSheoܹhѢ a1q vB-}f@?;JZPo9ΝB!;&oG>`v P.=ztvP6lW^yev0tи39rdvpƪU3hƌDĹO=Tv0o޼N:)~_e@UUUzs=SƌӧOΠ֭[_}vC#<Kv M>O>9ׯ_̙3';({nQ6mbʕѺumڴ)> p ;#8";F8%:@\r%Ceg3gΌ#Fdg@gώÇgg^zi<Pr*++c…1cƌ{,?xWS>վwuWvP瞸;3hO?=N@s9ƽ̒%Kb޼y9c u8^N***bҥѧO>RϚ5kb֬YPrZl˗/:+;`͚5/9?d4)&LԩSΠ$PsΠ|I hFcԨQ?1;j߾}ES fg?я3Έ();v 6 'vpǸqNh>(Ǝ/Rv TYYK.޽{g wV-[f@O> ;`l~ęgi\ŋ-qk׮nݺNx3fL)PRu=X 2$;&V__ӧOSƶm۲s>VZʕ+})@st)M6)VZڵkG)4G}ƍW^y%;jFZvm̜93;F5jT̘1#;?S]]+W={f@[lN8!^z(){glܸ18؇~rJ|){wuWvP~ĉ3h~ŋP(dgϟrHvOOq JQ`ƌqggW^ye7.;?7o^ 2$;F6mZ{v:(6m{Wv Mlq˳S̸qbҤI@b4˜1cK/O.0aBv0|䌍|۶mSO=5{P(ĢEo߾)eoҤI1q a1 ()_җG.]d~Ɛ!CbÆ )MnΜ9qAege`Ceg_}92; :4n n\p c͛c̘1[oe@m۶GΝScܹ4“O>i=Po>;&q衇Ə~e˖f͚ԩSv Pnzj )4PEEE,]4铝Pz˗/_1cć~@ 26 9yqx.2wqG|_ТEXlY)4k΋m۶epձlٲܹsv PXfMvpW\sMve]@06 D{;vlk)4PVb͚5ѥK5lذ3h'x". ( m۶I'B۲eKL81T[s=Q(S?>s)4PPEE߾}SJVmmm,_<*++Sh^z)N9غukv e(@/'|rlٲ%;mݢ.ZhPrz˖-_1cć~ESNóShbN5*g4 _K.͛c̘1[oe@sKΝSJNv.:v옝B1jԨx׳S(F#<_|qv0hР3gNv@Ihybرke@իW|{ߋC=4;&>8x衇S믿>>N:غukv ԫWX|PEEE,]4铝BDŽ gNΝ-Π&O'N( B!-Z}N~v }76n)4_|1c9&f̘A#<կ~5;ޱ>h)4 6СCW_Nh:(1>&L:<'~wZg PWWT?~:>TuubccS (**:>:y ӧO[cpرCcǎ΀={TAAuhy<u:>:wRRRt ڤIvZ[-Z,544X@@ ѯk@8tRSS-ƪB)0BCCU\\nݺYG3fXc֮]UVYg1chĉBBBTRR*++K{N `\.=˓΁555iܸq>}u kF%%% NA`:uuHJJR^^u%KhРAp`ƌzW3AQh!'N֭[3ҥKտ hϟ[g9s樲:5k0d]pAÆ ~iΜ9 H,[Lp`ܸqɱVoњ SeenFhnrss3@MM͛g@j߾jjjm?/u]wW^N6WJJu&L;cշo_ hz衂 8+33S^:F%}g:t.\`(**:ZݻH.:>fW_M6{NURRz-hS\.^z%p ) \xQw9b\]vNVsΪQS'Nv)HblZ{ァ 8p73Ƙɓ'5d>}:-[O>)mݦ}Y@2EDDX pQ:>ۘ^vSWYY:Q裏S;F@qq{9 8vkBhhJJJԭ[7YGցSH7tvءݻ[x vm:ru i?5| $vءz:Sj,_\΀<|M FȴiӴqF 80|{`nҥ8pu>}^{5 R^m6])_׺u > f/_p`̘14iu;v,fݺuϷal455i:pu |%$$X.z*-Y:7xCSg/233`Ar_ԍ7hĤIuV 8tR 4:$%%)//:ܹS?ϭ3J/R))):su |tWcǎ)z쩂 8{n=z)QFiƍNy^M:U&MRssuJeee+S@hhhPjjN4zg3@JJfϞm-"<<\ڵu |ب4c?h1? IDATMMMu($$D%%%JHHNSii¬SÇvҥK)Qhe߯Ç:>@JEDDXGǎSZZS^{|M:~JNN։'S= H>^ Nxa>f>|Xn[.]N deeB)cǎ_~G H={Vn[NN:t 6SN)hy<]uU)ѹs甒/:QoNj:|¥SWWT?~:_|a6tu "##U^^+:kjjjNTZZY%$$XG^Wڳgu c TXXhƏ \. hy^=ڵku s= Zy[N\zj D6mڤ3gZgd|~Z΀O=***32a;p ??_~uCO>2223>[gEDDDeff:`lѣGzkUMMڵkgRZGȑ#d@8pu lѢEVccu \qRLLu uuuJMMǭSHUWW+66:.&&FGQQQ)ɓ'5d>}:alЎ;4vX 8гgOXg$)::ZG;vNΝ;8q:Vn{z)𣦦&=C>}u 7ܠkrY q!:>UEE"""S@*..VnݬSf3F_S+F @]VV΀cƌĉ3(!!:>zҞ={Sh~V)ӯ~+!Ch l߾]SN΀III˳-YD ΀3fЫj16 lĉںuuXto ͟?_΀sQeeuڏcmݺU]vN}嗺뮻: ,ZH}Adٲe*,,΀ƍSNNu 6zh0UUUZxuc4|p})QXX*++u7ZBn[pFͳU2d|M}[߲NQTTu ҽ{wrYG~233l@5fUUU]v)]vwڷou TTT>-ŋ6l9b\]vND:wo:>:qnΟ?oe×v{ɱ΀7|3Z #ǁɓ2dN>m@5i$ NUrr>3@+uwkڴi =zT骯N:w 6@ 2]wu)QccGYpY16 mDqq{9 8vk UIIuf577k:pu ҢEm̺ut=̙3)Vn޼yJNNAdǎz衇3@=TPP` ,_\΀<|M .;F 6m6nhϟ{:@sip`z׬3h´fM6:~ /(33S )bu:_~Y/31c4i$ mرc0֭S~~u-QhC4zh8p:> QIISAcƌĉ3@UU,Yb@Ծ}{(;;:~Ԥ &hҤIz9ҹsg+44:I&i֭p`ҥ4hu6())I˖-΀;ws Z c|JIIљ3gS+RG;vNІSp`z+\}ڴiQ}}F+VXTOZg РT Uqquf ҥ***a;vL骯N06 m޽{XYWQQBB8uY555j׮u |t n]p:VK.ڲec?:yN[ܬYt]wYg ™~QTTu 6 22RՊNO>:ōeh3Xg͞=:@ Wyyvj566*--MNչ馛sNu:~駟;ۭSm@HH֭[|;) ޽[999p{***NVX[n:<ÜGcІ͚5Ku9s2223|:L:@2e3+zj L06 mXss~})RaaSq)'':iŊ:z7o:~evm㏭SmP߾}5w\ dM7ZgyiPrr-Zdz-M2:3@wYn:u:>С6lؠN:Y III˳΀;wd0j(mܸQ)#ǣ[ONa3fСC3@NBBBTRR$..N N>,ۭK.Y`Q׈#di\RXUVV*"":>:vT__o@2a[N)^xAxu s\z饗gɓ'3gXGx<رu ŋJMMn)F H5ku0`,X`Tuutb544(==]GNp\3g/_>ij+^ΝI&:$j( hQ{Uff^u |2Zh\. hƍ]vYg`/ ,X@eeepSVVuVlʕիu?~jkk3h5BCCj*͞=:~t%=Zs̱N[nE(fZfuW\RXΝ;Xؔ)Sv3@={3O< 4p@͝;:l޼Y3f̰`lL]]RSSuq(22RUUUkS" … 3@mm{1 Z:7ҬSGGU߾}[RaanFD|`wcbbu V$>>^ N,ۭK.Y*\{ھ} `?ڻwz{:IUW]%ǣvY rYn:u:>С6lؠN:YhxtUWYGΝSJJ ZF H:uuӧy ƸxꔚǏ[*|նm:~oo߾:tu 7߬ŋ[g ~ >\MMM)Q\\JKKyr!!!*))QBBu |z={X06 A,//Op`ɱ`rP)Ճ>]vY*$&&VSG555߿ ?:M6i̙p 99Y-`駟3SO= Z%F M0AunO> 8j wqo߮Xы/4YOZJ7tu2 .Tiiu2e36lf̘ajjjSOYgj16 Aŋ駟ZG*//W׮]SAip`z'3h7cǎ)׫sTccuz[iC*--UvS@zֻkV\^zYghAݻw׺urS})33S)ZtQ:>kUSSx HǫT)5rH555Y`?*++i?ijjC=9sX@;w.]dnf  )55UǏN"##U]]X- &&FGQQQ)љ3g4l0>}:VQ$iǎ;vuٳ 3\fx<رu |t9ĉ)K/05tP^:Zg@~3effZg s!n544XGPDDu (44T֭u |ܬQFi޽)znڵZju3f&Nh2 QIIS#׫,ٳ:S.KK.… SG'NНwީW_}:R^^*++3 iŊ~`LmmNjg2Zd df̘5>bl?L8Q[n΀K.U3\39shʔ))>HIIIڱcu l۷:#EEE(:::e˖:7N999.ѣGkpJ/ `06 4|p})QXX*++u7Z#ۭ\ 8PSSyYg`***J5551bu z[@;{222TWWg_Eկ3@0a~m 8}Zg=z:̔N `06 _>3 :T.\Nbbbxewﮢ"\.?TffS0͛7kРA)͛7+99Yu ӟ#Xg@҈#:/jذa:ru |ru:tY555j߾u |t n?:(+{ɱ΀7|3@xp9y ӧO[`n{:~/{ٳgS)((PQQu$-_\=zAѣJOOW}}u |ԹsgmذqB L]wu |بt}G)FPqqy 8v5m4 _ShhJJJԭ[7YGցS0Z[-Z,544X@5~xz*//WǎS@ٱcz! 8УGXg/_~YgGyDouQ?5uTmܸ:<3{3| =hO^{:3?m6u:~Ԥkz9Ц?^n[gϞN zzj ^~e򗿴΀G֤I3| cǎe9[N,FTSSFXG!!!*))QBBu ƌ'Zg*-Y:3~;}[߲Nkȑ@ ڷoC'Dzz~a &M-[XgKjРAHJJҲeˬ3GLK̙3)ѕW^)ǣ;ZAϞ=UPP`vޭx@^:cƌQS''OԀTQQaAgZfu$=ݻu2 JKKSP[n)|ХKUTT("":>:v옆:cݻWx)$ZΝ;FڵNN8!ۭ .X`bҤIZvíS'_ZZ/{Ygp[u 2CQTTu "22RՊNO>:bϪ3Xg͞=:?ru:>jllTZZ}:%]Zv\.u 2wVNNu޽b+V-b~am߾:6̚5Ku9s2223||:L: nI#N\. h7nrrr3@QQVXa@k߾jjjZt=)Bh":iӦiƍp`޼y,ۭK.YЦ06 Z߯#F:>LUee"""Scǎ)--M).]h˖-۬S'^W4is@CCF'NX]H!!|r Zɓ'3gXGx<رu x7\xQϭShs 5ku0`,X`HUWWK.)QCCuZM7ݤ;w{)K.iԨQzgS>8|233zSw߭\ o2ZArP)p`ܸqڵkumcod*++΀=3rJ:?^Gڶmzٳg5dZxWd Hz駕lPuu͛g9sXgAWFFuXx֮]k@(zG8!}Q~˵f ZO~wӷmѣGշo_mڴ:5<ھ}uF UqqtbٳUUUex 6p@͝;:l޼Y3f̰Mclv)Qdd*++u5XAaZpuc=f@rzm?[oUwN|M5jg@ΝU\\Pd|`\.*11: *--rA5JMMM)iСC9rS믿^G)@+,,:>:|n.]d@0a***tWXOvܩ~)oO>ш#hﯙ3gZg tYn:u:>С6lؠN:YmZtt<*ܹsJII_|a@(ox M::G?ufq0)55UǏNj SZ?O m~;=34sLu] ߿_Çg>ũG $$$D%%%JHHN^g7~_|:?^999@rTXXDԮ]ShZj.\h?WjjS~6gm޼:#腄hݺuwcЦM4sL 8EYgmO?[gzJ F~wޱ΀ۭ36'TFFuxg~z ZW\롇Nx^͝;W/l 5zh})AkQEEíS@ZpJKK3)Se)Æ ӌ33@MMz) cx>.pk׮)@0h ͞=:l޼YOs5J)Awތ^WzwSʕ+իW/ M޽֭['e۷O<@ clpY=zTiiiNZԨ]v)@@WiiBCCSࣃjȑjjjNܹmۦ;:~ryh͚5)uV9i%rss5tP ꔚǏ[GVllu bbbxe9sFÆ ӧS:.;vhرpgϞ*((Vtt<:vh;wN))):qu w]m߾]=zN|gׯ6nhha ,PMMuFs\z饗gСCv`ƪB)@@ Uqquf577kԨQڻwu AQevZZ:3F'NNHHJJJ`y^eeeiϞ=)\vZ}߷N|GZ oڿ)AꫯVYYQDmmNjl2 -YD ΀3fЫj@blpM8Q[n΀K.U32| <:̙3G\vwqjkkk?޽{/u ɓ'5|p]t:%r-ZpuR˖-Saau;vrrr32zhM<:TUUi5F]CC?:> SeenF nZg͛7:nСڸq:vh?ٴi[ZwyG3f̰΀ɓ'v[g 5ap ??_}B=TPP`}effZ>LCՅ Sࣘy<EEEYZUTT$e}Tssu OSUVV*22:~vZ ѠA3@˖-QٳzGuyc^SXXu d0`ʬSUկk/N zՓQ5S@:vzRE໅kՌrCNblpeggk޼ypv+--:rBCCf͚YGJJJSn\.̙YfYO***4tP7:P;wNn[)Ae˖JOOAjǎ QNNS*o߾zꩧ35knuSV-W^)3g6l` /VVVug?uRںuu3gtbT 111?uصkTAL9sFpu |t7nݺ)֭[kɒ%p`ׯ^u Ei&{g@Æ ޽{3^ڵ5kZ TVV Uvv5kfjԨa?~\ݻwWqqu ݻWg-DEEiʕ SÆ UXX5jXGO֕+WS.Nj۶u Oڵk/:.]D~ Ws,X`HOxTV-Ddd֭[ƍeeeJLLW_}eNL]V3gδ΀D ZhڷooF[Zgp]mz5뗿.\`&rrrl2 HZxZliTvv̙c4n8 5jRRR3@FF.]jcsz[G.Kj޼u pC :T ΀+WTFFuEn^ 4N,X@ɺzu >|>댠WF N:) Hiƍp`ƌ֭upCtIg϶΀Fe FUŋsYGk8B_|:|}f͚)׫1c(55U9jD={ԅ SOS+ HNBBBhjڴrssf>|Xn˳}W^N6mUVqVƍUPPJHHPiiu ~˗+<<:~PZZ޽{+==:POS@*--U||N>m~kҥ M:x飊 c*СCݻ˭S;CG)ҴiSZJaaa)ÇvuUQFڲeڵkg?Yv:uӧO[;wu$͝;Wm۶Aŋr:wu |Tvm_^ 4N:u^z)ѥKSNYQ@yf3:7ouOqquIYf***R˖-S' .TBBS?ƍۭ3^xxrss:}gϞN6m\.OD QNNS#׫ٳ:|O_|Q˖-΀Æ O\}u<`dĉJLL΀?VZetQu뭷Z^}jʔ))|JzREEuJ֭FeجYkF 'qqq?~u(,,ԴiӬ35blJJJc믿NÕ&MX)66i&M0:qz7TNKԽ{wegg[om޼YfͲ΀6ծ]; ^RRR[ {wjѢrS/RɪN׈Q@8vTZZj?TaajԨaKQQQZrBBxm(<޽{:>|i?8~:tw)lҤIڴiuF ӪUԠA'OZGZn7nlKחQZS .(..NϟN~I@@ٱc bZn%KXgN:x<[u |t%)رch"ϫ/zk.T߾}_[&M(77W) H:tHn[W^N7n|EDDX'44Tj֬u |TYY>}h޽)O88˗/g2WO=uBBBhjڳgu ,44T/f͚e?裏Զm[߿:ĉӧ***S^N4n8 Ķo߮4 8[g'==]]t΀ǏoaQ@@1bmf̙z:$7Qnݬ3ԩSUPP`5馛j* <:~~z=C:yu d۶m2eu$M6M;wAl̴΀C #$9:x< 2SGNTPP:AvkرpPӧOծ][6lP=S'ub̙~gBBB믫QF) =3… վ}{ VZiɒ%pO?U~zS16 X'NPu חQZSZh+WrYG_|񅒓UYYi5iذ}]=)ٳg+%%E)Meeq:t:%5lP999 NADqqq:zu |<5i:AaÆZ~j֬i>}Zn[/_Nc'hpe˖=cٳzGuy]wݥ"j:~PQQ!Chܸq) 28 IDAT\gΜQϞ=uU딠jҤI ;vL=zPiiu |#kՌrCN ck޼ypv+--:A$,,Lj֬u |TYY$߿:kr=H?OS/_c=%KXp]}o H8qy vUV< pBu:<ڲeuT cƌƍ3̙3յkW 9scǎp`ܸqz73&:t:~po[nsXgeeeZb/^l4rH !C0J`h" p16 ***[G!!!Qttu }ꩧ΀k֬Qzzuפ{ڸq֭k?8xbbb[px^ 8PN zz nXjjnjեK Ts111?uصk Fƙ3g .XG7|<LnZn%KXgݻw_~z)|oWAAjԨa?عsڶm/:ܹsr*..N zwOnXYYtA(44Tj֬u F)??_)ս{w~$T+{U QQQZrBBxmjذ  O֕+WSƎe˖),,:~;SN:qu f>S3:Խ{w xxׯ/ǣZjYԺuԸqcLꫯS ©]@vZ͜9:jFÕ&MXGJHHS^\.5k,\.AVV~_… )믿n\.^{5u]) ޽[ ΀-ZPVV-WjӦu>|3 (Z4i6l`}Y%&&ZgXhڷooF[ZgDDD(''G٣#FXg@ȑ#o^~($$D999NAjڴrssf>|Xn[W^NTkS^TQQa5mTVp ۵vZEDDXGǏWBBJKKSp;￯mZΞ=~Xyyy)Ty˖-˭3 ^STTubgϞUll.\`թSGGճNASRRx}7)cjﭷҤI3@ΝsYg DFFj͚5۬Sࣲ2%&&ѣ)8"Z>SCݻ3^:u5jX w^%''ZGQQQUhhu Rff7on;wZgCs=իW[gѣGk/^ 80l0M6ڶmnvg}OS(%%%JLLԅ S^-4g ֭[3fXgGyDSL@0a3 /˗[gcՀk.8$|30L`.\W_}:Ǻu-[A)[ב#GSH5h H:tkɓf 8$|0mٴiƏoFAXn['ON"##UPPn:UTΝ5k, 8}v=:ǒxTfM/K?:zj.n~f^gYG.Kj޼u (*44:>:x飊 P06 *R޽U^^nqx\}u<`c'NTbbuxj* |vM7)//OS.]RllrrrS6lؠ_|:/^-[Zg 7{lZgQF)%%:4~x 8PXXiӦYg*Q@*))c=:> W^^4ib#2euشi&L`n;r)cǎ}z뭷S cǎ{g"##o:1׫})p ##Cmڴ΀-Z(++K.:>/JP16 jǎSBBJKKS*,,T5SpEEEiʕ W8xz |ҨQ#mٲEڵNݻWm۶'|b@(++S޽u)딠ӟTurŊɓ'SH[N7N V~}y<ժU:>ptyPEqvء!CXg֭[kɒ%ԩ#ǣuZG.]RllN>mO5k"l:~}:tu Aȑ#ׯ*++S^Ϟ=5x` :$ۭWZG5R~~"""Sp*;;[͚5N*++էOݻ:Ta iW}jĈBBBhjڳgu >;vp(,,C=SNY6nܨٳg[g@պuk o߮4 8[gIOOW.]3oXg*QLj#m6 80w\=C~ߨ[np`ԩ*((';v͛u뭷Z-[[}Y;A鍊nիUn]ϟL 80d <:YRRFi<^x gϞ:ru |})NnƎk 5}t |7xC7|u ԩS5p@[IJJJұcǬSޏc\R.:'|R~uXpڷooUVZduOկ_?y^s u]W\Nׯ/ǣZjYZhB/JÇ@)F:}n._lcO>D΀-[daD6={V>Ο?o5vX-ZH!!u&]|YݻwҥKSغuMfIj۶urǎS=TZZj1JYkՌrCN ͛gnҬ3aaaWfͬSJ%%%i)|P˚5ku zoXcƌz뭷3^xxrss:;vphiժ,Yb?Yp:t`~imٲ:Fcƌƍ3̙3յkW \9scǎp`ܸqz73N7trrr1&8ر:R}ѡCSwܡ+VrY b -^:$%%iȑFC 9yҢE3@bloQQQ$/NBBBh|O}SO=e֬Yt Sڵa%&&Z?m۶ڷou p̙3իʬS^׮]5zh j֭p ==]]t[g]v1 7FgΜѣ> .XG7|<֭kZn%KXgݻw_~z)| w?l?شi:uo:||&L`I3gTv3@+++SBB}Zn[W\N[u]***RVS+WT׮]uEp ӵvZ댠UV[oNAwN~x|3@clM4Io3>D -R3ȑ#uV U}vG?N5аa4n8y^'ΝSϞ=UZZj:u[g(;;[s̱΀qqq| FRJJuҥK3@5(>T>}[G.K{S- AYg+W*##:oս{wmܸQuֵN5*--U޽xbpИ1c3 iԩzᇭ37Zgӧ[n:uٳ3{ァQFYgjQxt9vڰa4h`yYg>qX@ըQ:ٳܹSupBegg[geeeq) UVVS࣐(:::iӦUXXu |tan]z:Tо}ԫW/UTTXGM6ժU8DS~Zv"""SǏ+!!A)Kcǎkwjbbb}vp 2D_|uFkذjٳg .XGuԑQzS?j׮s)k)))Q||P06 [oiҤIpsz3 )22Rk֬mf)11QGN\.k֬Yr\9F{<AҥKJLLԕ+WSރ>ɓ'[gh޽JNNNx}r͛[CjΝalի3ѣ5`댠K/{΀Æ SQQu$""BzgS[lQvW_YORSS3 i„ ҥu֭[3fXgGyDSLz&LPbbux|r P 16 z5`ڵ:0tiga5,\PuVZ*,,T޽Sk֬ѯ~+?:yWb 댠ly):>r\TͭSNTTrssj}ufM| >:dddΨ&ND 8kժUΝ;kjРu ѱcԾ}{)KMMծ]3-ܢիW+"":@g3j(XgT[qqq?~u(,,ԴiӬ3@`l?*))c=:> W^^4ibRjʔ)p`ӦM0au'))Ioj׮mkݻS@())QϞ=uy딠w矷UJJ>c86mXgT;-ZPVV\.u |_*99Y) H06 ;vL *--N~P5j԰N6rJ:*Pz0`c~W4ydEbƌ:#թSGyyyQu o߮4 8[gtu:?^ouBp1B۶m΀sC=d~ߨ[np`ԩ*((@tM7)//OSp^N#F:2KNN z-Zܹs3$IWffu2dl4rH 8x /Xg (QYYz#GXGaaa*((wmpnƎk 5}t $I;#mkT^^Aiʔ))9r䈒zSސ!CԷo_ IғO>?:,\P:t8ZҒ%K3~~;alĉ޽\bկ__GjղN -Zʕ+rS/BɪN@5֭[ծ];\˗/+66Vu |M=xbEGG[gDqqq:zu |իWI&)aÆZ~j֬i>}Zn[/_NAQnO>D΀-[d& ;wݻǚ5kR Č3ORkp1o޼hԨQ2~ڵkNek\puH6mbڴize˖xhҤI2q(..[N 2$ƍ7U=0jԨ(--;wNrXeee'})9_zg?K-ZDYYY4m4uJrhѢ8S;wFIIIOJWD.\zk ХK6lXꌤ bܹqN!Cvݻǚ5kR4hsO92u _REEE\|1tPOj 69c z-УG8p` x饗3Bc鑗:%q:,\z饱|DzQHo~8uY$uF2ƍN:)uYcٲe3a53gZ:Tlݺ5{MWy晸Rgcƌc9&u@DD̘1#d(\yQZZ:,?>&M:l^zū: ɓ#HR.߿ 0mڴ?~| r؞{=PN/k/>8⡇JvmhѢ9q1gΜh޼y$⣏>JBҬ}7uJСCy睩3³>k9,Il2z8R%ѡCXreUUUf͚)9iӦE^^^;/V^: ̙3m۶SjL֭cѰa)dhڵQ\\;vH,Z7ވs=7***R֭[ǬYrOVb…ѨQ)dhѽ{ؾ}{rԡ˗/>:u _7o믿: mڴ)zaK-ХK4hP ߉;w͛7N!C{W{:瞱dɒi]m۶֭[lذ!u ?e(_Æ KAN=O~:ZƂ bKBv%%%N Gq|׿:/'o~Wu?NAD~ѡC>UUUSP6mbѠA)&///&OڵKB.o~: Y6 ȭsIA RgT &ı:,\r%|䨓O>9VXtP|08ؼys;6ϟ:#ٳc}I-#F b3_%%%3SNMF /0VZ:,s=r!UW]^xa pw}ݗ:եKxǢyS۷oر#u n+--_=uFkժU<: "" f1}S_rEҥKkM1F)//R˜?~~S2zj92uYXbE\}թ3Q} D&MR%TUU5\]vYTVVR>()))93όk&u@DJKKW^IBbѮ])_6mٳASК5kW^QQQ: c@-;DϞ=c׮]Sп˿DYYY4j(uʗֺu5kV4l0u Zvmǎ;R uۣgϞ1z)_wq嗧 "nرc GN!C{g,Y$w)_^{eee{N!C[l.]ğ)Ylj'x":,xqwR#\Q^^ݺu> u 9&///n9rdK裏N9sNg?iӦy 6s: ""xѣGTTTN!C[ٳg_̙3m۶SPUUU/: k@-vwƔ)SRgK.$:cɓ]vSPUUU\tE&u 9QF1cƌS%[.?#~_N6\rIꫩ3r3f̈ N<~ :uQFm7|suY37o^ b(r^zi(..O>$uJw]w] ό5*fϞ:,\yQZZ:#kEEEqצ /ǩ3vePm۶->Xn]2TPPs΍>8uJƺtÇOA.]_} rLF'38#u _SO='xb]6u @xc3ÇwQUU /N! Ǐ;.uFڷoӧO)d_}Feeef(^#vؑ: 1hܸq/o|#O7|3zS1M6S.\gqF|GSjԬYK<0u @DDGs2TXXϏ/ujѢE,\06m: }GѹsS|)^@bŊ0`@ pȑ#SgS1k֬h֬Y2e˖(..M6N꠻;wSVJo9sf4h u @DDQTTuHVbڴiyyy1y8Rݻw曩S{SĉSg.,v:s3&o CUUUq/:c⦛nAEeeedm=z?8uJ;c3>bŊkRg}{qW\]vY}٩3µ^:F:,̙3'n_FٱcGt=?N!CO3&u_i֬Yu]3ŋoN1[l]ƌ3R*͋;uFϏ3f!:3_|q׿NA bQPP:3q=DFRqEEUUUePѵkغuk2Խ{83Sg|fȑq Co߾QYY:C֯_'|r?OP+]uU3Ϥy-Z9sX۶mxwS#8"~̥^w\ 2q(..O?4u WʲQ^|8p` 0v(,,Lַb3ЦMsN?ѱcXjUZkΝqƍS?>F:3^Ď;RÇA:#Sg]v9oVePM>=d~Έ[o5]ѻwXzuyN k׮}FUUUꔜwWGQQQ <ѿdh믿>uF\{Ѽyd+_3WP]s5 u @#ѣSg似2eJz衩S>=ܓ: }ߏ֭['ԊӧǸqRgTF;/?N!p@|O6nJ6,X +t-nݚ:ιb3r{s̉ƍNe]˖-KA b:4GܪUb3eP|Q\\~i2pWD~~_Ӵo>\/UUUS:bԨQQZZvJP'ڵ+JJJbSrޱv[ ܹ3zk׮MB.g}j|{ãdދΝ;Gyyyje(O>zhz5>5>mܸ1u[nM1p:th:oqyEEEEꔜ7hРիW lذ!v(,,>};/cKvu֥Nv@=p¸[Sg~&Mx_ڵ+wk֬I~i}1q)ƓO>rK "bĉo|#ug^ze 0 jtE]T=^zi,_Sc>׾Vc=W\qE,[,uP|qiP nXtiꌜ{ܹsI&S>3s̸;RgoqWc:8ꨣjlg„ 1iҤ5ƲQg*++W^ꫯN(((38}563mڴ7n\ XfMt!~)Reee;֭[:%k.ec tڵ^b\25ʲQ>(**>(u DΝkdNAAAvi52FSgu~zzmÆ ѫWصkWꔜ7`8Sg|2;XzuҥK"{k׮رcGe(SoF'*++S9EAAA9餓b9uEnbSZ';ƺuR䄧z*nDĄ m۶3>iӦ(**-[NsqѺujsA7j)//ذaCg(c?Xͣ}>礓N잝;wƹkaOgqFl޼9u @N9rd,^8uFkڴi̝;76m:3/r'R9:vX3N:˫9K.$VZ: F[cΜ93w\Kb3Z}Ν;S䜪 N-&M:,Z(F:q֋o=N: FWZ:yTK}q}ꈼ 9kӦMѣGرcGꔜ׳g(--MW O?㟹kҥqצHʲQq9ƍS7:j=}٧Zge˖UW]:# SN)9瞋C "Ə_WFUUU\tEkNoy䑑_O(۷o_瓽իWG="u @R@x뭷Gk׮)C=Z?ê|v());wN>}ģ>{W5f̘X`AꌜWXXs̉f͚N͛k׮GN/_mͶٲeKŦMR$g('x":мy{ォ֭[Wd()))9$.G3 s7KPkX6 9sϥ|k_gn-f͚:;կ~G}t.\cIWFgN;-% IDATZTdgqM7U,m۶8cݺuSMɳҥKO#=uFkܸq,X'VxR{g{Td_}FeeeZŲQQ^s9})9m͚5ѳgϨH3{g,^8z: ']|ꫯyrHL:5R|)eÞyxSgƍWv6bƍ3zQFSO?4JJJb֭Sr^Ν.KWyXhQꌜרQj;}|zK,^{-u@d(n!oy~i]^^^mgÇI':bԨQ1v߁j+KADvmq'LQQQ|Oݻw.եK6lX zV)2SPPs΍>8u P 4(MSr̙3cʔ)3r^AAA̚5+w)Ѿ}>}zNyy_gDZ:ֱlrп#?UAmqSײe˘?~N;/{h֬Yr饗Ƌ/:#j*xKgXpa4m4u >>Ƃ e˖Sj/ 4k,ϟoP-~j;߯?Rg9SNOzX Pm%%%y)9??c3ՠAxKpZjeeeѨQ)eCc̙Ѷm)XvmoWdO>1hР@8cgOzׯ_ "bĈѱc@=zt~3 yg>vСC;6u@a([n%<{/o^m{Tq|'u=xNH3ΝǏO6ls΍?u Cz_~y FuޙY&|7p0`@ ZQ%%%1dȐիWW7. 69sD֭S9`}Kg: g\q3Ϥy̜934h:ַbҤI3|A|v~yyy[gu]q'HβQGydL2%R7{j=?7xZg}7-ZM6M䀦Mƒ%K⢋.Jv{nlܸ1uJ;SOs-[(,,LߨgFł NePϵh",5jMӑGӦM 6I&S䄵k\UUUSrްan *((9sDVR]-[%K{: Fkذa̛7/;)|{^`ǐ!CRg9"/// cǎ|m=ӟ4uFϏ|0<)@=tw':>>uQ1a„x5wwRg9V^NYti`r-qgrȠAb޼yQXX::thX"uFob̙Ѱa)@=2p0`@ >kʕ+c֭>ӷo߸Sg$a(S}AXxqoF"{1sh۶m ǣ>͛7OPڵ+z|Aꔜw'M7ݔ:':tcƌI?O'|RsEa=:N?5βQ>8qb ?\/gf͚EYYe_@:SbqAN?EϞ="uJλkK.3:yEƍSOC=Tc^ bƌqᇧNQ@=ǒ%KI&S'Xre[`Abi&MӮ]XbEi&u @OO~9////hݺu*,,ŋǁ:"/^\c,Y;wydE`hڴi"#1w8蠃R}lO+Rc=]taÆrL֭駟:N׆ K.MZhgώFNDZ:/#Ļ[c6lK,y#<2OyyySjeP7.:v:/PUUSLw_${7pCrL-3<3u @UYY}u֥Ny|z뭩3:+d`ҤI91СCSgF⋣3/~x7k|ib֭5>ɓ#HǬYm۶SePǵj*ʢQFSʕ+㩧J6n[&OZnfhZgƍqꩧ)T6lX}ݑT[o5,Y:#1gΜhܸqs=cɒ%>XG)S$?uXfMdnh޼yj5aM4 ~: G?J:ߏ{7i;S'?I ;~it5fΜ:j ϟM4IPTUUŅ^ovꔜo}+F:bʔ)Ѯ])dhȑ}dwzkdM61}t- P?>9d,-[:#F6lHA SgٱcG;n)TxGyS?0=رcGꔜ ;@-su9眓: +1iҤqNj/: u9n²Q, [nW]uUꌈشiS <8uY0aB{챩3TUUŐ!C/9T+W:]w] "{o|3Z{uHUUU?;wN0`!_}Y6 uЩ?ORgaÆo:3ӦM_3Paaa,X o);6[+rS=-VXa@5Oeee3rޞ{s΍=#u X6mbѠA)dhԩlٲyKAbѮ])_)Fiݺu̚5+6l: X"ƌ:TUUSVZ… QFSz3Έ͛7Nr!r8S+UUUqE[o:%k׮-Y{W{: :u߹ꪫ7HAsXdIᄅS2@qCݳiӦݻwڵ+uy뭷_~3B;L'N:ņ RPMZh?xuYSꕏ>( @'OvڥN! ;\͋I& \rI?u|7q 'ěo:j{ĢE&:u1aK |>Sx㍱r^C :uQFJX6 u 7%%%3¨QbѢE3РAKAƍ'tR \oVtIjժ)T ĉcȑS &ă>:#5m4bJԐkSg%Kԉcƌٳg W^ye,:K.1lذdaҥqקȶmώu֥N!C1w8SZ~}|__SFC ɓ'GÆ S_|qk3r^6mbĉ3о}>}zN!CzQYY: UUUEiii ScMX6 \6mbڴiuśo%%%QQQ:%c^t=oߞ: l2/^M4Ik˖-ѥK={vQiii̟?Ȗ-[$nݚ:%3.@5jѢEEӦMS͛7GQQQ|ǩS2V^^ݺu> u *,,EŁ:`y Xf͢,7o: }'QTT}Qꔬ=31pd裏'jǎѫW=ztQ׮]'}'u @/Dĸq⨣JT Č3OB*++W^kN;űcǎ)dyEFRFϏ3gF۶mS(--W^y%un:uj{ゥ3B>}bРA3k&.򨪪JC5կ~ZJP/L2%N:#ܹsYfSѣOOAxGRg+VSg:رcSgF[3LAO 4(zd;SN9%u|cF߾}cΝS&m۶g}6ڷo:^㥗^J1iҤWwq嗧 eeeqmƎ'ONAO5F*..!C /#FJܹ3zS K_h'Nxlٲرc:o۶mQRR7oNJJJK.I|:ꨘ8qb 6?J~SgqI': +@-sGƴi"///u ~TVVNʼѵkغuk2ԢE(++MN/_":u|A׾Xtit=u @oF~Rg?OcI| -[ŋ{: mܸ1O?Mٶm[Żロ: ܹsN1F,ٴiSt96oޜ:+/FSgˊK8bթS&7ٳgSys΍{'uFkܸq,X Zh: 1gΜhժU2k׮8s⭷J{sΉ۷N!C-[%KXV @-ѰaØ?~vaSPeeeݻ^/Ț1cFy睩3Bqqq 2$udCt1^z)T =#GLP]q /yrH<>uw'|r pW/g GuTL81u@F,Z;SN9%uY2dH<裩35\=X p-ęg:2~رc,]4u hȐ!qGÆ SY۷onݺŇ~:%uYq嗧0p@Kӧwߝ:=q= {.,ulj>}ĠARg3gѣSgԈݻw^:u Ϗ3gF۶mS@Fl;wsN]p`c=RYN\pQUU:%5*N<@:tcƌIAVZSa/XlY pw駟:l;cĉ3K/KQ>ҥKl޼9u j֬YES@Foz &Nu%|w}SY=eY@AAA̚5o[87o^4n8u Z~}t5SԘ;wFc͚5SP bƌqᇧN\@B-[ŋG&MR7Fqqqlݺ5uJ{ .)dM61mڴw-@PQQ^zi :oz㏏_W//Sʕ+SgVZbhѢ8S;wFIIIOJRr"U-ZhڴijD bܹqN!C;wݻǚ5kR$p¸[Sg.]İaRg@VF^xaڵ+u __g#<2u @k׮8ssꔜwgSgKA.X|yd^z߿ о}>}zN;@"ƍN:)uYcٲe3㡇JAn())IYy8nݚ:jrIJe`7O??*++S#FDNRg+da1iҤ͘1#d(:X6 \r%ѿdaڴi1a„Beeewy+N!Cyyy1yh׮]C??{r2E f-m}*A !l#'#Grrj(& TPAA][T IBH{g=a.3y=׽Ly]=[Iݺu}vnѶm4o<댐l5j:Hҥ,X`ݫ4대1qDm۶:̙3G={_o^/΀grn.^hժUKEEE_u ԩN:e?۔#GZiӦ7ߴy 6TvvíSi111UDDu |t)nݸq:%`|)QXXrrrkal*ԴiSmڴIQQQ)ٳgR딀s1_)QLL֯_˕sQw}㏭S'Zb͛'eT***3gXΝ;瞳 dq|/(11Q|uJpuevx|:>r\T\\u aG111Z~"""S࣓'Ovƍ)Aɓ0`n޼i5mTGQQQ)ܒkذa|ƚ7oݻwu)AS3L:,_\;v߿_:u_|a?RNNƏo?[viܹT 2eu(**ҬY3y|CIKKSJJu1P5c 8cM::Z)))c=:>T~~4ib-;r>N\.-\PK.x/^T~TZZj~in ^˖-n:~ "}  jN+WT۶m3@alJԢE eee),G%%%:9s2"wu UF nW_}|P{N3Fk׮Uddu @o?:#\.effYf)zf͚)˗K.YT;ӧΝ;gEGGkjܸu  @%;xTNիJHHŋS}ipM6ȰpႺv7Z[v)oζyu֕ <<\j޼u |TQQѣ)ɓ'vUVVf5nXN!Q*AXXrrrky^)ޚ5k 2֘1c3AJKKկ_?)]ꭷR S駟ZgVZi…EGpgՖ-[3={h„ p}ZtuP ^x=p`̙ڰauFxꩧ;Xgtu:\O>&Ol?j۶ۧNhW^URR_nFAYg45n8 8x`댐tReffZgÇ+55:TsnM4:jΜ9!L_|aEDDhÆ j֬u ?5zhUTTXO5kw}WmڴNh4vX Ho~֭[+##:|2d^uJH=z} 8|ru:TcjJYYYr\)ѧ~!C04e믿V޽uu^zxmEFF*??_M4Nc"Fυ ԫW/]|:%d}GJMM΀*͛7GѥKS'jRaa `F?:#iҥHiӦ)͛7շo_})!̙3۷JKKSࣆ Qep """TPPf͚YGJNNǭSB^vv/^ln&Md@xwԡC})𓨨(eggkĉ)XIIIf կ4tP βeXg4ܹ:#۷OO>uhݺ2223@5( ==]=u4inj1am۶: zG3/~ }g)˥kҥ )رcJMM΀+V__3Çg,1Ȭ[N˖-XvVZe5vX P$= jѢƿõkԻwo^:~ԥKjذu @)--URR.]d,@Ȋ͛ոqcLIII/S7DW<j֬iW&MSࣲ2%&&ĉ)6mڤ_|:kƌTr矷Nۧ')O QK/uT+V]vp`ԨQڽ{uC)55:lRYYYr\) 16 /_N:Ygqi׮][giӦ))):Jz5sL3F9{ァ: 饗^yW:uS2O? fVXW^y:>Vzzupݚy PܹS;vӧS'ZzfΜiP :T)!??u[g >%%%JLL7|c[t)QڵxTNைƍՠAƍ۷/cPoӧ[g]jܹT?ر;f?q\1c.]0֭[`댐\y)T*˥LY#F?tQ :T^:>jѢn OW\Rm۶΀FҞ={3PIΝ< 80~xXgPN8k߾})1c(??_)[oeYF.:J*)):,\Pk֬@%ټy̙cwﮙ3gZg (3<`^YlV^mJzZk۶m)>}h֭Su @@(//נAt딐׳gOYgP)w:رCSL@%1c6nhNP3pQB׮]5o< 8g?:~P\\,ۭsYGڸq4h`@vꫯZ:wݻwN_ r딐7o:,_\;vo#)5kPS̝;wN*++N ygV.]3IBBLb4k, Ty)77:)%%:0F!->>^3gδ΀;vԩS3`D=+(22RjҤu ~o롇7|c? WFF$߿_ӦMyaaaVFSZlurYG}  T1׫aÆ)p`ʕj۶uPBV-0~.'NЀT^^n#gΜQbbJKKSࣻKQu ~sw}h?q\1c-[wH,X͛7[g *;;[)UՓQ͚5S˗/+!!A.]NbGΝNyf5n: |ڵkN:)իW[ؾ}4|p 8ЦMeddXgW'NPǎuAѣaEGG[z6lN8a:wiӦYgl5o:>uQ;ynʬSƍ@QQQ) 06 9aaaQllu |zO>:b͚5Wk̘1_x@SG ڶm+@HpR딐7m4u:eѢEѣuxge ={h„ p}Zdu0B /={Zg3gjÆ 0O=y 8Ν;[gWW^U޽~zу>={I&)f>CM8:#䅅iݺuS$%''kܸqph0K.Uffu1bRSS3@alRn&Md 5g 2O_|u | 6Yf)Ս74h -\:~\{OS3/rrr3B^ TPPH@kݺ2223!CZ =ZuX|:udcѲeKeeerYG~  Z{׭Szf͚)ĉ5n8>VcO{C)f|I}!׬Y3!aÆ*,,n?^n[׮]NA*))QBBN>mEFF*??_M4NQ@H`.\pAz˗S>#ZgVZ1 K.СCUVVf?Wo߮={ZzT\\l&M޽{[gBPddԴiS͛۷>s3gΨo߾*--N6l"Ɵc/""Bj޼u |TQQd?~:A";;[/΀n[&MJ/K]r:~rk͌?Aƍy.KbbbS!fٲez3@ZZvi o>=puȰT{z衇3IuV  &h۶mp^У>j@x7եK;w:~ ͛7:/kZgGSTTu D > n:-[:AfڵZjuHNNرc3!F5f 8qF[g +99YǏN”Xć~￟+ܤI*7j(9r:#k׎|@h߾,Yb<8,nرck. 8=zXg#6m(##::tHC NA˗/[Gwq<ԩc@ӟ;꣏>N 6LQu @vt딐7n8:P5jH۬SࣳgϪw*..NA*++SbbN8a+;;[͛7NTK 6Taa!.Arݺ~u ѣG3ZDZh,K@h8{:u۷[{1ۺ;SԱcǔj\.V^f͚Yhm޼Y7Nʔ/:Ag;OzxTfMPx[PDFF*??_M4Nʔ'NXشi^|E 83fXgPe^^z)//:~t}wUӦMSTnn^y댐Wn]y<*݊+Ԯ]; 80j(޽:ġC80dZl,\.PT;˗/WN3O?]vYg>}^u 80m4%%%YgPenܸ(==:~e˖)Uj̘1:xuFkժ-ZdF4l0 8b &O IDATQ鲳m;ȸnM<:T!Fʈ#jb TC4h9b\.eff*..:*z5~x7N^:~ҸqcڵK;vN2%%%ׯ.]dFd @5ХK͟?:ݻWiii&Nm۶Yg9sgϞ06 6ڷo%KXg3 rtEVZ***RSRK.?2ɏ~#رC)U:t(`ժU~fb111UDDu |t)nݸq:@?n)''G) 06 6mM6)**:>:{UZZjjرc߿˭Sࣘ_U!'++Kn[ׯ_NvmíSLaa/_njժ|~) ıSRRD}7).\x]|:>]<ԩcQ@ЋƍՠAL}ӧS"x M>:tUsε:wsYOõj*͛7:<3z3BsK r880b}GСCzS-Z(//O)+Wm۶p`ȑڳguBܹsgƏ |x@NNM4I"""SLSBO<:D}Y%%%Yg j͚517o֜9s3@5sL G3<]YlV^mzZѣt!?7F)~_hСz)!oժUjժu t]?uرcLb5c mܸ:L:Ai1FAk׮7ouسgƏoV\\,ۭsYGڰa4h`@;s:ww}:~;w~)~e-\:#EGG+??_k׶N-Z(77W)џg 8P)Q=|u |r8RLL֯_ɓrݺqu Bɓ'5`ݼy:>{xe@xu|w=cwSNݻ3BO~Uvmy<խ[:>zzo:!ʕ+rݺxu |TV-q jQ@!S\\>}ܹs)$魷҄ 3@xb LjZju g?߯VZY͛7gZ+55:`”XjذaOSIұcԯ_?[G1113 cr8'ЁSeɒ%zW3ȑ#?r9R'ON5jHvRNSٳ4hC`ҥjӦu ̞=[={΀fRAAul߾]ӦM΀]t3@%blT{9%%%Yg h_5j(}p`رufϟݼy:~Rn]m߾{5g댐<թS:4e 8PTTYfYgռy󔛛kҔb* cѣG͘1:رCSN=cꫯSHI&)Yfbmݦ9:f͚۷[g{WuX˖-n:\.>Ӑ!CTQQaU^WÆ ӁSʕ+նm[ P -Z(77W)щ'4`[י3gR讻RaajԨa"uY~u $<<\+VмyVEEӧO[}ꩧW<j֬i]|Y tu wO>:wu |͛7q)blj׮-ǣ:uXGW^U||Ο?od߾}>|uhӦ22230ЩSSG&MkHo4p@ݼy:%䥧Tpeggy)QEEGZ>9ynʬSƍ@QQQ)`l”X*%%E|u Ț5k 2֘1c30uo?:t<nvxw5}t댐\y)*h":L2E[lٳg&L`ڷo%KXgQ@@{ԳgO 80sLmذ:%O=y 8Ν;[g`ꫯ҃>ݻw[z쩝;w?u _̛7O!{њ5krSU 99Yƍ΀G .nҥKiFT p,ۭI&YgB͙3:eeeeׯ (""B6lPfͬS0u= WsڵӾ}ԼysJz'NX={gY֭a>c 2D^:eGo/_N:Yg[( lRYYYr\)ѧ~C:Ak[ׯ_Nի'ǣ5kZ`Tg$k޼vޭNt.\Pu 딐7w\IÆ UXXo:>:n]f %%%JHHӧSHI&)!FѸsK.]N*G}T 8ЪU+FT^^Çk)Fi֭u @4i$댐׫~)J<5m:>y?:gΜQ߾}UZZj5lPEEEQu pQ@@ WNN7onUTT(99YǏN*Uvv/^ln&Nh@@?x ݼy:~RV-߿u @[d6nh4i\[*ѲeXg4ܹ:TӓO>iZn cݻ[gɓ'k֭_L0A۶m΀/}Q «D[O[T}g!K.q n:-[:kjժUp`;vucgƍZhu7JNNǭSࣰ0(66:PXX.])˥ jҥr\9ʕ+JJJb>^/_Ny<թS:o>=/SGcƌڵkiPi~+--:#䅅鷿5jdE5RAAn6ٳݻ7ʔ'NXGVͭS?(\Æ UXX5jXGϟ׭S*qQ=-Z(++KaaKI:|~_O?N *++SRRǃ2x%ԫWOG5kִNoLEFF*??_M4Nʔ'NXUjӦMz3@||f̘a@8y:t蠽{ZvzK 4N4#Gԑ#G3Bރ>ӧ[gZbڵkgFݻw[gUСCJMM΀-[TVV\.u Z|:ud~iڵ:01}tp`ڴiJJJ `|w֭lb?j۶ۧ{:R\vMIIIvuJ{Խ{w 4l0 8b +l[gۭɓ'[gQ#F(55:deeiŊ 4HGN\.233g@~zhA5׬Y3޽[mڴN֯k댐uN]t3޽{f8qmf̙={ZgQkɒ%p`\]x:>UT~}Fyy|I=)KwnP)֯_L댐X6lPddu oQnn"""SSNvƍ)?~:> SNNbccS(5nX6lPTTu |tY%&&:ǎSU^^nhT _z9sz)UTTXO^0`u @=z>#댐w}i#lD}7|c .(>>^/_Nj׮-ǣ:uX(JEGGkjԨu |TVV})@@y74}t 8еkW͝;:|r%&&:~lM8:+))QRRC`ĉzǬ3{ɂX)Z)JUA`N Kb "a2a)( IcF0hEX"d_f.rx ry]UBBu ݻ3r 4H^:>Waa"##S`lpC-]T۷΀ÇWyyufϞB 80n8[gpx =pu riܹVD@p;t萆 b\.^}U})+--:̛7O+WRqqfΜi4m4 ?x5aƎ[ъ+3z={Xo߮zHǎN9RVRttu wRTTŋ[g}{*,,T S %%%ip`˖-4iuвa 80ydnG}Ts̱΀7nu*++vui(&&Fׯmf@ٿz!}')kӦMjҤu w2fܹ:#o^sεEFFZG}: }}>N\.rss`@clwZڵke=zTn[׮]N‘#GԧOZGwu<4h`@ϔw}:~Թsgg[gΜN {GVjjuƍN._nݺ髯N¥KvuyQF*--UͭSkR:}u Tnݪ[g|P . ={V/[oY~ӟj׮]я~d=zTN {3 DDD(??_mڴN^222~ VQQZli@@z6mFzIllJKKa֪O>:}uJԀ3 mVWN> 8׀*##C|u Xtڷoo@alpǫP)QEE飺: $?~\=zPuuu |tD 6N `TUUe?Ҋ+4m4o/tR?ԬY3y<ZG/^TJJ.\`Jrh ĨXqqq)FUƍxԴiSJNN֙3gSsN 6:kN˗/ mذAO>.^h?q\Rvv""x^֭[5k,댰רQ#oN5k֨u)Q}}X!ȑ#rݪNn:5h:kpu|i:>zJOOSrJ+̀4rH {=C/SG#GTQQbbbS6mlb^Xg@H?tb&M7|:I?~uHLLԢE33gk׮p`ڴiZ~uҞ}Yر:,X@?uOz! 8P^:y<{= 8xbu:(;k۶rS࣏?X R}}u N<ݻի)Qfxk@@;y:uw)𣄄+>>:;vhڴia/&&FEEEjҤu -ZD7|u |tn]r: UUUJIIѱcǬShe˖)4F clܹs֭.\`?P3}ǘ6>r劺w|QV*11:Yf:#G?+bA'::Z;SZSNճgOUWW[G-ZPii6lh@blEFF*??_[Nտ:t:K.\hn&L`@vﯗ^z:~ԬY3m޼YOP~~ڴic@P֠ATSSc?+iӦYפI3M7ݤ"5m:Zbb-Zd8, F۷[g K.FkN˗/΀{zSH:{uEI&x< _]?.]d?q\RNN""x… x3^֭'e;кutM7YG'NPUYYi@RMMz衊 (22Rk֬Q֭S)8ҢE aÆ)љ3gvuUˁ4x`FH||G[n#VQQZli֪GNw5kuHNNVVVuAcCYRRRi&5m:?^n[)aoJLLd=p`Ĉ*++wݻWC΀m۶U^^\.u !Q/^;ZgѣGkSjƍp`ʔ)JKK h>|X:tЇ~h?/~ro>7:#EGG@S 3Fp`ɒ%okXf,X`n&Nh@H`lL ::iɒ%A}}?)Rnn^Ɖ'ԱcGm޼:~t\j.]իW[g;SVNS;wܹs3;C1cXg i& 80sLu:(%&&jѢEp`׮]AҥKJIISFiƍj޼u A֭ SG?;NFÇׁ3ޓO>qYgVZ@QQQ)ѣGvu5>CSࣈM6)5FT\\֯_ XG'NP=T]]mwުNZjk_v[5k͛7k׮)˗WZYfCp5jH7 "UUUJMMթSS8p9%''ŋ)QƍxԴiSc(&&Fź;Sࣚرc)~[SN΀>fϞm@Pz?~F-k?U\\CZSא!C3^TTu뭷Z r8ݻw[g8AtWaa"##SJK}p`*++̞=[p`ܸqJOO dggkNDFF7MfO^{-[TAACB?4 80o<\:wP\\3gZg$c[blw; +VXg^ӵg8@7N^^|I]t:~r\EEEYCÇ޽{3#}:>umYtl٢Ν;'eddhݺujذu UUU4]x:%M>]=uM|| i}g۷S\_裏S#˥\%$$XTZRAASࣣGvڵk)#GO>N.y<5h:?Q?駟Zz)}/ b"""zjYu׸qcy<r-)˗խ[7}W)K.v)QFTZZ͛[4OeeRSSui~uV?::#uIYYYp̘1C]v΀/֭[g6oެ)SXgΝ;kΜ9F^xAiiipॗ^ڵk3#Fo,Y:Xg.]nݺ:~۷ jٳ)aoҥu|g)))4iu())ы/h3g 3رcn@cl$eeeYg-[hn*=S/She˖)jWK.N}{Ӗ-[ԣGq=z)a-""Byyy[S[k۶V^-e}'4hS^WSҥKվ}{ cUXXHB}Q]]u ѣSo]%%%jذu AN#Fĉx a7t 4l0Q\\ Zg[oU?3[i֬<bccS࣋/*%%E.\NpUVV*55UONbbbT\\8c7n,ǣMZG/_VrrΜ9cΝ;a 2ڵ˭3jsUzzjkkS'Zl̙c7{9; 5k֨u)Q}}X0pnXGqqqZn4h`@@blTDDզM*==]N`hʕWg ZJn[W^N=szWeWݻ+@?tb&M7|:r?:$&&jѢE$F L͜9S]v΀ӧO3g}V;v΀/~a ƍ#0ӵa5l:௾ [uuu) ѿ=:x<͛7:@VnnuСC38@rݚ8qu())ь33Ku |{N {ر=j?JNNֶmԼysںu̙c/_nۧZ<{= 8xbu:(m*//O.:>5h [ 'OTuUYfxѿ.:u:Zh|)љ3gvuJ))):vu |"l:(F΂Ϲsԭ[7]p:@_:w}~p?~\/TVVf?]vN$׫:~u EGGPwyu |T[[={)ճgOUWW[G_~7l:(Hu)?Z>tu k…pvk„ ӺuSGwqo߮;ZHN<}:`rrrԩS' 80fm۶:@۹s'@Lv|r c&,X$ 80qD[i& 80k,=jGoSGr6oެ4I5}t @6lctAfɱVZe˖YghԨQclφ 4| ANסCSࣈM6):effjĉ)nI̴N$~Yg@bb-Zd8,GF۷[g K.blB\v|r 8w^ 8P^:@9{uEI&xԚ5kԺuk06 !E*))QÆ S3gvի)Ё4x`ƊH||n^{5уs^Sttu sgϞU^t5+..:>QZZ A7=YfxU=TQQa 5kuHNNVVVu!D<Μ9c?4h6lؠo:{O'OXdx 80bYgb{СC3@۶m'e (ŋcǎp`ھ}u0uTmܸ:L2Eiii]vSNϭSGݺuӶmt뭷Z0`h̘1Ȱ΀K,o[ !`͚5Z`upݚ8qu7c2335tP 8%KXgׯ?[G.K{S}~Ou Ν;պukƼ^222ta йsg͝;:;3fu2amڴ:̜9S]vblBLbb-Zdv8,ҥKJIISFiƍj޼u !/TNT^^n?jݺ:v:GZRAASࣣGvڵk)B@:dEDD(??_mڴNalBH\\֭[ XG'NP=T]]m Waa"##S;F D,]T۷΀ÇWYYu00{lZgqi: 6LӧON5h@;vu c:pu:z畖f͛+WZgŚ9suHJJRVVu~(c*==:hŊ„Uzzc-[Ɛ8יմi3Ϩ:~r4|eggrY0teի) ))%Af˖-4iu0 6Xg^x!q@cl\Ν5g 8P^^qYg3r:}u |nN ,YDN9R+WTttu Cȑ#3Q|| iUTTo߾NF>GYG.KJHHNo ֪U+(**:>:zRSSu5aȑ#ӧjkkSࣻKG 4N ׅ SG[oƍ[0+WZgƍN._d}W)ХKvuyQF*--UͭS F H9TVV*55UNNƶnݪ &Xg|P/u!iz衇_X}QmݺUvu CÇ׾}3EDD(??_mڴN^222~aիS#}Bc\.rss`y^=ڽ{u h…zW3#4d BաC}')kΝf*++/Z1cvj^|E[:yfM2:tYs̱clɓf^z%]:jĈz3%KԡC Bg}D){Geeej׮u 3СC3>JIIѤI3@II^|E 9s樠:;V\W@IJJҴiӬ3-[4yd ?SO/NUTT-[ZΞ=_z7SG~vء$f : ڶmիWrYG| z+׫ })p`ҥj߾u cDUXXHB}Q]]u ǏGNnvaÆ)+W{Zbu QF*--U>}S@=z4cC5k&ǣXŋJIIх SoTVV*55UONbbbT\\8 F H4nXGM6N._d9s:;wjذap]vZ|u!NCӭSG 4К5k4aFիW/ EFFj͚5jݺu |T__})9rDn[555)Q\\֭[ X16 A ""BjӦu |z[7Zr%Afzg3Y^WӦMȑ#U__o?q\;wS:pc|0`^u ?tb&M7|:QyyƏoh" 3^H@9svjO[gϞ}Yر:zᇭ3i999ٳSG#GԪUmƍmѣ3Ѽy3g͵΀:tu c? IDATn&NhJJJ4c pFznEEEi{SiGO<.\`?߿6mڤ&MX01a;~-_:۷ONGy{p`رucڶm<\.?֠AT__o:sn\bUUU)%%HttԲeKF @1|Ν;nݺ… )}m>?C:xu ߴk.G?Na?9`UXX;:>UϞ=ua֎?={:>zaÆ)8(HYF[Nտ:t:_] .΀n[&L UTT(11Q;wN}*++OS6mڤsZg@QN3O۶mlΝ 2ڵ˭3pQ@ ,P.]3ĉ[oYgu3~xmڴ:̚5KO>u!̙3zZh;vNa`ʔ)ںuuaÆ1dV^ŋ[guj*-[: 0@FF 09lذAϷ몮Nק~jEDD(??_?SyW\Qrr^}UQllJKKaB\]]Ǐ[@KLLԢE3={F;vXg ()):16 ˭3޽{5p@y^Ξ=nݺŋ)Q&MoiӦ)ZWܹsSGQQQZbMfBɓ'կ_?Y@Ⱥ;n:tM)щ'Խ{wUVVZuWSSTUTTXGW֭S c ZhR5l:>:sn^j~s }Z=zPMMu Ν;kܹpwј1c30a6mdfΜ]Zg16 h" 8k.aK.)%%EϟN5j7y)ݻw?XTVVxvڥ^x:ZVTPP(ѣrݺvu p_=tu ||i:Q0u֩A)щ'ԣGUWW[wުNZjkcjnÇCڳgu ֻᆱD͛b J5Rii)ǸHUURSSu)0s9%''ŋ)QƍxԴiSc` &&FŊNjjjcǎY~[SN΀>fϞm@X9y:uꤷ~:~ԬY3m޼YO:lٲE&M 6Xg^xs@@alnΝ;kܹp\ƍTYY)ۭӧO[G111Z~n6ŠՄ 4zh[O\.+''Gk֬EFFZG۷S |}>N\.rss`$Fiժ e=zTvu #GO>N.y<5h::~3hN!fذa㏭3 5nXGru |te%''뫯Nu%n?:>jԨJKKռysGR:uu [j„ p/l@XZzx ]t:~M6iӦ) \|Yiiizu M6)UFFU5&1j.F͉ݚ(h4(H1 8G(12(Jm$ UUqR2aZ{׬FoܹѡCN(@+++CABgjv @0`@}CϞ=㬳%=XlYv 裏_|1kg\xW_'xbvpUWŨQ3 3<]vYvp_@36 ~F3o1x ӳgϘ:ujvpǑG%W^?<͛B:cҤI}/;("wuWhZn}ΠƍW]uUv@cĈE]ݺuh@w\\q㷿mv@AO>9>h-|0k(Iovy1cƌ{/R4o<;("={7x#; {oePGoft5jjjS Nmmmt=MB=qq衇fgP4?FM6NϟrJTWWgŋG۶m2;:}cܸq6d@IZdIuQ3dЀvexgONڵkuֱzK;6nhŊqI''|P֯_mڴe˖ePG͚5~8sJQرccwN֬Y-[˗gɓ'G=3C9$ %k͚5qI'ȑ#Sh@nm6lXePGs̉]FMMMv @UVnݺh]vcvm%mذaQ^^UN>x韲S7o޼8ӌEkvqŶnB-_8;(po??36n-~dgPc„ %c^Z`92dHvE(Wt7A='x ӻwx'3k6~_dg@ɛ={v?3gfЀsxE)@;G}IxZ{cРA%s <8;zҥK\p1c_AΝ3&PsogPGM4Çǿ˿d@[xq} /dЀvy_m۶N ]v#[o5;z2eJuY% .'fgP7|swq)c_2dHv0s8<\ ?N:)VZBCŎ;%oʕ|x)!c_n;(`bҤI֬Yxc=N}p7Rg]vcvm@$@=m^{핝Bmܸ1ڵkNv Cŵ^A=l2. "sύ>}d.䒸c-N ƍ}dɒzΠziyΠ>6lXePDӠA⨣Π.˜0aBv_~裏fgP]vYk.;n!u7nNu-ƌlMv P,Y:u:իWt=;z㮻q7ggP^2&elzg}vv0lذ۳3555qꩧoBwx`v 7sOi&֯_BjٲeL0!gh„ q5dg|c=6n ᥗ^^zeg.x'3k&N< Q::#bÔ)S4rW֭[ʕ+S}k裏FGGeЀ~ĉ[Vv Pxg3>{#F-";:Z`Adž S yePGM4Ç@06 P{g5*jhɒ%ѶmۨN ̝;7:v){<2eJ'??;tĔ)S?Av P`jjjK.hѢ׾xG䪀TTTD6mbҥ)|+VD˖-cժU)ocǎw1;gl 4k,~s=Sh߾f O?Π~ŵ^7x#~_{?|uQ)@Ytik.SGYYY :4:{ꫯfgPGgώ]Fmmmv uȑ#iӦ)0c_;C=4;z/&M9rdvлw8ӳ3G/bv hvgy&ڷoɓ'{Ш\z饾n)x k&;z8/znݺegPv[KnݺӳSfFfŊ,FBzcqfnqegqW^yev0~۷ov__cƌΠ~fK36 =ظ3_|1~dg_>cٲe)Qfbo|#;;ѱc2dHv iӦqw_eee9@̟??;(a1"6mB͟??:u)|I]_NbСqAeP|;F[lEv u`hӦMlذ!;ދSN9%6nܘB}[ߊcV[mѣG';v%ğr-SbŊر*@>Ǝ;Sv uf͚hٲe|G)|EWXrev u}-y׿@16 8_>ڴiK.N`yg/Π7orKv)n83]Ƙ1cbmN ԩS]6&MNjkk{1k֬6sF:;:t c,tPv pgī&6`3={Yg|;ڵkׯNtI1a„u]S1p=ztvPB83*F&3e]A={qggP@Ѿ} ox ;ҳgϘ:ujvpǑG|~8N8Xrev ɓ'ǾݻǛo֭[G߾}3qUW]@cĈE]ݺu@;. aq饗fgЀ***O> ;:r-+;'N-Z… Sh@oL4)_)@Xzuo>֯_>8(++N̙]vHmmmt=MB=qq衇fgPD#GMfPGϏSN9%Sh`/mFeeev uǸqbmN>믿-Z9sdЀcx߳S׿5~_ggEj]vcvmBX"Zl|Iv lѦMXlYv uԬYxc=Ni IDAT36 >Ǝ;cv uf͚hٲe,_<;dѣG C!Cdgދ͛K/B~G:d`Ȑ!1lذ 4m4w}SҥK[)l&^i&S=3F[mUv Q5i$p@v uT[[ݻwYfes=+ L.]>?NmE]_odgEqggP}uM4)zA=qq뭷fgЈJ5\'xbvpWƨQ3Hrĉ3[n%9 3[.ZjwuWv ,[vmիS"йs 31cM7ݔ@СC3s=7> )c@:O>øqꫯ QUUUt!h-#F|8sNW>כosNvP82dHv0sڵkf΋_~9;z?3h%iv?O Ȝ9sk׮QSS@?0Zj֭No=Xmmm\zq]9l|x ?fgYf1|vmS˗Gyyy]6;dѺuXhQv u[ȑ#cN16 &M}{v ubŊ8餓O>N1cFs9qzAm۶QQQB:3cȐ!Gunj334`~Amܸ1ڵkNv ŋ]vQYYB}ߍ[n%;F(Prz?ϲ3ԩS̛7/;FdgP^{z~SF*:v˗/N~yTXpav bѮ]X~}v u6_@#`l( zhyƍC{eP`F^{mvutV[mњ5kOCf@o0`@vЈ-X v?Ft%;::ciӦYgAzo~3;dFЧO / &dgP=Xvu{FN3zظqcuYqWf@8hӦMvЈ=ѿ YfA⮻@q7ggP[newy$36 }'ZjA 6,n XMMMt)xK. jkk+ . jjjshv-;h.Ҙ4iRvHlvq9dgP/R\tEK.$| G;fg(P:hڴiv_`ʔ)qgggPV^[+Wf| K.aÆ6]w5n ۸qco>,Y4m۶ ,X }+SO=5͛am۶$26 =?m,Ym۶ܹscǎQ]]СCv% > ;MI&ѿ {kqfgwjvEfѵkרNs?v-;$F־}@=bҤI뮻.F}#:*~6>:N8 +b@j3x ;"?';Ѵi(// Q |Awߝ@N;-O8ߎ͛ǴiӲS؄.Ν;|$p?q?~|';"ׯ_3fLvxel(Z;sp|_|1. J룼<-[ghѢEv |qGSO=&ҢE8C3FnҥѩSظqcv 5o<;0ԩSTWWgPjkk[ng7oeee$06 -ZD&.6F ,6mĆ S(^r)C=45klk֬VZóSDz葝'_lF_|5kD˖-㣏>ND^:cʕ)|}/;V-֭O>9.]@yg/Sloo&aÆҥK 0 ;MSOv);(]w]7.;L܏ojkkNYfePbΝzjd)7o@c@:蠃gyf̘1#;5`3x`v D^ /pyvmEvPjkk[nfUW]=Pv%'w| JQh> o1x J\Ϟ=cԩ|; @8p`t5S ubŊСClذ!;h`7>ƍ*;wLj#3n(P4i{wvgq饗fg@TTT'|Av g}N}'pB^:;/㏏-";(SN>}dg 5eΜ9ѵkרNFcڴi)g7dl(J[ouv3o޼СCTWWg@DD,^8:t6lNog=qƲe˲Svy8ó3r뭷Ƙ1c3|';Yrel2>G۶m㣏>NoDYYYvQ( ͚5kuֱbŊq9dg7oP^yNK8CR[[gyf)@fmk_ZvQSS;wz+;/nGUUUv _.;(Pv$޽{̚5+;>=C &;qGƌ3S<0;(0+WX~}v x7 T&M޽{gg7p(P0q+cԨQ?8qbvFs~ұdɒ8ꨣgN__׸袋3Ma̘1qM7eg8p` :4;p(EF6d'qW__*:t~vJIk֬Yegɚ5ke˖fPGI?9oqfgfΜ]vBw^%/PJڜ9sk׮QSSuFVbݺu)%|UΞReeer)qmePnml&>7=U򗿌9sdgw|6UkQ^^k׮N:֭[ǢESJ(P>섒bŊhٲ3f9眓QVZeЈ";$_>;(2555_*.Bg`lt8[mzQ@[fMk~M|ٸqck.yŋGv2;dZ*;(P<ܔ&:woVv |)w_ 0 ;$y@ah(O?=SFKM9 b֬YqYgeg7k׮N׿uL0!;ɓ'{h"g8cl(J~O>OdgWһwx'3J_è+А [oz#;f'_w}wv᳉ 6, _ɟ;;$8&c@њ>}zvB&Lկ_xDz3ZMMM̜93;zXbEvF[pavPbV\w\;6;mذ!> 6㛖a|֫WxW3J&6wuWv4?n쌢PEf6lX 4(;\MMMt)x씢׿5/_AX`AvB72TTTDc)%m޼yQ]]fb`|rlѦM׆6w}7;"0}XrevFz饗W^Y\r%Ofg5kx@26 ?~쌢4eʔ8쳳3`Yzunڃ H<Û?~vP㗿e/;d͝;7;N(*sNh,XgqFfٳgg'P_(J ,ذaCv lqꩧƼySҋ/UUU$06 5Uޒ%Km۶QYYܹscǎQ]]RtĴiӲښ5k 鮾֭[lܸ1; lFnX";hL:5;7n\r-%cʔ)  7hӦM,]4;6+VD˖-cժU)EǙ t#?O]Ljjj`3>}zvBQ5kV]6; lܸ1N9gǪU3(oVE㦛nÇgg@1c5\Q4MoFvIE*|쌢0hРjkk[nw6/;"'[oQMy㨣 d3g*A3fN( Ʒg…ѥKN)Z^,Ȧvg'G߾}3Qc̘1Exfl(z~0M81zꕝm۶|씂_ ̳>PjkkcĉٳgG_N)j=\v ^xᅨ(x=Xv@yꩧ(ZƍNȌ1"3 ڼyCQ]]Bmmmt=fϞR6n#F QM<9^}쌂`h߾̟??:t7nN)Xc30f̘ .T .#<2&MRF@?f͚Q֯_o Kׯ_?>;YƐ<ܲe|g ֬Y[+Vd@jժ8cʕ)kѱdɒ J_P***M6th}ٸ⋳3 ڵkoΠ=s:l>hvZbE{=:;+dg'N(hN)*?xTTTdgPڨ(8ѽ{w#Ν:t씂tM7e'(Pzx׳3 NW_Fmqwgg!CG}Az*;ƢES(R'|Mwߍ}'jk(W^ye/;UTT{~av IbXpan)gϱz6쌂tַ);,Leee)aܸqQ^^555)蕕ň#})EK/egIvr}Ŕ)S3 o߾P0***UVdn( Ggg;(Pp.޽{lܸ1;u]FKƍ{(HÆ 34 \wuSOegAM:5쌂0gΜڵQ8cڴi)04 @DDEAq!ԩSiӦ)C˗g@9c„ [g4ZovxQQQBK(xo}+.]j*xfmS NEEE~F}'zh㠃Yfeg]v%O6? IDATS ҆ v,Y$;"nŜ9sbvNiVX?я⭷No;^yu]SիWǿ˿ @DD'PROwuWvFf͚hٲQ&O=zh.BClbݺu44 qOS:;DD;&L((&L04 }ѩSN)H#G44fW\hD.] {E6m|/W^yqEvѱciSرc )P^{c=߲SÇ_A=#;씂U[[gyf|)_… '-[;쐝S>СnǪUcǎk׮` Ǻu?yvJANJ+S(ӦMc=6oe4:}?OP,XV?>;y嗣GQSS@#Qa&M*;Ѹ++['?IvJ1o޼XjUv %d]wz+vq씂4rHb@Qs=㩧:(;ҥKw}42SLG#<Z(Zeee1z(//N)޽{ggPbk1cF|_Ni4ƌڵZq?18Fcʕq!ShD%wq7fg4 ƍ򨩩Nn+7SUVVGӧON]|q 7dgoVv &;#<-ZNiz83h;xꩧ3~zv @Qibڴi>d4zq|ߍ?8;{,ʲS͜937ok׮NѬYxoڴicǎi1nܸt3f̈.]M?O>9V^ .04J3 ]weh(J+V;.{FiʕqfgH= /dg4jC54 \2ڷo)~;Cy'⦛nHhѢhժQ***<,X[o54 *,lMm۶MRfΜC )4p5kD=8uJYxquQF 'ƀ2}رccцFi.]) κubFFkʔ)ѿ(--MR-Z44ꫯi55~⤓N24 آE4hP?>uJYzu($2s8c>HRgx(**g=M"ש#[Ɣ)S}q衇ΩU3gΌXvmh}ݘ1cFp Ѻu9"9眸R{ŲebȐ!:AزeK믿: {/IDĚ5k⡇.]DǎS|eNSxظqcNI*N=Ԙ1cF{u ;^z)vSՖ-[bС1w)^z)ڵkݺuKR-Z)̚5+͛|j*uWvưax]vQPP:+[n] 6,Ə:LNDԤh㡇C9$uʗlٲ8_N999q_-ZHȐ;:+~G&MR$QQQzj<#S:ēO>|pZc)7oNBFɓOLR/cРAϧN?͍;F:̚5+N8())I_Xv?>uʗnݺ8K|O<1&L{/e̙1rXzuv@%KDng?Yܪ;CЀ߾C>g/_<~QvXwyg :Qƀ |UVELRn6lX|TTTСCHRN04 ЀUWWѣoNR/MEEEFa}Gѯ_9rd|Gs:3M>= 'N9~{⭷ފC=4vuI<8SN_o/R 80Zl:^^:bΜ9Sʘ:ujlذ!M4Ig7pCԤ!jjj'GAAA:FQQQ̝;7u Ì3b۶mѳgWqwgS+{㮻֭[GAAAk1rȸv@cV^^'OYfE~~~ީ>Ӷmb„ 1dȐ?~vp9_@׮]WUtI  %\/b+hڴi=:kRlW^^w}w\wuz9Pco߾S3ΈkצNءt=&N:uJq'DzeRH{.7o:̜93F֭K4xu֩SjMiii}CN:ѡC ώ9ۭ\2⊘4i8O>qM7E׮]SlWYY?p\}; #|Iqãe˖I***G &ٳ]N;Űab̘1q&;ð]tQ\s5ѬY9".7nv).⋣iӦsOq7UW]shd<Ș2eJo>uWRSScǎ_QUU:/cǎCw)_oÇKN:~#Gv-ICuuuDŽ bڴim۶$@kҤI?ƌEEEѤI$qq뭷ƻロ26 i&ȑ#㨣:=_MMMk1iҤbuz> <0ƌÇ=ܳW^^1q(..6Cs!7={LR+}ٸ cũS2 ƏݺuK{8wIB#뮻رccё: {?a̛7/u yqee]yyysko[΁zQFE^yu~+Vă>wuWu~> ǙgrJtرϷu֘5kV{1uزeK(@-7Dc_>1cFիk%'''v8cje*,Ygώz*^z%4AD :4S|)oV?gy&u @ĠA⪫.]_+2N:;;C9$uRVVcܸqm۶9ԲN:Ÿq㢰0u6uO~b"uqǀc8V[^^/bnj3br\`o}+ 0FVj˗/9sĴigZ9.7F^{GqDt%:too>ڶmyyy}-cӦMq())XlY,Y$/^K,+WFMsOk޼yt9g}uѦM_QVVeeeqXre{r_-83?q|N,Z(nx dԱ1bD\zIGGϟo駟 RӦMcԨQq7ߩJKKcqM7@#лw㨣JĴibر1gΜ9`}_#8"9w!ڶm-Z6mD&MbÆ QZZ%%%QRR+WŋҥKcűb iӦA=/ڴi=͛cƍjժXjU\2^{X`A_>26 ͍\{Non>hz/hw?O-[ɓaK.nݺΉumv[|ǩsg?]tQ 0 4i:'ncرo`fl w}cȐ!1dȐ޽{&())x'caÆ$|Z6mQXX{ڱ.]f͊~:f̘vlo|p|1|رc4x@^qiã^]UU/B$6meeeQYY:ԩSz衱~mg}b]ve]>7o-[ƍcݺunzwb…EڵkѩSܹsm۶֭[GVbwQVVaÆO=/X",XqG@c`lN;ƍG7O@jѶmشiSTWWcal2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 alt IDAT2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #@F06 al2(dQcF #cd[+&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (s2HLF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Q~)9 IDATl&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 QعA $l&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0;w 0_$ (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (Li IDATF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 s2HQl&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 QlعA $&d0! (LF`B6 Ql&dP IDAT0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (@܁ {|LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 Ql&d0! (LF`B6 QEz4IDATl&d0! (LF`B6 Ql&d0! (LzdlIENDB`xknx-3.6.0/docs/binary_sensor.md000066400000000000000000000031101475530762600166510ustar00rootroot00000000000000--- layout: default title: Binary Sensor parent: Devices nav_order: 1 --- # Binary Sensor ## [](#header-2)Overview Binary sensors which have either the state "on" or "off". Binary sensors could be e.g. a switch in the wall (the thing you press on when switching on the light) or a motion detector. The logic within switches can further handle if a button is pressed once or twice - and trigger different actions in HA. Use the attribute `counter` for this purpose. ## [](#header-2)Interface - `xknx` is the XKNX object. - `name` is the name of the object. - `group_address_state` is the KNX group address of the sensor device. - `invert` inverts the payload so state "on" is represented by 0 on bus and "off" by 1. Defaults to `False` - `sync_state` defines if and how often the value should be actively read from the bus. If `False` no GroupValueRead telegrams will be sent to its group address. Defaults to `True` - `ignore_internal_state` allows callback call regardless of the current binary sensor state. Defaults to `False` - `context_timeout` time in seconds telegrams should be counted towards the current context to increment the counter. If set `ignore_internal_state` is set `True`. Defaults to `None` - `reset_after` may be used to reset the internal state to `OFF` again after given time in sec. Defaults to `None` - `device_updated_cb` Callback for each update. ## [](#header-2)Example ```python binarysensor = BinarySensor(xknx, 'TestInput', group_address_state='2/3/4') xknx.devices.async_add(binarysensor) # Returns the last received Telegram or None binarysensor.last_telegram ``` xknx-3.6.0/docs/changelog.md000066400000000000000000001465421475530762600157440ustar00rootroot00000000000000--- layout: default title: Changelog nav_order: 2 --- # Changelog # 3.6.0 DPT helpers and timezone 2025-02-19 ### Devices - Datetime: Accept `datetime.tzinfo` for `timezone` argument to send time information for specific timezone. Boolean works like before: If `True` use system localtime. If `False` an arbitrary time can be sent. ### DPT - Add `DPTBase.dpt_number_str` and `DPTBase.dpt_name` classmethods for human readable DPT number (eg. "9.001") and class name (eg. "DPTTemperature (9.001)"). - Add `DPTBase.get_dpt` classmethod to get a DPT class by its number, name or DPTBase type. ### Internal - Collect group addresses with decoding errors at eager decoder in `xknx.group_address_dpt.ga_decoding_error` set. # 3.5.0 Swing it 2025-01-28 ### Devices - Climate: Added swing and horizontal swing support to climate device # 3.4.0 8 byte energy and 4 byte pressure 2024-11-20 ### Devices - Weather: Support either DPT 9.006 (2byte) or DPT 14.058 (4byte) for `group_address_air_pressure` ### DPT - Add DPT 29 - 8byte signed definitions: generic, 29.010, 29.011, 20.012 ### Management - Add rate limit (in packets per second) option to P2PConnection. - Fix typo in management procedure (`nm_invididual_address_write` was renamed to `nm_individual_address_write`) - Fix TunnellingFeatureResponse missing `return_code` # 3.3.0 Climate humidity 2024-10-20 ### Devices - Climate: Added humidity support # 3.2.0 Climate Fan speed 2024-09-23 ### Devices - Climate: Added fan speed support # 3.1.1 Fix Eberle status 2024-08-19 ### Bugfixes - Fix DPTHVACStatus inverted bit order # 3.1.0 DPT 1 2024-08-13 ### DPT - Add DPT 1 definitions (as of KNX Specification 03_07_02 version 02.02.01) ### Devices - ClimateMode: Restore `Climate.suppports_operation_mode` and `Climate.supports_controller_mode` to be `True` when read-only (like pre 3.0.0) - ClimateMode: Filter custom controller / operation modes for available settable modes - ClimateMode: For binary operation modes, only list configured modes and `Standby` in `operation_modes` ### Bugfixes - Fix log message for DPT decoding errors in `GroupAddressDPT` parsing # 3.0.0 Eager telegram decoding, DPTComplex and DPTEnum 2024-07-31 ### Breaking changes - Drop support for Python 3.9 - Change callback signatures from awaitable to callable in `XKNX.device_updated_cb`, TelegramQueue, Device, Devices, ConnectionManager and RemoteValue. - Remove `async` from functions / methods (nothing has to be awaited there) - Tools: `group_value_write`, `group_value_response` and `group_value_read` - ConnectionManager: `.connection_state_changed` - Device: `.process`, `.process_group_write`, `.process_group_read`, `.process_group_response` - Devices: `.process` - RemoteValue: `.set`, `.respond`, `.process` and `.update_value` - ValueReader: `.send_group_read` - Rename DPT transcoder modules for schema `xknx.dpt.dpt_.py` ### Bugfixes - Fix value scaling for sensor types: time_period_100msec, time_period_10msec, delta_time_10ms, delta_time_100ms, percentV16 ### Features - Added eager telegram data decoding for GroupValueWrite / GroupValueResponse Telegrams. DPTs for group addresses can be set using `xknx.group_address_dpt.set()`. `Telegram` has a new attribute `decoded_data` which is set when a decoder was found. ### Devices - A Device doesn't auto-add to `xknx.devices` anymore. It can be done via `xknx.devices.async_add()` now. `xknx.devices.async_remove` stops a device from processing telegrams, removes from StateUpdater and cancels its internal tasks. Removed devices can be added again. - `Device.shutdown` method is removed - Refactor `ClimateMode` device - Rename `ClimateMode` argument `group_address_operation_mode_night` to `group_address_operation_mode_economy` - Remove DPT 3 special handling `stepwise_*` and `startstop_*` from Sensor device - Remove `DateTime` device in favour of `DateDevice`, `TimeDevice` and `DateTimeDevice` using `datetime` objects instead of `time.struct_time` ### DPT - DPTComplex: Common interface for DPT transcoders with multi-value data. Resulting dataclasses can be converted to and from a dict with DPT specific properties to be JSON compatible. - Added or refactored complex DPTs and dataclasses: - 3.007 - DPTControlDimming - 3.008 - DPTControlBlinds - 10.001 - DPTTime - KNXTime - 11.001 - DPTDate - KNXDate - 18.001 - DPTSceneControl - 19.001 - DPTDateTime - KNXDateTime - 232.600 - DPTColorRGB - RGBColor - 235.001 - DPTTariffActiveEnergy - TariffActiveEnergy - 242.600 - DPTColorXYY - XYYColor - 251.600 - DPTColorRGBW - RGBWColor - 20.60102 - DPTHVACStatus - HVACStatus (removed DPTControllerStatus in favour of this) - DPTEnum: Common interface for DPT representing enumueration values. Transcoders accept Enum, string or raw integer values for encoding. - 1.007 - DPTStep - 1.008 - DPTUpDown - 1.100 - DPTHeatCool - 20.102 - DPTHVACMode - HVACOperationMode - rename "NIGHT" to "ECONOMY" and "FROST_PROTECTION" to "BUILDING_PROTECTION" according to KNX specifications - 20.105 - DPTHVACContrMode - HVACControllerMode - rename "DRY" to "DEHUMIDIFICATION" and add some values according to KNX specifications - Change DPT number of Enthalpy from 9.999 to 9.60000 (manufacturer specific range) - Support dict values with "main" and "sub" keys for `DPTBase.parse_transcoder()` - Verify DPTBinary max payload bitsize when decoding by transcoders `payload_length` ### Address - `InternalGroupAddress` attribute `address` is renamed to `raw` to be in line with `GroupAddress` (although still str). Its value has an "i-" prefix. ### Internal - Use `slots` in addresses, Telegram, DPTBinary, DPTArray, TPCI, APCI, DPTComplexData - Convert Telegram and APCI to dataclasses. `Telegram` is not hashable anymore. - RemoteValue instances use pre-decoded data from Telegrams if available and `dpt_class` for is set - otherwise they decode the data themselves in `from_knx` like before. - Remove RemoteValueControl and unused RemoteValue1Count class - Add value argument to RemoteValue `after_update_cb` callback # 2.12.2 Fix thread leak 2024-03-05 ### Bugfixes - Fix thread leak when initial connection attempt fails (on threaded connection mode). # 2.12.1 Address error messages 2024-02-26 ### Internal - More detailed address parsing error messages. # 2.12.0 Broadcasts 2024-02-05 ### Bugfixes - `None` is not a valid address parameter for GroupAddress and IndividualAddress anymore. It raises `CouldNotParseAddress`. - `None` in a RemoteValue or Device group address list is now ignored instead of parsed as broadcast address. - Broadcast address ("0/0/0") is now invalid for RemoteValue and Device group addresses and raises `CouldNotParseAddress`. ### Management - Add handling mechanism and sending method for broadcast telegrams in the management class. - Add new management procedures for device management: `nm_invididual_address_write`, `nm_individual_address_read`, `nm_individual_address_serial_number_read` and `nm_individual_address_serial_number_write`. ### Secure - Parse `project_name` from an ETS Keyring. ### Internal - Use ruff format and more ruff linters. Remove black, isort, flake8 and pyupgrade from requirements. # 2.11.2 DPT 9 small negative fix 2023-07-24 ### Bugfixes - Fix DPT 9 handling of values < `0` and >= `-0.005`. These are now rounded to `0` instead of being sent as `-20.48`. # 2.11.1 DateTime fix 2023-06-26 ### Bugfixes - Fix processing custom time data in DateTime devices. # 2.11.0 DateTime state 2023-06-25 ### Devices - Add group_address_state, respond_to_read and sync_state arguments to DateTime devices. - Add DPT 9 support for Light color temperature. ### Internals - Remove pydocstyle and flake8 plugins, add pytest-icdiff to testing requirements. # 2.10.0 Tunnelling Feature 2023-05-08 ### Protocol - Support Tunnelling Feature service messages. # 2.9.0 Spring cleanup 2023-04-22 ### Dependencies - For Python <3.11 dependency `async_timeout` is added as backport for `asyncio.timeout`. ### Internals - Replace `asyncio.wait_for` with `asyncio.timeout`. - Add Ruff to pre-commit and tox. - Use pyproject.toml for specifying project metadata. # 2.8.0 Hostnames 2023-04-12 ### Connection - Resolve IP addresses from hostname or adapter name for `gateway_ip` or `local_ip`. ### Bugfixes - Handle empty list for group addresses in RemoteValue. ### Internals - Refactor DPTBase transcoder classes - Accept `DPTArray` or `DPTBinary` in `DPTBase.from_knx()` instead of raw `tuple[int]`. - Return `DPTArray` or `DPTBinary` from `DPTBase.to_knx()` instead of `tuple[int, ...]`. - Remove payload_valid() from RemoteValue and remove payload type form its generics parameters. # 2.7.0 IP Device Management 2023-03-15 ### Protocol - Add support for Device Management Configuration service. - Support CEMI M_Prop messages. - Don't ignore CEMIFrames with source address equal to `xknx.current_address`. ### Internals - Use CEMILData instead of CEMIFrame in DataSecure. - Move `init_from_telegram()` from CEMIFrame to CEMILData. `telegram()` is now a method of CEMILData instead of a property of CEMIFrame. # 2.6.0 Connection information 2023-02-27 ### Connection - When `ConnectionConfig.individual_address` is set and a Keyring is given `ConnectionType.AUTOMATIC` will try to connect to the host of this address. If not found (in keyfile or discovery) it will raise. - Add CEMIFrame counters connection type and timestamp of connection start. ### Internals - Lower log levels for unsupported Telegrams and add more information. - Move CEMIFrame parsing from Interface to CEMIHandler. # 2.5.0 Request IA 2023-02-14 ### Connection - Use only Interfaces listed in Keyring when `ConnectionType.AUTOMATIC` is used and a Keyring is configured. - Request specific tunnel by individual address for TCP connections when `ConnectionConfig.individual_address` is set. ### Bugfixes - Parse Data Secure credentials form Keyring from non-IP-Secure interfaces. - Parse Data Secure credentials from Keyrings exported for specific interfaces. - Fix callback for Cover target position when called with same value consecutively. - Fix Windows TCP transport bug when using IP Secure Tunnelling. - Don't create unreferenced asyncio Tasks. `xknx.task_registry.background()` can now be used to create background tasks. ### Protocol - Support Extended Connection Request Information (CRI) for requesting a specific individual address on Tunnelling v2. - Add Core v2 Error Code definitions. ### Cleanups - Accept `str | os.PathLike` for Keyring path. Previously only `str`. - Rename `_load_keyring` to `sync_load_keyring` to make it public e.g. when it should be used from an executor. - Update CI. Use `codespell` and `flake8-print`. # 2.4.0 Data Secure 2023-02-05 ### Data Secure - Support KNX Data Secure for group communication. Keys are sourced from an ETS keyring file. ### Bugfixes - Fix wrong string length in keyfile signature verification for multi-byte UTF-8 encoded attribute values. ### Internals - `destination_address` in `Telegram` init is no longer optional. - `timestamp` attribute in `Telegram` is removed. - Rename `xknx.secure.ip_secure` to `xknx.secure.security_primitives`. - Return `bytes` from `BaseAddress.to_knx()` instead of `tuple[int, int]`. This is used in `IndividualAddress` and `GroupAddress`. - Add `BaseAddress.from_knx()` to instantiate from `bytes`, remove instantiation form `tuple[int, int]`. - Refactor APCI to return complete Subclass `APCI.from_knx()` and removed `APCI.resolve_apci()`. # 2.3.0 Routing security, DPTs and CEMI-Refactoring 2023-01-10 ### DPTs - Add definitions for DPTs - 7.010 "prop_data_type" - 8.012 "length_m" - 9.009 "air_flow" - 9.029 "absolute_humidity" - 9.030 "concentration_ugm3" - 12.001 "pulse_4_ucount" - 12.100 "long_time_period_sec" - 12.101 "long_time_period_min" - 12.102 "long_time_period_hrs" - 13.016 "active_energy_mwh" - 14.080 "apparent_power" ### IP Secure - SecureRouting: verify MAC of received TimerNotify frames. - SecureRouting: verify and handle timer value of received SecureWrapper frames after verification of MAC. - SecureRouting: Discard received unencrypted RoutingIndication frames. ### Internals - Move `CEMIFrame`, `CEMIFlags` and `CEMIMessageCode` to xknx.cemi package. - Remove `CEMIFrame.telegram` setter in favour of `init_from_telegram()` staticmethod; convert `from_knx()` and `from_knx_data_link_layer()` to staticmethods returning a CEMIFrame. - Remove default values for `CEMIFrame` constructor. - Parse T_Data_Broadcast TPCI. Forward these telegrams to the Management class. - KNXIPHeader total_length is 2 bytes long. There are no reserved bytes. - Revert handling L_Data.req frames for incoming device management requests. - Decouple CEMIFrame handling from IP interface - Add CEMIHandler class. This class handles incoming CEMIFrames and dispatches them to the upper layers as Telegram objects and creates CEMIFrames from Telegram objects to be sent to the network. - Use `CEMIFrame` instead of `Telegram` in KNXIPInterface. # 2.2.0 Expose cooldown 2022-12-27 ### Devices - ExposeSensor: Add `cooldown` option to allow rate-limiting of sent telegrams. - ExposeSensor: Add `respond_to_read` option. ### Connection - Disconnect when tunnelling sequence number (UDP) gets out of sync. ### Internals - Add `task.done()` to TaskRegistry tasks. - Decouple KNXIPFrame parsing from CEMIFrame parsing. TunnellingRequest and RoutingIndication now carry the raw cemi frame payload as bytes. This allows decoupled CEMIFrame parsing at a later time (in Interface class rather than in KNXIPTransport class) for better error handling and upcoming features. - Make KNXIPFrame body non-optional. Return KNXIPFrame object and remaining bytes from `KNXIPFrame.from_knx()` staticmethod. - Add new logger `xknx.cemi` for incoming and outgoing CEMIFrames. - Remove timestamp and line break in knx and raw logger. # 2.1.0 Enhance notification device 2022-11-29 ### Devices - Notification: Add `respond_to_read` option. - Notification: Rename `self._message` to `self.remote_value`. # 2.0.0 Find and Connect 2022-11-25 ### Interface changes - Removed `own_address` from `XKNX` class. `ConnectionConfig` `individual_address` can be used to set a source address for routing instead. If set for a secure tunnelling connection, a tunnel with this IA will be read from the knxkeys file. - Disable TelegramQueue rate limiting by default. - Separate discovery multicast group from routing group. Add `multicast_group` and `multicast_port` `ConnectionConfig` parameters. ### Connection and Discovery - Use manually configured IP secure tunnel password over loading it from keyring. - GatewayScanFilter now also matches secure enabled gateways by default. The `secure` argument as been replaced by `secure_tunnelling` and `secure_routing` arguments. When multiple methods are `True` a gateway is matched if one of them is supported. Non-secure methods don't match if secure is required for that gateway. - Self description queries more information from Core v2 devices via SearchRequestExtended. ### Features - Add support for python 3.11 - Add methods to Keyring to get interfaces by individual address (host or tunnel). ### Internal - Remove `InterfaceWithUserIdNotFound` and `InvalidSignature` errors in favor of `InvalidSecureConfiguration`. - Keyring: rename `load_key_ring` to `load_keyring` and make it a coroutine. ### Management - Fix APCI service parsing for 10bit control fields. - Set reasonable default count values for APCI classes. - Set xknx.current_address for routing connections so management frames received over Routing are handled properly. - Fix wrong length of AuthorizeRequest. - Raise sane error messages in Management. # Bugfixes - No mutable default arguments. Fixes unexpected behaviour like GatewayScanner not finding all interfaces. # 1.2.1 Hotfix release 2022-11-20 ### Bugfixes - Fix Latency parsing in .knxkeys keyring files # 1.2.0 Secure Routing 2022-10-10 ### Features - We now support KNXnet/IP Secure multicast communication (secure routing) in addition to tunnelling! Thanks to Weinzierl for providing us a router for testing purposes! - Parse `latency` from a .knxkeys keyring files `Backbone` tag. - Use `multicast_group` from a .knxkeys keyring files `MulticastAddress` tag (Routing). - Support InternalGroupAddress in xknx.tools package. ### Protocol - Add TimerNotify frame parser ## 1.1.0 Routing flow control 2022-09-26 ### Added - Convenience functions for KNX group communication (`xknx.tools`) ### Routing - Support flow control for routing ### Protocol - Add RoutingBusy frame parser - Add RoutingLostMessage frame parser ## 1.0.2 Route-back reconnect 2022-08-31 ### Bugfixes - Fix expected sequence counter reset for UDP Tunnelling connections with route_back enabled. ## 1.0.1 Handle UDP hickups 2022-08-24 ### Bugfixes - Correctly retry sending a TunnellingRequest if no TunnellingAck was received for the first time for UDP tunnelling connections. - Ignore repeated TunnellingRequests received from UDP tunnelling connections. - Properly log repeated heartbeat errors ## 1.0.0 Support for lukewarm temperatures 2022-08-13 ### Internal - Fix DPT2ByteFloat numeric range issues - Fix keyring parsing - We can now correctly parse 20,48 °C thus xknx is now a stable library ## 0.22.1 Wrong delivery 2022-07-29 ### Management - Ignore received telegrams addressed to individual addresses other than XKNXs current address ## 0.22.0 Management 2022-07-26 ### Management - Add support for creating point-to-point connections to do device management - Add `nm_individual_address_check` procedure to check if an individual address is in use on the network - Add `dm_restart` procedure to request a basic restart of a device - Remove PayloadReader class. Management procedure functions should be used to request data from individual devices. ### Internals - Optionally return a list of Telegrams to be sent to an incoming request as reply. This is used for incoming device management requests. Callbacks for incoming requests (in Interface subclasses) are now handled in an asyncio Task. - Incoming L_DATA.req frames are confirmed (L_DATA.con) and replies / acks are sent as L_DATA.ind ## 0.21.5 Secure discovery bugfix 2022-06-28 ### Bugfix - Fix GatewayDescriptor parsing when SearchResponseExtended DIBs are in unexpected order ## 0.21.4 Fan out 2022-06-07 ### Devices - Fan: Add support for dedicated on/off switch GA - Sensor: Set `unit_of_measurement` for DPTString to `None` ### Internals - Lock sending telegrams via a Tunnel until a confirmation is received - Use device subclass for `device_updated_cb` callback argument type hint - Fix CEMI Frame Ack-request flag set wrongly ## 0.21.3 Cover updates 2022-05-17 ### Devices - Cover: call `device_updated_cb` periodically when cover is moving - Cover: auto-send a stop for covers not supporting setting position - Cover: add `invert_updown` option to decouple updown from position - Cover: fix travel time prediction when receiving updates from bus while moving ### Protocol - Parse and encode different TPCI in a CEMIFrame or Telegram - Set priority "System" flag for point-to-point CEMI frames initialized by a Telegram ## 0.21.2 IP Secure Bug fixes 2022-05-04 ### Bugfixes - IP Secure: Fix MAC calculation for 22-byte payloads - IP Secure: Fix Keyring loading ### Internals - Rename TaskRegistry.register and Task `task` attribute to `async_func` to avoid confusion; return Task from `start()` ## 0.21.1 Fix Task Registry 2022-05-01 ### Bugfixes - Fix exposure of datetime, time and date objects to the Bus again ### Internals - TaskRegistry takes functions returning coroutines instead of coroutines directly ## 0.21.0 Search and connect 2022-04-30 ### Discovery - Use unicast discovery endpoint to receive SearchRespones frames - Send SearchRequest and SearchRequestExtended simultaneously when using GatewayScanner - Skip SearchResponse results for Core-V2 devices - wait for SearchResponseExtended - Identify interfaces having KNX IP Secure Tunneling required and skip if using Automatic connection mode - Only send SearchRequests from one interface for each `scan()` call - Connect to next found interface in case of unsuccessful initial connection when using "automatic" mode ### Internals - Use `ifaddr` instead of `netifaces` - make HPAI hashable and add `addr_tuple` convenice property ## 0.20.4 Fix exposure of time and date 2022-04-20 ### Bugfixes - Fix exposure of datetime, time and date objects to the Bus ### Protocol - Add DIBSecuredServiceFamilies and DIBTunnelingInfo parser ### Internal - Include base class in `DPTBase.parse_transcoder()` lookup - Move `levels` instance attribute form `GroupAddress` to `address_format` class variable - Remove xknx form every class in the knxip package: CEMIFrame, KNXIPFrame and KNXIPBody (and subclasses) - Remove xknx form every class in the io.request_response package - Remove xknx form io.transport package and io.secure_session and io.self_description modules ## 0.20.3 Threading fixes 2022-04-15 ### Devices - Notification: add `value_type` argument to set "string" or "latin_1" text encoding ### Bug fixes - Fix call from wrong thread in ConnectionManager - Fix thread leak when restarting XKNX ### Internal - Change RemoteValueString to _RemoteValueGeneric subclass ## 0.20.2 Handle shutdown properly 2022-04-11 ### Bug fixes - Properly shutdown climate mode if climate.shutdown() is called and ClimateMode exists ## 0.20.1 Add support for DPT 16.001 and SearchRequestExtended 2022-04-05 ### Features - Add support for SearchRequestExtended to find interfaces that allow IP Secure - Use XKNX `state_updater` argument to set default method for StateUpdater. StateUpdater is always started - Device / RemoteValue can always opt in to use it, even if default is `False`. - Add support for DPT 16.001 (DPT_String_8859_1) as `DPTLatin1` with value_type "latin_1". ### Bug fixes - Stop SecureSession keepalive_task when session is stopped (and don't restart it from sending STATUS_CLOSE) - Fix encoding invalid characters for DPTString (value_type "string") ## 0.20.0 IP Secure 2022-03-29 ### Features - We now support IP Secure! Thanks to MDT for providing us an interface for testing purposes! - Add support for requesting tunnel interface information ### Protocol - add SessionRequest, SessionResponse, SessionAuthenticate, SessionStatus, SecureWrapper Frame parser ### Internals - Drop support for Python 3.8 to follow Home Assistant changes - Return `bytes` from to_knx() in knxip package instead of `list[int]` - Add a callback for `connection_lost` of TCP transports to Tunnel ## 0.19.2 TCP Heartbeat 2022-02-06 ### Connection - Do a ConnectionStateRequest heartbeat on TCP tunnel connections too ### Devices - Handle invalid payloads per RemoteValue, log a readable warning ## 0.19.1 Bugfix for route_back 2022-01-31 ### Connection - Tunneling: Fix route_back connections sending to invalid address ### Protocol - add DescriptionRequest and DescriptionResponse Frame parser ## 0.19.0 Tunneling connection protocol 2022-01-18 ### Devices - Handle ConversionError in RemoteValue, log a readable warning ### Connection - Raise if an initial connection can not be established, auto-reconnect only when the connection was successful once - Add support for TCP tunnel connections - Optionally run KNXIPInterface in separate thread - Handle separate Tunneling control and data endpoints - Fix rate limiter wait time: don't add time waiting for ACK or L_DATA.con frames to the rate_limit ## Internals - Some refactoring and code movement in the io module - especially in KNXIPInterface; renamed UDPClient to UDPTransport - Cleanup some list generating code in the knxip module ## 0.18.15 Come back almighty Gateway Scanner 2021-12-22 ### Internals - Fix Gateway Scanner on Linux ## 0.18.14 Tunnelling flow control 2021-12-20 ### Internals - Tunnel: Implement flow control according to KNX spec recommendations: wait for L_DATA.con frame before sending next L_DATA.req with 3 second timeout - Logging: Some changes to loggers like `knx` now includes the source/destination HPAI and a timestamp - Fix a rare race-condition in the gateway scanner where a non-existing interface was queried ## 0.18.13 Hold your colour 2021-11-13 ### Internals - Fix GatewayScanner on MacOS and Windows and only return one instance of a gateway ### Devices - Light: Only send to global switch or brightness address if individual colors are configured to not overwrite actuator colors - Light: Debounce individual colors callback to mitigate color flicker in visualizations ## 0.18.12 Add always callback to NumericValue and RawValue 2021-11-01 ### Internals - Gatewayscanner now also reports the individual address of the gateway - Outgoing telegrams will now have the correct source_address if tunneling is used ### Devices - Added `always_callback` option to NumericValue and RawValue ## 0.18.11 Task Registry 2021-10-16 ### Internals - Stop state updater if connection is lost and restart if restored - Add central task registry to keep track of tasks spawned in devices ## 0.18.10 Connection Manager 2021-10-13 ### Internals - DPTString: replace invalid characters with question marks in `to_knx` - Catch and log exceptions raised in callbacks to not stall the TelegramQueue - Handle callbacks in separate asyncio Tasks - GatewayScanFilter: Ignore non-gateway KNX/IP devices - Introduce connection state change handler ### Home Assistant Plugin - Properly handle disconnected state in the UI. ## 0.18.9 HS-color 2021-07-26 ### Devices - Light: Support for HS-color (DPT 5.003 hue and 5.001 saturation) ## 0.18.8 Position-only cover 2021-06-30 ### Devices - Cover: enable `set_up` and `set_down` with `group_address_position` only (without `group_address_long`). ## 0.18.7 RawValue 2021-06-18 ### Devices - Add RawValue device. - Remove unused HA-specific attributes (unique_id, device_class, create_sensors). - Climate: add `group_address_active_state`, `group_address_command_value_state` and a `is_active` property. - Configurable `sync_state` in all devices. ## 0.18.6 NumericValue 2021-06-11 ### Devices - Add `respond_to_read` option to Switch. If `True` GroupValueRead telegrams addressed to the `group_address` are answered. - Add NumericValue device. ### Internals - Add RemoteValueNumeric for values of type `float | int`. - Fix DPTBase classmethod return types ## 0.18.5 DPTNumeric 2021-06-08 ### Internals - `DPTBase.parse_transcoder` is now a classmethod to allow parsing only subclasses. - Add `DPTNumeric` as base class for DPTs representing numeric values. ## 0.18.4 ClimateMode bugfix 2021-06-04 ### Bugfix - ClimateMode: Fix telegram processing when operation_mode and controller_mode (heat/cool) are both used ## 0.18.3 XYY colors 2021-05-30 ### Devices - Light: Support for xyY-color (DPT 242.600) ## 0.18.2 Climate and Light improvements 2021-05-11 ### Devices - Climate: Make `setpoint_shift_mode` optional. When `None` assign its DPT from the first incoming payload. - Light: Support individual color lights without switch object ## 0.18.1 Internal group addresses 2021-04-23 ### Devices - Add InternalGroupAddress for communication between Devices without sending to the bus. ### Internals - RemoteValue.value changed to a settable property. It is used to create payloads for outgoing telegrams. - RemoteValue.update_value (async) sets a new value and awaits the callbacks without sending to the bus. - Round DPT 14 values to precision of 7 digits ## 0.18.0 ## Devices - Add support for cover lock - ExposeSensor values can now be read from other xknx devices that share a group address - Add more information to sensors and binary sensors in the HA integration ### Breaking Changes - Remove configuration handling from core library (use https://xknx.io/config-converter) ### Internals - Drop support for python 3.7 - use pytest tests instead of unittest TestCase - Move RequestResponse and subclasses to xknx.io.request_response.* - Move ConnectionConfig to xknx.io.connection - Store last Telegram and decoded value in RemoteValue - Improve CI to use Codecov instead of Coveralls for code coverage reports ## 0.17.5 Add support for unique ids 2021-03-30 ### HA integration - Add experimental (opt-in) support for unique ids ### Internals - Remove unfinished config v2 ## 0.17.4 Bugfix for ValueReader 2021-03-26 ### Internals - Comparing GroupAddress or IndividualAddress to other types don't raise TypeError anymore - Specify some type annotations ## 0.17.3 Passive addresses 2021-03-16 ### Devices - Accept lists of group addresses using the heads for group_address / group_address_state and the tails for passive_group_addresses in every Device (and RemoteValue) - Sensor: Don't allow floats in DPTBase value_type parser ## 0.17.2 Value templates 2021-03-10 ### Devices - BinarySensor, Sensor: add `ha_value_template` attribute to store HomeAssistant value templates ### Internals - Distribute type annotations ## 0.17.1 Cover up 2021-02-23 ### Devices - Cover: Use correct step direction when stopping ### Internals - Convert all Enums to upper case to satisfy pylint ## 0.17.0 Route back 2021-02-19 ### New Features - Add new optional config `route_back` for connections to be able to work behind NAT. - Read env vars after reading config file to allow dynamic config. ### HA integration - knx_event: fire also for outgoing telegrams ### Devices - BinarySensor: return `None` for `BinarySensor.counter` when context timeout is not used (and don't calculate it) - Climate: Add `create_temperature_sensors` option to create dedicated sensors for current and target temperature. - Weather (breaking change!): Renamed `expose_sensors` to `create_sensors` to prevent confusion with the XKNX `expose_sensor` device type. - Weather: Added wind bearing attribute that accepts a value in degrees (0-360) for determining wind direction. ### Internals - RemoteValue is Generic now accepting DPTArray or DPTBinary - split RemoteValueClimateMode into RemoteValueControllerMode and RemoteValueOperationMode - return the payload (or None) in RemoteValue.payload_valid(payload) instead of bool - Light colors are represented as `Tuple[Tuple[int,int,int], int]` instead of `Tuple[List[int], int]` now - DPT 3 payloads/values are not invertable anymore. - Tunnel: Interface changed - gateway_ip, gateway_port before local_ip, local_port added with default `0`. - Tunnel: default `auto_reconnect`to True ## 0.16.3 Fan contributions 2021-02-06 ### Devices - Fan: Add `max_step` attribute which defines the maximum amount of steps. If set, the fan is controlled by steps instead of percentage. - Fan: Add `group_address_oscillation` and `group_address_oscillation_state` attributes to control the oscillation of a fan. ## 0.16.2 Bugfix for YAML loader 2021-01-24 ### Internals - fix conflict with HA YAML loader ## 0.16.1 HA register services 2021-01-16 ### HA integration - knx_event: renamed `fire_event_filter` to `event_filter` and deprecated `fire_event` config option. A callback is now always registered for HA to be able to modify its `group_addresses` filter from a service. - added `knx.event_register` service allowing to add and remove group addresses to trigger knx_event without having to change configuration. - added `knx.exposure_register` service allowing to add and remove ExposeSensor instances at runtime ### Internals - remove DPTComparator: DPTBinary and DPTArray are not equal, even if their .value is, and are never equal to `None`. - add Device.shutdown() method (used eg. when removing ExposeSensor) - TelegramQueue.Callback: add `group_addresses` attribute to store a list of GroupAddress triggering the callback (additionally to `address_filters`). - add a lot of type annotations ## 0.16.0 APCI possibilities considerably increased 2021-01-01 ### Devices - Sensor: add DPT-3 datatypes "stepwise_dimming", "stepwise_blinds", "startstop_dimming", "startstop_blinds" - Light: It is now possible to control lights using individual group addresses for red, green, blue and white ### HA integration - knx_event: renamed `address` to `destination` and added `source`, `telegramtype`, `direction` attributes. ### Internals - Tunnel connections process DisconnectRequest now and closes/reconnects the tunnel when the other side closes gracefully - XKNX.connected Event can be used in future to await for a working connection or stop/relaunch tasks if the connection is lost - Lower heartbeat rate from every 15sec to every 70 sec and raise ConnectionstateRequest timeout from 1 to 10sec (3/8/1 KNXip Overview §5.8 Timeout Constants) - clean up Tunnel class - refactored timeout handling in GatewayScanner, RequestResponse and ValueReader. - renamed "PhysicalAddress" to "IndividualAddress" - Telegram: `group_address` renamed to `destination_address`, to prepare support for other APCI services and add `source_address` - Telegram: remove `Telegram.telegramtype` and replace with payload object derived from `xknx.telegram.apci.APCI`. - CEMIFrame: remove `CEMIFrame.cmd`, which can be derived from `CEMIFrame.payload`. - APCI: extend APCI services (e.g. `MemoryRead/Write/Response`, `PropertyRead/Write/Response`, etc). - Farewell Travis CI; Welcome Github Actions! - StateUpdater allow float values for `register_remote_value(tracker_options)` attribute. - Handle exceptions from received unsupported or not implemented KNXIP Service Type identifiers ## 0.15.6 Bugfix for StateUpater 2020-11-26 ### Bugfixes - StateUpdater: shield from cancellation so update_received() don't cancel ongoing RemoteValue.read_state() ## 0.15.5 A Telegram for everyone 2020-11-25 ### Internals - process every incoming Telegram in all Devices, regardless if a callback for the GA is registered (eg. StateUpdater) or not. ### Bugfixes - StateUpdater: always close the update task before starting a new in StateTracker - Cover: separate target and state position RemoteValue to fix position update from RemoteValue and call `after_update()` ## 0.15.4 Bugfix for switch 2020-11-22 ### Devices - Light, Switch: initialize state with `None` instead of `False` to account for unknown state. - Cover: `device_class` may be used to store the type of cover for Home-Assistant. - HA-Entity Light, Switch, Cover: initialize with `assumed_state = True` until we have received a state. ### Bugfixes - Switch.after_update was not called from RemoteValueSwitch.read_state (StateUpdater). Moved Switch.state to RemoteValue again. - StateUpdater: query less aggressive - 2 parallel reads with 2 seconds timeout (instead of 3 - 1). ## 0.15.3 Opposite day! 2020-10-29 ### Devices - BinarySensor: added option to invert payloads - BinarySensor: `ignore_internal_state` and counter are only applied to GroupValueWrite telegrams, not GroupValueRespond. - BinarySensor: if `context_timeout` is set `ignore_internal_state` is set to True. - Switch: added option to invert payloads ### Bugfixes - HA Switch entity: keep state without state_address - Cover: fix `set_position` without writable position / auto_stop_if_necessary - handle unsupported CEMI Messages without losing tunnel connection ## 0.15.2 Winter is coming ### Devices - ClimateMode: Refactor climate modes in operation_mode and controller_mode, also fixes a bug for binary operation modes where the mode would be set to AWAY no matter what value was sent to the bus. - Sensor: Add `always_callback` option - Switch: Allow resetting switches after x seconds with the new `reset_after` option. ### Internals - StateUpdater: Only request 3 GAs at a time. - RemoteValue: Add support for passive group addresses ## 0.15.1 bugfix for binary sensors ### Devices - BinarySensor: `reset_after` expects seconds, instead of ms now (to use same unit as `context_timeout`) - Binary Sensor: Change the default setting `context_timeout` for binary sensor from 1.0 to 0.0 and fixes a bug that would result in the update callback being executed twice thus executing certain automations in HA twice for binary sensor from 1.0 to 0.0 and fixes a bug that would result in the update callback being executed twice thus executing certain automations in HA twice. ## 0.15.0 Spring cleaning and quality of life changes ### Logging - An additional `log_directory` parameter has been introduced that allows you to log your KNX logs to a dedicated file. We will likely silence more logs over the time but this option will help you and us to triage issues easier in the future. It is disabled by default. ### Internals - The heartbeat task, that is used to monitor the state of the tunnel and trigger reconnects if it doesn't respond, is now properly stopped once we receive the first reconnect request - `XKNX.start()` no longer takes arguments. They are now passed directly to the constructor when instantiating `XKNX()` - Support for python 3.6 has been dropped - XKNX can now be used as an asynchronous context manager - Internal refactorings - Improve test coverage ## 0.14.4 Bugfix release ### Devices - Don't set standby operation mode if telegram was not processed by any RemoteValue - Allow covers to be inverted again - Correctly process outgoing telegrams in our own devices ## 0.14.3 Bugfix release ### Internals - Make connectivity less noisy on connection errors. ## 0.14.2 Bugfix release ### Bugfixes - Correctly reset the counter of the binary sensor after a trigger. ## 0.14.1 Bugfix release ### Bugfixes - Use correct DPT 9.006 for the air pressure attribute of weather devices - Reset binary sensor counters after the context has been timed out in order to be able to use state change events within HA - Code cleanups ## 0.14.0 New sensor types and refactoring of binary sensor automations ### Breaking changes - Binary sensor automations within the home assistant integration have been refactored to use the HA built in events as automation source instead of having the automation schema directly attached to the sensors. (Migration Guide: https://xknx.io/migration_ha_0116.html) ### New Features - Add support for new sensor types DPT 12.1200 (DPT_VolumeLiquid_Litre) and DPT 12.1201 (DPTVolumeM3). - Weather devices now have an additional `brightness_north` GA to measure the brightness. Additionally, all sensor values are now part of the HA device state attributes for a given weather device. ### Bugfixes - Fix hourly broadcasting of localtime ### Internals - Allow to pass GroupAddress and PhysicalAddress objects to wherever an address is acceptable. - Stop heartbeat and reconnect tasks before disconnecting ## 0.13.0 New weather device and bugfixes for HA integration ### Deprecation notes - Python 3.5 is no longer supported ### New Features - Adds support for a weather station via a dedicated weather device - support for configuring the previously hard-coded multicast address (@jochembroekhoff #312) ### Internals - GatewayScanner: Passing None or an integer <= 0 to the `stop_on_found` parameter now causes the scanner to only stop once the timeout is reached (@jochembroekhoff #311) - Devices are now added automatically to the xknx.devices list after initialization - Device.sync() method now again has a `wait_for_result` parameter that allows the user to wait for the telegrams - The default timeout of the `ValueReader` has been extended from 1 second to 2 seconds ### Bugfixes - Device: Fixes a bug (#339) introduced in 0.12.0 so that it is again possible to have multiple devices with the same name in the HA integration ## 0.12.0 New StateUpdater, improvements to the HA integrations and bug fixes 2020-08-14 ### Breaking changes - Climate: `setpoint_shift_step` renamed for `temperature_step`. This attribute can be applied to all temperature modes. Default is `0.1` - Removed significant_bit attribute in BinarySensor - DateTime devices are initialized with string for broadcast_type: "time", "date" or "datetime" instead of an Enum value - Removed `bind_to_multicast` option in ConnectionConfig and UDPClient ### New Features - Cover: add optional `group_address_stop` for manual stopping - Cover: start travel calculator when up/down telegram from bus is received - HA integration: `knx.send` service takes `type` attribute to allow sending DPT encoded values like `sensor` - HA integration: `sensor` and `expose` accept int and float values for `type` (parsed as DPT numbers) - new StateUpdater: Devices `sync_state` can be set to `init` to just initialize state on startup, `expire [minutes]` to read the state from the KNX bus when it was not updated for [minutes] or `every [minutes]` to update it regularly every [minutes] - Sensor and ExposeSensor now also accepts `value_type` of int (generic DPT) or float (specific DPT) if implemented. - Added config option ignore_internal_state in binary sensors (@andreasnanko #267) - Add support for 2byte float type (DPT 9.002) to climate shiftpoint - ClimateMode: add `group_address_operation_mode_standby` as binary operation mode - ClimateMode: add `group_address_heat_cool` and `group_address_heat_cool_state for switching heating mode / cooling mode with DPT1 ### Bugfixes - Tunneling: don't process incoming L_Data.con confirmation frames. This avoids processing every outgoing telegram twice. - enable multicast on macOS and fix a bug where unknown cemi frames raise a TypeError on routing connections - BinarySensor: reset_after is now implemented as asyncio.Task to prevent blocking the loop - ClimateMode: binary climate modes should be fully functional now (sending, receiving and syncing) - Cover: position update from bus does update current position, but not target position (when moving) ### Internals - Cover travelcalculator doesn't start from 0% but is initialized by first movement or status telegram - Cover uses 0% for open cover and 100% for closed cover now - DPT classes can now be searched via value_type string or dpt number from any parent class (DPTBase for all) to be used in Sensor - Use RemoteValue class in BinarySensor, DateTime and ClimateMode device - use time.struct_time for internal time and date representation - use a regular Bool type for BinarySensor state representation - RemoteValue.process has always_callback attribute to run the callbacks on every process even if the payload didn't change - Separate incoming and outgoing telegram queues; apply rate limit only for outgoing telegrams - Automatically publish packages to pypi (@Julius2342 #277) - keep xknx version in `xknx/__version__.py` (@farmio #278) - add raw_socket logger (@farmio #299) ## 0.11.3 Sensor types galore! 2020-04-28 ### New Features - added a lot of DPTs now usable as sensor type (@eXtenZy #255) ### Bugfixes - DPT_Step correction (used in Cover) (@recMartin #260) - prevent reconnects on unknown CEMI Messages (@farmio #271) - fix the parsing of operation mode strings to HVACOperationMode (@FredericMa #266) - corrected binding to multicast address in Windows (Routing) (@FredericMa #256) - finish tasks when stopping xknx (@farmio #264, #274) ### Internals - some code cleanup (dpt, telegram and remote_value module) (@farmio #232) - refactor Notification device (@farmio #245) ## 0.11.2 Add invert for climate on_off; fixed RGBW lights and stability improvements 2019-09-29 ### New Features - Sensor: add DPT 9.006 as pressure_2byte #223 (@michelde) - Climate: add new attribute on_off_invert #225 (@tombbo) ### Bugfixes - Light: Fix for wrong structure of RGBW DPT 251.600 #231 (@dstrigl) - Core: Correct handling of E_NO_MORE_CONNECTIONS within ConnectResponses #217 (@Julius2342) - Core: Fix exceptions #234 (@elupus) - Core: Avoid leaking ValueError exception on unknown APCI command #235 (@elupus) - add tests for Climate on_off_invert (#233) @farmio - merge HA plugin from upstream 0.97.2 (#224) @farmio - Small adjustments to the sensor documentation and example (#219) @biggestj - merge HA plugin from upstream @farmio ## 0.11.1 Bugfix release 2019-07-08 - Optionally disable reading (GroupValueRead) for sensor and binary_sensor #216 @farmio ## 0.11.0 Added new sensor types and fixed a couple of bugs 2019-06-12 ### Features - Auto detection of local ip: #184 (@farmio ) - Added new sensor types and fix existing: #189 (@farmio ) - binary mapped to RemoteValueSwitch - angle DPT 5.003 - percentU8DPT 5.004 (1 byte unscaled) - percentV8 DPT 6.001 (1 byte signed unscaled) - counter*pulses DPT 6.010 - DPT 8.\*\*\* types (percentV16, delta_time*\*, rotation_angle, 2byte_signed and DPT-8) - luminous_flux DPT 14.042 - pressure DPT 14.058 - string DPT 16.000 - scene_number DPT 17.001 - Binary values are now exposable - Add support for RGBW lights - DPT 251.600: #191 #206 (@phbaer ) - Bump PyYAML to latest version (5.1): #204 (@Julius2342 ) - Add DPT-8 support for Sensors and HA Sensors: #208 (@farmio ) ### Breaking changes - Scene: scene_number is now 1 indexed according to KNX standards - Replaced group_address in BinarySensor with group_address_state (not for Home Assistant component) ### Bugfixes - Fix pulse sensor type: #187 (@farmio ) - Fix climate device using setpoint_shift: #190 (@farmio ) - Read binary sensors on startup: #199 (@farmio ) - Updated YAML to use safe mode: #196 (@farmio ) - Update README.md #195 (thanks @amp-man) - Code refactoring: #200 (@farmio ) - Fix #194, #193, #129, #116, #114 - Fix #183 and #148 through #190 (@farmio ) ## 0.10.0 Bugfix release 2019-02-22 - Connection config can now be configured in xknx.yml (#179 @farmio ) - (breaking change) Introduced target_temperature_state for climate devices (#175 @marvin-w ) - Introduce a configurable rate limit (#178 @marvin-w) - updated HA plugin (#174 @marvin-w) - Migrate documentation in main project (#168 @marvin-w) - documentation updates (@farmio & @marvin-w) ## 0.9.4 - Release 2019-01-01 - updated hass plugin (@marvin-w #162) - tunable white and color temperature for lights (@farmio #154) ## 0.9.3 - Release 2018-12-23 - updated requirements (added flake8-isort) - some more unit tests - Breaking Change: ClimateMode is now a member of Climate (the hass plugin needs this kind of dependency. Please note the updated xknx.yml) ## 0.9.2 - Release 2018-12-22 - Min and max values for Climate device - split up Climate in Climate and ClimateMode - added **contains** method for Devices class. - fixed KeyError when action refers to a non existing device. ## 0.9.1 - Release 2018-10-28 - state_addresses of binary_sesor should return empty value if no state address is set. - state_address for notification device ## 0.9.0 - Release 2018-09-23 - Updated requirements - Feature: Added new DPTs for DPTEnthalpy, DPTPartsPerMillion, DPTVoltage. Thanks @magenbrot #146 - Breaking Change: Only read explicit state addresses #140 - Minor: Fixed some comments, @magenbrot #145 - Minor: lowered loglevel from INFO to DEBUG for 'correct answer from KNX bus' @magenbrot #144 - Feature: Add fan device, @itineric #139 - Bugfix: Tunnel: Use the bus address assigned by the server, @M-o-a-T #141 - Bugfix: Adde:wd a check for windows because windows does not support add_signal @pulse-mind #135 - Bugfix: correct testing if xknx exists within self @FireFrei #131 - Feature: Implement support to automatically reconnect KNX/IP tunnel, @rnixx #125 - Feature: Adjusted to Home Assistant's changes to light colors @oliverblaha #128 - Feature: Scan multiple gateways @DrMurx #111 - Bugfix: Pylint errors @rnixx #132 - Typo: @itineric #124 - Feature: Add support for KNX DPT 20.105 @cian #122 ## 0.8.5 -Release 2018-03-10 - Bugfix: fixed string representation of GroupAddress https://github.com/home-assistant/home-assistant/issues/13049 ## 0.8.4 -Release 2018-03-04 - Bugfix: invert scaling value #114 - Minor: current_brightness and current_color are now properties - Feature: Added DPT 5.010 DPTValue1Ucount @andreasnanko #109 ## 0.8.3 - Release 2018-02-05 - Color support for HASS plugin - Bugfixes (esp problem with unhashable exceptions) - Refactoring: split up remote_value.py - Better test coverage ## 0.8.1 - Release 2018-02-03 - Basic support for colored lights - Better unit test coverage ## 0.8.0 - Release 2018-01-27 - New example for MQTT forwarder (thanks @JohanElmis) - split up Address into GroupAddress and PhysicalAddress (thanks @encbladexp) - Time object was renamed to Datetime and does now support different broadcast types "time", "date" and "datetime" (thanks @Roemer) - Many new DTP datapoints esp for physical values (thanks @Straeng and @JohanElmis) - new asyncio `await` syntax - new device "ExposeSensor" to read a local value from KNX bus or to expose a local value to KNX bus. - Support for KNX-scenes - better test coverage - Fixed versions for dependencies (@encbladexp) And many more smaller improvements :-) ## 0.7.7-0.7.18 - Release 2017-11-05 - Many iterations and bugfixes to get climate support with setpoint shift working. - Support for invert-position and invert-angle within cover. - State updater may be switched of within home assistant plugin ## 0.7.6 - Release 2017-08-09 Introduced KNX HVAC/Climate support with operation modes (Frost protection, night, comfort). ## 0.7.0 - Released 2017-07-30 ### More asyncio: More intense usage of asyncio. All device operations and callback functions are now async. E.g. to switch on a light you have to do: ```python await light.set_on() ``` See updated [examples](https://github.com/XKNX/xknx/tree/main/examples) for details. ### Renaming of several objects: The naming of some device were changed in order to get the nomenclature closer to several other automation projects and to avoid confusion. The device objects were also moved into `xknx.devices`. #### Climate Renamed class `Thermostat` to `Climate` . Please rename the section within configuration: ```yaml groups: climate: Cellar.Thermostat: { group_address_temperature: "6/2/0" } ``` #### Cover Renamed class `Shutter` to `Cover`. Please rename the section within configuration: ```yaml groups: cover: Livingroom.Shutter_1: { group_address_long: "1/4/1", group_address_short: "1/4/2", group_address_position_feedback: "1/4/3", group_address_position: "1/4/4", travel_time_down: 50, travel_time_up: 60, } ``` #### Binary Sensor Renamed class `Switch` to `BinarySensor`. Please rename the section within configuration: ```yaml groups: binary_sensor: Kitchen.3Switch1: group_address: "5/0/0" ``` Sensors with `value_type=binary` are now integrated into the `BinarySensor` class: ```yaml groups: binary_sensor: SleepingRoom.Motion.Sensor: { group_address: "6/0/0", device_class: "motion" } ExtraRoom.Motion.Sensor: { group_address: "6/0/1", device_class: "motion" } ``` The attribute `significant_bit` is now only possible within `binary_sensors`: ```yaml groups: binary_sensor_motion_dection: Kitchen.Thermostat.Presence: { group_address: "3/0/2", device_class: "motion", significant_bit: 2 } ``` #### Switch Renamed `Outlet` to `Switch` (Sorry for the confusion...). The configuration now looks like: ```yaml groups: switch: Livingroom.Outlet_1: { group_address: "1/3/1" } Livingroom.Outlet_2: { group_address: "1/3/2" } ``` Within `Light` class i introduced an attribute `group_address_brightness_state`. The attribute `group_address_state` was renamed to `group_address_switch_state`. I also removed the attribute `group_address_dimm` (which did not have any implemented logic). ## Version 0.6.2 - Released 2017-07-24 XKNX Tunnel now does hartbeat - and reopens connections which are no longer valid. ## Version 0.6.0 - Released 2017-07-23 Using `asyncio` interface, XKNX has now to be stated and stopped asynchronously: ```python import asyncio from xknx import XKNX, Outlet async def main(): xknx = XKNX() await xknx.start() outlet = Outlet(xknx, name='TestOutlet', group_address='1/1/11') outlet.set_on() await asyncio.sleep(2) outlet.set_off() await xknx.stop() # pylint: disable=invalid-name loop = asyncio.get_event_loop() loop.run_until_complete(main()) loop.close() ``` `sync_state` was renamed to `sync`: ````python await sensor2.sync() ``` ```` xknx-3.6.0/docs/climate.md000066400000000000000000000163561475530762600154320ustar00rootroot00000000000000--- layout: default title: Climate / HVAC parent: Devices nav_order: 2 --- # [](#header-1)HVAC/Climate controls ## [](#header-2)Overview Climate are representations of KNX HVAC/Climate controls. ## [](#header-2)Interface - `xknx` is the XKNX object. - `name` is the name of the object. - `group_address_temperature` KNX address of current room temperature. *DPT 9.001* - `group_address_target_temperature` KNX address for setting the target temperature if setpoint shift is not supported. *DPT 9.001* - `group_address_target_temperature_state` KNX address for reading the target temperature from the KNX bus. Used in for setpoint_shift calculations as base temperature. *DPT 9.001* - `group_address_setpoint_shift` KNX address to set setpoint_shift (base temperature deviation). *DPT 6.010* or *DPT 9.002* - `group_address_setpoint_shift_state` KNX address to read current setpoint_shift. *DPT 6.010* or *DPT 9.002* - `group_address_fan_speed` KNX address for the fan speed. *DPT 5.001 / 5.010* - `group_address_fan_speed_state` KNX address for reading fan speed. *DPT 5.001 / 5.010* - `group_address_swing` KNX address for fan swing. If `group_address_horizontal_swing` is present this should refer to vertical swing *DPT 1* - `group_address_swing_state` KNX address for reading fan swing. If `group_address_horizontal_swing` is present this should refer to vertical swing *DPT 1* - `group_address_horizontal_swing` KNX address for horizontal fan swing. *DPT 1* - `group_address_horizontal_swing_state` KNX address for reading horizontal fan swing. *DPT 1* - `setpoint_shift_mode` SetpointShiftMode Enum for setpoint_shift payload encoding. When `None` it is inferred from first incoming payload. Default: `None` - `setpoint_shift_max` Maximum value for setpoint_shift. - `setpoint_shift_min` Minimum value for setpoint_shift. - `temperature_step` Set the multiplier for setpoint_shift calculations when DPT 6.010 is used. - `group_address_on_off` KNX address for turning climate device on or off. *DPT 1* - `group_address_on_off_state` KNX address for reading the on/off state. *DPT 1* - `on_off_invert` Invert on/off. Default: `False` - `group_address_active_state` KNX address for reading if the climate device is currently active. *DPT 1* - `group_address_command_value_state` KNX address for reading the current command value / valve position in %. *DPT 5.001* - `group_address_humidity_state` KNX address of current room humidity. *DPT 9.007* - `sync_state` defines if and how often the value should be actively read from the bus. If `False` no GroupValueRead telegrams will be sent to its group address. Defaults to `True` - `max_temp` Maximum value for target temperature. - `min_temp` Minimum value for target temperature. - `fan_speed_mode` Defines the mode of fan speed control. Can be either `FanSpeedMode.STEP` for step-wise control or `FanSpeedMode.PERCENT` for percentage control. For `STEP` mode, the fan speed is controlled by sending integer values, and the group address uses *DPT 5.010*. For `PERCENT` mode, the fan speed is controlled by sending percentage values (0-100%), and the group address uses *DPT 5.001*. Default: `FanSpeedMode.PERCENT`. - `mode` ClimateMode instance for this climate device - `group_address_operation_mode` KNX address for operation mode. *DPT 20.102* - `group_address_operation_mode_state` KNX address for operation mode status. *DPT 20.102* - `group_address_operation_mode_protection` KNX address for switching on/off building protection mode. *DPT 1* - `group_address_operation_mode_economy` KNX address for switching on/off economy mode. *DPT 1* - `group_address_operation_mode_comfort` KNX address for switching on/off comfort mode. *DPT 1* - `group_address_operation_mode_standby` KNX address for switching on/off standby mode. *DPT 1* - `group_address_controller_status` KNX address for controller status. - `group_address_controller_status_state` KNX address for controller status state. - `group_address_controller_mode` KNX address for controller mode. *DPT 20.105* - `group_address_controller_mode_state` KNX address for controller mode status. *DPT 20.105* - `group_address_heat_cool` KNX address for switching heating / cooling mode. *DPT 1* - `group_address_heat_cool_state` KNX address for reading heating / cooling mode. *DPT 1* - `operation_modes` Overrides the supported operation modes. - `controller_modes` Overrides the supported controller modes. - `device_updated_cb` Callback for each update. **Note:** `group_address_operation_mode_protection` / `group_address_operation_mode_economy` / `group_address_operation_mode_comfort` / `group_address_operation_mode_standby` are not necessary if `group_address_operation_mode` was specified. When one of these is set `True`, the others will be set `False`. When one of these is set `Standby`, `Comfort`, `Building Protection` and `Economy` will be set as supported. If `group_address_operation_mode_standby` is omitted, `Standby` is set when the other 3 are set to `False`. If only a subset of operation modes shall be used a list of supported modes may be passed to `operation_modes`. ```python climate_mode = ClimateMode( xknx, 'TestClimateMode', group_address_operation_mode='', group_address_operation_mode_state='', group_address_operation_mode_protection=None, group_address_operation_mode_economy=None, group_address_operation_mode_comfort=None, group_address_controller_status=None, group_address_controller_status_state=None, group_address_controller_mode=None, group_address_controller_mode_state=None, operation_modes=None, controller_modes=None, device_updated_cb=None, ) climate = Climate( xknx, 'TestClimate', group_address_temperature='', group_address_target_temperature='', group_address_target_temperature_state='', group_address_setpoint_shift='', group_address_setpoint_shift_state='', group_address_fan_speed=None, group_address_fan_speed_state=None, group_address_swing=None, group_address_swing_sate=None, group_address_horizontal_swing=None, group_address_horizontal_swing_state=None, temperature_step=0.1, setpoint_shift_max=6, setpoint_shift_min=-6, group_address_on_off='', group_address_on_off_state='', on_off_invert=False, min_temp=18, max_temp=26, mode=climate_mode, device_updated_cb=None, fan_speed_mode=FanSpeedMode.STEP, group_address_humidity_state='', ) xknx.devices.async_add(climate) xknx.devices.async_add(climate_mode) # Set target temperature to 23 degrees. Works with setpoint_shift too. await climate.set_target_temperature(23) # Set new setpoint shift value. await climate.set_setpoint_shift(1) # Reading climate device await climate.sync(wait_for_result=True) print("Current temperature: ", climate.temperature) # Shutdown the Climate and the underlying ClimateMode! climate.shutdown() ``` ## [](#header-2)Example ```python climate_setpoint_shift = Climate( xknx, 'TestClimateSPS', group_address_temperature='1/2/2', group_address_target_temperature_state='1/2/5', group_address_setpoint_shift='1/2/3', group_address_setpoint_shift_state='1/2/4' ) climate_target_temp = Climate( xknx, 'TestClimateTT', group_address_temperature='2/2/2', group_address_target_temperature='2/2/3', group_address_target_temperature_state='2/2/4' ) ``` xknx-3.6.0/docs/config-converter/000077500000000000000000000000001475530762600167315ustar00rootroot00000000000000xknx-3.6.0/docs/config-converter/index.html000066400000000000000000000041141475530762600207260ustar00rootroot00000000000000 XKNX config

Insert xknx.yaml config here here:

Home-Assistant configuration.yaml style knx config:

xknx-3.6.0/docs/config-converter/lib/000077500000000000000000000000001475530762600174775ustar00rootroot00000000000000xknx-3.6.0/docs/config-converter/lib/codemirror/000077500000000000000000000000001475530762600216445ustar00rootroot00000000000000xknx-3.6.0/docs/config-converter/lib/codemirror/codemirror.css000066400000000000000000000210301475530762600245170ustar00rootroot00000000000000/* BASICS */ .CodeMirror { /* Set height, width, borders, and global font properties here */ font-family: monospace; height: 300px; color: black; direction: ltr; } /* PADDING */ .CodeMirror-lines { padding: 4px 0; /* Vertical padding around content */ } .CodeMirror pre.CodeMirror-line, .CodeMirror pre.CodeMirror-line-like { padding: 0 4px; /* Horizontal padding of content */ } .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler { background-color: transparent; /* The little square between H and V scrollbars */ } /* GUTTER */ .CodeMirror-gutters { border-right: 1px solid #ddd; background-color: #f7f7f7; white-space: nowrap; } .CodeMirror-linenumbers {} .CodeMirror-linenumber { padding: 0 3px 0 5px; min-width: 20px; text-align: right; color: #999; white-space: nowrap; } .CodeMirror-guttermarker { color: black; } .CodeMirror-guttermarker-subtle { color: #999; } /* CURSOR */ .CodeMirror-cursor { border-left: 1px solid black; border-right: none; width: 0; } /* Shown when moving in bi-directional text */ .CodeMirror div.CodeMirror-secondarycursor { border-left: 1px solid silver; } .cm-fat-cursor .CodeMirror-cursor { width: auto; border: 0 !important; background: #7e7; } .cm-fat-cursor div.CodeMirror-cursors { z-index: 1; } .cm-fat-cursor-mark { background-color: rgba(20, 255, 20, 0.5); -webkit-animation: blink 1.06s steps(1) infinite; -moz-animation: blink 1.06s steps(1) infinite; animation: blink 1.06s steps(1) infinite; } .cm-animate-fat-cursor { width: auto; border: 0; -webkit-animation: blink 1.06s steps(1) infinite; -moz-animation: blink 1.06s steps(1) infinite; animation: blink 1.06s steps(1) infinite; background-color: #7e7; } @-moz-keyframes blink { 0% {} 50% { background-color: transparent; } 100% {} } @-webkit-keyframes blink { 0% {} 50% { background-color: transparent; } 100% {} } @keyframes blink { 0% {} 50% { background-color: transparent; } 100% {} } /* Can style cursor different in overwrite (non-insert) mode */ .CodeMirror-overwrite .CodeMirror-cursor {} .cm-tab { display: inline-block; text-decoration: inherit; } .CodeMirror-rulers { position: absolute; left: 0; right: 0; top: -50px; bottom: 0; overflow: hidden; } .CodeMirror-ruler { border-left: 1px solid #ccc; top: 0; bottom: 0; position: absolute; } /* DEFAULT THEME */ .cm-s-default .cm-header {color: blue;} .cm-s-default .cm-quote {color: #090;} .cm-negative {color: #d44;} .cm-positive {color: #292;} .cm-header, .cm-strong {font-weight: bold;} .cm-em {font-style: italic;} .cm-link {text-decoration: underline;} .cm-strikethrough {text-decoration: line-through;} .cm-s-default .cm-keyword {color: #708;} .cm-s-default .cm-atom {color: #219;} .cm-s-default .cm-number {color: #164;} .cm-s-default .cm-def {color: #00f;} .cm-s-default .cm-variable, .cm-s-default .cm-punctuation, .cm-s-default .cm-property, .cm-s-default .cm-operator {} .cm-s-default .cm-variable-2 {color: #05a;} .cm-s-default .cm-variable-3, .cm-s-default .cm-type {color: #085;} .cm-s-default .cm-comment {color: #a50;} .cm-s-default .cm-string {color: #a11;} .cm-s-default .cm-string-2 {color: #f50;} .cm-s-default .cm-meta {color: #555;} .cm-s-default .cm-qualifier {color: #555;} .cm-s-default .cm-builtin {color: #30a;} .cm-s-default .cm-bracket {color: #997;} .cm-s-default .cm-tag {color: #170;} .cm-s-default .cm-attribute {color: #00c;} .cm-s-default .cm-hr {color: #999;} .cm-s-default .cm-link {color: #00c;} .cm-s-default .cm-error {color: #f00;} .cm-invalidchar {color: #f00;} .CodeMirror-composing { border-bottom: 2px solid; } /* Default styles for common addons */ div.CodeMirror span.CodeMirror-matchingbracket {color: #0b0;} div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #a22;} .CodeMirror-matchingtag { background: rgba(255, 150, 0, .3); } .CodeMirror-activeline-background {background: #e8f2ff;} /* STOP */ /* The rest of this file contains styles related to the mechanics of the editor. You probably shouldn't touch them. */ .CodeMirror { position: relative; overflow: hidden; background: white; } .CodeMirror-scroll { overflow: scroll !important; /* Things will break if this is overridden */ /* 50px is the magic margin used to hide the element's real scrollbars */ /* See overflow: hidden in .CodeMirror */ margin-bottom: -50px; margin-right: -50px; padding-bottom: 50px; height: 100%; outline: none; /* Prevent dragging from highlighting the element */ position: relative; } .CodeMirror-sizer { position: relative; border-right: 50px solid transparent; } /* The fake, visible scrollbars. Used to force redraw during scrolling before actual scrolling happens, thus preventing shaking and flickering artifacts. */ .CodeMirror-vscrollbar, .CodeMirror-hscrollbar, .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler { position: absolute; z-index: 6; display: none; outline: none; } .CodeMirror-vscrollbar { right: 0; top: 0; overflow-x: hidden; overflow-y: scroll; } .CodeMirror-hscrollbar { bottom: 0; left: 0; overflow-y: hidden; overflow-x: scroll; } .CodeMirror-scrollbar-filler { right: 0; bottom: 0; } .CodeMirror-gutter-filler { left: 0; bottom: 0; } .CodeMirror-gutters { position: absolute; left: 0; top: 0; min-height: 100%; z-index: 3; } .CodeMirror-gutter { white-space: normal; height: 100%; display: inline-block; vertical-align: top; margin-bottom: -50px; } .CodeMirror-gutter-wrapper { position: absolute; z-index: 4; background: none !important; border: none !important; } .CodeMirror-gutter-background { position: absolute; top: 0; bottom: 0; z-index: 4; } .CodeMirror-gutter-elt { position: absolute; cursor: default; z-index: 4; } .CodeMirror-gutter-wrapper ::selection { background-color: transparent } .CodeMirror-gutter-wrapper ::-moz-selection { background-color: transparent } .CodeMirror-lines { cursor: text; min-height: 1px; /* prevents collapsing before first draw */ } .CodeMirror pre.CodeMirror-line, .CodeMirror pre.CodeMirror-line-like { /* Reset some styles that the rest of the page might have set */ -moz-border-radius: 0; -webkit-border-radius: 0; border-radius: 0; border-width: 0; background: transparent; font-family: inherit; font-size: inherit; margin: 0; white-space: pre; word-wrap: normal; line-height: inherit; color: inherit; z-index: 2; position: relative; overflow: visible; -webkit-tap-highlight-color: transparent; -webkit-font-variant-ligatures: contextual; font-variant-ligatures: contextual; } .CodeMirror-wrap pre.CodeMirror-line, .CodeMirror-wrap pre.CodeMirror-line-like { word-wrap: break-word; white-space: pre-wrap; word-break: normal; } .CodeMirror-linebackground { position: absolute; left: 0; right: 0; top: 0; bottom: 0; z-index: 0; } .CodeMirror-linewidget { position: relative; z-index: 2; padding: 0.1px; /* Force widget margins to stay inside of the container */ } .CodeMirror-widget {} .CodeMirror-rtl pre { direction: rtl; } .CodeMirror-code { outline: none; } /* Force content-box sizing for the elements where we expect it */ .CodeMirror-scroll, .CodeMirror-sizer, .CodeMirror-gutter, .CodeMirror-gutters, .CodeMirror-linenumber { -moz-box-sizing: content-box; box-sizing: content-box; } .CodeMirror-measure { position: absolute; width: 100%; height: 0; overflow: hidden; visibility: hidden; } .CodeMirror-cursor { position: absolute; pointer-events: none; } .CodeMirror-measure pre { position: static; } div.CodeMirror-cursors { visibility: hidden; position: relative; z-index: 3; } div.CodeMirror-dragcursors { visibility: visible; } .CodeMirror-focused div.CodeMirror-cursors { visibility: visible; } .CodeMirror-selected { background: #d9d9d9; } .CodeMirror-focused .CodeMirror-selected { background: #d7d4f0; } .CodeMirror-crosshair { cursor: crosshair; } .CodeMirror-line::selection, .CodeMirror-line > span::selection, .CodeMirror-line > span > span::selection { background: #d7d4f0; } .CodeMirror-line::-moz-selection, .CodeMirror-line > span::-moz-selection, .CodeMirror-line > span > span::-moz-selection { background: #d7d4f0; } .cm-searching { background-color: #ffa; background-color: rgba(255, 255, 0, .4); } /* Used to force a border model for a node */ .cm-force-border { padding-right: .1px; } @media print { /* Hide the cursor when printing */ .CodeMirror div.CodeMirror-cursors { visibility: hidden; } } /* See issue #2901 */ .cm-tab-wrap-hack:after { content: ''; } /* Help users use markselection to safely style text background */ span.CodeMirror-selectedtext { background: none; } xknx-3.6.0/docs/config-converter/lib/codemirror/codemirror.js000066400000000000000000014116411475530762600243570ustar00rootroot00000000000000// CodeMirror, copyright (c) by Marijn Haverbeke and others // Distributed under an MIT license: https://codemirror.net/LICENSE // This is CodeMirror (https://codemirror.net), a code editor // implemented in JavaScript on top of the browser's DOM. // // You can find some technical background for some of the code below // at http://marijnhaverbeke.nl/blog/#cm-internals . (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global = global || self, global.CodeMirror = factory()); }(this, (function () { 'use strict'; // Kludges for bugs and behavior differences that can't be feature // detected are enabled based on userAgent etc sniffing. var userAgent = navigator.userAgent; var platform = navigator.platform; var gecko = /gecko\/\d/i.test(userAgent); var ie_upto10 = /MSIE \d/.test(userAgent); var ie_11up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(userAgent); var edge = /Edge\/(\d+)/.exec(userAgent); var ie = ie_upto10 || ie_11up || edge; var ie_version = ie && (ie_upto10 ? document.documentMode || 6 : +(edge || ie_11up)[1]); var webkit = !edge && /WebKit\//.test(userAgent); var qtwebkit = webkit && /Qt\/\d+\.\d+/.test(userAgent); var chrome = !edge && /Chrome\//.test(userAgent); var presto = /Opera\//.test(userAgent); var safari = /Apple Computer/.test(navigator.vendor); var mac_geMountainLion = /Mac OS X 1\d\D([8-9]|\d\d)\D/.test(userAgent); var phantom = /PhantomJS/.test(userAgent); var ios = safari && (/Mobile\/\w+/.test(userAgent) || navigator.maxTouchPoints > 2); var android = /Android/.test(userAgent); // This is woefully incomplete. Suggestions for alternative methods welcome. var mobile = ios || android || /webOS|BlackBerry|Opera Mini|Opera Mobi|IEMobile/i.test(userAgent); var mac = ios || /Mac/.test(platform); var chromeOS = /\bCrOS\b/.test(userAgent); var windows = /win/i.test(platform); var presto_version = presto && userAgent.match(/Version\/(\d*\.\d*)/); if (presto_version) { presto_version = Number(presto_version[1]); } if (presto_version && presto_version >= 15) { presto = false; webkit = true; } // Some browsers use the wrong event properties to signal cmd/ctrl on OS X var flipCtrlCmd = mac && (qtwebkit || presto && (presto_version == null || presto_version < 12.11)); var captureRightClick = gecko || (ie && ie_version >= 9); function classTest(cls) { return new RegExp("(^|\\s)" + cls + "(?:$|\\s)\\s*") } var rmClass = function(node, cls) { var current = node.className; var match = classTest(cls).exec(current); if (match) { var after = current.slice(match.index + match[0].length); node.className = current.slice(0, match.index) + (after ? match[1] + after : ""); } }; function removeChildren(e) { for (var count = e.childNodes.length; count > 0; --count) { e.removeChild(e.firstChild); } return e } function removeChildrenAndAdd(parent, e) { return removeChildren(parent).appendChild(e) } function elt(tag, content, className, style) { var e = document.createElement(tag); if (className) { e.className = className; } if (style) { e.style.cssText = style; } if (typeof content == "string") { e.appendChild(document.createTextNode(content)); } else if (content) { for (var i = 0; i < content.length; ++i) { e.appendChild(content[i]); } } return e } // wrapper for elt, which removes the elt from the accessibility tree function eltP(tag, content, className, style) { var e = elt(tag, content, className, style); e.setAttribute("role", "presentation"); return e } var range; if (document.createRange) { range = function(node, start, end, endNode) { var r = document.createRange(); r.setEnd(endNode || node, end); r.setStart(node, start); return r }; } else { range = function(node, start, end) { var r = document.body.createTextRange(); try { r.moveToElementText(node.parentNode); } catch(e) { return r } r.collapse(true); r.moveEnd("character", end); r.moveStart("character", start); return r }; } function contains(parent, child) { if (child.nodeType == 3) // Android browser always returns false when child is a textnode { child = child.parentNode; } if (parent.contains) { return parent.contains(child) } do { if (child.nodeType == 11) { child = child.host; } if (child == parent) { return true } } while (child = child.parentNode) } function activeElt() { // IE and Edge may throw an "Unspecified Error" when accessing document.activeElement. // IE < 10 will throw when accessed while the page is loading or in an iframe. // IE > 9 and Edge will throw when accessed in an iframe if document.body is unavailable. var activeElement; try { activeElement = document.activeElement; } catch(e) { activeElement = document.body || null; } while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement) { activeElement = activeElement.shadowRoot.activeElement; } return activeElement } function addClass(node, cls) { var current = node.className; if (!classTest(cls).test(current)) { node.className += (current ? " " : "") + cls; } } function joinClasses(a, b) { var as = a.split(" "); for (var i = 0; i < as.length; i++) { if (as[i] && !classTest(as[i]).test(b)) { b += " " + as[i]; } } return b } var selectInput = function(node) { node.select(); }; if (ios) // Mobile Safari apparently has a bug where select() is broken. { selectInput = function(node) { node.selectionStart = 0; node.selectionEnd = node.value.length; }; } else if (ie) // Suppress mysterious IE10 errors { selectInput = function(node) { try { node.select(); } catch(_e) {} }; } function bind(f) { var args = Array.prototype.slice.call(arguments, 1); return function(){return f.apply(null, args)} } function copyObj(obj, target, overwrite) { if (!target) { target = {}; } for (var prop in obj) { if (obj.hasOwnProperty(prop) && (overwrite !== false || !target.hasOwnProperty(prop))) { target[prop] = obj[prop]; } } return target } // Counts the column offset in a string, taking tabs into account. // Used mostly to find indentation. function countColumn(string, end, tabSize, startIndex, startValue) { if (end == null) { end = string.search(/[^\s\u00a0]/); if (end == -1) { end = string.length; } } for (var i = startIndex || 0, n = startValue || 0;;) { var nextTab = string.indexOf("\t", i); if (nextTab < 0 || nextTab >= end) { return n + (end - i) } n += nextTab - i; n += tabSize - (n % tabSize); i = nextTab + 1; } } var Delayed = function() { this.id = null; this.f = null; this.time = 0; this.handler = bind(this.onTimeout, this); }; Delayed.prototype.onTimeout = function (self) { self.id = 0; if (self.time <= +new Date) { self.f(); } else { setTimeout(self.handler, self.time - +new Date); } }; Delayed.prototype.set = function (ms, f) { this.f = f; var time = +new Date + ms; if (!this.id || time < this.time) { clearTimeout(this.id); this.id = setTimeout(this.handler, ms); this.time = time; } }; function indexOf(array, elt) { for (var i = 0; i < array.length; ++i) { if (array[i] == elt) { return i } } return -1 } // Number of pixels added to scroller and sizer to hide scrollbar var scrollerGap = 50; // Returned or thrown by various protocols to signal 'I'm not // handling this'. var Pass = {toString: function(){return "CodeMirror.Pass"}}; // Reused option objects for setSelection & friends var sel_dontScroll = {scroll: false}, sel_mouse = {origin: "*mouse"}, sel_move = {origin: "+move"}; // The inverse of countColumn -- find the offset that corresponds to // a particular column. function findColumn(string, goal, tabSize) { for (var pos = 0, col = 0;;) { var nextTab = string.indexOf("\t", pos); if (nextTab == -1) { nextTab = string.length; } var skipped = nextTab - pos; if (nextTab == string.length || col + skipped >= goal) { return pos + Math.min(skipped, goal - col) } col += nextTab - pos; col += tabSize - (col % tabSize); pos = nextTab + 1; if (col >= goal) { return pos } } } var spaceStrs = [""]; function spaceStr(n) { while (spaceStrs.length <= n) { spaceStrs.push(lst(spaceStrs) + " "); } return spaceStrs[n] } function lst(arr) { return arr[arr.length-1] } function map(array, f) { var out = []; for (var i = 0; i < array.length; i++) { out[i] = f(array[i], i); } return out } function insertSorted(array, value, score) { var pos = 0, priority = score(value); while (pos < array.length && score(array[pos]) <= priority) { pos++; } array.splice(pos, 0, value); } function nothing() {} function createObj(base, props) { var inst; if (Object.create) { inst = Object.create(base); } else { nothing.prototype = base; inst = new nothing(); } if (props) { copyObj(props, inst); } return inst } var nonASCIISingleCaseWordChar = /[\u00df\u0587\u0590-\u05f4\u0600-\u06ff\u3040-\u309f\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcc\uac00-\ud7af]/; function isWordCharBasic(ch) { return /\w/.test(ch) || ch > "\x80" && (ch.toUpperCase() != ch.toLowerCase() || nonASCIISingleCaseWordChar.test(ch)) } function isWordChar(ch, helper) { if (!helper) { return isWordCharBasic(ch) } if (helper.source.indexOf("\\w") > -1 && isWordCharBasic(ch)) { return true } return helper.test(ch) } function isEmpty(obj) { for (var n in obj) { if (obj.hasOwnProperty(n) && obj[n]) { return false } } return true } // Extending unicode characters. A series of a non-extending char + // any number of extending chars is treated as a single unit as far // as editing and measuring is concerned. This is not fully correct, // since some scripts/fonts/browsers also treat other configurations // of code points as a group. var extendingChars = /[\u0300-\u036f\u0483-\u0489\u0591-\u05bd\u05bf\u05c1\u05c2\u05c4\u05c5\u05c7\u0610-\u061a\u064b-\u065e\u0670\u06d6-\u06dc\u06de-\u06e4\u06e7\u06e8\u06ea-\u06ed\u0711\u0730-\u074a\u07a6-\u07b0\u07eb-\u07f3\u0816-\u0819\u081b-\u0823\u0825-\u0827\u0829-\u082d\u0900-\u0902\u093c\u0941-\u0948\u094d\u0951-\u0955\u0962\u0963\u0981\u09bc\u09be\u09c1-\u09c4\u09cd\u09d7\u09e2\u09e3\u0a01\u0a02\u0a3c\u0a41\u0a42\u0a47\u0a48\u0a4b-\u0a4d\u0a51\u0a70\u0a71\u0a75\u0a81\u0a82\u0abc\u0ac1-\u0ac5\u0ac7\u0ac8\u0acd\u0ae2\u0ae3\u0b01\u0b3c\u0b3e\u0b3f\u0b41-\u0b44\u0b4d\u0b56\u0b57\u0b62\u0b63\u0b82\u0bbe\u0bc0\u0bcd\u0bd7\u0c3e-\u0c40\u0c46-\u0c48\u0c4a-\u0c4d\u0c55\u0c56\u0c62\u0c63\u0cbc\u0cbf\u0cc2\u0cc6\u0ccc\u0ccd\u0cd5\u0cd6\u0ce2\u0ce3\u0d3e\u0d41-\u0d44\u0d4d\u0d57\u0d62\u0d63\u0dca\u0dcf\u0dd2-\u0dd4\u0dd6\u0ddf\u0e31\u0e34-\u0e3a\u0e47-\u0e4e\u0eb1\u0eb4-\u0eb9\u0ebb\u0ebc\u0ec8-\u0ecd\u0f18\u0f19\u0f35\u0f37\u0f39\u0f71-\u0f7e\u0f80-\u0f84\u0f86\u0f87\u0f90-\u0f97\u0f99-\u0fbc\u0fc6\u102d-\u1030\u1032-\u1037\u1039\u103a\u103d\u103e\u1058\u1059\u105e-\u1060\u1071-\u1074\u1082\u1085\u1086\u108d\u109d\u135f\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17b7-\u17bd\u17c6\u17c9-\u17d3\u17dd\u180b-\u180d\u18a9\u1920-\u1922\u1927\u1928\u1932\u1939-\u193b\u1a17\u1a18\u1a56\u1a58-\u1a5e\u1a60\u1a62\u1a65-\u1a6c\u1a73-\u1a7c\u1a7f\u1b00-\u1b03\u1b34\u1b36-\u1b3a\u1b3c\u1b42\u1b6b-\u1b73\u1b80\u1b81\u1ba2-\u1ba5\u1ba8\u1ba9\u1c2c-\u1c33\u1c36\u1c37\u1cd0-\u1cd2\u1cd4-\u1ce0\u1ce2-\u1ce8\u1ced\u1dc0-\u1de6\u1dfd-\u1dff\u200c\u200d\u20d0-\u20f0\u2cef-\u2cf1\u2de0-\u2dff\u302a-\u302f\u3099\u309a\ua66f-\ua672\ua67c\ua67d\ua6f0\ua6f1\ua802\ua806\ua80b\ua825\ua826\ua8c4\ua8e0-\ua8f1\ua926-\ua92d\ua947-\ua951\ua980-\ua982\ua9b3\ua9b6-\ua9b9\ua9bc\uaa29-\uaa2e\uaa31\uaa32\uaa35\uaa36\uaa43\uaa4c\uaab0\uaab2-\uaab4\uaab7\uaab8\uaabe\uaabf\uaac1\uabe5\uabe8\uabed\udc00-\udfff\ufb1e\ufe00-\ufe0f\ufe20-\ufe26\uff9e\uff9f]/; function isExtendingChar(ch) { return ch.charCodeAt(0) >= 768 && extendingChars.test(ch) } // Returns a number from the range [`0`; `str.length`] unless `pos` is outside that range. function skipExtendingChars(str, pos, dir) { while ((dir < 0 ? pos > 0 : pos < str.length) && isExtendingChar(str.charAt(pos))) { pos += dir; } return pos } // Returns the value from the range [`from`; `to`] that satisfies // `pred` and is closest to `from`. Assumes that at least `to` // satisfies `pred`. Supports `from` being greater than `to`. function findFirst(pred, from, to) { // At any point we are certain `to` satisfies `pred`, don't know // whether `from` does. var dir = from > to ? -1 : 1; for (;;) { if (from == to) { return from } var midF = (from + to) / 2, mid = dir < 0 ? Math.ceil(midF) : Math.floor(midF); if (mid == from) { return pred(mid) ? from : to } if (pred(mid)) { to = mid; } else { from = mid + dir; } } } // BIDI HELPERS function iterateBidiSections(order, from, to, f) { if (!order) { return f(from, to, "ltr", 0) } var found = false; for (var i = 0; i < order.length; ++i) { var part = order[i]; if (part.from < to && part.to > from || from == to && part.to == from) { f(Math.max(part.from, from), Math.min(part.to, to), part.level == 1 ? "rtl" : "ltr", i); found = true; } } if (!found) { f(from, to, "ltr"); } } var bidiOther = null; function getBidiPartAt(order, ch, sticky) { var found; bidiOther = null; for (var i = 0; i < order.length; ++i) { var cur = order[i]; if (cur.from < ch && cur.to > ch) { return i } if (cur.to == ch) { if (cur.from != cur.to && sticky == "before") { found = i; } else { bidiOther = i; } } if (cur.from == ch) { if (cur.from != cur.to && sticky != "before") { found = i; } else { bidiOther = i; } } } return found != null ? found : bidiOther } // Bidirectional ordering algorithm // See http://unicode.org/reports/tr9/tr9-13.html for the algorithm // that this (partially) implements. // One-char codes used for character types: // L (L): Left-to-Right // R (R): Right-to-Left // r (AL): Right-to-Left Arabic // 1 (EN): European Number // + (ES): European Number Separator // % (ET): European Number Terminator // n (AN): Arabic Number // , (CS): Common Number Separator // m (NSM): Non-Spacing Mark // b (BN): Boundary Neutral // s (B): Paragraph Separator // t (S): Segment Separator // w (WS): Whitespace // N (ON): Other Neutrals // Returns null if characters are ordered as they appear // (left-to-right), or an array of sections ({from, to, level} // objects) in the order in which they occur visually. var bidiOrdering = (function() { // Character types for codepoints 0 to 0xff var lowTypes = "bbbbbbbbbtstwsbbbbbbbbbbbbbbssstwNN%%%NNNNNN,N,N1111111111NNNNNNNLLLLLLLLLLLLLLLLLLLLLLLLLLNNNNNNLLLLLLLLLLLLLLLLLLLLLLLLLLNNNNbbbbbbsbbbbbbbbbbbbbbbbbbbbbbbbbb,N%%%%NNNNLNNNNN%%11NLNNN1LNNNNNLLLLLLLLLLLLLLLLLLLLLLLNLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLN"; // Character types for codepoints 0x600 to 0x6f9 var arabicTypes = "nnnnnnNNr%%r,rNNmmmmmmmmmmmrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrmmmmmmmmmmmmmmmmmmmmmnnnnnnnnnn%nnrrrmrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrmmmmmmmnNmmmmmmrrmmNmmmmrr1111111111"; function charType(code) { if (code <= 0xf7) { return lowTypes.charAt(code) } else if (0x590 <= code && code <= 0x5f4) { return "R" } else if (0x600 <= code && code <= 0x6f9) { return arabicTypes.charAt(code - 0x600) } else if (0x6ee <= code && code <= 0x8ac) { return "r" } else if (0x2000 <= code && code <= 0x200b) { return "w" } else if (code == 0x200c) { return "b" } else { return "L" } } var bidiRE = /[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac]/; var isNeutral = /[stwN]/, isStrong = /[LRr]/, countsAsLeft = /[Lb1n]/, countsAsNum = /[1n]/; function BidiSpan(level, from, to) { this.level = level; this.from = from; this.to = to; } return function(str, direction) { var outerType = direction == "ltr" ? "L" : "R"; if (str.length == 0 || direction == "ltr" && !bidiRE.test(str)) { return false } var len = str.length, types = []; for (var i = 0; i < len; ++i) { types.push(charType(str.charCodeAt(i))); } // W1. Examine each non-spacing mark (NSM) in the level run, and // change the type of the NSM to the type of the previous // character. If the NSM is at the start of the level run, it will // get the type of sor. for (var i$1 = 0, prev = outerType; i$1 < len; ++i$1) { var type = types[i$1]; if (type == "m") { types[i$1] = prev; } else { prev = type; } } // W2. Search backwards from each instance of a European number // until the first strong type (R, L, AL, or sor) is found. If an // AL is found, change the type of the European number to Arabic // number. // W3. Change all ALs to R. for (var i$2 = 0, cur = outerType; i$2 < len; ++i$2) { var type$1 = types[i$2]; if (type$1 == "1" && cur == "r") { types[i$2] = "n"; } else if (isStrong.test(type$1)) { cur = type$1; if (type$1 == "r") { types[i$2] = "R"; } } } // W4. A single European separator between two European numbers // changes to a European number. A single common separator between // two numbers of the same type changes to that type. for (var i$3 = 1, prev$1 = types[0]; i$3 < len - 1; ++i$3) { var type$2 = types[i$3]; if (type$2 == "+" && prev$1 == "1" && types[i$3+1] == "1") { types[i$3] = "1"; } else if (type$2 == "," && prev$1 == types[i$3+1] && (prev$1 == "1" || prev$1 == "n")) { types[i$3] = prev$1; } prev$1 = type$2; } // W5. A sequence of European terminators adjacent to European // numbers changes to all European numbers. // W6. Otherwise, separators and terminators change to Other // Neutral. for (var i$4 = 0; i$4 < len; ++i$4) { var type$3 = types[i$4]; if (type$3 == ",") { types[i$4] = "N"; } else if (type$3 == "%") { var end = (void 0); for (end = i$4 + 1; end < len && types[end] == "%"; ++end) {} var replace = (i$4 && types[i$4-1] == "!") || (end < len && types[end] == "1") ? "1" : "N"; for (var j = i$4; j < end; ++j) { types[j] = replace; } i$4 = end - 1; } } // W7. Search backwards from each instance of a European number // until the first strong type (R, L, or sor) is found. If an L is // found, then change the type of the European number to L. for (var i$5 = 0, cur$1 = outerType; i$5 < len; ++i$5) { var type$4 = types[i$5]; if (cur$1 == "L" && type$4 == "1") { types[i$5] = "L"; } else if (isStrong.test(type$4)) { cur$1 = type$4; } } // N1. A sequence of neutrals takes the direction of the // surrounding strong text if the text on both sides has the same // direction. European and Arabic numbers act as if they were R in // terms of their influence on neutrals. Start-of-level-run (sor) // and end-of-level-run (eor) are used at level run boundaries. // N2. Any remaining neutrals take the embedding direction. for (var i$6 = 0; i$6 < len; ++i$6) { if (isNeutral.test(types[i$6])) { var end$1 = (void 0); for (end$1 = i$6 + 1; end$1 < len && isNeutral.test(types[end$1]); ++end$1) {} var before = (i$6 ? types[i$6-1] : outerType) == "L"; var after = (end$1 < len ? types[end$1] : outerType) == "L"; var replace$1 = before == after ? (before ? "L" : "R") : outerType; for (var j$1 = i$6; j$1 < end$1; ++j$1) { types[j$1] = replace$1; } i$6 = end$1 - 1; } } // Here we depart from the documented algorithm, in order to avoid // building up an actual levels array. Since there are only three // levels (0, 1, 2) in an implementation that doesn't take // explicit embedding into account, we can build up the order on // the fly, without following the level-based algorithm. var order = [], m; for (var i$7 = 0; i$7 < len;) { if (countsAsLeft.test(types[i$7])) { var start = i$7; for (++i$7; i$7 < len && countsAsLeft.test(types[i$7]); ++i$7) {} order.push(new BidiSpan(0, start, i$7)); } else { var pos = i$7, at = order.length, isRTL = direction == "rtl" ? 1 : 0; for (++i$7; i$7 < len && types[i$7] != "L"; ++i$7) {} for (var j$2 = pos; j$2 < i$7;) { if (countsAsNum.test(types[j$2])) { if (pos < j$2) { order.splice(at, 0, new BidiSpan(1, pos, j$2)); at += isRTL; } var nstart = j$2; for (++j$2; j$2 < i$7 && countsAsNum.test(types[j$2]); ++j$2) {} order.splice(at, 0, new BidiSpan(2, nstart, j$2)); at += isRTL; pos = j$2; } else { ++j$2; } } if (pos < i$7) { order.splice(at, 0, new BidiSpan(1, pos, i$7)); } } } if (direction == "ltr") { if (order[0].level == 1 && (m = str.match(/^\s+/))) { order[0].from = m[0].length; order.unshift(new BidiSpan(0, 0, m[0].length)); } if (lst(order).level == 1 && (m = str.match(/\s+$/))) { lst(order).to -= m[0].length; order.push(new BidiSpan(0, len - m[0].length, len)); } } return direction == "rtl" ? order.reverse() : order } })(); // Get the bidi ordering for the given line (and cache it). Returns // false for lines that are fully left-to-right, and an array of // BidiSpan objects otherwise. function getOrder(line, direction) { var order = line.order; if (order == null) { order = line.order = bidiOrdering(line.text, direction); } return order } // EVENT HANDLING // Lightweight event framework. on/off also work on DOM nodes, // registering native DOM handlers. var noHandlers = []; var on = function(emitter, type, f) { if (emitter.addEventListener) { emitter.addEventListener(type, f, false); } else if (emitter.attachEvent) { emitter.attachEvent("on" + type, f); } else { var map = emitter._handlers || (emitter._handlers = {}); map[type] = (map[type] || noHandlers).concat(f); } }; function getHandlers(emitter, type) { return emitter._handlers && emitter._handlers[type] || noHandlers } function off(emitter, type, f) { if (emitter.removeEventListener) { emitter.removeEventListener(type, f, false); } else if (emitter.detachEvent) { emitter.detachEvent("on" + type, f); } else { var map = emitter._handlers, arr = map && map[type]; if (arr) { var index = indexOf(arr, f); if (index > -1) { map[type] = arr.slice(0, index).concat(arr.slice(index + 1)); } } } } function signal(emitter, type /*, values...*/) { var handlers = getHandlers(emitter, type); if (!handlers.length) { return } var args = Array.prototype.slice.call(arguments, 2); for (var i = 0; i < handlers.length; ++i) { handlers[i].apply(null, args); } } // The DOM events that CodeMirror handles can be overridden by // registering a (non-DOM) handler on the editor for the event name, // and preventDefault-ing the event in that handler. function signalDOMEvent(cm, e, override) { if (typeof e == "string") { e = {type: e, preventDefault: function() { this.defaultPrevented = true; }}; } signal(cm, override || e.type, cm, e); return e_defaultPrevented(e) || e.codemirrorIgnore } function signalCursorActivity(cm) { var arr = cm._handlers && cm._handlers.cursorActivity; if (!arr) { return } var set = cm.curOp.cursorActivityHandlers || (cm.curOp.cursorActivityHandlers = []); for (var i = 0; i < arr.length; ++i) { if (indexOf(set, arr[i]) == -1) { set.push(arr[i]); } } } function hasHandler(emitter, type) { return getHandlers(emitter, type).length > 0 } // Add on and off methods to a constructor's prototype, to make // registering events on such objects more convenient. function eventMixin(ctor) { ctor.prototype.on = function(type, f) {on(this, type, f);}; ctor.prototype.off = function(type, f) {off(this, type, f);}; } // Due to the fact that we still support jurassic IE versions, some // compatibility wrappers are needed. function e_preventDefault(e) { if (e.preventDefault) { e.preventDefault(); } else { e.returnValue = false; } } function e_stopPropagation(e) { if (e.stopPropagation) { e.stopPropagation(); } else { e.cancelBubble = true; } } function e_defaultPrevented(e) { return e.defaultPrevented != null ? e.defaultPrevented : e.returnValue == false } function e_stop(e) {e_preventDefault(e); e_stopPropagation(e);} function e_target(e) {return e.target || e.srcElement} function e_button(e) { var b = e.which; if (b == null) { if (e.button & 1) { b = 1; } else if (e.button & 2) { b = 3; } else if (e.button & 4) { b = 2; } } if (mac && e.ctrlKey && b == 1) { b = 3; } return b } // Detect drag-and-drop var dragAndDrop = function() { // There is *some* kind of drag-and-drop support in IE6-8, but I // couldn't get it to work yet. if (ie && ie_version < 9) { return false } var div = elt('div'); return "draggable" in div || "dragDrop" in div }(); var zwspSupported; function zeroWidthElement(measure) { if (zwspSupported == null) { var test = elt("span", "\u200b"); removeChildrenAndAdd(measure, elt("span", [test, document.createTextNode("x")])); if (measure.firstChild.offsetHeight != 0) { zwspSupported = test.offsetWidth <= 1 && test.offsetHeight > 2 && !(ie && ie_version < 8); } } var node = zwspSupported ? elt("span", "\u200b") : elt("span", "\u00a0", null, "display: inline-block; width: 1px; margin-right: -1px"); node.setAttribute("cm-text", ""); return node } // Feature-detect IE's crummy client rect reporting for bidi text var badBidiRects; function hasBadBidiRects(measure) { if (badBidiRects != null) { return badBidiRects } var txt = removeChildrenAndAdd(measure, document.createTextNode("A\u062eA")); var r0 = range(txt, 0, 1).getBoundingClientRect(); var r1 = range(txt, 1, 2).getBoundingClientRect(); removeChildren(measure); if (!r0 || r0.left == r0.right) { return false } // Safari returns null in some cases (#2780) return badBidiRects = (r1.right - r0.right < 3) } // See if "".split is the broken IE version, if so, provide an // alternative way to split lines. var splitLinesAuto = "\n\nb".split(/\n/).length != 3 ? function (string) { var pos = 0, result = [], l = string.length; while (pos <= l) { var nl = string.indexOf("\n", pos); if (nl == -1) { nl = string.length; } var line = string.slice(pos, string.charAt(nl - 1) == "\r" ? nl - 1 : nl); var rt = line.indexOf("\r"); if (rt != -1) { result.push(line.slice(0, rt)); pos += rt + 1; } else { result.push(line); pos = nl + 1; } } return result } : function (string) { return string.split(/\r\n?|\n/); }; var hasSelection = window.getSelection ? function (te) { try { return te.selectionStart != te.selectionEnd } catch(e) { return false } } : function (te) { var range; try {range = te.ownerDocument.selection.createRange();} catch(e) {} if (!range || range.parentElement() != te) { return false } return range.compareEndPoints("StartToEnd", range) != 0 }; var hasCopyEvent = (function () { var e = elt("div"); if ("oncopy" in e) { return true } e.setAttribute("oncopy", "return;"); return typeof e.oncopy == "function" })(); var badZoomedRects = null; function hasBadZoomedRects(measure) { if (badZoomedRects != null) { return badZoomedRects } var node = removeChildrenAndAdd(measure, elt("span", "x")); var normal = node.getBoundingClientRect(); var fromRange = range(node, 0, 1).getBoundingClientRect(); return badZoomedRects = Math.abs(normal.left - fromRange.left) > 1 } // Known modes, by name and by MIME var modes = {}, mimeModes = {}; // Extra arguments are stored as the mode's dependencies, which is // used by (legacy) mechanisms like loadmode.js to automatically // load a mode. (Preferred mechanism is the require/define calls.) function defineMode(name, mode) { if (arguments.length > 2) { mode.dependencies = Array.prototype.slice.call(arguments, 2); } modes[name] = mode; } function defineMIME(mime, spec) { mimeModes[mime] = spec; } // Given a MIME type, a {name, ...options} config object, or a name // string, return a mode config object. function resolveMode(spec) { if (typeof spec == "string" && mimeModes.hasOwnProperty(spec)) { spec = mimeModes[spec]; } else if (spec && typeof spec.name == "string" && mimeModes.hasOwnProperty(spec.name)) { var found = mimeModes[spec.name]; if (typeof found == "string") { found = {name: found}; } spec = createObj(found, spec); spec.name = found.name; } else if (typeof spec == "string" && /^[\w\-]+\/[\w\-]+\+xml$/.test(spec)) { return resolveMode("application/xml") } else if (typeof spec == "string" && /^[\w\-]+\/[\w\-]+\+json$/.test(spec)) { return resolveMode("application/json") } if (typeof spec == "string") { return {name: spec} } else { return spec || {name: "null"} } } // Given a mode spec (anything that resolveMode accepts), find and // initialize an actual mode object. function getMode(options, spec) { spec = resolveMode(spec); var mfactory = modes[spec.name]; if (!mfactory) { return getMode(options, "text/plain") } var modeObj = mfactory(options, spec); if (modeExtensions.hasOwnProperty(spec.name)) { var exts = modeExtensions[spec.name]; for (var prop in exts) { if (!exts.hasOwnProperty(prop)) { continue } if (modeObj.hasOwnProperty(prop)) { modeObj["_" + prop] = modeObj[prop]; } modeObj[prop] = exts[prop]; } } modeObj.name = spec.name; if (spec.helperType) { modeObj.helperType = spec.helperType; } if (spec.modeProps) { for (var prop$1 in spec.modeProps) { modeObj[prop$1] = spec.modeProps[prop$1]; } } return modeObj } // This can be used to attach properties to mode objects from // outside the actual mode definition. var modeExtensions = {}; function extendMode(mode, properties) { var exts = modeExtensions.hasOwnProperty(mode) ? modeExtensions[mode] : (modeExtensions[mode] = {}); copyObj(properties, exts); } function copyState(mode, state) { if (state === true) { return state } if (mode.copyState) { return mode.copyState(state) } var nstate = {}; for (var n in state) { var val = state[n]; if (val instanceof Array) { val = val.concat([]); } nstate[n] = val; } return nstate } // Given a mode and a state (for that mode), find the inner mode and // state at the position that the state refers to. function innerMode(mode, state) { var info; while (mode.innerMode) { info = mode.innerMode(state); if (!info || info.mode == mode) { break } state = info.state; mode = info.mode; } return info || {mode: mode, state: state} } function startState(mode, a1, a2) { return mode.startState ? mode.startState(a1, a2) : true } // STRING STREAM // Fed to the mode parsers, provides helper functions to make // parsers more succinct. var StringStream = function(string, tabSize, lineOracle) { this.pos = this.start = 0; this.string = string; this.tabSize = tabSize || 8; this.lastColumnPos = this.lastColumnValue = 0; this.lineStart = 0; this.lineOracle = lineOracle; }; StringStream.prototype.eol = function () {return this.pos >= this.string.length}; StringStream.prototype.sol = function () {return this.pos == this.lineStart}; StringStream.prototype.peek = function () {return this.string.charAt(this.pos) || undefined}; StringStream.prototype.next = function () { if (this.pos < this.string.length) { return this.string.charAt(this.pos++) } }; StringStream.prototype.eat = function (match) { var ch = this.string.charAt(this.pos); var ok; if (typeof match == "string") { ok = ch == match; } else { ok = ch && (match.test ? match.test(ch) : match(ch)); } if (ok) {++this.pos; return ch} }; StringStream.prototype.eatWhile = function (match) { var start = this.pos; while (this.eat(match)){} return this.pos > start }; StringStream.prototype.eatSpace = function () { var start = this.pos; while (/[\s\u00a0]/.test(this.string.charAt(this.pos))) { ++this.pos; } return this.pos > start }; StringStream.prototype.skipToEnd = function () {this.pos = this.string.length;}; StringStream.prototype.skipTo = function (ch) { var found = this.string.indexOf(ch, this.pos); if (found > -1) {this.pos = found; return true} }; StringStream.prototype.backUp = function (n) {this.pos -= n;}; StringStream.prototype.column = function () { if (this.lastColumnPos < this.start) { this.lastColumnValue = countColumn(this.string, this.start, this.tabSize, this.lastColumnPos, this.lastColumnValue); this.lastColumnPos = this.start; } return this.lastColumnValue - (this.lineStart ? countColumn(this.string, this.lineStart, this.tabSize) : 0) }; StringStream.prototype.indentation = function () { return countColumn(this.string, null, this.tabSize) - (this.lineStart ? countColumn(this.string, this.lineStart, this.tabSize) : 0) }; StringStream.prototype.match = function (pattern, consume, caseInsensitive) { if (typeof pattern == "string") { var cased = function (str) { return caseInsensitive ? str.toLowerCase() : str; }; var substr = this.string.substr(this.pos, pattern.length); if (cased(substr) == cased(pattern)) { if (consume !== false) { this.pos += pattern.length; } return true } } else { var match = this.string.slice(this.pos).match(pattern); if (match && match.index > 0) { return null } if (match && consume !== false) { this.pos += match[0].length; } return match } }; StringStream.prototype.current = function (){return this.string.slice(this.start, this.pos)}; StringStream.prototype.hideFirstChars = function (n, inner) { this.lineStart += n; try { return inner() } finally { this.lineStart -= n; } }; StringStream.prototype.lookAhead = function (n) { var oracle = this.lineOracle; return oracle && oracle.lookAhead(n) }; StringStream.prototype.baseToken = function () { var oracle = this.lineOracle; return oracle && oracle.baseToken(this.pos) }; // Find the line object corresponding to the given line number. function getLine(doc, n) { n -= doc.first; if (n < 0 || n >= doc.size) { throw new Error("There is no line " + (n + doc.first) + " in the document.") } var chunk = doc; while (!chunk.lines) { for (var i = 0;; ++i) { var child = chunk.children[i], sz = child.chunkSize(); if (n < sz) { chunk = child; break } n -= sz; } } return chunk.lines[n] } // Get the part of a document between two positions, as an array of // strings. function getBetween(doc, start, end) { var out = [], n = start.line; doc.iter(start.line, end.line + 1, function (line) { var text = line.text; if (n == end.line) { text = text.slice(0, end.ch); } if (n == start.line) { text = text.slice(start.ch); } out.push(text); ++n; }); return out } // Get the lines between from and to, as array of strings. function getLines(doc, from, to) { var out = []; doc.iter(from, to, function (line) { out.push(line.text); }); // iter aborts when callback returns truthy value return out } // Update the height of a line, propagating the height change // upwards to parent nodes. function updateLineHeight(line, height) { var diff = height - line.height; if (diff) { for (var n = line; n; n = n.parent) { n.height += diff; } } } // Given a line object, find its line number by walking up through // its parent links. function lineNo(line) { if (line.parent == null) { return null } var cur = line.parent, no = indexOf(cur.lines, line); for (var chunk = cur.parent; chunk; cur = chunk, chunk = chunk.parent) { for (var i = 0;; ++i) { if (chunk.children[i] == cur) { break } no += chunk.children[i].chunkSize(); } } return no + cur.first } // Find the line at the given vertical position, using the height // information in the document tree. function lineAtHeight(chunk, h) { var n = chunk.first; outer: do { for (var i$1 = 0; i$1 < chunk.children.length; ++i$1) { var child = chunk.children[i$1], ch = child.height; if (h < ch) { chunk = child; continue outer } h -= ch; n += child.chunkSize(); } return n } while (!chunk.lines) var i = 0; for (; i < chunk.lines.length; ++i) { var line = chunk.lines[i], lh = line.height; if (h < lh) { break } h -= lh; } return n + i } function isLine(doc, l) {return l >= doc.first && l < doc.first + doc.size} function lineNumberFor(options, i) { return String(options.lineNumberFormatter(i + options.firstLineNumber)) } // A Pos instance represents a position within the text. function Pos(line, ch, sticky) { if ( sticky === void 0 ) sticky = null; if (!(this instanceof Pos)) { return new Pos(line, ch, sticky) } this.line = line; this.ch = ch; this.sticky = sticky; } // Compare two positions, return 0 if they are the same, a negative // number when a is less, and a positive number otherwise. function cmp(a, b) { return a.line - b.line || a.ch - b.ch } function equalCursorPos(a, b) { return a.sticky == b.sticky && cmp(a, b) == 0 } function copyPos(x) {return Pos(x.line, x.ch)} function maxPos(a, b) { return cmp(a, b) < 0 ? b : a } function minPos(a, b) { return cmp(a, b) < 0 ? a : b } // Most of the external API clips given positions to make sure they // actually exist within the document. function clipLine(doc, n) {return Math.max(doc.first, Math.min(n, doc.first + doc.size - 1))} function clipPos(doc, pos) { if (pos.line < doc.first) { return Pos(doc.first, 0) } var last = doc.first + doc.size - 1; if (pos.line > last) { return Pos(last, getLine(doc, last).text.length) } return clipToLen(pos, getLine(doc, pos.line).text.length) } function clipToLen(pos, linelen) { var ch = pos.ch; if (ch == null || ch > linelen) { return Pos(pos.line, linelen) } else if (ch < 0) { return Pos(pos.line, 0) } else { return pos } } function clipPosArray(doc, array) { var out = []; for (var i = 0; i < array.length; i++) { out[i] = clipPos(doc, array[i]); } return out } var SavedContext = function(state, lookAhead) { this.state = state; this.lookAhead = lookAhead; }; var Context = function(doc, state, line, lookAhead) { this.state = state; this.doc = doc; this.line = line; this.maxLookAhead = lookAhead || 0; this.baseTokens = null; this.baseTokenPos = 1; }; Context.prototype.lookAhead = function (n) { var line = this.doc.getLine(this.line + n); if (line != null && n > this.maxLookAhead) { this.maxLookAhead = n; } return line }; Context.prototype.baseToken = function (n) { if (!this.baseTokens) { return null } while (this.baseTokens[this.baseTokenPos] <= n) { this.baseTokenPos += 2; } var type = this.baseTokens[this.baseTokenPos + 1]; return {type: type && type.replace(/( |^)overlay .*/, ""), size: this.baseTokens[this.baseTokenPos] - n} }; Context.prototype.nextLine = function () { this.line++; if (this.maxLookAhead > 0) { this.maxLookAhead--; } }; Context.fromSaved = function (doc, saved, line) { if (saved instanceof SavedContext) { return new Context(doc, copyState(doc.mode, saved.state), line, saved.lookAhead) } else { return new Context(doc, copyState(doc.mode, saved), line) } }; Context.prototype.save = function (copy) { var state = copy !== false ? copyState(this.doc.mode, this.state) : this.state; return this.maxLookAhead > 0 ? new SavedContext(state, this.maxLookAhead) : state }; // Compute a style array (an array starting with a mode generation // -- for invalidation -- followed by pairs of end positions and // style strings), which is used to highlight the tokens on the // line. function highlightLine(cm, line, context, forceToEnd) { // A styles array always starts with a number identifying the // mode/overlays that it is based on (for easy invalidation). var st = [cm.state.modeGen], lineClasses = {}; // Compute the base array of styles runMode(cm, line.text, cm.doc.mode, context, function (end, style) { return st.push(end, style); }, lineClasses, forceToEnd); var state = context.state; // Run overlays, adjust style array. var loop = function ( o ) { context.baseTokens = st; var overlay = cm.state.overlays[o], i = 1, at = 0; context.state = true; runMode(cm, line.text, overlay.mode, context, function (end, style) { var start = i; // Ensure there's a token end at the current position, and that i points at it while (at < end) { var i_end = st[i]; if (i_end > end) { st.splice(i, 1, end, st[i+1], i_end); } i += 2; at = Math.min(end, i_end); } if (!style) { return } if (overlay.opaque) { st.splice(start, i - start, end, "overlay " + style); i = start + 2; } else { for (; start < i; start += 2) { var cur = st[start+1]; st[start+1] = (cur ? cur + " " : "") + "overlay " + style; } } }, lineClasses); context.state = state; context.baseTokens = null; context.baseTokenPos = 1; }; for (var o = 0; o < cm.state.overlays.length; ++o) loop( o ); return {styles: st, classes: lineClasses.bgClass || lineClasses.textClass ? lineClasses : null} } function getLineStyles(cm, line, updateFrontier) { if (!line.styles || line.styles[0] != cm.state.modeGen) { var context = getContextBefore(cm, lineNo(line)); var resetState = line.text.length > cm.options.maxHighlightLength && copyState(cm.doc.mode, context.state); var result = highlightLine(cm, line, context); if (resetState) { context.state = resetState; } line.stateAfter = context.save(!resetState); line.styles = result.styles; if (result.classes) { line.styleClasses = result.classes; } else if (line.styleClasses) { line.styleClasses = null; } if (updateFrontier === cm.doc.highlightFrontier) { cm.doc.modeFrontier = Math.max(cm.doc.modeFrontier, ++cm.doc.highlightFrontier); } } return line.styles } function getContextBefore(cm, n, precise) { var doc = cm.doc, display = cm.display; if (!doc.mode.startState) { return new Context(doc, true, n) } var start = findStartLine(cm, n, precise); var saved = start > doc.first && getLine(doc, start - 1).stateAfter; var context = saved ? Context.fromSaved(doc, saved, start) : new Context(doc, startState(doc.mode), start); doc.iter(start, n, function (line) { processLine(cm, line.text, context); var pos = context.line; line.stateAfter = pos == n - 1 || pos % 5 == 0 || pos >= display.viewFrom && pos < display.viewTo ? context.save() : null; context.nextLine(); }); if (precise) { doc.modeFrontier = context.line; } return context } // Lightweight form of highlight -- proceed over this line and // update state, but don't save a style array. Used for lines that // aren't currently visible. function processLine(cm, text, context, startAt) { var mode = cm.doc.mode; var stream = new StringStream(text, cm.options.tabSize, context); stream.start = stream.pos = startAt || 0; if (text == "") { callBlankLine(mode, context.state); } while (!stream.eol()) { readToken(mode, stream, context.state); stream.start = stream.pos; } } function callBlankLine(mode, state) { if (mode.blankLine) { return mode.blankLine(state) } if (!mode.innerMode) { return } var inner = innerMode(mode, state); if (inner.mode.blankLine) { return inner.mode.blankLine(inner.state) } } function readToken(mode, stream, state, inner) { for (var i = 0; i < 10; i++) { if (inner) { inner[0] = innerMode(mode, state).mode; } var style = mode.token(stream, state); if (stream.pos > stream.start) { return style } } throw new Error("Mode " + mode.name + " failed to advance stream.") } var Token = function(stream, type, state) { this.start = stream.start; this.end = stream.pos; this.string = stream.current(); this.type = type || null; this.state = state; }; // Utility for getTokenAt and getLineTokens function takeToken(cm, pos, precise, asArray) { var doc = cm.doc, mode = doc.mode, style; pos = clipPos(doc, pos); var line = getLine(doc, pos.line), context = getContextBefore(cm, pos.line, precise); var stream = new StringStream(line.text, cm.options.tabSize, context), tokens; if (asArray) { tokens = []; } while ((asArray || stream.pos < pos.ch) && !stream.eol()) { stream.start = stream.pos; style = readToken(mode, stream, context.state); if (asArray) { tokens.push(new Token(stream, style, copyState(doc.mode, context.state))); } } return asArray ? tokens : new Token(stream, style, context.state) } function extractLineClasses(type, output) { if (type) { for (;;) { var lineClass = type.match(/(?:^|\s+)line-(background-)?(\S+)/); if (!lineClass) { break } type = type.slice(0, lineClass.index) + type.slice(lineClass.index + lineClass[0].length); var prop = lineClass[1] ? "bgClass" : "textClass"; if (output[prop] == null) { output[prop] = lineClass[2]; } else if (!(new RegExp("(?:^|\\s)" + lineClass[2] + "(?:$|\\s)")).test(output[prop])) { output[prop] += " " + lineClass[2]; } } } return type } // Run the given mode's parser over a line, calling f for each token. function runMode(cm, text, mode, context, f, lineClasses, forceToEnd) { var flattenSpans = mode.flattenSpans; if (flattenSpans == null) { flattenSpans = cm.options.flattenSpans; } var curStart = 0, curStyle = null; var stream = new StringStream(text, cm.options.tabSize, context), style; var inner = cm.options.addModeClass && [null]; if (text == "") { extractLineClasses(callBlankLine(mode, context.state), lineClasses); } while (!stream.eol()) { if (stream.pos > cm.options.maxHighlightLength) { flattenSpans = false; if (forceToEnd) { processLine(cm, text, context, stream.pos); } stream.pos = text.length; style = null; } else { style = extractLineClasses(readToken(mode, stream, context.state, inner), lineClasses); } if (inner) { var mName = inner[0].name; if (mName) { style = "m-" + (style ? mName + " " + style : mName); } } if (!flattenSpans || curStyle != style) { while (curStart < stream.start) { curStart = Math.min(stream.start, curStart + 5000); f(curStart, curStyle); } curStyle = style; } stream.start = stream.pos; } while (curStart < stream.pos) { // Webkit seems to refuse to render text nodes longer than 57444 // characters, and returns inaccurate measurements in nodes // starting around 5000 chars. var pos = Math.min(stream.pos, curStart + 5000); f(pos, curStyle); curStart = pos; } } // Finds the line to start with when starting a parse. Tries to // find a line with a stateAfter, so that it can start with a // valid state. If that fails, it returns the line with the // smallest indentation, which tends to need the least context to // parse correctly. function findStartLine(cm, n, precise) { var minindent, minline, doc = cm.doc; var lim = precise ? -1 : n - (cm.doc.mode.innerMode ? 1000 : 100); for (var search = n; search > lim; --search) { if (search <= doc.first) { return doc.first } var line = getLine(doc, search - 1), after = line.stateAfter; if (after && (!precise || search + (after instanceof SavedContext ? after.lookAhead : 0) <= doc.modeFrontier)) { return search } var indented = countColumn(line.text, null, cm.options.tabSize); if (minline == null || minindent > indented) { minline = search - 1; minindent = indented; } } return minline } function retreatFrontier(doc, n) { doc.modeFrontier = Math.min(doc.modeFrontier, n); if (doc.highlightFrontier < n - 10) { return } var start = doc.first; for (var line = n - 1; line > start; line--) { var saved = getLine(doc, line).stateAfter; // change is on 3 // state on line 1 looked ahead 2 -- so saw 3 // test 1 + 2 < 3 should cover this if (saved && (!(saved instanceof SavedContext) || line + saved.lookAhead < n)) { start = line + 1; break } } doc.highlightFrontier = Math.min(doc.highlightFrontier, start); } // Optimize some code when these features are not used. var sawReadOnlySpans = false, sawCollapsedSpans = false; function seeReadOnlySpans() { sawReadOnlySpans = true; } function seeCollapsedSpans() { sawCollapsedSpans = true; } // TEXTMARKER SPANS function MarkedSpan(marker, from, to) { this.marker = marker; this.from = from; this.to = to; } // Search an array of spans for a span matching the given marker. function getMarkedSpanFor(spans, marker) { if (spans) { for (var i = 0; i < spans.length; ++i) { var span = spans[i]; if (span.marker == marker) { return span } } } } // Remove a span from an array, returning undefined if no spans are // left (we don't store arrays for lines without spans). function removeMarkedSpan(spans, span) { var r; for (var i = 0; i < spans.length; ++i) { if (spans[i] != span) { (r || (r = [])).push(spans[i]); } } return r } // Add a span to a line. function addMarkedSpan(line, span) { line.markedSpans = line.markedSpans ? line.markedSpans.concat([span]) : [span]; span.marker.attachLine(line); } // Used for the algorithm that adjusts markers for a change in the // document. These functions cut an array of spans at a given // character position, returning an array of remaining chunks (or // undefined if nothing remains). function markedSpansBefore(old, startCh, isInsert) { var nw; if (old) { for (var i = 0; i < old.length; ++i) { var span = old[i], marker = span.marker; var startsBefore = span.from == null || (marker.inclusiveLeft ? span.from <= startCh : span.from < startCh); if (startsBefore || span.from == startCh && marker.type == "bookmark" && (!isInsert || !span.marker.insertLeft)) { var endsAfter = span.to == null || (marker.inclusiveRight ? span.to >= startCh : span.to > startCh) ;(nw || (nw = [])).push(new MarkedSpan(marker, span.from, endsAfter ? null : span.to)); } } } return nw } function markedSpansAfter(old, endCh, isInsert) { var nw; if (old) { for (var i = 0; i < old.length; ++i) { var span = old[i], marker = span.marker; var endsAfter = span.to == null || (marker.inclusiveRight ? span.to >= endCh : span.to > endCh); if (endsAfter || span.from == endCh && marker.type == "bookmark" && (!isInsert || span.marker.insertLeft)) { var startsBefore = span.from == null || (marker.inclusiveLeft ? span.from <= endCh : span.from < endCh) ;(nw || (nw = [])).push(new MarkedSpan(marker, startsBefore ? null : span.from - endCh, span.to == null ? null : span.to - endCh)); } } } return nw } // Given a change object, compute the new set of marker spans that // cover the line in which the change took place. Removes spans // entirely within the change, reconnects spans belonging to the // same marker that appear on both sides of the change, and cuts off // spans partially within the change. Returns an array of span // arrays with one element for each line in (after) the change. function stretchSpansOverChange(doc, change) { if (change.full) { return null } var oldFirst = isLine(doc, change.from.line) && getLine(doc, change.from.line).markedSpans; var oldLast = isLine(doc, change.to.line) && getLine(doc, change.to.line).markedSpans; if (!oldFirst && !oldLast) { return null } var startCh = change.from.ch, endCh = change.to.ch, isInsert = cmp(change.from, change.to) == 0; // Get the spans that 'stick out' on both sides var first = markedSpansBefore(oldFirst, startCh, isInsert); var last = markedSpansAfter(oldLast, endCh, isInsert); // Next, merge those two ends var sameLine = change.text.length == 1, offset = lst(change.text).length + (sameLine ? startCh : 0); if (first) { // Fix up .to properties of first for (var i = 0; i < first.length; ++i) { var span = first[i]; if (span.to == null) { var found = getMarkedSpanFor(last, span.marker); if (!found) { span.to = startCh; } else if (sameLine) { span.to = found.to == null ? null : found.to + offset; } } } } if (last) { // Fix up .from in last (or move them into first in case of sameLine) for (var i$1 = 0; i$1 < last.length; ++i$1) { var span$1 = last[i$1]; if (span$1.to != null) { span$1.to += offset; } if (span$1.from == null) { var found$1 = getMarkedSpanFor(first, span$1.marker); if (!found$1) { span$1.from = offset; if (sameLine) { (first || (first = [])).push(span$1); } } } else { span$1.from += offset; if (sameLine) { (first || (first = [])).push(span$1); } } } } // Make sure we didn't create any zero-length spans if (first) { first = clearEmptySpans(first); } if (last && last != first) { last = clearEmptySpans(last); } var newMarkers = [first]; if (!sameLine) { // Fill gap with whole-line-spans var gap = change.text.length - 2, gapMarkers; if (gap > 0 && first) { for (var i$2 = 0; i$2 < first.length; ++i$2) { if (first[i$2].to == null) { (gapMarkers || (gapMarkers = [])).push(new MarkedSpan(first[i$2].marker, null, null)); } } } for (var i$3 = 0; i$3 < gap; ++i$3) { newMarkers.push(gapMarkers); } newMarkers.push(last); } return newMarkers } // Remove spans that are empty and don't have a clearWhenEmpty // option of false. function clearEmptySpans(spans) { for (var i = 0; i < spans.length; ++i) { var span = spans[i]; if (span.from != null && span.from == span.to && span.marker.clearWhenEmpty !== false) { spans.splice(i--, 1); } } if (!spans.length) { return null } return spans } // Used to 'clip' out readOnly ranges when making a change. function removeReadOnlyRanges(doc, from, to) { var markers = null; doc.iter(from.line, to.line + 1, function (line) { if (line.markedSpans) { for (var i = 0; i < line.markedSpans.length; ++i) { var mark = line.markedSpans[i].marker; if (mark.readOnly && (!markers || indexOf(markers, mark) == -1)) { (markers || (markers = [])).push(mark); } } } }); if (!markers) { return null } var parts = [{from: from, to: to}]; for (var i = 0; i < markers.length; ++i) { var mk = markers[i], m = mk.find(0); for (var j = 0; j < parts.length; ++j) { var p = parts[j]; if (cmp(p.to, m.from) < 0 || cmp(p.from, m.to) > 0) { continue } var newParts = [j, 1], dfrom = cmp(p.from, m.from), dto = cmp(p.to, m.to); if (dfrom < 0 || !mk.inclusiveLeft && !dfrom) { newParts.push({from: p.from, to: m.from}); } if (dto > 0 || !mk.inclusiveRight && !dto) { newParts.push({from: m.to, to: p.to}); } parts.splice.apply(parts, newParts); j += newParts.length - 3; } } return parts } // Connect or disconnect spans from a line. function detachMarkedSpans(line) { var spans = line.markedSpans; if (!spans) { return } for (var i = 0; i < spans.length; ++i) { spans[i].marker.detachLine(line); } line.markedSpans = null; } function attachMarkedSpans(line, spans) { if (!spans) { return } for (var i = 0; i < spans.length; ++i) { spans[i].marker.attachLine(line); } line.markedSpans = spans; } // Helpers used when computing which overlapping collapsed span // counts as the larger one. function extraLeft(marker) { return marker.inclusiveLeft ? -1 : 0 } function extraRight(marker) { return marker.inclusiveRight ? 1 : 0 } // Returns a number indicating which of two overlapping collapsed // spans is larger (and thus includes the other). Falls back to // comparing ids when the spans cover exactly the same range. function compareCollapsedMarkers(a, b) { var lenDiff = a.lines.length - b.lines.length; if (lenDiff != 0) { return lenDiff } var aPos = a.find(), bPos = b.find(); var fromCmp = cmp(aPos.from, bPos.from) || extraLeft(a) - extraLeft(b); if (fromCmp) { return -fromCmp } var toCmp = cmp(aPos.to, bPos.to) || extraRight(a) - extraRight(b); if (toCmp) { return toCmp } return b.id - a.id } // Find out whether a line ends or starts in a collapsed span. If // so, return the marker for that span. function collapsedSpanAtSide(line, start) { var sps = sawCollapsedSpans && line.markedSpans, found; if (sps) { for (var sp = (void 0), i = 0; i < sps.length; ++i) { sp = sps[i]; if (sp.marker.collapsed && (start ? sp.from : sp.to) == null && (!found || compareCollapsedMarkers(found, sp.marker) < 0)) { found = sp.marker; } } } return found } function collapsedSpanAtStart(line) { return collapsedSpanAtSide(line, true) } function collapsedSpanAtEnd(line) { return collapsedSpanAtSide(line, false) } function collapsedSpanAround(line, ch) { var sps = sawCollapsedSpans && line.markedSpans, found; if (sps) { for (var i = 0; i < sps.length; ++i) { var sp = sps[i]; if (sp.marker.collapsed && (sp.from == null || sp.from < ch) && (sp.to == null || sp.to > ch) && (!found || compareCollapsedMarkers(found, sp.marker) < 0)) { found = sp.marker; } } } return found } // Test whether there exists a collapsed span that partially // overlaps (covers the start or end, but not both) of a new span. // Such overlap is not allowed. function conflictingCollapsedRange(doc, lineNo, from, to, marker) { var line = getLine(doc, lineNo); var sps = sawCollapsedSpans && line.markedSpans; if (sps) { for (var i = 0; i < sps.length; ++i) { var sp = sps[i]; if (!sp.marker.collapsed) { continue } var found = sp.marker.find(0); var fromCmp = cmp(found.from, from) || extraLeft(sp.marker) - extraLeft(marker); var toCmp = cmp(found.to, to) || extraRight(sp.marker) - extraRight(marker); if (fromCmp >= 0 && toCmp <= 0 || fromCmp <= 0 && toCmp >= 0) { continue } if (fromCmp <= 0 && (sp.marker.inclusiveRight && marker.inclusiveLeft ? cmp(found.to, from) >= 0 : cmp(found.to, from) > 0) || fromCmp >= 0 && (sp.marker.inclusiveRight && marker.inclusiveLeft ? cmp(found.from, to) <= 0 : cmp(found.from, to) < 0)) { return true } } } } // A visual line is a line as drawn on the screen. Folding, for // example, can cause multiple logical lines to appear on the same // visual line. This finds the start of the visual line that the // given line is part of (usually that is the line itself). function visualLine(line) { var merged; while (merged = collapsedSpanAtStart(line)) { line = merged.find(-1, true).line; } return line } function visualLineEnd(line) { var merged; while (merged = collapsedSpanAtEnd(line)) { line = merged.find(1, true).line; } return line } // Returns an array of logical lines that continue the visual line // started by the argument, or undefined if there are no such lines. function visualLineContinued(line) { var merged, lines; while (merged = collapsedSpanAtEnd(line)) { line = merged.find(1, true).line ;(lines || (lines = [])).push(line); } return lines } // Get the line number of the start of the visual line that the // given line number is part of. function visualLineNo(doc, lineN) { var line = getLine(doc, lineN), vis = visualLine(line); if (line == vis) { return lineN } return lineNo(vis) } // Get the line number of the start of the next visual line after // the given line. function visualLineEndNo(doc, lineN) { if (lineN > doc.lastLine()) { return lineN } var line = getLine(doc, lineN), merged; if (!lineIsHidden(doc, line)) { return lineN } while (merged = collapsedSpanAtEnd(line)) { line = merged.find(1, true).line; } return lineNo(line) + 1 } // Compute whether a line is hidden. Lines count as hidden when they // are part of a visual line that starts with another line, or when // they are entirely covered by collapsed, non-widget span. function lineIsHidden(doc, line) { var sps = sawCollapsedSpans && line.markedSpans; if (sps) { for (var sp = (void 0), i = 0; i < sps.length; ++i) { sp = sps[i]; if (!sp.marker.collapsed) { continue } if (sp.from == null) { return true } if (sp.marker.widgetNode) { continue } if (sp.from == 0 && sp.marker.inclusiveLeft && lineIsHiddenInner(doc, line, sp)) { return true } } } } function lineIsHiddenInner(doc, line, span) { if (span.to == null) { var end = span.marker.find(1, true); return lineIsHiddenInner(doc, end.line, getMarkedSpanFor(end.line.markedSpans, span.marker)) } if (span.marker.inclusiveRight && span.to == line.text.length) { return true } for (var sp = (void 0), i = 0; i < line.markedSpans.length; ++i) { sp = line.markedSpans[i]; if (sp.marker.collapsed && !sp.marker.widgetNode && sp.from == span.to && (sp.to == null || sp.to != span.from) && (sp.marker.inclusiveLeft || span.marker.inclusiveRight) && lineIsHiddenInner(doc, line, sp)) { return true } } } // Find the height above the given line. function heightAtLine(lineObj) { lineObj = visualLine(lineObj); var h = 0, chunk = lineObj.parent; for (var i = 0; i < chunk.lines.length; ++i) { var line = chunk.lines[i]; if (line == lineObj) { break } else { h += line.height; } } for (var p = chunk.parent; p; chunk = p, p = chunk.parent) { for (var i$1 = 0; i$1 < p.children.length; ++i$1) { var cur = p.children[i$1]; if (cur == chunk) { break } else { h += cur.height; } } } return h } // Compute the character length of a line, taking into account // collapsed ranges (see markText) that might hide parts, and join // other lines onto it. function lineLength(line) { if (line.height == 0) { return 0 } var len = line.text.length, merged, cur = line; while (merged = collapsedSpanAtStart(cur)) { var found = merged.find(0, true); cur = found.from.line; len += found.from.ch - found.to.ch; } cur = line; while (merged = collapsedSpanAtEnd(cur)) { var found$1 = merged.find(0, true); len -= cur.text.length - found$1.from.ch; cur = found$1.to.line; len += cur.text.length - found$1.to.ch; } return len } // Find the longest line in the document. function findMaxLine(cm) { var d = cm.display, doc = cm.doc; d.maxLine = getLine(doc, doc.first); d.maxLineLength = lineLength(d.maxLine); d.maxLineChanged = true; doc.iter(function (line) { var len = lineLength(line); if (len > d.maxLineLength) { d.maxLineLength = len; d.maxLine = line; } }); } // LINE DATA STRUCTURE // Line objects. These hold state related to a line, including // highlighting info (the styles array). var Line = function(text, markedSpans, estimateHeight) { this.text = text; attachMarkedSpans(this, markedSpans); this.height = estimateHeight ? estimateHeight(this) : 1; }; Line.prototype.lineNo = function () { return lineNo(this) }; eventMixin(Line); // Change the content (text, markers) of a line. Automatically // invalidates cached information and tries to re-estimate the // line's height. function updateLine(line, text, markedSpans, estimateHeight) { line.text = text; if (line.stateAfter) { line.stateAfter = null; } if (line.styles) { line.styles = null; } if (line.order != null) { line.order = null; } detachMarkedSpans(line); attachMarkedSpans(line, markedSpans); var estHeight = estimateHeight ? estimateHeight(line) : 1; if (estHeight != line.height) { updateLineHeight(line, estHeight); } } // Detach a line from the document tree and its markers. function cleanUpLine(line) { line.parent = null; detachMarkedSpans(line); } // Convert a style as returned by a mode (either null, or a string // containing one or more styles) to a CSS style. This is cached, // and also looks for line-wide styles. var styleToClassCache = {}, styleToClassCacheWithMode = {}; function interpretTokenStyle(style, options) { if (!style || /^\s*$/.test(style)) { return null } var cache = options.addModeClass ? styleToClassCacheWithMode : styleToClassCache; return cache[style] || (cache[style] = style.replace(/\S+/g, "cm-$&")) } // Render the DOM representation of the text of a line. Also builds // up a 'line map', which points at the DOM nodes that represent // specific stretches of text, and is used by the measuring code. // The returned object contains the DOM node, this map, and // information about line-wide styles that were set by the mode. function buildLineContent(cm, lineView) { // The padding-right forces the element to have a 'border', which // is needed on Webkit to be able to get line-level bounding // rectangles for it (in measureChar). var content = eltP("span", null, null, webkit ? "padding-right: .1px" : null); var builder = {pre: eltP("pre", [content], "CodeMirror-line"), content: content, col: 0, pos: 0, cm: cm, trailingSpace: false, splitSpaces: cm.getOption("lineWrapping")}; lineView.measure = {}; // Iterate over the logical lines that make up this visual line. for (var i = 0; i <= (lineView.rest ? lineView.rest.length : 0); i++) { var line = i ? lineView.rest[i - 1] : lineView.line, order = (void 0); builder.pos = 0; builder.addToken = buildToken; // Optionally wire in some hacks into the token-rendering // algorithm, to deal with browser quirks. if (hasBadBidiRects(cm.display.measure) && (order = getOrder(line, cm.doc.direction))) { builder.addToken = buildTokenBadBidi(builder.addToken, order); } builder.map = []; var allowFrontierUpdate = lineView != cm.display.externalMeasured && lineNo(line); insertLineContent(line, builder, getLineStyles(cm, line, allowFrontierUpdate)); if (line.styleClasses) { if (line.styleClasses.bgClass) { builder.bgClass = joinClasses(line.styleClasses.bgClass, builder.bgClass || ""); } if (line.styleClasses.textClass) { builder.textClass = joinClasses(line.styleClasses.textClass, builder.textClass || ""); } } // Ensure at least a single node is present, for measuring. if (builder.map.length == 0) { builder.map.push(0, 0, builder.content.appendChild(zeroWidthElement(cm.display.measure))); } // Store the map and a cache object for the current logical line if (i == 0) { lineView.measure.map = builder.map; lineView.measure.cache = {}; } else { (lineView.measure.maps || (lineView.measure.maps = [])).push(builder.map) ;(lineView.measure.caches || (lineView.measure.caches = [])).push({}); } } // See issue #2901 if (webkit) { var last = builder.content.lastChild; if (/\bcm-tab\b/.test(last.className) || (last.querySelector && last.querySelector(".cm-tab"))) { builder.content.className = "cm-tab-wrap-hack"; } } signal(cm, "renderLine", cm, lineView.line, builder.pre); if (builder.pre.className) { builder.textClass = joinClasses(builder.pre.className, builder.textClass || ""); } return builder } function defaultSpecialCharPlaceholder(ch) { var token = elt("span", "\u2022", "cm-invalidchar"); token.title = "\\u" + ch.charCodeAt(0).toString(16); token.setAttribute("aria-label", token.title); return token } // Build up the DOM representation for a single token, and add it to // the line map. Takes care to render special characters separately. function buildToken(builder, text, style, startStyle, endStyle, css, attributes) { if (!text) { return } var displayText = builder.splitSpaces ? splitSpaces(text, builder.trailingSpace) : text; var special = builder.cm.state.specialChars, mustWrap = false; var content; if (!special.test(text)) { builder.col += text.length; content = document.createTextNode(displayText); builder.map.push(builder.pos, builder.pos + text.length, content); if (ie && ie_version < 9) { mustWrap = true; } builder.pos += text.length; } else { content = document.createDocumentFragment(); var pos = 0; while (true) { special.lastIndex = pos; var m = special.exec(text); var skipped = m ? m.index - pos : text.length - pos; if (skipped) { var txt = document.createTextNode(displayText.slice(pos, pos + skipped)); if (ie && ie_version < 9) { content.appendChild(elt("span", [txt])); } else { content.appendChild(txt); } builder.map.push(builder.pos, builder.pos + skipped, txt); builder.col += skipped; builder.pos += skipped; } if (!m) { break } pos += skipped + 1; var txt$1 = (void 0); if (m[0] == "\t") { var tabSize = builder.cm.options.tabSize, tabWidth = tabSize - builder.col % tabSize; txt$1 = content.appendChild(elt("span", spaceStr(tabWidth), "cm-tab")); txt$1.setAttribute("role", "presentation"); txt$1.setAttribute("cm-text", "\t"); builder.col += tabWidth; } else if (m[0] == "\r" || m[0] == "\n") { txt$1 = content.appendChild(elt("span", m[0] == "\r" ? "\u240d" : "\u2424", "cm-invalidchar")); txt$1.setAttribute("cm-text", m[0]); builder.col += 1; } else { txt$1 = builder.cm.options.specialCharPlaceholder(m[0]); txt$1.setAttribute("cm-text", m[0]); if (ie && ie_version < 9) { content.appendChild(elt("span", [txt$1])); } else { content.appendChild(txt$1); } builder.col += 1; } builder.map.push(builder.pos, builder.pos + 1, txt$1); builder.pos++; } } builder.trailingSpace = displayText.charCodeAt(text.length - 1) == 32; if (style || startStyle || endStyle || mustWrap || css || attributes) { var fullStyle = style || ""; if (startStyle) { fullStyle += startStyle; } if (endStyle) { fullStyle += endStyle; } var token = elt("span", [content], fullStyle, css); if (attributes) { for (var attr in attributes) { if (attributes.hasOwnProperty(attr) && attr != "style" && attr != "class") { token.setAttribute(attr, attributes[attr]); } } } return builder.content.appendChild(token) } builder.content.appendChild(content); } // Change some spaces to NBSP to prevent the browser from collapsing // trailing spaces at the end of a line when rendering text (issue #1362). function splitSpaces(text, trailingBefore) { if (text.length > 1 && !/ /.test(text)) { return text } var spaceBefore = trailingBefore, result = ""; for (var i = 0; i < text.length; i++) { var ch = text.charAt(i); if (ch == " " && spaceBefore && (i == text.length - 1 || text.charCodeAt(i + 1) == 32)) { ch = "\u00a0"; } result += ch; spaceBefore = ch == " "; } return result } // Work around nonsense dimensions being reported for stretches of // right-to-left text. function buildTokenBadBidi(inner, order) { return function (builder, text, style, startStyle, endStyle, css, attributes) { style = style ? style + " cm-force-border" : "cm-force-border"; var start = builder.pos, end = start + text.length; for (;;) { // Find the part that overlaps with the start of this text var part = (void 0); for (var i = 0; i < order.length; i++) { part = order[i]; if (part.to > start && part.from <= start) { break } } if (part.to >= end) { return inner(builder, text, style, startStyle, endStyle, css, attributes) } inner(builder, text.slice(0, part.to - start), style, startStyle, null, css, attributes); startStyle = null; text = text.slice(part.to - start); start = part.to; } } } function buildCollapsedSpan(builder, size, marker, ignoreWidget) { var widget = !ignoreWidget && marker.widgetNode; if (widget) { builder.map.push(builder.pos, builder.pos + size, widget); } if (!ignoreWidget && builder.cm.display.input.needsContentAttribute) { if (!widget) { widget = builder.content.appendChild(document.createElement("span")); } widget.setAttribute("cm-marker", marker.id); } if (widget) { builder.cm.display.input.setUneditable(widget); builder.content.appendChild(widget); } builder.pos += size; builder.trailingSpace = false; } // Outputs a number of spans to make up a line, taking highlighting // and marked text into account. function insertLineContent(line, builder, styles) { var spans = line.markedSpans, allText = line.text, at = 0; if (!spans) { for (var i$1 = 1; i$1 < styles.length; i$1+=2) { builder.addToken(builder, allText.slice(at, at = styles[i$1]), interpretTokenStyle(styles[i$1+1], builder.cm.options)); } return } var len = allText.length, pos = 0, i = 1, text = "", style, css; var nextChange = 0, spanStyle, spanEndStyle, spanStartStyle, collapsed, attributes; for (;;) { if (nextChange == pos) { // Update current marker set spanStyle = spanEndStyle = spanStartStyle = css = ""; attributes = null; collapsed = null; nextChange = Infinity; var foundBookmarks = [], endStyles = (void 0); for (var j = 0; j < spans.length; ++j) { var sp = spans[j], m = sp.marker; if (m.type == "bookmark" && sp.from == pos && m.widgetNode) { foundBookmarks.push(m); } else if (sp.from <= pos && (sp.to == null || sp.to > pos || m.collapsed && sp.to == pos && sp.from == pos)) { if (sp.to != null && sp.to != pos && nextChange > sp.to) { nextChange = sp.to; spanEndStyle = ""; } if (m.className) { spanStyle += " " + m.className; } if (m.css) { css = (css ? css + ";" : "") + m.css; } if (m.startStyle && sp.from == pos) { spanStartStyle += " " + m.startStyle; } if (m.endStyle && sp.to == nextChange) { (endStyles || (endStyles = [])).push(m.endStyle, sp.to); } // support for the old title property // https://github.com/codemirror/CodeMirror/pull/5673 if (m.title) { (attributes || (attributes = {})).title = m.title; } if (m.attributes) { for (var attr in m.attributes) { (attributes || (attributes = {}))[attr] = m.attributes[attr]; } } if (m.collapsed && (!collapsed || compareCollapsedMarkers(collapsed.marker, m) < 0)) { collapsed = sp; } } else if (sp.from > pos && nextChange > sp.from) { nextChange = sp.from; } } if (endStyles) { for (var j$1 = 0; j$1 < endStyles.length; j$1 += 2) { if (endStyles[j$1 + 1] == nextChange) { spanEndStyle += " " + endStyles[j$1]; } } } if (!collapsed || collapsed.from == pos) { for (var j$2 = 0; j$2 < foundBookmarks.length; ++j$2) { buildCollapsedSpan(builder, 0, foundBookmarks[j$2]); } } if (collapsed && (collapsed.from || 0) == pos) { buildCollapsedSpan(builder, (collapsed.to == null ? len + 1 : collapsed.to) - pos, collapsed.marker, collapsed.from == null); if (collapsed.to == null) { return } if (collapsed.to == pos) { collapsed = false; } } } if (pos >= len) { break } var upto = Math.min(len, nextChange); while (true) { if (text) { var end = pos + text.length; if (!collapsed) { var tokenText = end > upto ? text.slice(0, upto - pos) : text; builder.addToken(builder, tokenText, style ? style + spanStyle : spanStyle, spanStartStyle, pos + tokenText.length == nextChange ? spanEndStyle : "", css, attributes); } if (end >= upto) {text = text.slice(upto - pos); pos = upto; break} pos = end; spanStartStyle = ""; } text = allText.slice(at, at = styles[i++]); style = interpretTokenStyle(styles[i++], builder.cm.options); } } } // These objects are used to represent the visible (currently drawn) // part of the document. A LineView may correspond to multiple // logical lines, if those are connected by collapsed ranges. function LineView(doc, line, lineN) { // The starting line this.line = line; // Continuing lines, if any this.rest = visualLineContinued(line); // Number of logical lines in this visual line this.size = this.rest ? lineNo(lst(this.rest)) - lineN + 1 : 1; this.node = this.text = null; this.hidden = lineIsHidden(doc, line); } // Create a range of LineView objects for the given lines. function buildViewArray(cm, from, to) { var array = [], nextPos; for (var pos = from; pos < to; pos = nextPos) { var view = new LineView(cm.doc, getLine(cm.doc, pos), pos); nextPos = pos + view.size; array.push(view); } return array } var operationGroup = null; function pushOperation(op) { if (operationGroup) { operationGroup.ops.push(op); } else { op.ownsGroup = operationGroup = { ops: [op], delayedCallbacks: [] }; } } function fireCallbacksForOps(group) { // Calls delayed callbacks and cursorActivity handlers until no // new ones appear var callbacks = group.delayedCallbacks, i = 0; do { for (; i < callbacks.length; i++) { callbacks[i].call(null); } for (var j = 0; j < group.ops.length; j++) { var op = group.ops[j]; if (op.cursorActivityHandlers) { while (op.cursorActivityCalled < op.cursorActivityHandlers.length) { op.cursorActivityHandlers[op.cursorActivityCalled++].call(null, op.cm); } } } } while (i < callbacks.length) } function finishOperation(op, endCb) { var group = op.ownsGroup; if (!group) { return } try { fireCallbacksForOps(group); } finally { operationGroup = null; endCb(group); } } var orphanDelayedCallbacks = null; // Often, we want to signal events at a point where we are in the // middle of some work, but don't want the handler to start calling // other methods on the editor, which might be in an inconsistent // state or simply not expect any other events to happen. // signalLater looks whether there are any handlers, and schedules // them to be executed when the last operation ends, or, if no // operation is active, when a timeout fires. function signalLater(emitter, type /*, values...*/) { var arr = getHandlers(emitter, type); if (!arr.length) { return } var args = Array.prototype.slice.call(arguments, 2), list; if (operationGroup) { list = operationGroup.delayedCallbacks; } else if (orphanDelayedCallbacks) { list = orphanDelayedCallbacks; } else { list = orphanDelayedCallbacks = []; setTimeout(fireOrphanDelayed, 0); } var loop = function ( i ) { list.push(function () { return arr[i].apply(null, args); }); }; for (var i = 0; i < arr.length; ++i) loop( i ); } function fireOrphanDelayed() { var delayed = orphanDelayedCallbacks; orphanDelayedCallbacks = null; for (var i = 0; i < delayed.length; ++i) { delayed[i](); } } // When an aspect of a line changes, a string is added to // lineView.changes. This updates the relevant part of the line's // DOM structure. function updateLineForChanges(cm, lineView, lineN, dims) { for (var j = 0; j < lineView.changes.length; j++) { var type = lineView.changes[j]; if (type == "text") { updateLineText(cm, lineView); } else if (type == "gutter") { updateLineGutter(cm, lineView, lineN, dims); } else if (type == "class") { updateLineClasses(cm, lineView); } else if (type == "widget") { updateLineWidgets(cm, lineView, dims); } } lineView.changes = null; } // Lines with gutter elements, widgets or a background class need to // be wrapped, and have the extra elements added to the wrapper div function ensureLineWrapped(lineView) { if (lineView.node == lineView.text) { lineView.node = elt("div", null, null, "position: relative"); if (lineView.text.parentNode) { lineView.text.parentNode.replaceChild(lineView.node, lineView.text); } lineView.node.appendChild(lineView.text); if (ie && ie_version < 8) { lineView.node.style.zIndex = 2; } } return lineView.node } function updateLineBackground(cm, lineView) { var cls = lineView.bgClass ? lineView.bgClass + " " + (lineView.line.bgClass || "") : lineView.line.bgClass; if (cls) { cls += " CodeMirror-linebackground"; } if (lineView.background) { if (cls) { lineView.background.className = cls; } else { lineView.background.parentNode.removeChild(lineView.background); lineView.background = null; } } else if (cls) { var wrap = ensureLineWrapped(lineView); lineView.background = wrap.insertBefore(elt("div", null, cls), wrap.firstChild); cm.display.input.setUneditable(lineView.background); } } // Wrapper around buildLineContent which will reuse the structure // in display.externalMeasured when possible. function getLineContent(cm, lineView) { var ext = cm.display.externalMeasured; if (ext && ext.line == lineView.line) { cm.display.externalMeasured = null; lineView.measure = ext.measure; return ext.built } return buildLineContent(cm, lineView) } // Redraw the line's text. Interacts with the background and text // classes because the mode may output tokens that influence these // classes. function updateLineText(cm, lineView) { var cls = lineView.text.className; var built = getLineContent(cm, lineView); if (lineView.text == lineView.node) { lineView.node = built.pre; } lineView.text.parentNode.replaceChild(built.pre, lineView.text); lineView.text = built.pre; if (built.bgClass != lineView.bgClass || built.textClass != lineView.textClass) { lineView.bgClass = built.bgClass; lineView.textClass = built.textClass; updateLineClasses(cm, lineView); } else if (cls) { lineView.text.className = cls; } } function updateLineClasses(cm, lineView) { updateLineBackground(cm, lineView); if (lineView.line.wrapClass) { ensureLineWrapped(lineView).className = lineView.line.wrapClass; } else if (lineView.node != lineView.text) { lineView.node.className = ""; } var textClass = lineView.textClass ? lineView.textClass + " " + (lineView.line.textClass || "") : lineView.line.textClass; lineView.text.className = textClass || ""; } function updateLineGutter(cm, lineView, lineN, dims) { if (lineView.gutter) { lineView.node.removeChild(lineView.gutter); lineView.gutter = null; } if (lineView.gutterBackground) { lineView.node.removeChild(lineView.gutterBackground); lineView.gutterBackground = null; } if (lineView.line.gutterClass) { var wrap = ensureLineWrapped(lineView); lineView.gutterBackground = elt("div", null, "CodeMirror-gutter-background " + lineView.line.gutterClass, ("left: " + (cm.options.fixedGutter ? dims.fixedPos : -dims.gutterTotalWidth) + "px; width: " + (dims.gutterTotalWidth) + "px")); cm.display.input.setUneditable(lineView.gutterBackground); wrap.insertBefore(lineView.gutterBackground, lineView.text); } var markers = lineView.line.gutterMarkers; if (cm.options.lineNumbers || markers) { var wrap$1 = ensureLineWrapped(lineView); var gutterWrap = lineView.gutter = elt("div", null, "CodeMirror-gutter-wrapper", ("left: " + (cm.options.fixedGutter ? dims.fixedPos : -dims.gutterTotalWidth) + "px")); cm.display.input.setUneditable(gutterWrap); wrap$1.insertBefore(gutterWrap, lineView.text); if (lineView.line.gutterClass) { gutterWrap.className += " " + lineView.line.gutterClass; } if (cm.options.lineNumbers && (!markers || !markers["CodeMirror-linenumbers"])) { lineView.lineNumber = gutterWrap.appendChild( elt("div", lineNumberFor(cm.options, lineN), "CodeMirror-linenumber CodeMirror-gutter-elt", ("left: " + (dims.gutterLeft["CodeMirror-linenumbers"]) + "px; width: " + (cm.display.lineNumInnerWidth) + "px"))); } if (markers) { for (var k = 0; k < cm.display.gutterSpecs.length; ++k) { var id = cm.display.gutterSpecs[k].className, found = markers.hasOwnProperty(id) && markers[id]; if (found) { gutterWrap.appendChild(elt("div", [found], "CodeMirror-gutter-elt", ("left: " + (dims.gutterLeft[id]) + "px; width: " + (dims.gutterWidth[id]) + "px"))); } } } } } function updateLineWidgets(cm, lineView, dims) { if (lineView.alignable) { lineView.alignable = null; } var isWidget = classTest("CodeMirror-linewidget"); for (var node = lineView.node.firstChild, next = (void 0); node; node = next) { next = node.nextSibling; if (isWidget.test(node.className)) { lineView.node.removeChild(node); } } insertLineWidgets(cm, lineView, dims); } // Build a line's DOM representation from scratch function buildLineElement(cm, lineView, lineN, dims) { var built = getLineContent(cm, lineView); lineView.text = lineView.node = built.pre; if (built.bgClass) { lineView.bgClass = built.bgClass; } if (built.textClass) { lineView.textClass = built.textClass; } updateLineClasses(cm, lineView); updateLineGutter(cm, lineView, lineN, dims); insertLineWidgets(cm, lineView, dims); return lineView.node } // A lineView may contain multiple logical lines (when merged by // collapsed spans). The widgets for all of them need to be drawn. function insertLineWidgets(cm, lineView, dims) { insertLineWidgetsFor(cm, lineView.line, lineView, dims, true); if (lineView.rest) { for (var i = 0; i < lineView.rest.length; i++) { insertLineWidgetsFor(cm, lineView.rest[i], lineView, dims, false); } } } function insertLineWidgetsFor(cm, line, lineView, dims, allowAbove) { if (!line.widgets) { return } var wrap = ensureLineWrapped(lineView); for (var i = 0, ws = line.widgets; i < ws.length; ++i) { var widget = ws[i], node = elt("div", [widget.node], "CodeMirror-linewidget" + (widget.className ? " " + widget.className : "")); if (!widget.handleMouseEvents) { node.setAttribute("cm-ignore-events", "true"); } positionLineWidget(widget, node, lineView, dims); cm.display.input.setUneditable(node); if (allowAbove && widget.above) { wrap.insertBefore(node, lineView.gutter || lineView.text); } else { wrap.appendChild(node); } signalLater(widget, "redraw"); } } function positionLineWidget(widget, node, lineView, dims) { if (widget.noHScroll) { (lineView.alignable || (lineView.alignable = [])).push(node); var width = dims.wrapperWidth; node.style.left = dims.fixedPos + "px"; if (!widget.coverGutter) { width -= dims.gutterTotalWidth; node.style.paddingLeft = dims.gutterTotalWidth + "px"; } node.style.width = width + "px"; } if (widget.coverGutter) { node.style.zIndex = 5; node.style.position = "relative"; if (!widget.noHScroll) { node.style.marginLeft = -dims.gutterTotalWidth + "px"; } } } function widgetHeight(widget) { if (widget.height != null) { return widget.height } var cm = widget.doc.cm; if (!cm) { return 0 } if (!contains(document.body, widget.node)) { var parentStyle = "position: relative;"; if (widget.coverGutter) { parentStyle += "margin-left: -" + cm.display.gutters.offsetWidth + "px;"; } if (widget.noHScroll) { parentStyle += "width: " + cm.display.wrapper.clientWidth + "px;"; } removeChildrenAndAdd(cm.display.measure, elt("div", [widget.node], null, parentStyle)); } return widget.height = widget.node.parentNode.offsetHeight } // Return true when the given mouse event happened in a widget function eventInWidget(display, e) { for (var n = e_target(e); n != display.wrapper; n = n.parentNode) { if (!n || (n.nodeType == 1 && n.getAttribute("cm-ignore-events") == "true") || (n.parentNode == display.sizer && n != display.mover)) { return true } } } // POSITION MEASUREMENT function paddingTop(display) {return display.lineSpace.offsetTop} function paddingVert(display) {return display.mover.offsetHeight - display.lineSpace.offsetHeight} function paddingH(display) { if (display.cachedPaddingH) { return display.cachedPaddingH } var e = removeChildrenAndAdd(display.measure, elt("pre", "x", "CodeMirror-line-like")); var style = window.getComputedStyle ? window.getComputedStyle(e) : e.currentStyle; var data = {left: parseInt(style.paddingLeft), right: parseInt(style.paddingRight)}; if (!isNaN(data.left) && !isNaN(data.right)) { display.cachedPaddingH = data; } return data } function scrollGap(cm) { return scrollerGap - cm.display.nativeBarWidth } function displayWidth(cm) { return cm.display.scroller.clientWidth - scrollGap(cm) - cm.display.barWidth } function displayHeight(cm) { return cm.display.scroller.clientHeight - scrollGap(cm) - cm.display.barHeight } // Ensure the lineView.wrapping.heights array is populated. This is // an array of bottom offsets for the lines that make up a drawn // line. When lineWrapping is on, there might be more than one // height. function ensureLineHeights(cm, lineView, rect) { var wrapping = cm.options.lineWrapping; var curWidth = wrapping && displayWidth(cm); if (!lineView.measure.heights || wrapping && lineView.measure.width != curWidth) { var heights = lineView.measure.heights = []; if (wrapping) { lineView.measure.width = curWidth; var rects = lineView.text.firstChild.getClientRects(); for (var i = 0; i < rects.length - 1; i++) { var cur = rects[i], next = rects[i + 1]; if (Math.abs(cur.bottom - next.bottom) > 2) { heights.push((cur.bottom + next.top) / 2 - rect.top); } } } heights.push(rect.bottom - rect.top); } } // Find a line map (mapping character offsets to text nodes) and a // measurement cache for the given line number. (A line view might // contain multiple lines when collapsed ranges are present.) function mapFromLineView(lineView, line, lineN) { if (lineView.line == line) { return {map: lineView.measure.map, cache: lineView.measure.cache} } for (var i = 0; i < lineView.rest.length; i++) { if (lineView.rest[i] == line) { return {map: lineView.measure.maps[i], cache: lineView.measure.caches[i]} } } for (var i$1 = 0; i$1 < lineView.rest.length; i$1++) { if (lineNo(lineView.rest[i$1]) > lineN) { return {map: lineView.measure.maps[i$1], cache: lineView.measure.caches[i$1], before: true} } } } // Render a line into the hidden node display.externalMeasured. Used // when measurement is needed for a line that's not in the viewport. function updateExternalMeasurement(cm, line) { line = visualLine(line); var lineN = lineNo(line); var view = cm.display.externalMeasured = new LineView(cm.doc, line, lineN); view.lineN = lineN; var built = view.built = buildLineContent(cm, view); view.text = built.pre; removeChildrenAndAdd(cm.display.lineMeasure, built.pre); return view } // Get a {top, bottom, left, right} box (in line-local coordinates) // for a given character. function measureChar(cm, line, ch, bias) { return measureCharPrepared(cm, prepareMeasureForLine(cm, line), ch, bias) } // Find a line view that corresponds to the given line number. function findViewForLine(cm, lineN) { if (lineN >= cm.display.viewFrom && lineN < cm.display.viewTo) { return cm.display.view[findViewIndex(cm, lineN)] } var ext = cm.display.externalMeasured; if (ext && lineN >= ext.lineN && lineN < ext.lineN + ext.size) { return ext } } // Measurement can be split in two steps, the set-up work that // applies to the whole line, and the measurement of the actual // character. Functions like coordsChar, that need to do a lot of // measurements in a row, can thus ensure that the set-up work is // only done once. function prepareMeasureForLine(cm, line) { var lineN = lineNo(line); var view = findViewForLine(cm, lineN); if (view && !view.text) { view = null; } else if (view && view.changes) { updateLineForChanges(cm, view, lineN, getDimensions(cm)); cm.curOp.forceUpdate = true; } if (!view) { view = updateExternalMeasurement(cm, line); } var info = mapFromLineView(view, line, lineN); return { line: line, view: view, rect: null, map: info.map, cache: info.cache, before: info.before, hasHeights: false } } // Given a prepared measurement object, measures the position of an // actual character (or fetches it from the cache). function measureCharPrepared(cm, prepared, ch, bias, varHeight) { if (prepared.before) { ch = -1; } var key = ch + (bias || ""), found; if (prepared.cache.hasOwnProperty(key)) { found = prepared.cache[key]; } else { if (!prepared.rect) { prepared.rect = prepared.view.text.getBoundingClientRect(); } if (!prepared.hasHeights) { ensureLineHeights(cm, prepared.view, prepared.rect); prepared.hasHeights = true; } found = measureCharInner(cm, prepared, ch, bias); if (!found.bogus) { prepared.cache[key] = found; } } return {left: found.left, right: found.right, top: varHeight ? found.rtop : found.top, bottom: varHeight ? found.rbottom : found.bottom} } var nullRect = {left: 0, right: 0, top: 0, bottom: 0}; function nodeAndOffsetInLineMap(map, ch, bias) { var node, start, end, collapse, mStart, mEnd; // First, search the line map for the text node corresponding to, // or closest to, the target character. for (var i = 0; i < map.length; i += 3) { mStart = map[i]; mEnd = map[i + 1]; if (ch < mStart) { start = 0; end = 1; collapse = "left"; } else if (ch < mEnd) { start = ch - mStart; end = start + 1; } else if (i == map.length - 3 || ch == mEnd && map[i + 3] > ch) { end = mEnd - mStart; start = end - 1; if (ch >= mEnd) { collapse = "right"; } } if (start != null) { node = map[i + 2]; if (mStart == mEnd && bias == (node.insertLeft ? "left" : "right")) { collapse = bias; } if (bias == "left" && start == 0) { while (i && map[i - 2] == map[i - 3] && map[i - 1].insertLeft) { node = map[(i -= 3) + 2]; collapse = "left"; } } if (bias == "right" && start == mEnd - mStart) { while (i < map.length - 3 && map[i + 3] == map[i + 4] && !map[i + 5].insertLeft) { node = map[(i += 3) + 2]; collapse = "right"; } } break } } return {node: node, start: start, end: end, collapse: collapse, coverStart: mStart, coverEnd: mEnd} } function getUsefulRect(rects, bias) { var rect = nullRect; if (bias == "left") { for (var i = 0; i < rects.length; i++) { if ((rect = rects[i]).left != rect.right) { break } } } else { for (var i$1 = rects.length - 1; i$1 >= 0; i$1--) { if ((rect = rects[i$1]).left != rect.right) { break } } } return rect } function measureCharInner(cm, prepared, ch, bias) { var place = nodeAndOffsetInLineMap(prepared.map, ch, bias); var node = place.node, start = place.start, end = place.end, collapse = place.collapse; var rect; if (node.nodeType == 3) { // If it is a text node, use a range to retrieve the coordinates. for (var i$1 = 0; i$1 < 4; i$1++) { // Retry a maximum of 4 times when nonsense rectangles are returned while (start && isExtendingChar(prepared.line.text.charAt(place.coverStart + start))) { --start; } while (place.coverStart + end < place.coverEnd && isExtendingChar(prepared.line.text.charAt(place.coverStart + end))) { ++end; } if (ie && ie_version < 9 && start == 0 && end == place.coverEnd - place.coverStart) { rect = node.parentNode.getBoundingClientRect(); } else { rect = getUsefulRect(range(node, start, end).getClientRects(), bias); } if (rect.left || rect.right || start == 0) { break } end = start; start = start - 1; collapse = "right"; } if (ie && ie_version < 11) { rect = maybeUpdateRectForZooming(cm.display.measure, rect); } } else { // If it is a widget, simply get the box for the whole widget. if (start > 0) { collapse = bias = "right"; } var rects; if (cm.options.lineWrapping && (rects = node.getClientRects()).length > 1) { rect = rects[bias == "right" ? rects.length - 1 : 0]; } else { rect = node.getBoundingClientRect(); } } if (ie && ie_version < 9 && !start && (!rect || !rect.left && !rect.right)) { var rSpan = node.parentNode.getClientRects()[0]; if (rSpan) { rect = {left: rSpan.left, right: rSpan.left + charWidth(cm.display), top: rSpan.top, bottom: rSpan.bottom}; } else { rect = nullRect; } } var rtop = rect.top - prepared.rect.top, rbot = rect.bottom - prepared.rect.top; var mid = (rtop + rbot) / 2; var heights = prepared.view.measure.heights; var i = 0; for (; i < heights.length - 1; i++) { if (mid < heights[i]) { break } } var top = i ? heights[i - 1] : 0, bot = heights[i]; var result = {left: (collapse == "right" ? rect.right : rect.left) - prepared.rect.left, right: (collapse == "left" ? rect.left : rect.right) - prepared.rect.left, top: top, bottom: bot}; if (!rect.left && !rect.right) { result.bogus = true; } if (!cm.options.singleCursorHeightPerLine) { result.rtop = rtop; result.rbottom = rbot; } return result } // Work around problem with bounding client rects on ranges being // returned incorrectly when zoomed on IE10 and below. function maybeUpdateRectForZooming(measure, rect) { if (!window.screen || screen.logicalXDPI == null || screen.logicalXDPI == screen.deviceXDPI || !hasBadZoomedRects(measure)) { return rect } var scaleX = screen.logicalXDPI / screen.deviceXDPI; var scaleY = screen.logicalYDPI / screen.deviceYDPI; return {left: rect.left * scaleX, right: rect.right * scaleX, top: rect.top * scaleY, bottom: rect.bottom * scaleY} } function clearLineMeasurementCacheFor(lineView) { if (lineView.measure) { lineView.measure.cache = {}; lineView.measure.heights = null; if (lineView.rest) { for (var i = 0; i < lineView.rest.length; i++) { lineView.measure.caches[i] = {}; } } } } function clearLineMeasurementCache(cm) { cm.display.externalMeasure = null; removeChildren(cm.display.lineMeasure); for (var i = 0; i < cm.display.view.length; i++) { clearLineMeasurementCacheFor(cm.display.view[i]); } } function clearCaches(cm) { clearLineMeasurementCache(cm); cm.display.cachedCharWidth = cm.display.cachedTextHeight = cm.display.cachedPaddingH = null; if (!cm.options.lineWrapping) { cm.display.maxLineChanged = true; } cm.display.lineNumChars = null; } function pageScrollX() { // Work around https://bugs.chromium.org/p/chromium/issues/detail?id=489206 // which causes page_Offset and bounding client rects to use // different reference viewports and invalidate our calculations. if (chrome && android) { return -(document.body.getBoundingClientRect().left - parseInt(getComputedStyle(document.body).marginLeft)) } return window.pageXOffset || (document.documentElement || document.body).scrollLeft } function pageScrollY() { if (chrome && android) { return -(document.body.getBoundingClientRect().top - parseInt(getComputedStyle(document.body).marginTop)) } return window.pageYOffset || (document.documentElement || document.body).scrollTop } function widgetTopHeight(lineObj) { var height = 0; if (lineObj.widgets) { for (var i = 0; i < lineObj.widgets.length; ++i) { if (lineObj.widgets[i].above) { height += widgetHeight(lineObj.widgets[i]); } } } return height } // Converts a {top, bottom, left, right} box from line-local // coordinates into another coordinate system. Context may be one of // "line", "div" (display.lineDiv), "local"./null (editor), "window", // or "page". function intoCoordSystem(cm, lineObj, rect, context, includeWidgets) { if (!includeWidgets) { var height = widgetTopHeight(lineObj); rect.top += height; rect.bottom += height; } if (context == "line") { return rect } if (!context) { context = "local"; } var yOff = heightAtLine(lineObj); if (context == "local") { yOff += paddingTop(cm.display); } else { yOff -= cm.display.viewOffset; } if (context == "page" || context == "window") { var lOff = cm.display.lineSpace.getBoundingClientRect(); yOff += lOff.top + (context == "window" ? 0 : pageScrollY()); var xOff = lOff.left + (context == "window" ? 0 : pageScrollX()); rect.left += xOff; rect.right += xOff; } rect.top += yOff; rect.bottom += yOff; return rect } // Coverts a box from "div" coords to another coordinate system. // Context may be "window", "page", "div", or "local"./null. function fromCoordSystem(cm, coords, context) { if (context == "div") { return coords } var left = coords.left, top = coords.top; // First move into "page" coordinate system if (context == "page") { left -= pageScrollX(); top -= pageScrollY(); } else if (context == "local" || !context) { var localBox = cm.display.sizer.getBoundingClientRect(); left += localBox.left; top += localBox.top; } var lineSpaceBox = cm.display.lineSpace.getBoundingClientRect(); return {left: left - lineSpaceBox.left, top: top - lineSpaceBox.top} } function charCoords(cm, pos, context, lineObj, bias) { if (!lineObj) { lineObj = getLine(cm.doc, pos.line); } return intoCoordSystem(cm, lineObj, measureChar(cm, lineObj, pos.ch, bias), context) } // Returns a box for a given cursor position, which may have an // 'other' property containing the position of the secondary cursor // on a bidi boundary. // A cursor Pos(line, char, "before") is on the same visual line as `char - 1` // and after `char - 1` in writing order of `char - 1` // A cursor Pos(line, char, "after") is on the same visual line as `char` // and before `char` in writing order of `char` // Examples (upper-case letters are RTL, lower-case are LTR): // Pos(0, 1, ...) // before after // ab a|b a|b // aB a|B aB| // Ab |Ab A|b // AB B|A B|A // Every position after the last character on a line is considered to stick // to the last character on the line. function cursorCoords(cm, pos, context, lineObj, preparedMeasure, varHeight) { lineObj = lineObj || getLine(cm.doc, pos.line); if (!preparedMeasure) { preparedMeasure = prepareMeasureForLine(cm, lineObj); } function get(ch, right) { var m = measureCharPrepared(cm, preparedMeasure, ch, right ? "right" : "left", varHeight); if (right) { m.left = m.right; } else { m.right = m.left; } return intoCoordSystem(cm, lineObj, m, context) } var order = getOrder(lineObj, cm.doc.direction), ch = pos.ch, sticky = pos.sticky; if (ch >= lineObj.text.length) { ch = lineObj.text.length; sticky = "before"; } else if (ch <= 0) { ch = 0; sticky = "after"; } if (!order) { return get(sticky == "before" ? ch - 1 : ch, sticky == "before") } function getBidi(ch, partPos, invert) { var part = order[partPos], right = part.level == 1; return get(invert ? ch - 1 : ch, right != invert) } var partPos = getBidiPartAt(order, ch, sticky); var other = bidiOther; var val = getBidi(ch, partPos, sticky == "before"); if (other != null) { val.other = getBidi(ch, other, sticky != "before"); } return val } // Used to cheaply estimate the coordinates for a position. Used for // intermediate scroll updates. function estimateCoords(cm, pos) { var left = 0; pos = clipPos(cm.doc, pos); if (!cm.options.lineWrapping) { left = charWidth(cm.display) * pos.ch; } var lineObj = getLine(cm.doc, pos.line); var top = heightAtLine(lineObj) + paddingTop(cm.display); return {left: left, right: left, top: top, bottom: top + lineObj.height} } // Positions returned by coordsChar contain some extra information. // xRel is the relative x position of the input coordinates compared // to the found position (so xRel > 0 means the coordinates are to // the right of the character position, for example). When outside // is true, that means the coordinates lie outside the line's // vertical range. function PosWithInfo(line, ch, sticky, outside, xRel) { var pos = Pos(line, ch, sticky); pos.xRel = xRel; if (outside) { pos.outside = outside; } return pos } // Compute the character position closest to the given coordinates. // Input must be lineSpace-local ("div" coordinate system). function coordsChar(cm, x, y) { var doc = cm.doc; y += cm.display.viewOffset; if (y < 0) { return PosWithInfo(doc.first, 0, null, -1, -1) } var lineN = lineAtHeight(doc, y), last = doc.first + doc.size - 1; if (lineN > last) { return PosWithInfo(doc.first + doc.size - 1, getLine(doc, last).text.length, null, 1, 1) } if (x < 0) { x = 0; } var lineObj = getLine(doc, lineN); for (;;) { var found = coordsCharInner(cm, lineObj, lineN, x, y); var collapsed = collapsedSpanAround(lineObj, found.ch + (found.xRel > 0 || found.outside > 0 ? 1 : 0)); if (!collapsed) { return found } var rangeEnd = collapsed.find(1); if (rangeEnd.line == lineN) { return rangeEnd } lineObj = getLine(doc, lineN = rangeEnd.line); } } function wrappedLineExtent(cm, lineObj, preparedMeasure, y) { y -= widgetTopHeight(lineObj); var end = lineObj.text.length; var begin = findFirst(function (ch) { return measureCharPrepared(cm, preparedMeasure, ch - 1).bottom <= y; }, end, 0); end = findFirst(function (ch) { return measureCharPrepared(cm, preparedMeasure, ch).top > y; }, begin, end); return {begin: begin, end: end} } function wrappedLineExtentChar(cm, lineObj, preparedMeasure, target) { if (!preparedMeasure) { preparedMeasure = prepareMeasureForLine(cm, lineObj); } var targetTop = intoCoordSystem(cm, lineObj, measureCharPrepared(cm, preparedMeasure, target), "line").top; return wrappedLineExtent(cm, lineObj, preparedMeasure, targetTop) } // Returns true if the given side of a box is after the given // coordinates, in top-to-bottom, left-to-right order. function boxIsAfter(box, x, y, left) { return box.bottom <= y ? false : box.top > y ? true : (left ? box.left : box.right) > x } function coordsCharInner(cm, lineObj, lineNo, x, y) { // Move y into line-local coordinate space y -= heightAtLine(lineObj); var preparedMeasure = prepareMeasureForLine(cm, lineObj); // When directly calling `measureCharPrepared`, we have to adjust // for the widgets at this line. var widgetHeight = widgetTopHeight(lineObj); var begin = 0, end = lineObj.text.length, ltr = true; var order = getOrder(lineObj, cm.doc.direction); // If the line isn't plain left-to-right text, first figure out // which bidi section the coordinates fall into. if (order) { var part = (cm.options.lineWrapping ? coordsBidiPartWrapped : coordsBidiPart) (cm, lineObj, lineNo, preparedMeasure, order, x, y); ltr = part.level != 1; // The awkward -1 offsets are needed because findFirst (called // on these below) will treat its first bound as inclusive, // second as exclusive, but we want to actually address the // characters in the part's range begin = ltr ? part.from : part.to - 1; end = ltr ? part.to : part.from - 1; } // A binary search to find the first character whose bounding box // starts after the coordinates. If we run across any whose box wrap // the coordinates, store that. var chAround = null, boxAround = null; var ch = findFirst(function (ch) { var box = measureCharPrepared(cm, preparedMeasure, ch); box.top += widgetHeight; box.bottom += widgetHeight; if (!boxIsAfter(box, x, y, false)) { return false } if (box.top <= y && box.left <= x) { chAround = ch; boxAround = box; } return true }, begin, end); var baseX, sticky, outside = false; // If a box around the coordinates was found, use that if (boxAround) { // Distinguish coordinates nearer to the left or right side of the box var atLeft = x - boxAround.left < boxAround.right - x, atStart = atLeft == ltr; ch = chAround + (atStart ? 0 : 1); sticky = atStart ? "after" : "before"; baseX = atLeft ? boxAround.left : boxAround.right; } else { // (Adjust for extended bound, if necessary.) if (!ltr && (ch == end || ch == begin)) { ch++; } // To determine which side to associate with, get the box to the // left of the character and compare it's vertical position to the // coordinates sticky = ch == 0 ? "after" : ch == lineObj.text.length ? "before" : (measureCharPrepared(cm, preparedMeasure, ch - (ltr ? 1 : 0)).bottom + widgetHeight <= y) == ltr ? "after" : "before"; // Now get accurate coordinates for this place, in order to get a // base X position var coords = cursorCoords(cm, Pos(lineNo, ch, sticky), "line", lineObj, preparedMeasure); baseX = coords.left; outside = y < coords.top ? -1 : y >= coords.bottom ? 1 : 0; } ch = skipExtendingChars(lineObj.text, ch, 1); return PosWithInfo(lineNo, ch, sticky, outside, x - baseX) } function coordsBidiPart(cm, lineObj, lineNo, preparedMeasure, order, x, y) { // Bidi parts are sorted left-to-right, and in a non-line-wrapping // situation, we can take this ordering to correspond to the visual // ordering. This finds the first part whose end is after the given // coordinates. var index = findFirst(function (i) { var part = order[i], ltr = part.level != 1; return boxIsAfter(cursorCoords(cm, Pos(lineNo, ltr ? part.to : part.from, ltr ? "before" : "after"), "line", lineObj, preparedMeasure), x, y, true) }, 0, order.length - 1); var part = order[index]; // If this isn't the first part, the part's start is also after // the coordinates, and the coordinates aren't on the same line as // that start, move one part back. if (index > 0) { var ltr = part.level != 1; var start = cursorCoords(cm, Pos(lineNo, ltr ? part.from : part.to, ltr ? "after" : "before"), "line", lineObj, preparedMeasure); if (boxIsAfter(start, x, y, true) && start.top > y) { part = order[index - 1]; } } return part } function coordsBidiPartWrapped(cm, lineObj, _lineNo, preparedMeasure, order, x, y) { // In a wrapped line, rtl text on wrapping boundaries can do things // that don't correspond to the ordering in our `order` array at // all, so a binary search doesn't work, and we want to return a // part that only spans one line so that the binary search in // coordsCharInner is safe. As such, we first find the extent of the // wrapped line, and then do a flat search in which we discard any // spans that aren't on the line. var ref = wrappedLineExtent(cm, lineObj, preparedMeasure, y); var begin = ref.begin; var end = ref.end; if (/\s/.test(lineObj.text.charAt(end - 1))) { end--; } var part = null, closestDist = null; for (var i = 0; i < order.length; i++) { var p = order[i]; if (p.from >= end || p.to <= begin) { continue } var ltr = p.level != 1; var endX = measureCharPrepared(cm, preparedMeasure, ltr ? Math.min(end, p.to) - 1 : Math.max(begin, p.from)).right; // Weigh against spans ending before this, so that they are only // picked if nothing ends after var dist = endX < x ? x - endX + 1e9 : endX - x; if (!part || closestDist > dist) { part = p; closestDist = dist; } } if (!part) { part = order[order.length - 1]; } // Clip the part to the wrapped line. if (part.from < begin) { part = {from: begin, to: part.to, level: part.level}; } if (part.to > end) { part = {from: part.from, to: end, level: part.level}; } return part } var measureText; // Compute the default text height. function textHeight(display) { if (display.cachedTextHeight != null) { return display.cachedTextHeight } if (measureText == null) { measureText = elt("pre", null, "CodeMirror-line-like"); // Measure a bunch of lines, for browsers that compute // fractional heights. for (var i = 0; i < 49; ++i) { measureText.appendChild(document.createTextNode("x")); measureText.appendChild(elt("br")); } measureText.appendChild(document.createTextNode("x")); } removeChildrenAndAdd(display.measure, measureText); var height = measureText.offsetHeight / 50; if (height > 3) { display.cachedTextHeight = height; } removeChildren(display.measure); return height || 1 } // Compute the default character width. function charWidth(display) { if (display.cachedCharWidth != null) { return display.cachedCharWidth } var anchor = elt("span", "xxxxxxxxxx"); var pre = elt("pre", [anchor], "CodeMirror-line-like"); removeChildrenAndAdd(display.measure, pre); var rect = anchor.getBoundingClientRect(), width = (rect.right - rect.left) / 10; if (width > 2) { display.cachedCharWidth = width; } return width || 10 } // Do a bulk-read of the DOM positions and sizes needed to draw the // view, so that we don't interleave reading and writing to the DOM. function getDimensions(cm) { var d = cm.display, left = {}, width = {}; var gutterLeft = d.gutters.clientLeft; for (var n = d.gutters.firstChild, i = 0; n; n = n.nextSibling, ++i) { var id = cm.display.gutterSpecs[i].className; left[id] = n.offsetLeft + n.clientLeft + gutterLeft; width[id] = n.clientWidth; } return {fixedPos: compensateForHScroll(d), gutterTotalWidth: d.gutters.offsetWidth, gutterLeft: left, gutterWidth: width, wrapperWidth: d.wrapper.clientWidth} } // Computes display.scroller.scrollLeft + display.gutters.offsetWidth, // but using getBoundingClientRect to get a sub-pixel-accurate // result. function compensateForHScroll(display) { return display.scroller.getBoundingClientRect().left - display.sizer.getBoundingClientRect().left } // Returns a function that estimates the height of a line, to use as // first approximation until the line becomes visible (and is thus // properly measurable). function estimateHeight(cm) { var th = textHeight(cm.display), wrapping = cm.options.lineWrapping; var perLine = wrapping && Math.max(5, cm.display.scroller.clientWidth / charWidth(cm.display) - 3); return function (line) { if (lineIsHidden(cm.doc, line)) { return 0 } var widgetsHeight = 0; if (line.widgets) { for (var i = 0; i < line.widgets.length; i++) { if (line.widgets[i].height) { widgetsHeight += line.widgets[i].height; } } } if (wrapping) { return widgetsHeight + (Math.ceil(line.text.length / perLine) || 1) * th } else { return widgetsHeight + th } } } function estimateLineHeights(cm) { var doc = cm.doc, est = estimateHeight(cm); doc.iter(function (line) { var estHeight = est(line); if (estHeight != line.height) { updateLineHeight(line, estHeight); } }); } // Given a mouse event, find the corresponding position. If liberal // is false, it checks whether a gutter or scrollbar was clicked, // and returns null if it was. forRect is used by rectangular // selections, and tries to estimate a character position even for // coordinates beyond the right of the text. function posFromMouse(cm, e, liberal, forRect) { var display = cm.display; if (!liberal && e_target(e).getAttribute("cm-not-content") == "true") { return null } var x, y, space = display.lineSpace.getBoundingClientRect(); // Fails unpredictably on IE[67] when mouse is dragged around quickly. try { x = e.clientX - space.left; y = e.clientY - space.top; } catch (e$1) { return null } var coords = coordsChar(cm, x, y), line; if (forRect && coords.xRel > 0 && (line = getLine(cm.doc, coords.line).text).length == coords.ch) { var colDiff = countColumn(line, line.length, cm.options.tabSize) - line.length; coords = Pos(coords.line, Math.max(0, Math.round((x - paddingH(cm.display).left) / charWidth(cm.display)) - colDiff)); } return coords } // Find the view element corresponding to a given line. Return null // when the line isn't visible. function findViewIndex(cm, n) { if (n >= cm.display.viewTo) { return null } n -= cm.display.viewFrom; if (n < 0) { return null } var view = cm.display.view; for (var i = 0; i < view.length; i++) { n -= view[i].size; if (n < 0) { return i } } } // Updates the display.view data structure for a given change to the // document. From and to are in pre-change coordinates. Lendiff is // the amount of lines added or subtracted by the change. This is // used for changes that span multiple lines, or change the way // lines are divided into visual lines. regLineChange (below) // registers single-line changes. function regChange(cm, from, to, lendiff) { if (from == null) { from = cm.doc.first; } if (to == null) { to = cm.doc.first + cm.doc.size; } if (!lendiff) { lendiff = 0; } var display = cm.display; if (lendiff && to < display.viewTo && (display.updateLineNumbers == null || display.updateLineNumbers > from)) { display.updateLineNumbers = from; } cm.curOp.viewChanged = true; if (from >= display.viewTo) { // Change after if (sawCollapsedSpans && visualLineNo(cm.doc, from) < display.viewTo) { resetView(cm); } } else if (to <= display.viewFrom) { // Change before if (sawCollapsedSpans && visualLineEndNo(cm.doc, to + lendiff) > display.viewFrom) { resetView(cm); } else { display.viewFrom += lendiff; display.viewTo += lendiff; } } else if (from <= display.viewFrom && to >= display.viewTo) { // Full overlap resetView(cm); } else if (from <= display.viewFrom) { // Top overlap var cut = viewCuttingPoint(cm, to, to + lendiff, 1); if (cut) { display.view = display.view.slice(cut.index); display.viewFrom = cut.lineN; display.viewTo += lendiff; } else { resetView(cm); } } else if (to >= display.viewTo) { // Bottom overlap var cut$1 = viewCuttingPoint(cm, from, from, -1); if (cut$1) { display.view = display.view.slice(0, cut$1.index); display.viewTo = cut$1.lineN; } else { resetView(cm); } } else { // Gap in the middle var cutTop = viewCuttingPoint(cm, from, from, -1); var cutBot = viewCuttingPoint(cm, to, to + lendiff, 1); if (cutTop && cutBot) { display.view = display.view.slice(0, cutTop.index) .concat(buildViewArray(cm, cutTop.lineN, cutBot.lineN)) .concat(display.view.slice(cutBot.index)); display.viewTo += lendiff; } else { resetView(cm); } } var ext = display.externalMeasured; if (ext) { if (to < ext.lineN) { ext.lineN += lendiff; } else if (from < ext.lineN + ext.size) { display.externalMeasured = null; } } } // Register a change to a single line. Type must be one of "text", // "gutter", "class", "widget" function regLineChange(cm, line, type) { cm.curOp.viewChanged = true; var display = cm.display, ext = cm.display.externalMeasured; if (ext && line >= ext.lineN && line < ext.lineN + ext.size) { display.externalMeasured = null; } if (line < display.viewFrom || line >= display.viewTo) { return } var lineView = display.view[findViewIndex(cm, line)]; if (lineView.node == null) { return } var arr = lineView.changes || (lineView.changes = []); if (indexOf(arr, type) == -1) { arr.push(type); } } // Clear the view. function resetView(cm) { cm.display.viewFrom = cm.display.viewTo = cm.doc.first; cm.display.view = []; cm.display.viewOffset = 0; } function viewCuttingPoint(cm, oldN, newN, dir) { var index = findViewIndex(cm, oldN), diff, view = cm.display.view; if (!sawCollapsedSpans || newN == cm.doc.first + cm.doc.size) { return {index: index, lineN: newN} } var n = cm.display.viewFrom; for (var i = 0; i < index; i++) { n += view[i].size; } if (n != oldN) { if (dir > 0) { if (index == view.length - 1) { return null } diff = (n + view[index].size) - oldN; index++; } else { diff = n - oldN; } oldN += diff; newN += diff; } while (visualLineNo(cm.doc, newN) != newN) { if (index == (dir < 0 ? 0 : view.length - 1)) { return null } newN += dir * view[index - (dir < 0 ? 1 : 0)].size; index += dir; } return {index: index, lineN: newN} } // Force the view to cover a given range, adding empty view element // or clipping off existing ones as needed. function adjustView(cm, from, to) { var display = cm.display, view = display.view; if (view.length == 0 || from >= display.viewTo || to <= display.viewFrom) { display.view = buildViewArray(cm, from, to); display.viewFrom = from; } else { if (display.viewFrom > from) { display.view = buildViewArray(cm, from, display.viewFrom).concat(display.view); } else if (display.viewFrom < from) { display.view = display.view.slice(findViewIndex(cm, from)); } display.viewFrom = from; if (display.viewTo < to) { display.view = display.view.concat(buildViewArray(cm, display.viewTo, to)); } else if (display.viewTo > to) { display.view = display.view.slice(0, findViewIndex(cm, to)); } } display.viewTo = to; } // Count the number of lines in the view whose DOM representation is // out of date (or nonexistent). function countDirtyView(cm) { var view = cm.display.view, dirty = 0; for (var i = 0; i < view.length; i++) { var lineView = view[i]; if (!lineView.hidden && (!lineView.node || lineView.changes)) { ++dirty; } } return dirty } function updateSelection(cm) { cm.display.input.showSelection(cm.display.input.prepareSelection()); } function prepareSelection(cm, primary) { if ( primary === void 0 ) primary = true; var doc = cm.doc, result = {}; var curFragment = result.cursors = document.createDocumentFragment(); var selFragment = result.selection = document.createDocumentFragment(); for (var i = 0; i < doc.sel.ranges.length; i++) { if (!primary && i == doc.sel.primIndex) { continue } var range = doc.sel.ranges[i]; if (range.from().line >= cm.display.viewTo || range.to().line < cm.display.viewFrom) { continue } var collapsed = range.empty(); if (collapsed || cm.options.showCursorWhenSelecting) { drawSelectionCursor(cm, range.head, curFragment); } if (!collapsed) { drawSelectionRange(cm, range, selFragment); } } return result } // Draws a cursor for the given range function drawSelectionCursor(cm, head, output) { var pos = cursorCoords(cm, head, "div", null, null, !cm.options.singleCursorHeightPerLine); var cursor = output.appendChild(elt("div", "\u00a0", "CodeMirror-cursor")); cursor.style.left = pos.left + "px"; cursor.style.top = pos.top + "px"; cursor.style.height = Math.max(0, pos.bottom - pos.top) * cm.options.cursorHeight + "px"; if (pos.other) { // Secondary cursor, shown when on a 'jump' in bi-directional text var otherCursor = output.appendChild(elt("div", "\u00a0", "CodeMirror-cursor CodeMirror-secondarycursor")); otherCursor.style.display = ""; otherCursor.style.left = pos.other.left + "px"; otherCursor.style.top = pos.other.top + "px"; otherCursor.style.height = (pos.other.bottom - pos.other.top) * .85 + "px"; } } function cmpCoords(a, b) { return a.top - b.top || a.left - b.left } // Draws the given range as a highlighted selection function drawSelectionRange(cm, range, output) { var display = cm.display, doc = cm.doc; var fragment = document.createDocumentFragment(); var padding = paddingH(cm.display), leftSide = padding.left; var rightSide = Math.max(display.sizerWidth, displayWidth(cm) - display.sizer.offsetLeft) - padding.right; var docLTR = doc.direction == "ltr"; function add(left, top, width, bottom) { if (top < 0) { top = 0; } top = Math.round(top); bottom = Math.round(bottom); fragment.appendChild(elt("div", null, "CodeMirror-selected", ("position: absolute; left: " + left + "px;\n top: " + top + "px; width: " + (width == null ? rightSide - left : width) + "px;\n height: " + (bottom - top) + "px"))); } function drawForLine(line, fromArg, toArg) { var lineObj = getLine(doc, line); var lineLen = lineObj.text.length; var start, end; function coords(ch, bias) { return charCoords(cm, Pos(line, ch), "div", lineObj, bias) } function wrapX(pos, dir, side) { var extent = wrappedLineExtentChar(cm, lineObj, null, pos); var prop = (dir == "ltr") == (side == "after") ? "left" : "right"; var ch = side == "after" ? extent.begin : extent.end - (/\s/.test(lineObj.text.charAt(extent.end - 1)) ? 2 : 1); return coords(ch, prop)[prop] } var order = getOrder(lineObj, doc.direction); iterateBidiSections(order, fromArg || 0, toArg == null ? lineLen : toArg, function (from, to, dir, i) { var ltr = dir == "ltr"; var fromPos = coords(from, ltr ? "left" : "right"); var toPos = coords(to - 1, ltr ? "right" : "left"); var openStart = fromArg == null && from == 0, openEnd = toArg == null && to == lineLen; var first = i == 0, last = !order || i == order.length - 1; if (toPos.top - fromPos.top <= 3) { // Single line var openLeft = (docLTR ? openStart : openEnd) && first; var openRight = (docLTR ? openEnd : openStart) && last; var left = openLeft ? leftSide : (ltr ? fromPos : toPos).left; var right = openRight ? rightSide : (ltr ? toPos : fromPos).right; add(left, fromPos.top, right - left, fromPos.bottom); } else { // Multiple lines var topLeft, topRight, botLeft, botRight; if (ltr) { topLeft = docLTR && openStart && first ? leftSide : fromPos.left; topRight = docLTR ? rightSide : wrapX(from, dir, "before"); botLeft = docLTR ? leftSide : wrapX(to, dir, "after"); botRight = docLTR && openEnd && last ? rightSide : toPos.right; } else { topLeft = !docLTR ? leftSide : wrapX(from, dir, "before"); topRight = !docLTR && openStart && first ? rightSide : fromPos.right; botLeft = !docLTR && openEnd && last ? leftSide : toPos.left; botRight = !docLTR ? rightSide : wrapX(to, dir, "after"); } add(topLeft, fromPos.top, topRight - topLeft, fromPos.bottom); if (fromPos.bottom < toPos.top) { add(leftSide, fromPos.bottom, null, toPos.top); } add(botLeft, toPos.top, botRight - botLeft, toPos.bottom); } if (!start || cmpCoords(fromPos, start) < 0) { start = fromPos; } if (cmpCoords(toPos, start) < 0) { start = toPos; } if (!end || cmpCoords(fromPos, end) < 0) { end = fromPos; } if (cmpCoords(toPos, end) < 0) { end = toPos; } }); return {start: start, end: end} } var sFrom = range.from(), sTo = range.to(); if (sFrom.line == sTo.line) { drawForLine(sFrom.line, sFrom.ch, sTo.ch); } else { var fromLine = getLine(doc, sFrom.line), toLine = getLine(doc, sTo.line); var singleVLine = visualLine(fromLine) == visualLine(toLine); var leftEnd = drawForLine(sFrom.line, sFrom.ch, singleVLine ? fromLine.text.length + 1 : null).end; var rightStart = drawForLine(sTo.line, singleVLine ? 0 : null, sTo.ch).start; if (singleVLine) { if (leftEnd.top < rightStart.top - 2) { add(leftEnd.right, leftEnd.top, null, leftEnd.bottom); add(leftSide, rightStart.top, rightStart.left, rightStart.bottom); } else { add(leftEnd.right, leftEnd.top, rightStart.left - leftEnd.right, leftEnd.bottom); } } if (leftEnd.bottom < rightStart.top) { add(leftSide, leftEnd.bottom, null, rightStart.top); } } output.appendChild(fragment); } // Cursor-blinking function restartBlink(cm) { if (!cm.state.focused) { return } var display = cm.display; clearInterval(display.blinker); var on = true; display.cursorDiv.style.visibility = ""; if (cm.options.cursorBlinkRate > 0) { display.blinker = setInterval(function () { if (!cm.hasFocus()) { onBlur(cm); } display.cursorDiv.style.visibility = (on = !on) ? "" : "hidden"; }, cm.options.cursorBlinkRate); } else if (cm.options.cursorBlinkRate < 0) { display.cursorDiv.style.visibility = "hidden"; } } function ensureFocus(cm) { if (!cm.hasFocus()) { cm.display.input.focus(); if (!cm.state.focused) { onFocus(cm); } } } function delayBlurEvent(cm) { cm.state.delayingBlurEvent = true; setTimeout(function () { if (cm.state.delayingBlurEvent) { cm.state.delayingBlurEvent = false; if (cm.state.focused) { onBlur(cm); } } }, 100); } function onFocus(cm, e) { if (cm.state.delayingBlurEvent && !cm.state.draggingText) { cm.state.delayingBlurEvent = false; } if (cm.options.readOnly == "nocursor") { return } if (!cm.state.focused) { signal(cm, "focus", cm, e); cm.state.focused = true; addClass(cm.display.wrapper, "CodeMirror-focused"); // This test prevents this from firing when a context // menu is closed (since the input reset would kill the // select-all detection hack) if (!cm.curOp && cm.display.selForContextMenu != cm.doc.sel) { cm.display.input.reset(); if (webkit) { setTimeout(function () { return cm.display.input.reset(true); }, 20); } // Issue #1730 } cm.display.input.receivedFocus(); } restartBlink(cm); } function onBlur(cm, e) { if (cm.state.delayingBlurEvent) { return } if (cm.state.focused) { signal(cm, "blur", cm, e); cm.state.focused = false; rmClass(cm.display.wrapper, "CodeMirror-focused"); } clearInterval(cm.display.blinker); setTimeout(function () { if (!cm.state.focused) { cm.display.shift = false; } }, 150); } // Read the actual heights of the rendered lines, and update their // stored heights to match. function updateHeightsInViewport(cm) { var display = cm.display; var prevBottom = display.lineDiv.offsetTop; for (var i = 0; i < display.view.length; i++) { var cur = display.view[i], wrapping = cm.options.lineWrapping; var height = (void 0), width = 0; if (cur.hidden) { continue } if (ie && ie_version < 8) { var bot = cur.node.offsetTop + cur.node.offsetHeight; height = bot - prevBottom; prevBottom = bot; } else { var box = cur.node.getBoundingClientRect(); height = box.bottom - box.top; // Check that lines don't extend past the right of the current // editor width if (!wrapping && cur.text.firstChild) { width = cur.text.firstChild.getBoundingClientRect().right - box.left - 1; } } var diff = cur.line.height - height; if (diff > .005 || diff < -.005) { updateLineHeight(cur.line, height); updateWidgetHeight(cur.line); if (cur.rest) { for (var j = 0; j < cur.rest.length; j++) { updateWidgetHeight(cur.rest[j]); } } } if (width > cm.display.sizerWidth) { var chWidth = Math.ceil(width / charWidth(cm.display)); if (chWidth > cm.display.maxLineLength) { cm.display.maxLineLength = chWidth; cm.display.maxLine = cur.line; cm.display.maxLineChanged = true; } } } } // Read and store the height of line widgets associated with the // given line. function updateWidgetHeight(line) { if (line.widgets) { for (var i = 0; i < line.widgets.length; ++i) { var w = line.widgets[i], parent = w.node.parentNode; if (parent) { w.height = parent.offsetHeight; } } } } // Compute the lines that are visible in a given viewport (defaults // the the current scroll position). viewport may contain top, // height, and ensure (see op.scrollToPos) properties. function visibleLines(display, doc, viewport) { var top = viewport && viewport.top != null ? Math.max(0, viewport.top) : display.scroller.scrollTop; top = Math.floor(top - paddingTop(display)); var bottom = viewport && viewport.bottom != null ? viewport.bottom : top + display.wrapper.clientHeight; var from = lineAtHeight(doc, top), to = lineAtHeight(doc, bottom); // Ensure is a {from: {line, ch}, to: {line, ch}} object, and // forces those lines into the viewport (if possible). if (viewport && viewport.ensure) { var ensureFrom = viewport.ensure.from.line, ensureTo = viewport.ensure.to.line; if (ensureFrom < from) { from = ensureFrom; to = lineAtHeight(doc, heightAtLine(getLine(doc, ensureFrom)) + display.wrapper.clientHeight); } else if (Math.min(ensureTo, doc.lastLine()) >= to) { from = lineAtHeight(doc, heightAtLine(getLine(doc, ensureTo)) - display.wrapper.clientHeight); to = ensureTo; } } return {from: from, to: Math.max(to, from + 1)} } // SCROLLING THINGS INTO VIEW // If an editor sits on the top or bottom of the window, partially // scrolled out of view, this ensures that the cursor is visible. function maybeScrollWindow(cm, rect) { if (signalDOMEvent(cm, "scrollCursorIntoView")) { return } var display = cm.display, box = display.sizer.getBoundingClientRect(), doScroll = null; if (rect.top + box.top < 0) { doScroll = true; } else if (rect.bottom + box.top > (window.innerHeight || document.documentElement.clientHeight)) { doScroll = false; } if (doScroll != null && !phantom) { var scrollNode = elt("div", "\u200b", null, ("position: absolute;\n top: " + (rect.top - display.viewOffset - paddingTop(cm.display)) + "px;\n height: " + (rect.bottom - rect.top + scrollGap(cm) + display.barHeight) + "px;\n left: " + (rect.left) + "px; width: " + (Math.max(2, rect.right - rect.left)) + "px;")); cm.display.lineSpace.appendChild(scrollNode); scrollNode.scrollIntoView(doScroll); cm.display.lineSpace.removeChild(scrollNode); } } // Scroll a given position into view (immediately), verifying that // it actually became visible (as line heights are accurately // measured, the position of something may 'drift' during drawing). function scrollPosIntoView(cm, pos, end, margin) { if (margin == null) { margin = 0; } var rect; if (!cm.options.lineWrapping && pos == end) { // Set pos and end to the cursor positions around the character pos sticks to // If pos.sticky == "before", that is around pos.ch - 1, otherwise around pos.ch // If pos == Pos(_, 0, "before"), pos and end are unchanged pos = pos.ch ? Pos(pos.line, pos.sticky == "before" ? pos.ch - 1 : pos.ch, "after") : pos; end = pos.sticky == "before" ? Pos(pos.line, pos.ch + 1, "before") : pos; } for (var limit = 0; limit < 5; limit++) { var changed = false; var coords = cursorCoords(cm, pos); var endCoords = !end || end == pos ? coords : cursorCoords(cm, end); rect = {left: Math.min(coords.left, endCoords.left), top: Math.min(coords.top, endCoords.top) - margin, right: Math.max(coords.left, endCoords.left), bottom: Math.max(coords.bottom, endCoords.bottom) + margin}; var scrollPos = calculateScrollPos(cm, rect); var startTop = cm.doc.scrollTop, startLeft = cm.doc.scrollLeft; if (scrollPos.scrollTop != null) { updateScrollTop(cm, scrollPos.scrollTop); if (Math.abs(cm.doc.scrollTop - startTop) > 1) { changed = true; } } if (scrollPos.scrollLeft != null) { setScrollLeft(cm, scrollPos.scrollLeft); if (Math.abs(cm.doc.scrollLeft - startLeft) > 1) { changed = true; } } if (!changed) { break } } return rect } // Scroll a given set of coordinates into view (immediately). function scrollIntoView(cm, rect) { var scrollPos = calculateScrollPos(cm, rect); if (scrollPos.scrollTop != null) { updateScrollTop(cm, scrollPos.scrollTop); } if (scrollPos.scrollLeft != null) { setScrollLeft(cm, scrollPos.scrollLeft); } } // Calculate a new scroll position needed to scroll the given // rectangle into view. Returns an object with scrollTop and // scrollLeft properties. When these are undefined, the // vertical/horizontal position does not need to be adjusted. function calculateScrollPos(cm, rect) { var display = cm.display, snapMargin = textHeight(cm.display); if (rect.top < 0) { rect.top = 0; } var screentop = cm.curOp && cm.curOp.scrollTop != null ? cm.curOp.scrollTop : display.scroller.scrollTop; var screen = displayHeight(cm), result = {}; if (rect.bottom - rect.top > screen) { rect.bottom = rect.top + screen; } var docBottom = cm.doc.height + paddingVert(display); var atTop = rect.top < snapMargin, atBottom = rect.bottom > docBottom - snapMargin; if (rect.top < screentop) { result.scrollTop = atTop ? 0 : rect.top; } else if (rect.bottom > screentop + screen) { var newTop = Math.min(rect.top, (atBottom ? docBottom : rect.bottom) - screen); if (newTop != screentop) { result.scrollTop = newTop; } } var gutterSpace = cm.options.fixedGutter ? 0 : display.gutters.offsetWidth; var screenleft = cm.curOp && cm.curOp.scrollLeft != null ? cm.curOp.scrollLeft : display.scroller.scrollLeft - gutterSpace; var screenw = displayWidth(cm) - display.gutters.offsetWidth; var tooWide = rect.right - rect.left > screenw; if (tooWide) { rect.right = rect.left + screenw; } if (rect.left < 10) { result.scrollLeft = 0; } else if (rect.left < screenleft) { result.scrollLeft = Math.max(0, rect.left + gutterSpace - (tooWide ? 0 : 10)); } else if (rect.right > screenw + screenleft - 3) { result.scrollLeft = rect.right + (tooWide ? 0 : 10) - screenw; } return result } // Store a relative adjustment to the scroll position in the current // operation (to be applied when the operation finishes). function addToScrollTop(cm, top) { if (top == null) { return } resolveScrollToPos(cm); cm.curOp.scrollTop = (cm.curOp.scrollTop == null ? cm.doc.scrollTop : cm.curOp.scrollTop) + top; } // Make sure that at the end of the operation the current cursor is // shown. function ensureCursorVisible(cm) { resolveScrollToPos(cm); var cur = cm.getCursor(); cm.curOp.scrollToPos = {from: cur, to: cur, margin: cm.options.cursorScrollMargin}; } function scrollToCoords(cm, x, y) { if (x != null || y != null) { resolveScrollToPos(cm); } if (x != null) { cm.curOp.scrollLeft = x; } if (y != null) { cm.curOp.scrollTop = y; } } function scrollToRange(cm, range) { resolveScrollToPos(cm); cm.curOp.scrollToPos = range; } // When an operation has its scrollToPos property set, and another // scroll action is applied before the end of the operation, this // 'simulates' scrolling that position into view in a cheap way, so // that the effect of intermediate scroll commands is not ignored. function resolveScrollToPos(cm) { var range = cm.curOp.scrollToPos; if (range) { cm.curOp.scrollToPos = null; var from = estimateCoords(cm, range.from), to = estimateCoords(cm, range.to); scrollToCoordsRange(cm, from, to, range.margin); } } function scrollToCoordsRange(cm, from, to, margin) { var sPos = calculateScrollPos(cm, { left: Math.min(from.left, to.left), top: Math.min(from.top, to.top) - margin, right: Math.max(from.right, to.right), bottom: Math.max(from.bottom, to.bottom) + margin }); scrollToCoords(cm, sPos.scrollLeft, sPos.scrollTop); } // Sync the scrollable area and scrollbars, ensure the viewport // covers the visible area. function updateScrollTop(cm, val) { if (Math.abs(cm.doc.scrollTop - val) < 2) { return } if (!gecko) { updateDisplaySimple(cm, {top: val}); } setScrollTop(cm, val, true); if (gecko) { updateDisplaySimple(cm); } startWorker(cm, 100); } function setScrollTop(cm, val, forceScroll) { val = Math.max(0, Math.min(cm.display.scroller.scrollHeight - cm.display.scroller.clientHeight, val)); if (cm.display.scroller.scrollTop == val && !forceScroll) { return } cm.doc.scrollTop = val; cm.display.scrollbars.setScrollTop(val); if (cm.display.scroller.scrollTop != val) { cm.display.scroller.scrollTop = val; } } // Sync scroller and scrollbar, ensure the gutter elements are // aligned. function setScrollLeft(cm, val, isScroller, forceScroll) { val = Math.max(0, Math.min(val, cm.display.scroller.scrollWidth - cm.display.scroller.clientWidth)); if ((isScroller ? val == cm.doc.scrollLeft : Math.abs(cm.doc.scrollLeft - val) < 2) && !forceScroll) { return } cm.doc.scrollLeft = val; alignHorizontally(cm); if (cm.display.scroller.scrollLeft != val) { cm.display.scroller.scrollLeft = val; } cm.display.scrollbars.setScrollLeft(val); } // SCROLLBARS // Prepare DOM reads needed to update the scrollbars. Done in one // shot to minimize update/measure roundtrips. function measureForScrollbars(cm) { var d = cm.display, gutterW = d.gutters.offsetWidth; var docH = Math.round(cm.doc.height + paddingVert(cm.display)); return { clientHeight: d.scroller.clientHeight, viewHeight: d.wrapper.clientHeight, scrollWidth: d.scroller.scrollWidth, clientWidth: d.scroller.clientWidth, viewWidth: d.wrapper.clientWidth, barLeft: cm.options.fixedGutter ? gutterW : 0, docHeight: docH, scrollHeight: docH + scrollGap(cm) + d.barHeight, nativeBarWidth: d.nativeBarWidth, gutterWidth: gutterW } } var NativeScrollbars = function(place, scroll, cm) { this.cm = cm; var vert = this.vert = elt("div", [elt("div", null, null, "min-width: 1px")], "CodeMirror-vscrollbar"); var horiz = this.horiz = elt("div", [elt("div", null, null, "height: 100%; min-height: 1px")], "CodeMirror-hscrollbar"); vert.tabIndex = horiz.tabIndex = -1; place(vert); place(horiz); on(vert, "scroll", function () { if (vert.clientHeight) { scroll(vert.scrollTop, "vertical"); } }); on(horiz, "scroll", function () { if (horiz.clientWidth) { scroll(horiz.scrollLeft, "horizontal"); } }); this.checkedZeroWidth = false; // Need to set a minimum width to see the scrollbar on IE7 (but must not set it on IE8). if (ie && ie_version < 8) { this.horiz.style.minHeight = this.vert.style.minWidth = "18px"; } }; NativeScrollbars.prototype.update = function (measure) { var needsH = measure.scrollWidth > measure.clientWidth + 1; var needsV = measure.scrollHeight > measure.clientHeight + 1; var sWidth = measure.nativeBarWidth; if (needsV) { this.vert.style.display = "block"; this.vert.style.bottom = needsH ? sWidth + "px" : "0"; var totalHeight = measure.viewHeight - (needsH ? sWidth : 0); // A bug in IE8 can cause this value to be negative, so guard it. this.vert.firstChild.style.height = Math.max(0, measure.scrollHeight - measure.clientHeight + totalHeight) + "px"; } else { this.vert.style.display = ""; this.vert.firstChild.style.height = "0"; } if (needsH) { this.horiz.style.display = "block"; this.horiz.style.right = needsV ? sWidth + "px" : "0"; this.horiz.style.left = measure.barLeft + "px"; var totalWidth = measure.viewWidth - measure.barLeft - (needsV ? sWidth : 0); this.horiz.firstChild.style.width = Math.max(0, measure.scrollWidth - measure.clientWidth + totalWidth) + "px"; } else { this.horiz.style.display = ""; this.horiz.firstChild.style.width = "0"; } if (!this.checkedZeroWidth && measure.clientHeight > 0) { if (sWidth == 0) { this.zeroWidthHack(); } this.checkedZeroWidth = true; } return {right: needsV ? sWidth : 0, bottom: needsH ? sWidth : 0} }; NativeScrollbars.prototype.setScrollLeft = function (pos) { if (this.horiz.scrollLeft != pos) { this.horiz.scrollLeft = pos; } if (this.disableHoriz) { this.enableZeroWidthBar(this.horiz, this.disableHoriz, "horiz"); } }; NativeScrollbars.prototype.setScrollTop = function (pos) { if (this.vert.scrollTop != pos) { this.vert.scrollTop = pos; } if (this.disableVert) { this.enableZeroWidthBar(this.vert, this.disableVert, "vert"); } }; NativeScrollbars.prototype.zeroWidthHack = function () { var w = mac && !mac_geMountainLion ? "12px" : "18px"; this.horiz.style.height = this.vert.style.width = w; this.horiz.style.pointerEvents = this.vert.style.pointerEvents = "none"; this.disableHoriz = new Delayed; this.disableVert = new Delayed; }; NativeScrollbars.prototype.enableZeroWidthBar = function (bar, delay, type) { bar.style.pointerEvents = "auto"; function maybeDisable() { // To find out whether the scrollbar is still visible, we // check whether the element under the pixel in the bottom // right corner of the scrollbar box is the scrollbar box // itself (when the bar is still visible) or its filler child // (when the bar is hidden). If it is still visible, we keep // it enabled, if it's hidden, we disable pointer events. var box = bar.getBoundingClientRect(); var elt = type == "vert" ? document.elementFromPoint(box.right - 1, (box.top + box.bottom) / 2) : document.elementFromPoint((box.right + box.left) / 2, box.bottom - 1); if (elt != bar) { bar.style.pointerEvents = "none"; } else { delay.set(1000, maybeDisable); } } delay.set(1000, maybeDisable); }; NativeScrollbars.prototype.clear = function () { var parent = this.horiz.parentNode; parent.removeChild(this.horiz); parent.removeChild(this.vert); }; var NullScrollbars = function () {}; NullScrollbars.prototype.update = function () { return {bottom: 0, right: 0} }; NullScrollbars.prototype.setScrollLeft = function () {}; NullScrollbars.prototype.setScrollTop = function () {}; NullScrollbars.prototype.clear = function () {}; function updateScrollbars(cm, measure) { if (!measure) { measure = measureForScrollbars(cm); } var startWidth = cm.display.barWidth, startHeight = cm.display.barHeight; updateScrollbarsInner(cm, measure); for (var i = 0; i < 4 && startWidth != cm.display.barWidth || startHeight != cm.display.barHeight; i++) { if (startWidth != cm.display.barWidth && cm.options.lineWrapping) { updateHeightsInViewport(cm); } updateScrollbarsInner(cm, measureForScrollbars(cm)); startWidth = cm.display.barWidth; startHeight = cm.display.barHeight; } } // Re-synchronize the fake scrollbars with the actual size of the // content. function updateScrollbarsInner(cm, measure) { var d = cm.display; var sizes = d.scrollbars.update(measure); d.sizer.style.paddingRight = (d.barWidth = sizes.right) + "px"; d.sizer.style.paddingBottom = (d.barHeight = sizes.bottom) + "px"; d.heightForcer.style.borderBottom = sizes.bottom + "px solid transparent"; if (sizes.right && sizes.bottom) { d.scrollbarFiller.style.display = "block"; d.scrollbarFiller.style.height = sizes.bottom + "px"; d.scrollbarFiller.style.width = sizes.right + "px"; } else { d.scrollbarFiller.style.display = ""; } if (sizes.bottom && cm.options.coverGutterNextToScrollbar && cm.options.fixedGutter) { d.gutterFiller.style.display = "block"; d.gutterFiller.style.height = sizes.bottom + "px"; d.gutterFiller.style.width = measure.gutterWidth + "px"; } else { d.gutterFiller.style.display = ""; } } var scrollbarModel = {"native": NativeScrollbars, "null": NullScrollbars}; function initScrollbars(cm) { if (cm.display.scrollbars) { cm.display.scrollbars.clear(); if (cm.display.scrollbars.addClass) { rmClass(cm.display.wrapper, cm.display.scrollbars.addClass); } } cm.display.scrollbars = new scrollbarModel[cm.options.scrollbarStyle](function (node) { cm.display.wrapper.insertBefore(node, cm.display.scrollbarFiller); // Prevent clicks in the scrollbars from killing focus on(node, "mousedown", function () { if (cm.state.focused) { setTimeout(function () { return cm.display.input.focus(); }, 0); } }); node.setAttribute("cm-not-content", "true"); }, function (pos, axis) { if (axis == "horizontal") { setScrollLeft(cm, pos); } else { updateScrollTop(cm, pos); } }, cm); if (cm.display.scrollbars.addClass) { addClass(cm.display.wrapper, cm.display.scrollbars.addClass); } } // Operations are used to wrap a series of changes to the editor // state in such a way that each change won't have to update the // cursor and display (which would be awkward, slow, and // error-prone). Instead, display updates are batched and then all // combined and executed at once. var nextOpId = 0; // Start a new operation. function startOperation(cm) { cm.curOp = { cm: cm, viewChanged: false, // Flag that indicates that lines might need to be redrawn startHeight: cm.doc.height, // Used to detect need to update scrollbar forceUpdate: false, // Used to force a redraw updateInput: 0, // Whether to reset the input textarea typing: false, // Whether this reset should be careful to leave existing text (for compositing) changeObjs: null, // Accumulated changes, for firing change events cursorActivityHandlers: null, // Set of handlers to fire cursorActivity on cursorActivityCalled: 0, // Tracks which cursorActivity handlers have been called already selectionChanged: false, // Whether the selection needs to be redrawn updateMaxLine: false, // Set when the widest line needs to be determined anew scrollLeft: null, scrollTop: null, // Intermediate scroll position, not pushed to DOM yet scrollToPos: null, // Used to scroll to a specific position focus: false, id: ++nextOpId // Unique ID }; pushOperation(cm.curOp); } // Finish an operation, updating the display and signalling delayed events function endOperation(cm) { var op = cm.curOp; if (op) { finishOperation(op, function (group) { for (var i = 0; i < group.ops.length; i++) { group.ops[i].cm.curOp = null; } endOperations(group); }); } } // The DOM updates done when an operation finishes are batched so // that the minimum number of relayouts are required. function endOperations(group) { var ops = group.ops; for (var i = 0; i < ops.length; i++) // Read DOM { endOperation_R1(ops[i]); } for (var i$1 = 0; i$1 < ops.length; i$1++) // Write DOM (maybe) { endOperation_W1(ops[i$1]); } for (var i$2 = 0; i$2 < ops.length; i$2++) // Read DOM { endOperation_R2(ops[i$2]); } for (var i$3 = 0; i$3 < ops.length; i$3++) // Write DOM (maybe) { endOperation_W2(ops[i$3]); } for (var i$4 = 0; i$4 < ops.length; i$4++) // Read DOM { endOperation_finish(ops[i$4]); } } function endOperation_R1(op) { var cm = op.cm, display = cm.display; maybeClipScrollbars(cm); if (op.updateMaxLine) { findMaxLine(cm); } op.mustUpdate = op.viewChanged || op.forceUpdate || op.scrollTop != null || op.scrollToPos && (op.scrollToPos.from.line < display.viewFrom || op.scrollToPos.to.line >= display.viewTo) || display.maxLineChanged && cm.options.lineWrapping; op.update = op.mustUpdate && new DisplayUpdate(cm, op.mustUpdate && {top: op.scrollTop, ensure: op.scrollToPos}, op.forceUpdate); } function endOperation_W1(op) { op.updatedDisplay = op.mustUpdate && updateDisplayIfNeeded(op.cm, op.update); } function endOperation_R2(op) { var cm = op.cm, display = cm.display; if (op.updatedDisplay) { updateHeightsInViewport(cm); } op.barMeasure = measureForScrollbars(cm); // If the max line changed since it was last measured, measure it, // and ensure the document's width matches it. // updateDisplay_W2 will use these properties to do the actual resizing if (display.maxLineChanged && !cm.options.lineWrapping) { op.adjustWidthTo = measureChar(cm, display.maxLine, display.maxLine.text.length).left + 3; cm.display.sizerWidth = op.adjustWidthTo; op.barMeasure.scrollWidth = Math.max(display.scroller.clientWidth, display.sizer.offsetLeft + op.adjustWidthTo + scrollGap(cm) + cm.display.barWidth); op.maxScrollLeft = Math.max(0, display.sizer.offsetLeft + op.adjustWidthTo - displayWidth(cm)); } if (op.updatedDisplay || op.selectionChanged) { op.preparedSelection = display.input.prepareSelection(); } } function endOperation_W2(op) { var cm = op.cm; if (op.adjustWidthTo != null) { cm.display.sizer.style.minWidth = op.adjustWidthTo + "px"; if (op.maxScrollLeft < cm.doc.scrollLeft) { setScrollLeft(cm, Math.min(cm.display.scroller.scrollLeft, op.maxScrollLeft), true); } cm.display.maxLineChanged = false; } var takeFocus = op.focus && op.focus == activeElt(); if (op.preparedSelection) { cm.display.input.showSelection(op.preparedSelection, takeFocus); } if (op.updatedDisplay || op.startHeight != cm.doc.height) { updateScrollbars(cm, op.barMeasure); } if (op.updatedDisplay) { setDocumentHeight(cm, op.barMeasure); } if (op.selectionChanged) { restartBlink(cm); } if (cm.state.focused && op.updateInput) { cm.display.input.reset(op.typing); } if (takeFocus) { ensureFocus(op.cm); } } function endOperation_finish(op) { var cm = op.cm, display = cm.display, doc = cm.doc; if (op.updatedDisplay) { postUpdateDisplay(cm, op.update); } // Abort mouse wheel delta measurement, when scrolling explicitly if (display.wheelStartX != null && (op.scrollTop != null || op.scrollLeft != null || op.scrollToPos)) { display.wheelStartX = display.wheelStartY = null; } // Propagate the scroll position to the actual DOM scroller if (op.scrollTop != null) { setScrollTop(cm, op.scrollTop, op.forceScroll); } if (op.scrollLeft != null) { setScrollLeft(cm, op.scrollLeft, true, true); } // If we need to scroll a specific position into view, do so. if (op.scrollToPos) { var rect = scrollPosIntoView(cm, clipPos(doc, op.scrollToPos.from), clipPos(doc, op.scrollToPos.to), op.scrollToPos.margin); maybeScrollWindow(cm, rect); } // Fire events for markers that are hidden/unidden by editing or // undoing var hidden = op.maybeHiddenMarkers, unhidden = op.maybeUnhiddenMarkers; if (hidden) { for (var i = 0; i < hidden.length; ++i) { if (!hidden[i].lines.length) { signal(hidden[i], "hide"); } } } if (unhidden) { for (var i$1 = 0; i$1 < unhidden.length; ++i$1) { if (unhidden[i$1].lines.length) { signal(unhidden[i$1], "unhide"); } } } if (display.wrapper.offsetHeight) { doc.scrollTop = cm.display.scroller.scrollTop; } // Fire change events, and delayed event handlers if (op.changeObjs) { signal(cm, "changes", cm, op.changeObjs); } if (op.update) { op.update.finish(); } } // Run the given function in an operation function runInOp(cm, f) { if (cm.curOp) { return f() } startOperation(cm); try { return f() } finally { endOperation(cm); } } // Wraps a function in an operation. Returns the wrapped function. function operation(cm, f) { return function() { if (cm.curOp) { return f.apply(cm, arguments) } startOperation(cm); try { return f.apply(cm, arguments) } finally { endOperation(cm); } } } // Used to add methods to editor and doc instances, wrapping them in // operations. function methodOp(f) { return function() { if (this.curOp) { return f.apply(this, arguments) } startOperation(this); try { return f.apply(this, arguments) } finally { endOperation(this); } } } function docMethodOp(f) { return function() { var cm = this.cm; if (!cm || cm.curOp) { return f.apply(this, arguments) } startOperation(cm); try { return f.apply(this, arguments) } finally { endOperation(cm); } } } // HIGHLIGHT WORKER function startWorker(cm, time) { if (cm.doc.highlightFrontier < cm.display.viewTo) { cm.state.highlight.set(time, bind(highlightWorker, cm)); } } function highlightWorker(cm) { var doc = cm.doc; if (doc.highlightFrontier >= cm.display.viewTo) { return } var end = +new Date + cm.options.workTime; var context = getContextBefore(cm, doc.highlightFrontier); var changedLines = []; doc.iter(context.line, Math.min(doc.first + doc.size, cm.display.viewTo + 500), function (line) { if (context.line >= cm.display.viewFrom) { // Visible var oldStyles = line.styles; var resetState = line.text.length > cm.options.maxHighlightLength ? copyState(doc.mode, context.state) : null; var highlighted = highlightLine(cm, line, context, true); if (resetState) { context.state = resetState; } line.styles = highlighted.styles; var oldCls = line.styleClasses, newCls = highlighted.classes; if (newCls) { line.styleClasses = newCls; } else if (oldCls) { line.styleClasses = null; } var ischange = !oldStyles || oldStyles.length != line.styles.length || oldCls != newCls && (!oldCls || !newCls || oldCls.bgClass != newCls.bgClass || oldCls.textClass != newCls.textClass); for (var i = 0; !ischange && i < oldStyles.length; ++i) { ischange = oldStyles[i] != line.styles[i]; } if (ischange) { changedLines.push(context.line); } line.stateAfter = context.save(); context.nextLine(); } else { if (line.text.length <= cm.options.maxHighlightLength) { processLine(cm, line.text, context); } line.stateAfter = context.line % 5 == 0 ? context.save() : null; context.nextLine(); } if (+new Date > end) { startWorker(cm, cm.options.workDelay); return true } }); doc.highlightFrontier = context.line; doc.modeFrontier = Math.max(doc.modeFrontier, context.line); if (changedLines.length) { runInOp(cm, function () { for (var i = 0; i < changedLines.length; i++) { regLineChange(cm, changedLines[i], "text"); } }); } } // DISPLAY DRAWING var DisplayUpdate = function(cm, viewport, force) { var display = cm.display; this.viewport = viewport; // Store some values that we'll need later (but don't want to force a relayout for) this.visible = visibleLines(display, cm.doc, viewport); this.editorIsHidden = !display.wrapper.offsetWidth; this.wrapperHeight = display.wrapper.clientHeight; this.wrapperWidth = display.wrapper.clientWidth; this.oldDisplayWidth = displayWidth(cm); this.force = force; this.dims = getDimensions(cm); this.events = []; }; DisplayUpdate.prototype.signal = function (emitter, type) { if (hasHandler(emitter, type)) { this.events.push(arguments); } }; DisplayUpdate.prototype.finish = function () { for (var i = 0; i < this.events.length; i++) { signal.apply(null, this.events[i]); } }; function maybeClipScrollbars(cm) { var display = cm.display; if (!display.scrollbarsClipped && display.scroller.offsetWidth) { display.nativeBarWidth = display.scroller.offsetWidth - display.scroller.clientWidth; display.heightForcer.style.height = scrollGap(cm) + "px"; display.sizer.style.marginBottom = -display.nativeBarWidth + "px"; display.sizer.style.borderRightWidth = scrollGap(cm) + "px"; display.scrollbarsClipped = true; } } function selectionSnapshot(cm) { if (cm.hasFocus()) { return null } var active = activeElt(); if (!active || !contains(cm.display.lineDiv, active)) { return null } var result = {activeElt: active}; if (window.getSelection) { var sel = window.getSelection(); if (sel.anchorNode && sel.extend && contains(cm.display.lineDiv, sel.anchorNode)) { result.anchorNode = sel.anchorNode; result.anchorOffset = sel.anchorOffset; result.focusNode = sel.focusNode; result.focusOffset = sel.focusOffset; } } return result } function restoreSelection(snapshot) { if (!snapshot || !snapshot.activeElt || snapshot.activeElt == activeElt()) { return } snapshot.activeElt.focus(); if (!/^(INPUT|TEXTAREA)$/.test(snapshot.activeElt.nodeName) && snapshot.anchorNode && contains(document.body, snapshot.anchorNode) && contains(document.body, snapshot.focusNode)) { var sel = window.getSelection(), range = document.createRange(); range.setEnd(snapshot.anchorNode, snapshot.anchorOffset); range.collapse(false); sel.removeAllRanges(); sel.addRange(range); sel.extend(snapshot.focusNode, snapshot.focusOffset); } } // Does the actual updating of the line display. Bails out // (returning false) when there is nothing to be done and forced is // false. function updateDisplayIfNeeded(cm, update) { var display = cm.display, doc = cm.doc; if (update.editorIsHidden) { resetView(cm); return false } // Bail out if the visible area is already rendered and nothing changed. if (!update.force && update.visible.from >= display.viewFrom && update.visible.to <= display.viewTo && (display.updateLineNumbers == null || display.updateLineNumbers >= display.viewTo) && display.renderedView == display.view && countDirtyView(cm) == 0) { return false } if (maybeUpdateLineNumberWidth(cm)) { resetView(cm); update.dims = getDimensions(cm); } // Compute a suitable new viewport (from & to) var end = doc.first + doc.size; var from = Math.max(update.visible.from - cm.options.viewportMargin, doc.first); var to = Math.min(end, update.visible.to + cm.options.viewportMargin); if (display.viewFrom < from && from - display.viewFrom < 20) { from = Math.max(doc.first, display.viewFrom); } if (display.viewTo > to && display.viewTo - to < 20) { to = Math.min(end, display.viewTo); } if (sawCollapsedSpans) { from = visualLineNo(cm.doc, from); to = visualLineEndNo(cm.doc, to); } var different = from != display.viewFrom || to != display.viewTo || display.lastWrapHeight != update.wrapperHeight || display.lastWrapWidth != update.wrapperWidth; adjustView(cm, from, to); display.viewOffset = heightAtLine(getLine(cm.doc, display.viewFrom)); // Position the mover div to align with the current scroll position cm.display.mover.style.top = display.viewOffset + "px"; var toUpdate = countDirtyView(cm); if (!different && toUpdate == 0 && !update.force && display.renderedView == display.view && (display.updateLineNumbers == null || display.updateLineNumbers >= display.viewTo)) { return false } // For big changes, we hide the enclosing element during the // update, since that speeds up the operations on most browsers. var selSnapshot = selectionSnapshot(cm); if (toUpdate > 4) { display.lineDiv.style.display = "none"; } patchDisplay(cm, display.updateLineNumbers, update.dims); if (toUpdate > 4) { display.lineDiv.style.display = ""; } display.renderedView = display.view; // There might have been a widget with a focused element that got // hidden or updated, if so re-focus it. restoreSelection(selSnapshot); // Prevent selection and cursors from interfering with the scroll // width and height. removeChildren(display.cursorDiv); removeChildren(display.selectionDiv); display.gutters.style.height = display.sizer.style.minHeight = 0; if (different) { display.lastWrapHeight = update.wrapperHeight; display.lastWrapWidth = update.wrapperWidth; startWorker(cm, 400); } display.updateLineNumbers = null; return true } function postUpdateDisplay(cm, update) { var viewport = update.viewport; for (var first = true;; first = false) { if (!first || !cm.options.lineWrapping || update.oldDisplayWidth == displayWidth(cm)) { // Clip forced viewport to actual scrollable area. if (viewport && viewport.top != null) { viewport = {top: Math.min(cm.doc.height + paddingVert(cm.display) - displayHeight(cm), viewport.top)}; } // Updated line heights might result in the drawn area not // actually covering the viewport. Keep looping until it does. update.visible = visibleLines(cm.display, cm.doc, viewport); if (update.visible.from >= cm.display.viewFrom && update.visible.to <= cm.display.viewTo) { break } } else if (first) { update.visible = visibleLines(cm.display, cm.doc, viewport); } if (!updateDisplayIfNeeded(cm, update)) { break } updateHeightsInViewport(cm); var barMeasure = measureForScrollbars(cm); updateSelection(cm); updateScrollbars(cm, barMeasure); setDocumentHeight(cm, barMeasure); update.force = false; } update.signal(cm, "update", cm); if (cm.display.viewFrom != cm.display.reportedViewFrom || cm.display.viewTo != cm.display.reportedViewTo) { update.signal(cm, "viewportChange", cm, cm.display.viewFrom, cm.display.viewTo); cm.display.reportedViewFrom = cm.display.viewFrom; cm.display.reportedViewTo = cm.display.viewTo; } } function updateDisplaySimple(cm, viewport) { var update = new DisplayUpdate(cm, viewport); if (updateDisplayIfNeeded(cm, update)) { updateHeightsInViewport(cm); postUpdateDisplay(cm, update); var barMeasure = measureForScrollbars(cm); updateSelection(cm); updateScrollbars(cm, barMeasure); setDocumentHeight(cm, barMeasure); update.finish(); } } // Sync the actual display DOM structure with display.view, removing // nodes for lines that are no longer in view, and creating the ones // that are not there yet, and updating the ones that are out of // date. function patchDisplay(cm, updateNumbersFrom, dims) { var display = cm.display, lineNumbers = cm.options.lineNumbers; var container = display.lineDiv, cur = container.firstChild; function rm(node) { var next = node.nextSibling; // Works around a throw-scroll bug in OS X Webkit if (webkit && mac && cm.display.currentWheelTarget == node) { node.style.display = "none"; } else { node.parentNode.removeChild(node); } return next } var view = display.view, lineN = display.viewFrom; // Loop over the elements in the view, syncing cur (the DOM nodes // in display.lineDiv) with the view as we go. for (var i = 0; i < view.length; i++) { var lineView = view[i]; if (lineView.hidden) ; else if (!lineView.node || lineView.node.parentNode != container) { // Not drawn yet var node = buildLineElement(cm, lineView, lineN, dims); container.insertBefore(node, cur); } else { // Already drawn while (cur != lineView.node) { cur = rm(cur); } var updateNumber = lineNumbers && updateNumbersFrom != null && updateNumbersFrom <= lineN && lineView.lineNumber; if (lineView.changes) { if (indexOf(lineView.changes, "gutter") > -1) { updateNumber = false; } updateLineForChanges(cm, lineView, lineN, dims); } if (updateNumber) { removeChildren(lineView.lineNumber); lineView.lineNumber.appendChild(document.createTextNode(lineNumberFor(cm.options, lineN))); } cur = lineView.node.nextSibling; } lineN += lineView.size; } while (cur) { cur = rm(cur); } } function updateGutterSpace(display) { var width = display.gutters.offsetWidth; display.sizer.style.marginLeft = width + "px"; } function setDocumentHeight(cm, measure) { cm.display.sizer.style.minHeight = measure.docHeight + "px"; cm.display.heightForcer.style.top = measure.docHeight + "px"; cm.display.gutters.style.height = (measure.docHeight + cm.display.barHeight + scrollGap(cm)) + "px"; } // Re-align line numbers and gutter marks to compensate for // horizontal scrolling. function alignHorizontally(cm) { var display = cm.display, view = display.view; if (!display.alignWidgets && (!display.gutters.firstChild || !cm.options.fixedGutter)) { return } var comp = compensateForHScroll(display) - display.scroller.scrollLeft + cm.doc.scrollLeft; var gutterW = display.gutters.offsetWidth, left = comp + "px"; for (var i = 0; i < view.length; i++) { if (!view[i].hidden) { if (cm.options.fixedGutter) { if (view[i].gutter) { view[i].gutter.style.left = left; } if (view[i].gutterBackground) { view[i].gutterBackground.style.left = left; } } var align = view[i].alignable; if (align) { for (var j = 0; j < align.length; j++) { align[j].style.left = left; } } } } if (cm.options.fixedGutter) { display.gutters.style.left = (comp + gutterW) + "px"; } } // Used to ensure that the line number gutter is still the right // size for the current document size. Returns true when an update // is needed. function maybeUpdateLineNumberWidth(cm) { if (!cm.options.lineNumbers) { return false } var doc = cm.doc, last = lineNumberFor(cm.options, doc.first + doc.size - 1), display = cm.display; if (last.length != display.lineNumChars) { var test = display.measure.appendChild(elt("div", [elt("div", last)], "CodeMirror-linenumber CodeMirror-gutter-elt")); var innerW = test.firstChild.offsetWidth, padding = test.offsetWidth - innerW; display.lineGutter.style.width = ""; display.lineNumInnerWidth = Math.max(innerW, display.lineGutter.offsetWidth - padding) + 1; display.lineNumWidth = display.lineNumInnerWidth + padding; display.lineNumChars = display.lineNumInnerWidth ? last.length : -1; display.lineGutter.style.width = display.lineNumWidth + "px"; updateGutterSpace(cm.display); return true } return false } function getGutters(gutters, lineNumbers) { var result = [], sawLineNumbers = false; for (var i = 0; i < gutters.length; i++) { var name = gutters[i], style = null; if (typeof name != "string") { style = name.style; name = name.className; } if (name == "CodeMirror-linenumbers") { if (!lineNumbers) { continue } else { sawLineNumbers = true; } } result.push({className: name, style: style}); } if (lineNumbers && !sawLineNumbers) { result.push({className: "CodeMirror-linenumbers", style: null}); } return result } // Rebuild the gutter elements, ensure the margin to the left of the // code matches their width. function renderGutters(display) { var gutters = display.gutters, specs = display.gutterSpecs; removeChildren(gutters); display.lineGutter = null; for (var i = 0; i < specs.length; ++i) { var ref = specs[i]; var className = ref.className; var style = ref.style; var gElt = gutters.appendChild(elt("div", null, "CodeMirror-gutter " + className)); if (style) { gElt.style.cssText = style; } if (className == "CodeMirror-linenumbers") { display.lineGutter = gElt; gElt.style.width = (display.lineNumWidth || 1) + "px"; } } gutters.style.display = specs.length ? "" : "none"; updateGutterSpace(display); } function updateGutters(cm) { renderGutters(cm.display); regChange(cm); alignHorizontally(cm); } // The display handles the DOM integration, both for input reading // and content drawing. It holds references to DOM nodes and // display-related state. function Display(place, doc, input, options) { var d = this; this.input = input; // Covers bottom-right square when both scrollbars are present. d.scrollbarFiller = elt("div", null, "CodeMirror-scrollbar-filler"); d.scrollbarFiller.setAttribute("cm-not-content", "true"); // Covers bottom of gutter when coverGutterNextToScrollbar is on // and h scrollbar is present. d.gutterFiller = elt("div", null, "CodeMirror-gutter-filler"); d.gutterFiller.setAttribute("cm-not-content", "true"); // Will contain the actual code, positioned to cover the viewport. d.lineDiv = eltP("div", null, "CodeMirror-code"); // Elements are added to these to represent selection and cursors. d.selectionDiv = elt("div", null, null, "position: relative; z-index: 1"); d.cursorDiv = elt("div", null, "CodeMirror-cursors"); // A visibility: hidden element used to find the size of things. d.measure = elt("div", null, "CodeMirror-measure"); // When lines outside of the viewport are measured, they are drawn in this. d.lineMeasure = elt("div", null, "CodeMirror-measure"); // Wraps everything that needs to exist inside the vertically-padded coordinate system d.lineSpace = eltP("div", [d.measure, d.lineMeasure, d.selectionDiv, d.cursorDiv, d.lineDiv], null, "position: relative; outline: none"); var lines = eltP("div", [d.lineSpace], "CodeMirror-lines"); // Moved around its parent to cover visible view. d.mover = elt("div", [lines], null, "position: relative"); // Set to the height of the document, allowing scrolling. d.sizer = elt("div", [d.mover], "CodeMirror-sizer"); d.sizerWidth = null; // Behavior of elts with overflow: auto and padding is // inconsistent across browsers. This is used to ensure the // scrollable area is big enough. d.heightForcer = elt("div", null, null, "position: absolute; height: " + scrollerGap + "px; width: 1px;"); // Will contain the gutters, if any. d.gutters = elt("div", null, "CodeMirror-gutters"); d.lineGutter = null; // Actual scrollable element. d.scroller = elt("div", [d.sizer, d.heightForcer, d.gutters], "CodeMirror-scroll"); d.scroller.setAttribute("tabIndex", "-1"); // The element in which the editor lives. d.wrapper = elt("div", [d.scrollbarFiller, d.gutterFiller, d.scroller], "CodeMirror"); // Work around IE7 z-index bug (not perfect, hence IE7 not really being supported) if (ie && ie_version < 8) { d.gutters.style.zIndex = -1; d.scroller.style.paddingRight = 0; } if (!webkit && !(gecko && mobile)) { d.scroller.draggable = true; } if (place) { if (place.appendChild) { place.appendChild(d.wrapper); } else { place(d.wrapper); } } // Current rendered range (may be bigger than the view window). d.viewFrom = d.viewTo = doc.first; d.reportedViewFrom = d.reportedViewTo = doc.first; // Information about the rendered lines. d.view = []; d.renderedView = null; // Holds info about a single rendered line when it was rendered // for measurement, while not in view. d.externalMeasured = null; // Empty space (in pixels) above the view d.viewOffset = 0; d.lastWrapHeight = d.lastWrapWidth = 0; d.updateLineNumbers = null; d.nativeBarWidth = d.barHeight = d.barWidth = 0; d.scrollbarsClipped = false; // Used to only resize the line number gutter when necessary (when // the amount of lines crosses a boundary that makes its width change) d.lineNumWidth = d.lineNumInnerWidth = d.lineNumChars = null; // Set to true when a non-horizontal-scrolling line widget is // added. As an optimization, line widget aligning is skipped when // this is false. d.alignWidgets = false; d.cachedCharWidth = d.cachedTextHeight = d.cachedPaddingH = null; // Tracks the maximum line length so that the horizontal scrollbar // can be kept static when scrolling. d.maxLine = null; d.maxLineLength = 0; d.maxLineChanged = false; // Used for measuring wheel scrolling granularity d.wheelDX = d.wheelDY = d.wheelStartX = d.wheelStartY = null; // True when shift is held down. d.shift = false; // Used to track whether anything happened since the context menu // was opened. d.selForContextMenu = null; d.activeTouch = null; d.gutterSpecs = getGutters(options.gutters, options.lineNumbers); renderGutters(d); input.init(d); } // Since the delta values reported on mouse wheel events are // unstandardized between browsers and even browser versions, and // generally horribly unpredictable, this code starts by measuring // the scroll effect that the first few mouse wheel events have, // and, from that, detects the way it can convert deltas to pixel // offsets afterwards. // // The reason we want to know the amount a wheel event will scroll // is that it gives us a chance to update the display before the // actual scrolling happens, reducing flickering. var wheelSamples = 0, wheelPixelsPerUnit = null; // Fill in a browser-detected starting value on browsers where we // know one. These don't have to be accurate -- the result of them // being wrong would just be a slight flicker on the first wheel // scroll (if it is large enough). if (ie) { wheelPixelsPerUnit = -.53; } else if (gecko) { wheelPixelsPerUnit = 15; } else if (chrome) { wheelPixelsPerUnit = -.7; } else if (safari) { wheelPixelsPerUnit = -1/3; } function wheelEventDelta(e) { var dx = e.wheelDeltaX, dy = e.wheelDeltaY; if (dx == null && e.detail && e.axis == e.HORIZONTAL_AXIS) { dx = e.detail; } if (dy == null && e.detail && e.axis == e.VERTICAL_AXIS) { dy = e.detail; } else if (dy == null) { dy = e.wheelDelta; } return {x: dx, y: dy} } function wheelEventPixels(e) { var delta = wheelEventDelta(e); delta.x *= wheelPixelsPerUnit; delta.y *= wheelPixelsPerUnit; return delta } function onScrollWheel(cm, e) { var delta = wheelEventDelta(e), dx = delta.x, dy = delta.y; var display = cm.display, scroll = display.scroller; // Quit if there's nothing to scroll here var canScrollX = scroll.scrollWidth > scroll.clientWidth; var canScrollY = scroll.scrollHeight > scroll.clientHeight; if (!(dx && canScrollX || dy && canScrollY)) { return } // Webkit browsers on OS X abort momentum scrolls when the target // of the scroll event is removed from the scrollable element. // This hack (see related code in patchDisplay) makes sure the // element is kept around. if (dy && mac && webkit) { outer: for (var cur = e.target, view = display.view; cur != scroll; cur = cur.parentNode) { for (var i = 0; i < view.length; i++) { if (view[i].node == cur) { cm.display.currentWheelTarget = cur; break outer } } } } // On some browsers, horizontal scrolling will cause redraws to // happen before the gutter has been realigned, causing it to // wriggle around in a most unseemly way. When we have an // estimated pixels/delta value, we just handle horizontal // scrolling entirely here. It'll be slightly off from native, but // better than glitching out. if (dx && !gecko && !presto && wheelPixelsPerUnit != null) { if (dy && canScrollY) { updateScrollTop(cm, Math.max(0, scroll.scrollTop + dy * wheelPixelsPerUnit)); } setScrollLeft(cm, Math.max(0, scroll.scrollLeft + dx * wheelPixelsPerUnit)); // Only prevent default scrolling if vertical scrolling is // actually possible. Otherwise, it causes vertical scroll // jitter on OSX trackpads when deltaX is small and deltaY // is large (issue #3579) if (!dy || (dy && canScrollY)) { e_preventDefault(e); } display.wheelStartX = null; // Abort measurement, if in progress return } // 'Project' the visible viewport to cover the area that is being // scrolled into view (if we know enough to estimate it). if (dy && wheelPixelsPerUnit != null) { var pixels = dy * wheelPixelsPerUnit; var top = cm.doc.scrollTop, bot = top + display.wrapper.clientHeight; if (pixels < 0) { top = Math.max(0, top + pixels - 50); } else { bot = Math.min(cm.doc.height, bot + pixels + 50); } updateDisplaySimple(cm, {top: top, bottom: bot}); } if (wheelSamples < 20) { if (display.wheelStartX == null) { display.wheelStartX = scroll.scrollLeft; display.wheelStartY = scroll.scrollTop; display.wheelDX = dx; display.wheelDY = dy; setTimeout(function () { if (display.wheelStartX == null) { return } var movedX = scroll.scrollLeft - display.wheelStartX; var movedY = scroll.scrollTop - display.wheelStartY; var sample = (movedY && display.wheelDY && movedY / display.wheelDY) || (movedX && display.wheelDX && movedX / display.wheelDX); display.wheelStartX = display.wheelStartY = null; if (!sample) { return } wheelPixelsPerUnit = (wheelPixelsPerUnit * wheelSamples + sample) / (wheelSamples + 1); ++wheelSamples; }, 200); } else { display.wheelDX += dx; display.wheelDY += dy; } } } // Selection objects are immutable. A new one is created every time // the selection changes. A selection is one or more non-overlapping // (and non-touching) ranges, sorted, and an integer that indicates // which one is the primary selection (the one that's scrolled into // view, that getCursor returns, etc). var Selection = function(ranges, primIndex) { this.ranges = ranges; this.primIndex = primIndex; }; Selection.prototype.primary = function () { return this.ranges[this.primIndex] }; Selection.prototype.equals = function (other) { if (other == this) { return true } if (other.primIndex != this.primIndex || other.ranges.length != this.ranges.length) { return false } for (var i = 0; i < this.ranges.length; i++) { var here = this.ranges[i], there = other.ranges[i]; if (!equalCursorPos(here.anchor, there.anchor) || !equalCursorPos(here.head, there.head)) { return false } } return true }; Selection.prototype.deepCopy = function () { var out = []; for (var i = 0; i < this.ranges.length; i++) { out[i] = new Range(copyPos(this.ranges[i].anchor), copyPos(this.ranges[i].head)); } return new Selection(out, this.primIndex) }; Selection.prototype.somethingSelected = function () { for (var i = 0; i < this.ranges.length; i++) { if (!this.ranges[i].empty()) { return true } } return false }; Selection.prototype.contains = function (pos, end) { if (!end) { end = pos; } for (var i = 0; i < this.ranges.length; i++) { var range = this.ranges[i]; if (cmp(end, range.from()) >= 0 && cmp(pos, range.to()) <= 0) { return i } } return -1 }; var Range = function(anchor, head) { this.anchor = anchor; this.head = head; }; Range.prototype.from = function () { return minPos(this.anchor, this.head) }; Range.prototype.to = function () { return maxPos(this.anchor, this.head) }; Range.prototype.empty = function () { return this.head.line == this.anchor.line && this.head.ch == this.anchor.ch }; // Take an unsorted, potentially overlapping set of ranges, and // build a selection out of it. 'Consumes' ranges array (modifying // it). function normalizeSelection(cm, ranges, primIndex) { var mayTouch = cm && cm.options.selectionsMayTouch; var prim = ranges[primIndex]; ranges.sort(function (a, b) { return cmp(a.from(), b.from()); }); primIndex = indexOf(ranges, prim); for (var i = 1; i < ranges.length; i++) { var cur = ranges[i], prev = ranges[i - 1]; var diff = cmp(prev.to(), cur.from()); if (mayTouch && !cur.empty() ? diff > 0 : diff >= 0) { var from = minPos(prev.from(), cur.from()), to = maxPos(prev.to(), cur.to()); var inv = prev.empty() ? cur.from() == cur.head : prev.from() == prev.head; if (i <= primIndex) { --primIndex; } ranges.splice(--i, 2, new Range(inv ? to : from, inv ? from : to)); } } return new Selection(ranges, primIndex) } function simpleSelection(anchor, head) { return new Selection([new Range(anchor, head || anchor)], 0) } // Compute the position of the end of a change (its 'to' property // refers to the pre-change end). function changeEnd(change) { if (!change.text) { return change.to } return Pos(change.from.line + change.text.length - 1, lst(change.text).length + (change.text.length == 1 ? change.from.ch : 0)) } // Adjust a position to refer to the post-change position of the // same text, or the end of the change if the change covers it. function adjustForChange(pos, change) { if (cmp(pos, change.from) < 0) { return pos } if (cmp(pos, change.to) <= 0) { return changeEnd(change) } var line = pos.line + change.text.length - (change.to.line - change.from.line) - 1, ch = pos.ch; if (pos.line == change.to.line) { ch += changeEnd(change).ch - change.to.ch; } return Pos(line, ch) } function computeSelAfterChange(doc, change) { var out = []; for (var i = 0; i < doc.sel.ranges.length; i++) { var range = doc.sel.ranges[i]; out.push(new Range(adjustForChange(range.anchor, change), adjustForChange(range.head, change))); } return normalizeSelection(doc.cm, out, doc.sel.primIndex) } function offsetPos(pos, old, nw) { if (pos.line == old.line) { return Pos(nw.line, pos.ch - old.ch + nw.ch) } else { return Pos(nw.line + (pos.line - old.line), pos.ch) } } // Used by replaceSelections to allow moving the selection to the // start or around the replaced test. Hint may be "start" or "around". function computeReplacedSel(doc, changes, hint) { var out = []; var oldPrev = Pos(doc.first, 0), newPrev = oldPrev; for (var i = 0; i < changes.length; i++) { var change = changes[i]; var from = offsetPos(change.from, oldPrev, newPrev); var to = offsetPos(changeEnd(change), oldPrev, newPrev); oldPrev = change.to; newPrev = to; if (hint == "around") { var range = doc.sel.ranges[i], inv = cmp(range.head, range.anchor) < 0; out[i] = new Range(inv ? to : from, inv ? from : to); } else { out[i] = new Range(from, from); } } return new Selection(out, doc.sel.primIndex) } // Used to get the editor into a consistent state again when options change. function loadMode(cm) { cm.doc.mode = getMode(cm.options, cm.doc.modeOption); resetModeState(cm); } function resetModeState(cm) { cm.doc.iter(function (line) { if (line.stateAfter) { line.stateAfter = null; } if (line.styles) { line.styles = null; } }); cm.doc.modeFrontier = cm.doc.highlightFrontier = cm.doc.first; startWorker(cm, 100); cm.state.modeGen++; if (cm.curOp) { regChange(cm); } } // DOCUMENT DATA STRUCTURE // By default, updates that start and end at the beginning of a line // are treated specially, in order to make the association of line // widgets and marker elements with the text behave more intuitive. function isWholeLineUpdate(doc, change) { return change.from.ch == 0 && change.to.ch == 0 && lst(change.text) == "" && (!doc.cm || doc.cm.options.wholeLineUpdateBefore) } // Perform a change on the document data structure. function updateDoc(doc, change, markedSpans, estimateHeight) { function spansFor(n) {return markedSpans ? markedSpans[n] : null} function update(line, text, spans) { updateLine(line, text, spans, estimateHeight); signalLater(line, "change", line, change); } function linesFor(start, end) { var result = []; for (var i = start; i < end; ++i) { result.push(new Line(text[i], spansFor(i), estimateHeight)); } return result } var from = change.from, to = change.to, text = change.text; var firstLine = getLine(doc, from.line), lastLine = getLine(doc, to.line); var lastText = lst(text), lastSpans = spansFor(text.length - 1), nlines = to.line - from.line; // Adjust the line structure if (change.full) { doc.insert(0, linesFor(0, text.length)); doc.remove(text.length, doc.size - text.length); } else if (isWholeLineUpdate(doc, change)) { // This is a whole-line replace. Treated specially to make // sure line objects move the way they are supposed to. var added = linesFor(0, text.length - 1); update(lastLine, lastLine.text, lastSpans); if (nlines) { doc.remove(from.line, nlines); } if (added.length) { doc.insert(from.line, added); } } else if (firstLine == lastLine) { if (text.length == 1) { update(firstLine, firstLine.text.slice(0, from.ch) + lastText + firstLine.text.slice(to.ch), lastSpans); } else { var added$1 = linesFor(1, text.length - 1); added$1.push(new Line(lastText + firstLine.text.slice(to.ch), lastSpans, estimateHeight)); update(firstLine, firstLine.text.slice(0, from.ch) + text[0], spansFor(0)); doc.insert(from.line + 1, added$1); } } else if (text.length == 1) { update(firstLine, firstLine.text.slice(0, from.ch) + text[0] + lastLine.text.slice(to.ch), spansFor(0)); doc.remove(from.line + 1, nlines); } else { update(firstLine, firstLine.text.slice(0, from.ch) + text[0], spansFor(0)); update(lastLine, lastText + lastLine.text.slice(to.ch), lastSpans); var added$2 = linesFor(1, text.length - 1); if (nlines > 1) { doc.remove(from.line + 1, nlines - 1); } doc.insert(from.line + 1, added$2); } signalLater(doc, "change", doc, change); } // Call f for all linked documents. function linkedDocs(doc, f, sharedHistOnly) { function propagate(doc, skip, sharedHist) { if (doc.linked) { for (var i = 0; i < doc.linked.length; ++i) { var rel = doc.linked[i]; if (rel.doc == skip) { continue } var shared = sharedHist && rel.sharedHist; if (sharedHistOnly && !shared) { continue } f(rel.doc, shared); propagate(rel.doc, doc, shared); } } } propagate(doc, null, true); } // Attach a document to an editor. function attachDoc(cm, doc) { if (doc.cm) { throw new Error("This document is already in use.") } cm.doc = doc; doc.cm = cm; estimateLineHeights(cm); loadMode(cm); setDirectionClass(cm); if (!cm.options.lineWrapping) { findMaxLine(cm); } cm.options.mode = doc.modeOption; regChange(cm); } function setDirectionClass(cm) { (cm.doc.direction == "rtl" ? addClass : rmClass)(cm.display.lineDiv, "CodeMirror-rtl"); } function directionChanged(cm) { runInOp(cm, function () { setDirectionClass(cm); regChange(cm); }); } function History(startGen) { // Arrays of change events and selections. Doing something adds an // event to done and clears undo. Undoing moves events from done // to undone, redoing moves them in the other direction. this.done = []; this.undone = []; this.undoDepth = Infinity; // Used to track when changes can be merged into a single undo // event this.lastModTime = this.lastSelTime = 0; this.lastOp = this.lastSelOp = null; this.lastOrigin = this.lastSelOrigin = null; // Used by the isClean() method this.generation = this.maxGeneration = startGen || 1; } // Create a history change event from an updateDoc-style change // object. function historyChangeFromChange(doc, change) { var histChange = {from: copyPos(change.from), to: changeEnd(change), text: getBetween(doc, change.from, change.to)}; attachLocalSpans(doc, histChange, change.from.line, change.to.line + 1); linkedDocs(doc, function (doc) { return attachLocalSpans(doc, histChange, change.from.line, change.to.line + 1); }, true); return histChange } // Pop all selection events off the end of a history array. Stop at // a change event. function clearSelectionEvents(array) { while (array.length) { var last = lst(array); if (last.ranges) { array.pop(); } else { break } } } // Find the top change event in the history. Pop off selection // events that are in the way. function lastChangeEvent(hist, force) { if (force) { clearSelectionEvents(hist.done); return lst(hist.done) } else if (hist.done.length && !lst(hist.done).ranges) { return lst(hist.done) } else if (hist.done.length > 1 && !hist.done[hist.done.length - 2].ranges) { hist.done.pop(); return lst(hist.done) } } // Register a change in the history. Merges changes that are within // a single operation, or are close together with an origin that // allows merging (starting with "+") into a single event. function addChangeToHistory(doc, change, selAfter, opId) { var hist = doc.history; hist.undone.length = 0; var time = +new Date, cur; var last; if ((hist.lastOp == opId || hist.lastOrigin == change.origin && change.origin && ((change.origin.charAt(0) == "+" && hist.lastModTime > time - (doc.cm ? doc.cm.options.historyEventDelay : 500)) || change.origin.charAt(0) == "*")) && (cur = lastChangeEvent(hist, hist.lastOp == opId))) { // Merge this change into the last event last = lst(cur.changes); if (cmp(change.from, change.to) == 0 && cmp(change.from, last.to) == 0) { // Optimized case for simple insertion -- don't want to add // new changesets for every character typed last.to = changeEnd(change); } else { // Add new sub-event cur.changes.push(historyChangeFromChange(doc, change)); } } else { // Can not be merged, start a new event. var before = lst(hist.done); if (!before || !before.ranges) { pushSelectionToHistory(doc.sel, hist.done); } cur = {changes: [historyChangeFromChange(doc, change)], generation: hist.generation}; hist.done.push(cur); while (hist.done.length > hist.undoDepth) { hist.done.shift(); if (!hist.done[0].ranges) { hist.done.shift(); } } } hist.done.push(selAfter); hist.generation = ++hist.maxGeneration; hist.lastModTime = hist.lastSelTime = time; hist.lastOp = hist.lastSelOp = opId; hist.lastOrigin = hist.lastSelOrigin = change.origin; if (!last) { signal(doc, "historyAdded"); } } function selectionEventCanBeMerged(doc, origin, prev, sel) { var ch = origin.charAt(0); return ch == "*" || ch == "+" && prev.ranges.length == sel.ranges.length && prev.somethingSelected() == sel.somethingSelected() && new Date - doc.history.lastSelTime <= (doc.cm ? doc.cm.options.historyEventDelay : 500) } // Called whenever the selection changes, sets the new selection as // the pending selection in the history, and pushes the old pending // selection into the 'done' array when it was significantly // different (in number of selected ranges, emptiness, or time). function addSelectionToHistory(doc, sel, opId, options) { var hist = doc.history, origin = options && options.origin; // A new event is started when the previous origin does not match // the current, or the origins don't allow matching. Origins // starting with * are always merged, those starting with + are // merged when similar and close together in time. if (opId == hist.lastSelOp || (origin && hist.lastSelOrigin == origin && (hist.lastModTime == hist.lastSelTime && hist.lastOrigin == origin || selectionEventCanBeMerged(doc, origin, lst(hist.done), sel)))) { hist.done[hist.done.length - 1] = sel; } else { pushSelectionToHistory(sel, hist.done); } hist.lastSelTime = +new Date; hist.lastSelOrigin = origin; hist.lastSelOp = opId; if (options && options.clearRedo !== false) { clearSelectionEvents(hist.undone); } } function pushSelectionToHistory(sel, dest) { var top = lst(dest); if (!(top && top.ranges && top.equals(sel))) { dest.push(sel); } } // Used to store marked span information in the history. function attachLocalSpans(doc, change, from, to) { var existing = change["spans_" + doc.id], n = 0; doc.iter(Math.max(doc.first, from), Math.min(doc.first + doc.size, to), function (line) { if (line.markedSpans) { (existing || (existing = change["spans_" + doc.id] = {}))[n] = line.markedSpans; } ++n; }); } // When un/re-doing restores text containing marked spans, those // that have been explicitly cleared should not be restored. function removeClearedSpans(spans) { if (!spans) { return null } var out; for (var i = 0; i < spans.length; ++i) { if (spans[i].marker.explicitlyCleared) { if (!out) { out = spans.slice(0, i); } } else if (out) { out.push(spans[i]); } } return !out ? spans : out.length ? out : null } // Retrieve and filter the old marked spans stored in a change event. function getOldSpans(doc, change) { var found = change["spans_" + doc.id]; if (!found) { return null } var nw = []; for (var i = 0; i < change.text.length; ++i) { nw.push(removeClearedSpans(found[i])); } return nw } // Used for un/re-doing changes from the history. Combines the // result of computing the existing spans with the set of spans that // existed in the history (so that deleting around a span and then // undoing brings back the span). function mergeOldSpans(doc, change) { var old = getOldSpans(doc, change); var stretched = stretchSpansOverChange(doc, change); if (!old) { return stretched } if (!stretched) { return old } for (var i = 0; i < old.length; ++i) { var oldCur = old[i], stretchCur = stretched[i]; if (oldCur && stretchCur) { spans: for (var j = 0; j < stretchCur.length; ++j) { var span = stretchCur[j]; for (var k = 0; k < oldCur.length; ++k) { if (oldCur[k].marker == span.marker) { continue spans } } oldCur.push(span); } } else if (stretchCur) { old[i] = stretchCur; } } return old } // Used both to provide a JSON-safe object in .getHistory, and, when // detaching a document, to split the history in two function copyHistoryArray(events, newGroup, instantiateSel) { var copy = []; for (var i = 0; i < events.length; ++i) { var event = events[i]; if (event.ranges) { copy.push(instantiateSel ? Selection.prototype.deepCopy.call(event) : event); continue } var changes = event.changes, newChanges = []; copy.push({changes: newChanges}); for (var j = 0; j < changes.length; ++j) { var change = changes[j], m = (void 0); newChanges.push({from: change.from, to: change.to, text: change.text}); if (newGroup) { for (var prop in change) { if (m = prop.match(/^spans_(\d+)$/)) { if (indexOf(newGroup, Number(m[1])) > -1) { lst(newChanges)[prop] = change[prop]; delete change[prop]; } } } } } } return copy } // The 'scroll' parameter given to many of these indicated whether // the new cursor position should be scrolled into view after // modifying the selection. // If shift is held or the extend flag is set, extends a range to // include a given position (and optionally a second position). // Otherwise, simply returns the range between the given positions. // Used for cursor motion and such. function extendRange(range, head, other, extend) { if (extend) { var anchor = range.anchor; if (other) { var posBefore = cmp(head, anchor) < 0; if (posBefore != (cmp(other, anchor) < 0)) { anchor = head; head = other; } else if (posBefore != (cmp(head, other) < 0)) { head = other; } } return new Range(anchor, head) } else { return new Range(other || head, head) } } // Extend the primary selection range, discard the rest. function extendSelection(doc, head, other, options, extend) { if (extend == null) { extend = doc.cm && (doc.cm.display.shift || doc.extend); } setSelection(doc, new Selection([extendRange(doc.sel.primary(), head, other, extend)], 0), options); } // Extend all selections (pos is an array of selections with length // equal the number of selections) function extendSelections(doc, heads, options) { var out = []; var extend = doc.cm && (doc.cm.display.shift || doc.extend); for (var i = 0; i < doc.sel.ranges.length; i++) { out[i] = extendRange(doc.sel.ranges[i], heads[i], null, extend); } var newSel = normalizeSelection(doc.cm, out, doc.sel.primIndex); setSelection(doc, newSel, options); } // Updates a single range in the selection. function replaceOneSelection(doc, i, range, options) { var ranges = doc.sel.ranges.slice(0); ranges[i] = range; setSelection(doc, normalizeSelection(doc.cm, ranges, doc.sel.primIndex), options); } // Reset the selection to a single range. function setSimpleSelection(doc, anchor, head, options) { setSelection(doc, simpleSelection(anchor, head), options); } // Give beforeSelectionChange handlers a change to influence a // selection update. function filterSelectionChange(doc, sel, options) { var obj = { ranges: sel.ranges, update: function(ranges) { this.ranges = []; for (var i = 0; i < ranges.length; i++) { this.ranges[i] = new Range(clipPos(doc, ranges[i].anchor), clipPos(doc, ranges[i].head)); } }, origin: options && options.origin }; signal(doc, "beforeSelectionChange", doc, obj); if (doc.cm) { signal(doc.cm, "beforeSelectionChange", doc.cm, obj); } if (obj.ranges != sel.ranges) { return normalizeSelection(doc.cm, obj.ranges, obj.ranges.length - 1) } else { return sel } } function setSelectionReplaceHistory(doc, sel, options) { var done = doc.history.done, last = lst(done); if (last && last.ranges) { done[done.length - 1] = sel; setSelectionNoUndo(doc, sel, options); } else { setSelection(doc, sel, options); } } // Set a new selection. function setSelection(doc, sel, options) { setSelectionNoUndo(doc, sel, options); addSelectionToHistory(doc, doc.sel, doc.cm ? doc.cm.curOp.id : NaN, options); } function setSelectionNoUndo(doc, sel, options) { if (hasHandler(doc, "beforeSelectionChange") || doc.cm && hasHandler(doc.cm, "beforeSelectionChange")) { sel = filterSelectionChange(doc, sel, options); } var bias = options && options.bias || (cmp(sel.primary().head, doc.sel.primary().head) < 0 ? -1 : 1); setSelectionInner(doc, skipAtomicInSelection(doc, sel, bias, true)); if (!(options && options.scroll === false) && doc.cm && doc.cm.getOption("readOnly") != "nocursor") { ensureCursorVisible(doc.cm); } } function setSelectionInner(doc, sel) { if (sel.equals(doc.sel)) { return } doc.sel = sel; if (doc.cm) { doc.cm.curOp.updateInput = 1; doc.cm.curOp.selectionChanged = true; signalCursorActivity(doc.cm); } signalLater(doc, "cursorActivity", doc); } // Verify that the selection does not partially select any atomic // marked ranges. function reCheckSelection(doc) { setSelectionInner(doc, skipAtomicInSelection(doc, doc.sel, null, false)); } // Return a selection that does not partially select any atomic // ranges. function skipAtomicInSelection(doc, sel, bias, mayClear) { var out; for (var i = 0; i < sel.ranges.length; i++) { var range = sel.ranges[i]; var old = sel.ranges.length == doc.sel.ranges.length && doc.sel.ranges[i]; var newAnchor = skipAtomic(doc, range.anchor, old && old.anchor, bias, mayClear); var newHead = skipAtomic(doc, range.head, old && old.head, bias, mayClear); if (out || newAnchor != range.anchor || newHead != range.head) { if (!out) { out = sel.ranges.slice(0, i); } out[i] = new Range(newAnchor, newHead); } } return out ? normalizeSelection(doc.cm, out, sel.primIndex) : sel } function skipAtomicInner(doc, pos, oldPos, dir, mayClear) { var line = getLine(doc, pos.line); if (line.markedSpans) { for (var i = 0; i < line.markedSpans.length; ++i) { var sp = line.markedSpans[i], m = sp.marker; // Determine if we should prevent the cursor being placed to the left/right of an atomic marker // Historically this was determined using the inclusiveLeft/Right option, but the new way to control it // is with selectLeft/Right var preventCursorLeft = ("selectLeft" in m) ? !m.selectLeft : m.inclusiveLeft; var preventCursorRight = ("selectRight" in m) ? !m.selectRight : m.inclusiveRight; if ((sp.from == null || (preventCursorLeft ? sp.from <= pos.ch : sp.from < pos.ch)) && (sp.to == null || (preventCursorRight ? sp.to >= pos.ch : sp.to > pos.ch))) { if (mayClear) { signal(m, "beforeCursorEnter"); if (m.explicitlyCleared) { if (!line.markedSpans) { break } else {--i; continue} } } if (!m.atomic) { continue } if (oldPos) { var near = m.find(dir < 0 ? 1 : -1), diff = (void 0); if (dir < 0 ? preventCursorRight : preventCursorLeft) { near = movePos(doc, near, -dir, near && near.line == pos.line ? line : null); } if (near && near.line == pos.line && (diff = cmp(near, oldPos)) && (dir < 0 ? diff < 0 : diff > 0)) { return skipAtomicInner(doc, near, pos, dir, mayClear) } } var far = m.find(dir < 0 ? -1 : 1); if (dir < 0 ? preventCursorLeft : preventCursorRight) { far = movePos(doc, far, dir, far.line == pos.line ? line : null); } return far ? skipAtomicInner(doc, far, pos, dir, mayClear) : null } } } return pos } // Ensure a given position is not inside an atomic range. function skipAtomic(doc, pos, oldPos, bias, mayClear) { var dir = bias || 1; var found = skipAtomicInner(doc, pos, oldPos, dir, mayClear) || (!mayClear && skipAtomicInner(doc, pos, oldPos, dir, true)) || skipAtomicInner(doc, pos, oldPos, -dir, mayClear) || (!mayClear && skipAtomicInner(doc, pos, oldPos, -dir, true)); if (!found) { doc.cantEdit = true; return Pos(doc.first, 0) } return found } function movePos(doc, pos, dir, line) { if (dir < 0 && pos.ch == 0) { if (pos.line > doc.first) { return clipPos(doc, Pos(pos.line - 1)) } else { return null } } else if (dir > 0 && pos.ch == (line || getLine(doc, pos.line)).text.length) { if (pos.line < doc.first + doc.size - 1) { return Pos(pos.line + 1, 0) } else { return null } } else { return new Pos(pos.line, pos.ch + dir) } } function selectAll(cm) { cm.setSelection(Pos(cm.firstLine(), 0), Pos(cm.lastLine()), sel_dontScroll); } // UPDATING // Allow "beforeChange" event handlers to influence a change function filterChange(doc, change, update) { var obj = { canceled: false, from: change.from, to: change.to, text: change.text, origin: change.origin, cancel: function () { return obj.canceled = true; } }; if (update) { obj.update = function (from, to, text, origin) { if (from) { obj.from = clipPos(doc, from); } if (to) { obj.to = clipPos(doc, to); } if (text) { obj.text = text; } if (origin !== undefined) { obj.origin = origin; } }; } signal(doc, "beforeChange", doc, obj); if (doc.cm) { signal(doc.cm, "beforeChange", doc.cm, obj); } if (obj.canceled) { if (doc.cm) { doc.cm.curOp.updateInput = 2; } return null } return {from: obj.from, to: obj.to, text: obj.text, origin: obj.origin} } // Apply a change to a document, and add it to the document's // history, and propagating it to all linked documents. function makeChange(doc, change, ignoreReadOnly) { if (doc.cm) { if (!doc.cm.curOp) { return operation(doc.cm, makeChange)(doc, change, ignoreReadOnly) } if (doc.cm.state.suppressEdits) { return } } if (hasHandler(doc, "beforeChange") || doc.cm && hasHandler(doc.cm, "beforeChange")) { change = filterChange(doc, change, true); if (!change) { return } } // Possibly split or suppress the update based on the presence // of read-only spans in its range. var split = sawReadOnlySpans && !ignoreReadOnly && removeReadOnlyRanges(doc, change.from, change.to); if (split) { for (var i = split.length - 1; i >= 0; --i) { makeChangeInner(doc, {from: split[i].from, to: split[i].to, text: i ? [""] : change.text, origin: change.origin}); } } else { makeChangeInner(doc, change); } } function makeChangeInner(doc, change) { if (change.text.length == 1 && change.text[0] == "" && cmp(change.from, change.to) == 0) { return } var selAfter = computeSelAfterChange(doc, change); addChangeToHistory(doc, change, selAfter, doc.cm ? doc.cm.curOp.id : NaN); makeChangeSingleDoc(doc, change, selAfter, stretchSpansOverChange(doc, change)); var rebased = []; linkedDocs(doc, function (doc, sharedHist) { if (!sharedHist && indexOf(rebased, doc.history) == -1) { rebaseHist(doc.history, change); rebased.push(doc.history); } makeChangeSingleDoc(doc, change, null, stretchSpansOverChange(doc, change)); }); } // Revert a change stored in a document's history. function makeChangeFromHistory(doc, type, allowSelectionOnly) { var suppress = doc.cm && doc.cm.state.suppressEdits; if (suppress && !allowSelectionOnly) { return } var hist = doc.history, event, selAfter = doc.sel; var source = type == "undo" ? hist.done : hist.undone, dest = type == "undo" ? hist.undone : hist.done; // Verify that there is a useable event (so that ctrl-z won't // needlessly clear selection events) var i = 0; for (; i < source.length; i++) { event = source[i]; if (allowSelectionOnly ? event.ranges && !event.equals(doc.sel) : !event.ranges) { break } } if (i == source.length) { return } hist.lastOrigin = hist.lastSelOrigin = null; for (;;) { event = source.pop(); if (event.ranges) { pushSelectionToHistory(event, dest); if (allowSelectionOnly && !event.equals(doc.sel)) { setSelection(doc, event, {clearRedo: false}); return } selAfter = event; } else if (suppress) { source.push(event); return } else { break } } // Build up a reverse change object to add to the opposite history // stack (redo when undoing, and vice versa). var antiChanges = []; pushSelectionToHistory(selAfter, dest); dest.push({changes: antiChanges, generation: hist.generation}); hist.generation = event.generation || ++hist.maxGeneration; var filter = hasHandler(doc, "beforeChange") || doc.cm && hasHandler(doc.cm, "beforeChange"); var loop = function ( i ) { var change = event.changes[i]; change.origin = type; if (filter && !filterChange(doc, change, false)) { source.length = 0; return {} } antiChanges.push(historyChangeFromChange(doc, change)); var after = i ? computeSelAfterChange(doc, change) : lst(source); makeChangeSingleDoc(doc, change, after, mergeOldSpans(doc, change)); if (!i && doc.cm) { doc.cm.scrollIntoView({from: change.from, to: changeEnd(change)}); } var rebased = []; // Propagate to the linked documents linkedDocs(doc, function (doc, sharedHist) { if (!sharedHist && indexOf(rebased, doc.history) == -1) { rebaseHist(doc.history, change); rebased.push(doc.history); } makeChangeSingleDoc(doc, change, null, mergeOldSpans(doc, change)); }); }; for (var i$1 = event.changes.length - 1; i$1 >= 0; --i$1) { var returned = loop( i$1 ); if ( returned ) return returned.v; } } // Sub-views need their line numbers shifted when text is added // above or below them in the parent document. function shiftDoc(doc, distance) { if (distance == 0) { return } doc.first += distance; doc.sel = new Selection(map(doc.sel.ranges, function (range) { return new Range( Pos(range.anchor.line + distance, range.anchor.ch), Pos(range.head.line + distance, range.head.ch) ); }), doc.sel.primIndex); if (doc.cm) { regChange(doc.cm, doc.first, doc.first - distance, distance); for (var d = doc.cm.display, l = d.viewFrom; l < d.viewTo; l++) { regLineChange(doc.cm, l, "gutter"); } } } // More lower-level change function, handling only a single document // (not linked ones). function makeChangeSingleDoc(doc, change, selAfter, spans) { if (doc.cm && !doc.cm.curOp) { return operation(doc.cm, makeChangeSingleDoc)(doc, change, selAfter, spans) } if (change.to.line < doc.first) { shiftDoc(doc, change.text.length - 1 - (change.to.line - change.from.line)); return } if (change.from.line > doc.lastLine()) { return } // Clip the change to the size of this doc if (change.from.line < doc.first) { var shift = change.text.length - 1 - (doc.first - change.from.line); shiftDoc(doc, shift); change = {from: Pos(doc.first, 0), to: Pos(change.to.line + shift, change.to.ch), text: [lst(change.text)], origin: change.origin}; } var last = doc.lastLine(); if (change.to.line > last) { change = {from: change.from, to: Pos(last, getLine(doc, last).text.length), text: [change.text[0]], origin: change.origin}; } change.removed = getBetween(doc, change.from, change.to); if (!selAfter) { selAfter = computeSelAfterChange(doc, change); } if (doc.cm) { makeChangeSingleDocInEditor(doc.cm, change, spans); } else { updateDoc(doc, change, spans); } setSelectionNoUndo(doc, selAfter, sel_dontScroll); if (doc.cantEdit && skipAtomic(doc, Pos(doc.firstLine(), 0))) { doc.cantEdit = false; } } // Handle the interaction of a change to a document with the editor // that this document is part of. function makeChangeSingleDocInEditor(cm, change, spans) { var doc = cm.doc, display = cm.display, from = change.from, to = change.to; var recomputeMaxLength = false, checkWidthStart = from.line; if (!cm.options.lineWrapping) { checkWidthStart = lineNo(visualLine(getLine(doc, from.line))); doc.iter(checkWidthStart, to.line + 1, function (line) { if (line == display.maxLine) { recomputeMaxLength = true; return true } }); } if (doc.sel.contains(change.from, change.to) > -1) { signalCursorActivity(cm); } updateDoc(doc, change, spans, estimateHeight(cm)); if (!cm.options.lineWrapping) { doc.iter(checkWidthStart, from.line + change.text.length, function (line) { var len = lineLength(line); if (len > display.maxLineLength) { display.maxLine = line; display.maxLineLength = len; display.maxLineChanged = true; recomputeMaxLength = false; } }); if (recomputeMaxLength) { cm.curOp.updateMaxLine = true; } } retreatFrontier(doc, from.line); startWorker(cm, 400); var lendiff = change.text.length - (to.line - from.line) - 1; // Remember that these lines changed, for updating the display if (change.full) { regChange(cm); } else if (from.line == to.line && change.text.length == 1 && !isWholeLineUpdate(cm.doc, change)) { regLineChange(cm, from.line, "text"); } else { regChange(cm, from.line, to.line + 1, lendiff); } var changesHandler = hasHandler(cm, "changes"), changeHandler = hasHandler(cm, "change"); if (changeHandler || changesHandler) { var obj = { from: from, to: to, text: change.text, removed: change.removed, origin: change.origin }; if (changeHandler) { signalLater(cm, "change", cm, obj); } if (changesHandler) { (cm.curOp.changeObjs || (cm.curOp.changeObjs = [])).push(obj); } } cm.display.selForContextMenu = null; } function replaceRange(doc, code, from, to, origin) { var assign; if (!to) { to = from; } if (cmp(to, from) < 0) { (assign = [to, from], from = assign[0], to = assign[1]); } if (typeof code == "string") { code = doc.splitLines(code); } makeChange(doc, {from: from, to: to, text: code, origin: origin}); } // Rebasing/resetting history to deal with externally-sourced changes function rebaseHistSelSingle(pos, from, to, diff) { if (to < pos.line) { pos.line += diff; } else if (from < pos.line) { pos.line = from; pos.ch = 0; } } // Tries to rebase an array of history events given a change in the // document. If the change touches the same lines as the event, the // event, and everything 'behind' it, is discarded. If the change is // before the event, the event's positions are updated. Uses a // copy-on-write scheme for the positions, to avoid having to // reallocate them all on every rebase, but also avoid problems with // shared position objects being unsafely updated. function rebaseHistArray(array, from, to, diff) { for (var i = 0; i < array.length; ++i) { var sub = array[i], ok = true; if (sub.ranges) { if (!sub.copied) { sub = array[i] = sub.deepCopy(); sub.copied = true; } for (var j = 0; j < sub.ranges.length; j++) { rebaseHistSelSingle(sub.ranges[j].anchor, from, to, diff); rebaseHistSelSingle(sub.ranges[j].head, from, to, diff); } continue } for (var j$1 = 0; j$1 < sub.changes.length; ++j$1) { var cur = sub.changes[j$1]; if (to < cur.from.line) { cur.from = Pos(cur.from.line + diff, cur.from.ch); cur.to = Pos(cur.to.line + diff, cur.to.ch); } else if (from <= cur.to.line) { ok = false; break } } if (!ok) { array.splice(0, i + 1); i = 0; } } } function rebaseHist(hist, change) { var from = change.from.line, to = change.to.line, diff = change.text.length - (to - from) - 1; rebaseHistArray(hist.done, from, to, diff); rebaseHistArray(hist.undone, from, to, diff); } // Utility for applying a change to a line by handle or number, // returning the number and optionally registering the line as // changed. function changeLine(doc, handle, changeType, op) { var no = handle, line = handle; if (typeof handle == "number") { line = getLine(doc, clipLine(doc, handle)); } else { no = lineNo(handle); } if (no == null) { return null } if (op(line, no) && doc.cm) { regLineChange(doc.cm, no, changeType); } return line } // The document is represented as a BTree consisting of leaves, with // chunk of lines in them, and branches, with up to ten leaves or // other branch nodes below them. The top node is always a branch // node, and is the document object itself (meaning it has // additional methods and properties). // // All nodes have parent links. The tree is used both to go from // line numbers to line objects, and to go from objects to numbers. // It also indexes by height, and is used to convert between height // and line object, and to find the total height of the document. // // See also http://marijnhaverbeke.nl/blog/codemirror-line-tree.html function LeafChunk(lines) { this.lines = lines; this.parent = null; var height = 0; for (var i = 0; i < lines.length; ++i) { lines[i].parent = this; height += lines[i].height; } this.height = height; } LeafChunk.prototype = { chunkSize: function() { return this.lines.length }, // Remove the n lines at offset 'at'. removeInner: function(at, n) { for (var i = at, e = at + n; i < e; ++i) { var line = this.lines[i]; this.height -= line.height; cleanUpLine(line); signalLater(line, "delete"); } this.lines.splice(at, n); }, // Helper used to collapse a small branch into a single leaf. collapse: function(lines) { lines.push.apply(lines, this.lines); }, // Insert the given array of lines at offset 'at', count them as // having the given height. insertInner: function(at, lines, height) { this.height += height; this.lines = this.lines.slice(0, at).concat(lines).concat(this.lines.slice(at)); for (var i = 0; i < lines.length; ++i) { lines[i].parent = this; } }, // Used to iterate over a part of the tree. iterN: function(at, n, op) { for (var e = at + n; at < e; ++at) { if (op(this.lines[at])) { return true } } } }; function BranchChunk(children) { this.children = children; var size = 0, height = 0; for (var i = 0; i < children.length; ++i) { var ch = children[i]; size += ch.chunkSize(); height += ch.height; ch.parent = this; } this.size = size; this.height = height; this.parent = null; } BranchChunk.prototype = { chunkSize: function() { return this.size }, removeInner: function(at, n) { this.size -= n; for (var i = 0; i < this.children.length; ++i) { var child = this.children[i], sz = child.chunkSize(); if (at < sz) { var rm = Math.min(n, sz - at), oldHeight = child.height; child.removeInner(at, rm); this.height -= oldHeight - child.height; if (sz == rm) { this.children.splice(i--, 1); child.parent = null; } if ((n -= rm) == 0) { break } at = 0; } else { at -= sz; } } // If the result is smaller than 25 lines, ensure that it is a // single leaf node. if (this.size - n < 25 && (this.children.length > 1 || !(this.children[0] instanceof LeafChunk))) { var lines = []; this.collapse(lines); this.children = [new LeafChunk(lines)]; this.children[0].parent = this; } }, collapse: function(lines) { for (var i = 0; i < this.children.length; ++i) { this.children[i].collapse(lines); } }, insertInner: function(at, lines, height) { this.size += lines.length; this.height += height; for (var i = 0; i < this.children.length; ++i) { var child = this.children[i], sz = child.chunkSize(); if (at <= sz) { child.insertInner(at, lines, height); if (child.lines && child.lines.length > 50) { // To avoid memory thrashing when child.lines is huge (e.g. first view of a large file), it's never spliced. // Instead, small slices are taken. They're taken in order because sequential memory accesses are fastest. var remaining = child.lines.length % 25 + 25; for (var pos = remaining; pos < child.lines.length;) { var leaf = new LeafChunk(child.lines.slice(pos, pos += 25)); child.height -= leaf.height; this.children.splice(++i, 0, leaf); leaf.parent = this; } child.lines = child.lines.slice(0, remaining); this.maybeSpill(); } break } at -= sz; } }, // When a node has grown, check whether it should be split. maybeSpill: function() { if (this.children.length <= 10) { return } var me = this; do { var spilled = me.children.splice(me.children.length - 5, 5); var sibling = new BranchChunk(spilled); if (!me.parent) { // Become the parent node var copy = new BranchChunk(me.children); copy.parent = me; me.children = [copy, sibling]; me = copy; } else { me.size -= sibling.size; me.height -= sibling.height; var myIndex = indexOf(me.parent.children, me); me.parent.children.splice(myIndex + 1, 0, sibling); } sibling.parent = me.parent; } while (me.children.length > 10) me.parent.maybeSpill(); }, iterN: function(at, n, op) { for (var i = 0; i < this.children.length; ++i) { var child = this.children[i], sz = child.chunkSize(); if (at < sz) { var used = Math.min(n, sz - at); if (child.iterN(at, used, op)) { return true } if ((n -= used) == 0) { break } at = 0; } else { at -= sz; } } } }; // Line widgets are block elements displayed above or below a line. var LineWidget = function(doc, node, options) { if (options) { for (var opt in options) { if (options.hasOwnProperty(opt)) { this[opt] = options[opt]; } } } this.doc = doc; this.node = node; }; LineWidget.prototype.clear = function () { var cm = this.doc.cm, ws = this.line.widgets, line = this.line, no = lineNo(line); if (no == null || !ws) { return } for (var i = 0; i < ws.length; ++i) { if (ws[i] == this) { ws.splice(i--, 1); } } if (!ws.length) { line.widgets = null; } var height = widgetHeight(this); updateLineHeight(line, Math.max(0, line.height - height)); if (cm) { runInOp(cm, function () { adjustScrollWhenAboveVisible(cm, line, -height); regLineChange(cm, no, "widget"); }); signalLater(cm, "lineWidgetCleared", cm, this, no); } }; LineWidget.prototype.changed = function () { var this$1 = this; var oldH = this.height, cm = this.doc.cm, line = this.line; this.height = null; var diff = widgetHeight(this) - oldH; if (!diff) { return } if (!lineIsHidden(this.doc, line)) { updateLineHeight(line, line.height + diff); } if (cm) { runInOp(cm, function () { cm.curOp.forceUpdate = true; adjustScrollWhenAboveVisible(cm, line, diff); signalLater(cm, "lineWidgetChanged", cm, this$1, lineNo(line)); }); } }; eventMixin(LineWidget); function adjustScrollWhenAboveVisible(cm, line, diff) { if (heightAtLine(line) < ((cm.curOp && cm.curOp.scrollTop) || cm.doc.scrollTop)) { addToScrollTop(cm, diff); } } function addLineWidget(doc, handle, node, options) { var widget = new LineWidget(doc, node, options); var cm = doc.cm; if (cm && widget.noHScroll) { cm.display.alignWidgets = true; } changeLine(doc, handle, "widget", function (line) { var widgets = line.widgets || (line.widgets = []); if (widget.insertAt == null) { widgets.push(widget); } else { widgets.splice(Math.min(widgets.length, Math.max(0, widget.insertAt)), 0, widget); } widget.line = line; if (cm && !lineIsHidden(doc, line)) { var aboveVisible = heightAtLine(line) < doc.scrollTop; updateLineHeight(line, line.height + widgetHeight(widget)); if (aboveVisible) { addToScrollTop(cm, widget.height); } cm.curOp.forceUpdate = true; } return true }); if (cm) { signalLater(cm, "lineWidgetAdded", cm, widget, typeof handle == "number" ? handle : lineNo(handle)); } return widget } // TEXTMARKERS // Created with markText and setBookmark methods. A TextMarker is a // handle that can be used to clear or find a marked position in the // document. Line objects hold arrays (markedSpans) containing // {from, to, marker} object pointing to such marker objects, and // indicating that such a marker is present on that line. Multiple // lines may point to the same marker when it spans across lines. // The spans will have null for their from/to properties when the // marker continues beyond the start/end of the line. Markers have // links back to the lines they currently touch. // Collapsed markers have unique ids, in order to be able to order // them, which is needed for uniquely determining an outer marker // when they overlap (they may nest, but not partially overlap). var nextMarkerId = 0; var TextMarker = function(doc, type) { this.lines = []; this.type = type; this.doc = doc; this.id = ++nextMarkerId; }; // Clear the marker. TextMarker.prototype.clear = function () { if (this.explicitlyCleared) { return } var cm = this.doc.cm, withOp = cm && !cm.curOp; if (withOp) { startOperation(cm); } if (hasHandler(this, "clear")) { var found = this.find(); if (found) { signalLater(this, "clear", found.from, found.to); } } var min = null, max = null; for (var i = 0; i < this.lines.length; ++i) { var line = this.lines[i]; var span = getMarkedSpanFor(line.markedSpans, this); if (cm && !this.collapsed) { regLineChange(cm, lineNo(line), "text"); } else if (cm) { if (span.to != null) { max = lineNo(line); } if (span.from != null) { min = lineNo(line); } } line.markedSpans = removeMarkedSpan(line.markedSpans, span); if (span.from == null && this.collapsed && !lineIsHidden(this.doc, line) && cm) { updateLineHeight(line, textHeight(cm.display)); } } if (cm && this.collapsed && !cm.options.lineWrapping) { for (var i$1 = 0; i$1 < this.lines.length; ++i$1) { var visual = visualLine(this.lines[i$1]), len = lineLength(visual); if (len > cm.display.maxLineLength) { cm.display.maxLine = visual; cm.display.maxLineLength = len; cm.display.maxLineChanged = true; } } } if (min != null && cm && this.collapsed) { regChange(cm, min, max + 1); } this.lines.length = 0; this.explicitlyCleared = true; if (this.atomic && this.doc.cantEdit) { this.doc.cantEdit = false; if (cm) { reCheckSelection(cm.doc); } } if (cm) { signalLater(cm, "markerCleared", cm, this, min, max); } if (withOp) { endOperation(cm); } if (this.parent) { this.parent.clear(); } }; // Find the position of the marker in the document. Returns a {from, // to} object by default. Side can be passed to get a specific side // -- 0 (both), -1 (left), or 1 (right). When lineObj is true, the // Pos objects returned contain a line object, rather than a line // number (used to prevent looking up the same line twice). TextMarker.prototype.find = function (side, lineObj) { if (side == null && this.type == "bookmark") { side = 1; } var from, to; for (var i = 0; i < this.lines.length; ++i) { var line = this.lines[i]; var span = getMarkedSpanFor(line.markedSpans, this); if (span.from != null) { from = Pos(lineObj ? line : lineNo(line), span.from); if (side == -1) { return from } } if (span.to != null) { to = Pos(lineObj ? line : lineNo(line), span.to); if (side == 1) { return to } } } return from && {from: from, to: to} }; // Signals that the marker's widget changed, and surrounding layout // should be recomputed. TextMarker.prototype.changed = function () { var this$1 = this; var pos = this.find(-1, true), widget = this, cm = this.doc.cm; if (!pos || !cm) { return } runInOp(cm, function () { var line = pos.line, lineN = lineNo(pos.line); var view = findViewForLine(cm, lineN); if (view) { clearLineMeasurementCacheFor(view); cm.curOp.selectionChanged = cm.curOp.forceUpdate = true; } cm.curOp.updateMaxLine = true; if (!lineIsHidden(widget.doc, line) && widget.height != null) { var oldHeight = widget.height; widget.height = null; var dHeight = widgetHeight(widget) - oldHeight; if (dHeight) { updateLineHeight(line, line.height + dHeight); } } signalLater(cm, "markerChanged", cm, this$1); }); }; TextMarker.prototype.attachLine = function (line) { if (!this.lines.length && this.doc.cm) { var op = this.doc.cm.curOp; if (!op.maybeHiddenMarkers || indexOf(op.maybeHiddenMarkers, this) == -1) { (op.maybeUnhiddenMarkers || (op.maybeUnhiddenMarkers = [])).push(this); } } this.lines.push(line); }; TextMarker.prototype.detachLine = function (line) { this.lines.splice(indexOf(this.lines, line), 1); if (!this.lines.length && this.doc.cm) { var op = this.doc.cm.curOp ;(op.maybeHiddenMarkers || (op.maybeHiddenMarkers = [])).push(this); } }; eventMixin(TextMarker); // Create a marker, wire it up to the right lines, and function markText(doc, from, to, options, type) { // Shared markers (across linked documents) are handled separately // (markTextShared will call out to this again, once per // document). if (options && options.shared) { return markTextShared(doc, from, to, options, type) } // Ensure we are in an operation. if (doc.cm && !doc.cm.curOp) { return operation(doc.cm, markText)(doc, from, to, options, type) } var marker = new TextMarker(doc, type), diff = cmp(from, to); if (options) { copyObj(options, marker, false); } // Don't connect empty markers unless clearWhenEmpty is false if (diff > 0 || diff == 0 && marker.clearWhenEmpty !== false) { return marker } if (marker.replacedWith) { // Showing up as a widget implies collapsed (widget replaces text) marker.collapsed = true; marker.widgetNode = eltP("span", [marker.replacedWith], "CodeMirror-widget"); if (!options.handleMouseEvents) { marker.widgetNode.setAttribute("cm-ignore-events", "true"); } if (options.insertLeft) { marker.widgetNode.insertLeft = true; } } if (marker.collapsed) { if (conflictingCollapsedRange(doc, from.line, from, to, marker) || from.line != to.line && conflictingCollapsedRange(doc, to.line, from, to, marker)) { throw new Error("Inserting collapsed marker partially overlapping an existing one") } seeCollapsedSpans(); } if (marker.addToHistory) { addChangeToHistory(doc, {from: from, to: to, origin: "markText"}, doc.sel, NaN); } var curLine = from.line, cm = doc.cm, updateMaxLine; doc.iter(curLine, to.line + 1, function (line) { if (cm && marker.collapsed && !cm.options.lineWrapping && visualLine(line) == cm.display.maxLine) { updateMaxLine = true; } if (marker.collapsed && curLine != from.line) { updateLineHeight(line, 0); } addMarkedSpan(line, new MarkedSpan(marker, curLine == from.line ? from.ch : null, curLine == to.line ? to.ch : null)); ++curLine; }); // lineIsHidden depends on the presence of the spans, so needs a second pass if (marker.collapsed) { doc.iter(from.line, to.line + 1, function (line) { if (lineIsHidden(doc, line)) { updateLineHeight(line, 0); } }); } if (marker.clearOnEnter) { on(marker, "beforeCursorEnter", function () { return marker.clear(); }); } if (marker.readOnly) { seeReadOnlySpans(); if (doc.history.done.length || doc.history.undone.length) { doc.clearHistory(); } } if (marker.collapsed) { marker.id = ++nextMarkerId; marker.atomic = true; } if (cm) { // Sync editor state if (updateMaxLine) { cm.curOp.updateMaxLine = true; } if (marker.collapsed) { regChange(cm, from.line, to.line + 1); } else if (marker.className || marker.startStyle || marker.endStyle || marker.css || marker.attributes || marker.title) { for (var i = from.line; i <= to.line; i++) { regLineChange(cm, i, "text"); } } if (marker.atomic) { reCheckSelection(cm.doc); } signalLater(cm, "markerAdded", cm, marker); } return marker } // SHARED TEXTMARKERS // A shared marker spans multiple linked documents. It is // implemented as a meta-marker-object controlling multiple normal // markers. var SharedTextMarker = function(markers, primary) { this.markers = markers; this.primary = primary; for (var i = 0; i < markers.length; ++i) { markers[i].parent = this; } }; SharedTextMarker.prototype.clear = function () { if (this.explicitlyCleared) { return } this.explicitlyCleared = true; for (var i = 0; i < this.markers.length; ++i) { this.markers[i].clear(); } signalLater(this, "clear"); }; SharedTextMarker.prototype.find = function (side, lineObj) { return this.primary.find(side, lineObj) }; eventMixin(SharedTextMarker); function markTextShared(doc, from, to, options, type) { options = copyObj(options); options.shared = false; var markers = [markText(doc, from, to, options, type)], primary = markers[0]; var widget = options.widgetNode; linkedDocs(doc, function (doc) { if (widget) { options.widgetNode = widget.cloneNode(true); } markers.push(markText(doc, clipPos(doc, from), clipPos(doc, to), options, type)); for (var i = 0; i < doc.linked.length; ++i) { if (doc.linked[i].isParent) { return } } primary = lst(markers); }); return new SharedTextMarker(markers, primary) } function findSharedMarkers(doc) { return doc.findMarks(Pos(doc.first, 0), doc.clipPos(Pos(doc.lastLine())), function (m) { return m.parent; }) } function copySharedMarkers(doc, markers) { for (var i = 0; i < markers.length; i++) { var marker = markers[i], pos = marker.find(); var mFrom = doc.clipPos(pos.from), mTo = doc.clipPos(pos.to); if (cmp(mFrom, mTo)) { var subMark = markText(doc, mFrom, mTo, marker.primary, marker.primary.type); marker.markers.push(subMark); subMark.parent = marker; } } } function detachSharedMarkers(markers) { var loop = function ( i ) { var marker = markers[i], linked = [marker.primary.doc]; linkedDocs(marker.primary.doc, function (d) { return linked.push(d); }); for (var j = 0; j < marker.markers.length; j++) { var subMarker = marker.markers[j]; if (indexOf(linked, subMarker.doc) == -1) { subMarker.parent = null; marker.markers.splice(j--, 1); } } }; for (var i = 0; i < markers.length; i++) loop( i ); } var nextDocId = 0; var Doc = function(text, mode, firstLine, lineSep, direction) { if (!(this instanceof Doc)) { return new Doc(text, mode, firstLine, lineSep, direction) } if (firstLine == null) { firstLine = 0; } BranchChunk.call(this, [new LeafChunk([new Line("", null)])]); this.first = firstLine; this.scrollTop = this.scrollLeft = 0; this.cantEdit = false; this.cleanGeneration = 1; this.modeFrontier = this.highlightFrontier = firstLine; var start = Pos(firstLine, 0); this.sel = simpleSelection(start); this.history = new History(null); this.id = ++nextDocId; this.modeOption = mode; this.lineSep = lineSep; this.direction = (direction == "rtl") ? "rtl" : "ltr"; this.extend = false; if (typeof text == "string") { text = this.splitLines(text); } updateDoc(this, {from: start, to: start, text: text}); setSelection(this, simpleSelection(start), sel_dontScroll); }; Doc.prototype = createObj(BranchChunk.prototype, { constructor: Doc, // Iterate over the document. Supports two forms -- with only one // argument, it calls that for each line in the document. With // three, it iterates over the range given by the first two (with // the second being non-inclusive). iter: function(from, to, op) { if (op) { this.iterN(from - this.first, to - from, op); } else { this.iterN(this.first, this.first + this.size, from); } }, // Non-public interface for adding and removing lines. insert: function(at, lines) { var height = 0; for (var i = 0; i < lines.length; ++i) { height += lines[i].height; } this.insertInner(at - this.first, lines, height); }, remove: function(at, n) { this.removeInner(at - this.first, n); }, // From here, the methods are part of the public interface. Most // are also available from CodeMirror (editor) instances. getValue: function(lineSep) { var lines = getLines(this, this.first, this.first + this.size); if (lineSep === false) { return lines } return lines.join(lineSep || this.lineSeparator()) }, setValue: docMethodOp(function(code) { var top = Pos(this.first, 0), last = this.first + this.size - 1; makeChange(this, {from: top, to: Pos(last, getLine(this, last).text.length), text: this.splitLines(code), origin: "setValue", full: true}, true); if (this.cm) { scrollToCoords(this.cm, 0, 0); } setSelection(this, simpleSelection(top), sel_dontScroll); }), replaceRange: function(code, from, to, origin) { from = clipPos(this, from); to = to ? clipPos(this, to) : from; replaceRange(this, code, from, to, origin); }, getRange: function(from, to, lineSep) { var lines = getBetween(this, clipPos(this, from), clipPos(this, to)); if (lineSep === false) { return lines } return lines.join(lineSep || this.lineSeparator()) }, getLine: function(line) {var l = this.getLineHandle(line); return l && l.text}, getLineHandle: function(line) {if (isLine(this, line)) { return getLine(this, line) }}, getLineNumber: function(line) {return lineNo(line)}, getLineHandleVisualStart: function(line) { if (typeof line == "number") { line = getLine(this, line); } return visualLine(line) }, lineCount: function() {return this.size}, firstLine: function() {return this.first}, lastLine: function() {return this.first + this.size - 1}, clipPos: function(pos) {return clipPos(this, pos)}, getCursor: function(start) { var range = this.sel.primary(), pos; if (start == null || start == "head") { pos = range.head; } else if (start == "anchor") { pos = range.anchor; } else if (start == "end" || start == "to" || start === false) { pos = range.to(); } else { pos = range.from(); } return pos }, listSelections: function() { return this.sel.ranges }, somethingSelected: function() {return this.sel.somethingSelected()}, setCursor: docMethodOp(function(line, ch, options) { setSimpleSelection(this, clipPos(this, typeof line == "number" ? Pos(line, ch || 0) : line), null, options); }), setSelection: docMethodOp(function(anchor, head, options) { setSimpleSelection(this, clipPos(this, anchor), clipPos(this, head || anchor), options); }), extendSelection: docMethodOp(function(head, other, options) { extendSelection(this, clipPos(this, head), other && clipPos(this, other), options); }), extendSelections: docMethodOp(function(heads, options) { extendSelections(this, clipPosArray(this, heads), options); }), extendSelectionsBy: docMethodOp(function(f, options) { var heads = map(this.sel.ranges, f); extendSelections(this, clipPosArray(this, heads), options); }), setSelections: docMethodOp(function(ranges, primary, options) { if (!ranges.length) { return } var out = []; for (var i = 0; i < ranges.length; i++) { out[i] = new Range(clipPos(this, ranges[i].anchor), clipPos(this, ranges[i].head)); } if (primary == null) { primary = Math.min(ranges.length - 1, this.sel.primIndex); } setSelection(this, normalizeSelection(this.cm, out, primary), options); }), addSelection: docMethodOp(function(anchor, head, options) { var ranges = this.sel.ranges.slice(0); ranges.push(new Range(clipPos(this, anchor), clipPos(this, head || anchor))); setSelection(this, normalizeSelection(this.cm, ranges, ranges.length - 1), options); }), getSelection: function(lineSep) { var ranges = this.sel.ranges, lines; for (var i = 0; i < ranges.length; i++) { var sel = getBetween(this, ranges[i].from(), ranges[i].to()); lines = lines ? lines.concat(sel) : sel; } if (lineSep === false) { return lines } else { return lines.join(lineSep || this.lineSeparator()) } }, getSelections: function(lineSep) { var parts = [], ranges = this.sel.ranges; for (var i = 0; i < ranges.length; i++) { var sel = getBetween(this, ranges[i].from(), ranges[i].to()); if (lineSep !== false) { sel = sel.join(lineSep || this.lineSeparator()); } parts[i] = sel; } return parts }, replaceSelection: function(code, collapse, origin) { var dup = []; for (var i = 0; i < this.sel.ranges.length; i++) { dup[i] = code; } this.replaceSelections(dup, collapse, origin || "+input"); }, replaceSelections: docMethodOp(function(code, collapse, origin) { var changes = [], sel = this.sel; for (var i = 0; i < sel.ranges.length; i++) { var range = sel.ranges[i]; changes[i] = {from: range.from(), to: range.to(), text: this.splitLines(code[i]), origin: origin}; } var newSel = collapse && collapse != "end" && computeReplacedSel(this, changes, collapse); for (var i$1 = changes.length - 1; i$1 >= 0; i$1--) { makeChange(this, changes[i$1]); } if (newSel) { setSelectionReplaceHistory(this, newSel); } else if (this.cm) { ensureCursorVisible(this.cm); } }), undo: docMethodOp(function() {makeChangeFromHistory(this, "undo");}), redo: docMethodOp(function() {makeChangeFromHistory(this, "redo");}), undoSelection: docMethodOp(function() {makeChangeFromHistory(this, "undo", true);}), redoSelection: docMethodOp(function() {makeChangeFromHistory(this, "redo", true);}), setExtending: function(val) {this.extend = val;}, getExtending: function() {return this.extend}, historySize: function() { var hist = this.history, done = 0, undone = 0; for (var i = 0; i < hist.done.length; i++) { if (!hist.done[i].ranges) { ++done; } } for (var i$1 = 0; i$1 < hist.undone.length; i$1++) { if (!hist.undone[i$1].ranges) { ++undone; } } return {undo: done, redo: undone} }, clearHistory: function() { var this$1 = this; this.history = new History(this.history.maxGeneration); linkedDocs(this, function (doc) { return doc.history = this$1.history; }, true); }, markClean: function() { this.cleanGeneration = this.changeGeneration(true); }, changeGeneration: function(forceSplit) { if (forceSplit) { this.history.lastOp = this.history.lastSelOp = this.history.lastOrigin = null; } return this.history.generation }, isClean: function (gen) { return this.history.generation == (gen || this.cleanGeneration) }, getHistory: function() { return {done: copyHistoryArray(this.history.done), undone: copyHistoryArray(this.history.undone)} }, setHistory: function(histData) { var hist = this.history = new History(this.history.maxGeneration); hist.done = copyHistoryArray(histData.done.slice(0), null, true); hist.undone = copyHistoryArray(histData.undone.slice(0), null, true); }, setGutterMarker: docMethodOp(function(line, gutterID, value) { return changeLine(this, line, "gutter", function (line) { var markers = line.gutterMarkers || (line.gutterMarkers = {}); markers[gutterID] = value; if (!value && isEmpty(markers)) { line.gutterMarkers = null; } return true }) }), clearGutter: docMethodOp(function(gutterID) { var this$1 = this; this.iter(function (line) { if (line.gutterMarkers && line.gutterMarkers[gutterID]) { changeLine(this$1, line, "gutter", function () { line.gutterMarkers[gutterID] = null; if (isEmpty(line.gutterMarkers)) { line.gutterMarkers = null; } return true }); } }); }), lineInfo: function(line) { var n; if (typeof line == "number") { if (!isLine(this, line)) { return null } n = line; line = getLine(this, line); if (!line) { return null } } else { n = lineNo(line); if (n == null) { return null } } return {line: n, handle: line, text: line.text, gutterMarkers: line.gutterMarkers, textClass: line.textClass, bgClass: line.bgClass, wrapClass: line.wrapClass, widgets: line.widgets} }, addLineClass: docMethodOp(function(handle, where, cls) { return changeLine(this, handle, where == "gutter" ? "gutter" : "class", function (line) { var prop = where == "text" ? "textClass" : where == "background" ? "bgClass" : where == "gutter" ? "gutterClass" : "wrapClass"; if (!line[prop]) { line[prop] = cls; } else if (classTest(cls).test(line[prop])) { return false } else { line[prop] += " " + cls; } return true }) }), removeLineClass: docMethodOp(function(handle, where, cls) { return changeLine(this, handle, where == "gutter" ? "gutter" : "class", function (line) { var prop = where == "text" ? "textClass" : where == "background" ? "bgClass" : where == "gutter" ? "gutterClass" : "wrapClass"; var cur = line[prop]; if (!cur) { return false } else if (cls == null) { line[prop] = null; } else { var found = cur.match(classTest(cls)); if (!found) { return false } var end = found.index + found[0].length; line[prop] = cur.slice(0, found.index) + (!found.index || end == cur.length ? "" : " ") + cur.slice(end) || null; } return true }) }), addLineWidget: docMethodOp(function(handle, node, options) { return addLineWidget(this, handle, node, options) }), removeLineWidget: function(widget) { widget.clear(); }, markText: function(from, to, options) { return markText(this, clipPos(this, from), clipPos(this, to), options, options && options.type || "range") }, setBookmark: function(pos, options) { var realOpts = {replacedWith: options && (options.nodeType == null ? options.widget : options), insertLeft: options && options.insertLeft, clearWhenEmpty: false, shared: options && options.shared, handleMouseEvents: options && options.handleMouseEvents}; pos = clipPos(this, pos); return markText(this, pos, pos, realOpts, "bookmark") }, findMarksAt: function(pos) { pos = clipPos(this, pos); var markers = [], spans = getLine(this, pos.line).markedSpans; if (spans) { for (var i = 0; i < spans.length; ++i) { var span = spans[i]; if ((span.from == null || span.from <= pos.ch) && (span.to == null || span.to >= pos.ch)) { markers.push(span.marker.parent || span.marker); } } } return markers }, findMarks: function(from, to, filter) { from = clipPos(this, from); to = clipPos(this, to); var found = [], lineNo = from.line; this.iter(from.line, to.line + 1, function (line) { var spans = line.markedSpans; if (spans) { for (var i = 0; i < spans.length; i++) { var span = spans[i]; if (!(span.to != null && lineNo == from.line && from.ch >= span.to || span.from == null && lineNo != from.line || span.from != null && lineNo == to.line && span.from >= to.ch) && (!filter || filter(span.marker))) { found.push(span.marker.parent || span.marker); } } } ++lineNo; }); return found }, getAllMarks: function() { var markers = []; this.iter(function (line) { var sps = line.markedSpans; if (sps) { for (var i = 0; i < sps.length; ++i) { if (sps[i].from != null) { markers.push(sps[i].marker); } } } }); return markers }, posFromIndex: function(off) { var ch, lineNo = this.first, sepSize = this.lineSeparator().length; this.iter(function (line) { var sz = line.text.length + sepSize; if (sz > off) { ch = off; return true } off -= sz; ++lineNo; }); return clipPos(this, Pos(lineNo, ch)) }, indexFromPos: function (coords) { coords = clipPos(this, coords); var index = coords.ch; if (coords.line < this.first || coords.ch < 0) { return 0 } var sepSize = this.lineSeparator().length; this.iter(this.first, coords.line, function (line) { // iter aborts when callback returns a truthy value index += line.text.length + sepSize; }); return index }, copy: function(copyHistory) { var doc = new Doc(getLines(this, this.first, this.first + this.size), this.modeOption, this.first, this.lineSep, this.direction); doc.scrollTop = this.scrollTop; doc.scrollLeft = this.scrollLeft; doc.sel = this.sel; doc.extend = false; if (copyHistory) { doc.history.undoDepth = this.history.undoDepth; doc.setHistory(this.getHistory()); } return doc }, linkedDoc: function(options) { if (!options) { options = {}; } var from = this.first, to = this.first + this.size; if (options.from != null && options.from > from) { from = options.from; } if (options.to != null && options.to < to) { to = options.to; } var copy = new Doc(getLines(this, from, to), options.mode || this.modeOption, from, this.lineSep, this.direction); if (options.sharedHist) { copy.history = this.history ; }(this.linked || (this.linked = [])).push({doc: copy, sharedHist: options.sharedHist}); copy.linked = [{doc: this, isParent: true, sharedHist: options.sharedHist}]; copySharedMarkers(copy, findSharedMarkers(this)); return copy }, unlinkDoc: function(other) { if (other instanceof CodeMirror) { other = other.doc; } if (this.linked) { for (var i = 0; i < this.linked.length; ++i) { var link = this.linked[i]; if (link.doc != other) { continue } this.linked.splice(i, 1); other.unlinkDoc(this); detachSharedMarkers(findSharedMarkers(this)); break } } // If the histories were shared, split them again if (other.history == this.history) { var splitIds = [other.id]; linkedDocs(other, function (doc) { return splitIds.push(doc.id); }, true); other.history = new History(null); other.history.done = copyHistoryArray(this.history.done, splitIds); other.history.undone = copyHistoryArray(this.history.undone, splitIds); } }, iterLinkedDocs: function(f) {linkedDocs(this, f);}, getMode: function() {return this.mode}, getEditor: function() {return this.cm}, splitLines: function(str) { if (this.lineSep) { return str.split(this.lineSep) } return splitLinesAuto(str) }, lineSeparator: function() { return this.lineSep || "\n" }, setDirection: docMethodOp(function (dir) { if (dir != "rtl") { dir = "ltr"; } if (dir == this.direction) { return } this.direction = dir; this.iter(function (line) { return line.order = null; }); if (this.cm) { directionChanged(this.cm); } }) }); // Public alias. Doc.prototype.eachLine = Doc.prototype.iter; // Kludge to work around strange IE behavior where it'll sometimes // re-fire a series of drag-related events right after the drop (#1551) var lastDrop = 0; function onDrop(e) { var cm = this; clearDragCursor(cm); if (signalDOMEvent(cm, e) || eventInWidget(cm.display, e)) { return } e_preventDefault(e); if (ie) { lastDrop = +new Date; } var pos = posFromMouse(cm, e, true), files = e.dataTransfer.files; if (!pos || cm.isReadOnly()) { return } // Might be a file drop, in which case we simply extract the text // and insert it. if (files && files.length && window.FileReader && window.File) { var n = files.length, text = Array(n), read = 0; var markAsReadAndPasteIfAllFilesAreRead = function () { if (++read == n) { operation(cm, function () { pos = clipPos(cm.doc, pos); var change = {from: pos, to: pos, text: cm.doc.splitLines( text.filter(function (t) { return t != null; }).join(cm.doc.lineSeparator())), origin: "paste"}; makeChange(cm.doc, change); setSelectionReplaceHistory(cm.doc, simpleSelection(clipPos(cm.doc, pos), clipPos(cm.doc, changeEnd(change)))); })(); } }; var readTextFromFile = function (file, i) { if (cm.options.allowDropFileTypes && indexOf(cm.options.allowDropFileTypes, file.type) == -1) { markAsReadAndPasteIfAllFilesAreRead(); return } var reader = new FileReader; reader.onerror = function () { return markAsReadAndPasteIfAllFilesAreRead(); }; reader.onload = function () { var content = reader.result; if (/[\x00-\x08\x0e-\x1f]{2}/.test(content)) { markAsReadAndPasteIfAllFilesAreRead(); return } text[i] = content; markAsReadAndPasteIfAllFilesAreRead(); }; reader.readAsText(file); }; for (var i = 0; i < files.length; i++) { readTextFromFile(files[i], i); } } else { // Normal drop // Don't do a replace if the drop happened inside of the selected text. if (cm.state.draggingText && cm.doc.sel.contains(pos) > -1) { cm.state.draggingText(e); // Ensure the editor is re-focused setTimeout(function () { return cm.display.input.focus(); }, 20); return } try { var text$1 = e.dataTransfer.getData("Text"); if (text$1) { var selected; if (cm.state.draggingText && !cm.state.draggingText.copy) { selected = cm.listSelections(); } setSelectionNoUndo(cm.doc, simpleSelection(pos, pos)); if (selected) { for (var i$1 = 0; i$1 < selected.length; ++i$1) { replaceRange(cm.doc, "", selected[i$1].anchor, selected[i$1].head, "drag"); } } cm.replaceSelection(text$1, "around", "paste"); cm.display.input.focus(); } } catch(e$1){} } } function onDragStart(cm, e) { if (ie && (!cm.state.draggingText || +new Date - lastDrop < 100)) { e_stop(e); return } if (signalDOMEvent(cm, e) || eventInWidget(cm.display, e)) { return } e.dataTransfer.setData("Text", cm.getSelection()); e.dataTransfer.effectAllowed = "copyMove"; // Use dummy image instead of default browsers image. // Recent Safari (~6.0.2) have a tendency to segfault when this happens, so we don't do it there. if (e.dataTransfer.setDragImage && !safari) { var img = elt("img", null, null, "position: fixed; left: 0; top: 0;"); img.src = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; if (presto) { img.width = img.height = 1; cm.display.wrapper.appendChild(img); // Force a relayout, or Opera won't use our image for some obscure reason img._top = img.offsetTop; } e.dataTransfer.setDragImage(img, 0, 0); if (presto) { img.parentNode.removeChild(img); } } } function onDragOver(cm, e) { var pos = posFromMouse(cm, e); if (!pos) { return } var frag = document.createDocumentFragment(); drawSelectionCursor(cm, pos, frag); if (!cm.display.dragCursor) { cm.display.dragCursor = elt("div", null, "CodeMirror-cursors CodeMirror-dragcursors"); cm.display.lineSpace.insertBefore(cm.display.dragCursor, cm.display.cursorDiv); } removeChildrenAndAdd(cm.display.dragCursor, frag); } function clearDragCursor(cm) { if (cm.display.dragCursor) { cm.display.lineSpace.removeChild(cm.display.dragCursor); cm.display.dragCursor = null; } } // These must be handled carefully, because naively registering a // handler for each editor will cause the editors to never be // garbage collected. function forEachCodeMirror(f) { if (!document.getElementsByClassName) { return } var byClass = document.getElementsByClassName("CodeMirror"), editors = []; for (var i = 0; i < byClass.length; i++) { var cm = byClass[i].CodeMirror; if (cm) { editors.push(cm); } } if (editors.length) { editors[0].operation(function () { for (var i = 0; i < editors.length; i++) { f(editors[i]); } }); } } var globalsRegistered = false; function ensureGlobalHandlers() { if (globalsRegistered) { return } registerGlobalHandlers(); globalsRegistered = true; } function registerGlobalHandlers() { // When the window resizes, we need to refresh active editors. var resizeTimer; on(window, "resize", function () { if (resizeTimer == null) { resizeTimer = setTimeout(function () { resizeTimer = null; forEachCodeMirror(onResize); }, 100); } }); // When the window loses focus, we want to show the editor as blurred on(window, "blur", function () { return forEachCodeMirror(onBlur); }); } // Called when the window resizes function onResize(cm) { var d = cm.display; // Might be a text scaling operation, clear size caches. d.cachedCharWidth = d.cachedTextHeight = d.cachedPaddingH = null; d.scrollbarsClipped = false; cm.setSize(); } var keyNames = { 3: "Pause", 8: "Backspace", 9: "Tab", 13: "Enter", 16: "Shift", 17: "Ctrl", 18: "Alt", 19: "Pause", 20: "CapsLock", 27: "Esc", 32: "Space", 33: "PageUp", 34: "PageDown", 35: "End", 36: "Home", 37: "Left", 38: "Up", 39: "Right", 40: "Down", 44: "PrintScrn", 45: "Insert", 46: "Delete", 59: ";", 61: "=", 91: "Mod", 92: "Mod", 93: "Mod", 106: "*", 107: "=", 109: "-", 110: ".", 111: "/", 145: "ScrollLock", 173: "-", 186: ";", 187: "=", 188: ",", 189: "-", 190: ".", 191: "/", 192: "`", 219: "[", 220: "\\", 221: "]", 222: "'", 224: "Mod", 63232: "Up", 63233: "Down", 63234: "Left", 63235: "Right", 63272: "Delete", 63273: "Home", 63275: "End", 63276: "PageUp", 63277: "PageDown", 63302: "Insert" }; // Number keys for (var i = 0; i < 10; i++) { keyNames[i + 48] = keyNames[i + 96] = String(i); } // Alphabetic keys for (var i$1 = 65; i$1 <= 90; i$1++) { keyNames[i$1] = String.fromCharCode(i$1); } // Function keys for (var i$2 = 1; i$2 <= 12; i$2++) { keyNames[i$2 + 111] = keyNames[i$2 + 63235] = "F" + i$2; } var keyMap = {}; keyMap.basic = { "Left": "goCharLeft", "Right": "goCharRight", "Up": "goLineUp", "Down": "goLineDown", "End": "goLineEnd", "Home": "goLineStartSmart", "PageUp": "goPageUp", "PageDown": "goPageDown", "Delete": "delCharAfter", "Backspace": "delCharBefore", "Shift-Backspace": "delCharBefore", "Tab": "defaultTab", "Shift-Tab": "indentAuto", "Enter": "newlineAndIndent", "Insert": "toggleOverwrite", "Esc": "singleSelection" }; // Note that the save and find-related commands aren't defined by // default. User code or addons can define them. Unknown commands // are simply ignored. keyMap.pcDefault = { "Ctrl-A": "selectAll", "Ctrl-D": "deleteLine", "Ctrl-Z": "undo", "Shift-Ctrl-Z": "redo", "Ctrl-Y": "redo", "Ctrl-Home": "goDocStart", "Ctrl-End": "goDocEnd", "Ctrl-Up": "goLineUp", "Ctrl-Down": "goLineDown", "Ctrl-Left": "goGroupLeft", "Ctrl-Right": "goGroupRight", "Alt-Left": "goLineStart", "Alt-Right": "goLineEnd", "Ctrl-Backspace": "delGroupBefore", "Ctrl-Delete": "delGroupAfter", "Ctrl-S": "save", "Ctrl-F": "find", "Ctrl-G": "findNext", "Shift-Ctrl-G": "findPrev", "Shift-Ctrl-F": "replace", "Shift-Ctrl-R": "replaceAll", "Ctrl-[": "indentLess", "Ctrl-]": "indentMore", "Ctrl-U": "undoSelection", "Shift-Ctrl-U": "redoSelection", "Alt-U": "redoSelection", "fallthrough": "basic" }; // Very basic readline/emacs-style bindings, which are standard on Mac. keyMap.emacsy = { "Ctrl-F": "goCharRight", "Ctrl-B": "goCharLeft", "Ctrl-P": "goLineUp", "Ctrl-N": "goLineDown", "Alt-F": "goWordRight", "Alt-B": "goWordLeft", "Ctrl-A": "goLineStart", "Ctrl-E": "goLineEnd", "Ctrl-V": "goPageDown", "Shift-Ctrl-V": "goPageUp", "Ctrl-D": "delCharAfter", "Ctrl-H": "delCharBefore", "Alt-D": "delWordAfter", "Alt-Backspace": "delWordBefore", "Ctrl-K": "killLine", "Ctrl-T": "transposeChars", "Ctrl-O": "openLine" }; keyMap.macDefault = { "Cmd-A": "selectAll", "Cmd-D": "deleteLine", "Cmd-Z": "undo", "Shift-Cmd-Z": "redo", "Cmd-Y": "redo", "Cmd-Home": "goDocStart", "Cmd-Up": "goDocStart", "Cmd-End": "goDocEnd", "Cmd-Down": "goDocEnd", "Alt-Left": "goGroupLeft", "Alt-Right": "goGroupRight", "Cmd-Left": "goLineLeft", "Cmd-Right": "goLineRight", "Alt-Backspace": "delGroupBefore", "Ctrl-Alt-Backspace": "delGroupAfter", "Alt-Delete": "delGroupAfter", "Cmd-S": "save", "Cmd-F": "find", "Cmd-G": "findNext", "Shift-Cmd-G": "findPrev", "Cmd-Alt-F": "replace", "Shift-Cmd-Alt-F": "replaceAll", "Cmd-[": "indentLess", "Cmd-]": "indentMore", "Cmd-Backspace": "delWrappedLineLeft", "Cmd-Delete": "delWrappedLineRight", "Cmd-U": "undoSelection", "Shift-Cmd-U": "redoSelection", "Ctrl-Up": "goDocStart", "Ctrl-Down": "goDocEnd", "fallthrough": ["basic", "emacsy"] }; keyMap["default"] = mac ? keyMap.macDefault : keyMap.pcDefault; // KEYMAP DISPATCH function normalizeKeyName(name) { var parts = name.split(/-(?!$)/); name = parts[parts.length - 1]; var alt, ctrl, shift, cmd; for (var i = 0; i < parts.length - 1; i++) { var mod = parts[i]; if (/^(cmd|meta|m)$/i.test(mod)) { cmd = true; } else if (/^a(lt)?$/i.test(mod)) { alt = true; } else if (/^(c|ctrl|control)$/i.test(mod)) { ctrl = true; } else if (/^s(hift)?$/i.test(mod)) { shift = true; } else { throw new Error("Unrecognized modifier name: " + mod) } } if (alt) { name = "Alt-" + name; } if (ctrl) { name = "Ctrl-" + name; } if (cmd) { name = "Cmd-" + name; } if (shift) { name = "Shift-" + name; } return name } // This is a kludge to keep keymaps mostly working as raw objects // (backwards compatibility) while at the same time support features // like normalization and multi-stroke key bindings. It compiles a // new normalized keymap, and then updates the old object to reflect // this. function normalizeKeyMap(keymap) { var copy = {}; for (var keyname in keymap) { if (keymap.hasOwnProperty(keyname)) { var value = keymap[keyname]; if (/^(name|fallthrough|(de|at)tach)$/.test(keyname)) { continue } if (value == "...") { delete keymap[keyname]; continue } var keys = map(keyname.split(" "), normalizeKeyName); for (var i = 0; i < keys.length; i++) { var val = (void 0), name = (void 0); if (i == keys.length - 1) { name = keys.join(" "); val = value; } else { name = keys.slice(0, i + 1).join(" "); val = "..."; } var prev = copy[name]; if (!prev) { copy[name] = val; } else if (prev != val) { throw new Error("Inconsistent bindings for " + name) } } delete keymap[keyname]; } } for (var prop in copy) { keymap[prop] = copy[prop]; } return keymap } function lookupKey(key, map, handle, context) { map = getKeyMap(map); var found = map.call ? map.call(key, context) : map[key]; if (found === false) { return "nothing" } if (found === "...") { return "multi" } if (found != null && handle(found)) { return "handled" } if (map.fallthrough) { if (Object.prototype.toString.call(map.fallthrough) != "[object Array]") { return lookupKey(key, map.fallthrough, handle, context) } for (var i = 0; i < map.fallthrough.length; i++) { var result = lookupKey(key, map.fallthrough[i], handle, context); if (result) { return result } } } } // Modifier key presses don't count as 'real' key presses for the // purpose of keymap fallthrough. function isModifierKey(value) { var name = typeof value == "string" ? value : keyNames[value.keyCode]; return name == "Ctrl" || name == "Alt" || name == "Shift" || name == "Mod" } function addModifierNames(name, event, noShift) { var base = name; if (event.altKey && base != "Alt") { name = "Alt-" + name; } if ((flipCtrlCmd ? event.metaKey : event.ctrlKey) && base != "Ctrl") { name = "Ctrl-" + name; } if ((flipCtrlCmd ? event.ctrlKey : event.metaKey) && base != "Mod") { name = "Cmd-" + name; } if (!noShift && event.shiftKey && base != "Shift") { name = "Shift-" + name; } return name } // Look up the name of a key as indicated by an event object. function keyName(event, noShift) { if (presto && event.keyCode == 34 && event["char"]) { return false } var name = keyNames[event.keyCode]; if (name == null || event.altGraphKey) { return false } // Ctrl-ScrollLock has keyCode 3, same as Ctrl-Pause, // so we'll use event.code when available (Chrome 48+, FF 38+, Safari 10.1+) if (event.keyCode == 3 && event.code) { name = event.code; } return addModifierNames(name, event, noShift) } function getKeyMap(val) { return typeof val == "string" ? keyMap[val] : val } // Helper for deleting text near the selection(s), used to implement // backspace, delete, and similar functionality. function deleteNearSelection(cm, compute) { var ranges = cm.doc.sel.ranges, kill = []; // Build up a set of ranges to kill first, merging overlapping // ranges. for (var i = 0; i < ranges.length; i++) { var toKill = compute(ranges[i]); while (kill.length && cmp(toKill.from, lst(kill).to) <= 0) { var replaced = kill.pop(); if (cmp(replaced.from, toKill.from) < 0) { toKill.from = replaced.from; break } } kill.push(toKill); } // Next, remove those actual ranges. runInOp(cm, function () { for (var i = kill.length - 1; i >= 0; i--) { replaceRange(cm.doc, "", kill[i].from, kill[i].to, "+delete"); } ensureCursorVisible(cm); }); } function moveCharLogically(line, ch, dir) { var target = skipExtendingChars(line.text, ch + dir, dir); return target < 0 || target > line.text.length ? null : target } function moveLogically(line, start, dir) { var ch = moveCharLogically(line, start.ch, dir); return ch == null ? null : new Pos(start.line, ch, dir < 0 ? "after" : "before") } function endOfLine(visually, cm, lineObj, lineNo, dir) { if (visually) { if (cm.doc.direction == "rtl") { dir = -dir; } var order = getOrder(lineObj, cm.doc.direction); if (order) { var part = dir < 0 ? lst(order) : order[0]; var moveInStorageOrder = (dir < 0) == (part.level == 1); var sticky = moveInStorageOrder ? "after" : "before"; var ch; // With a wrapped rtl chunk (possibly spanning multiple bidi parts), // it could be that the last bidi part is not on the last visual line, // since visual lines contain content order-consecutive chunks. // Thus, in rtl, we are looking for the first (content-order) character // in the rtl chunk that is on the last line (that is, the same line // as the last (content-order) character). if (part.level > 0 || cm.doc.direction == "rtl") { var prep = prepareMeasureForLine(cm, lineObj); ch = dir < 0 ? lineObj.text.length - 1 : 0; var targetTop = measureCharPrepared(cm, prep, ch).top; ch = findFirst(function (ch) { return measureCharPrepared(cm, prep, ch).top == targetTop; }, (dir < 0) == (part.level == 1) ? part.from : part.to - 1, ch); if (sticky == "before") { ch = moveCharLogically(lineObj, ch, 1); } } else { ch = dir < 0 ? part.to : part.from; } return new Pos(lineNo, ch, sticky) } } return new Pos(lineNo, dir < 0 ? lineObj.text.length : 0, dir < 0 ? "before" : "after") } function moveVisually(cm, line, start, dir) { var bidi = getOrder(line, cm.doc.direction); if (!bidi) { return moveLogically(line, start, dir) } if (start.ch >= line.text.length) { start.ch = line.text.length; start.sticky = "before"; } else if (start.ch <= 0) { start.ch = 0; start.sticky = "after"; } var partPos = getBidiPartAt(bidi, start.ch, start.sticky), part = bidi[partPos]; if (cm.doc.direction == "ltr" && part.level % 2 == 0 && (dir > 0 ? part.to > start.ch : part.from < start.ch)) { // Case 1: We move within an ltr part in an ltr editor. Even with wrapped lines, // nothing interesting happens. return moveLogically(line, start, dir) } var mv = function (pos, dir) { return moveCharLogically(line, pos instanceof Pos ? pos.ch : pos, dir); }; var prep; var getWrappedLineExtent = function (ch) { if (!cm.options.lineWrapping) { return {begin: 0, end: line.text.length} } prep = prep || prepareMeasureForLine(cm, line); return wrappedLineExtentChar(cm, line, prep, ch) }; var wrappedLineExtent = getWrappedLineExtent(start.sticky == "before" ? mv(start, -1) : start.ch); if (cm.doc.direction == "rtl" || part.level == 1) { var moveInStorageOrder = (part.level == 1) == (dir < 0); var ch = mv(start, moveInStorageOrder ? 1 : -1); if (ch != null && (!moveInStorageOrder ? ch >= part.from && ch >= wrappedLineExtent.begin : ch <= part.to && ch <= wrappedLineExtent.end)) { // Case 2: We move within an rtl part or in an rtl editor on the same visual line var sticky = moveInStorageOrder ? "before" : "after"; return new Pos(start.line, ch, sticky) } } // Case 3: Could not move within this bidi part in this visual line, so leave // the current bidi part var searchInVisualLine = function (partPos, dir, wrappedLineExtent) { var getRes = function (ch, moveInStorageOrder) { return moveInStorageOrder ? new Pos(start.line, mv(ch, 1), "before") : new Pos(start.line, ch, "after"); }; for (; partPos >= 0 && partPos < bidi.length; partPos += dir) { var part = bidi[partPos]; var moveInStorageOrder = (dir > 0) == (part.level != 1); var ch = moveInStorageOrder ? wrappedLineExtent.begin : mv(wrappedLineExtent.end, -1); if (part.from <= ch && ch < part.to) { return getRes(ch, moveInStorageOrder) } ch = moveInStorageOrder ? part.from : mv(part.to, -1); if (wrappedLineExtent.begin <= ch && ch < wrappedLineExtent.end) { return getRes(ch, moveInStorageOrder) } } }; // Case 3a: Look for other bidi parts on the same visual line var res = searchInVisualLine(partPos + dir, dir, wrappedLineExtent); if (res) { return res } // Case 3b: Look for other bidi parts on the next visual line var nextCh = dir > 0 ? wrappedLineExtent.end : mv(wrappedLineExtent.begin, -1); if (nextCh != null && !(dir > 0 && nextCh == line.text.length)) { res = searchInVisualLine(dir > 0 ? 0 : bidi.length - 1, dir, getWrappedLineExtent(nextCh)); if (res) { return res } } // Case 4: Nowhere to move return null } // Commands are parameter-less actions that can be performed on an // editor, mostly used for keybindings. var commands = { selectAll: selectAll, singleSelection: function (cm) { return cm.setSelection(cm.getCursor("anchor"), cm.getCursor("head"), sel_dontScroll); }, killLine: function (cm) { return deleteNearSelection(cm, function (range) { if (range.empty()) { var len = getLine(cm.doc, range.head.line).text.length; if (range.head.ch == len && range.head.line < cm.lastLine()) { return {from: range.head, to: Pos(range.head.line + 1, 0)} } else { return {from: range.head, to: Pos(range.head.line, len)} } } else { return {from: range.from(), to: range.to()} } }); }, deleteLine: function (cm) { return deleteNearSelection(cm, function (range) { return ({ from: Pos(range.from().line, 0), to: clipPos(cm.doc, Pos(range.to().line + 1, 0)) }); }); }, delLineLeft: function (cm) { return deleteNearSelection(cm, function (range) { return ({ from: Pos(range.from().line, 0), to: range.from() }); }); }, delWrappedLineLeft: function (cm) { return deleteNearSelection(cm, function (range) { var top = cm.charCoords(range.head, "div").top + 5; var leftPos = cm.coordsChar({left: 0, top: top}, "div"); return {from: leftPos, to: range.from()} }); }, delWrappedLineRight: function (cm) { return deleteNearSelection(cm, function (range) { var top = cm.charCoords(range.head, "div").top + 5; var rightPos = cm.coordsChar({left: cm.display.lineDiv.offsetWidth + 100, top: top}, "div"); return {from: range.from(), to: rightPos } }); }, undo: function (cm) { return cm.undo(); }, redo: function (cm) { return cm.redo(); }, undoSelection: function (cm) { return cm.undoSelection(); }, redoSelection: function (cm) { return cm.redoSelection(); }, goDocStart: function (cm) { return cm.extendSelection(Pos(cm.firstLine(), 0)); }, goDocEnd: function (cm) { return cm.extendSelection(Pos(cm.lastLine())); }, goLineStart: function (cm) { return cm.extendSelectionsBy(function (range) { return lineStart(cm, range.head.line); }, {origin: "+move", bias: 1} ); }, goLineStartSmart: function (cm) { return cm.extendSelectionsBy(function (range) { return lineStartSmart(cm, range.head); }, {origin: "+move", bias: 1} ); }, goLineEnd: function (cm) { return cm.extendSelectionsBy(function (range) { return lineEnd(cm, range.head.line); }, {origin: "+move", bias: -1} ); }, goLineRight: function (cm) { return cm.extendSelectionsBy(function (range) { var top = cm.cursorCoords(range.head, "div").top + 5; return cm.coordsChar({left: cm.display.lineDiv.offsetWidth + 100, top: top}, "div") }, sel_move); }, goLineLeft: function (cm) { return cm.extendSelectionsBy(function (range) { var top = cm.cursorCoords(range.head, "div").top + 5; return cm.coordsChar({left: 0, top: top}, "div") }, sel_move); }, goLineLeftSmart: function (cm) { return cm.extendSelectionsBy(function (range) { var top = cm.cursorCoords(range.head, "div").top + 5; var pos = cm.coordsChar({left: 0, top: top}, "div"); if (pos.ch < cm.getLine(pos.line).search(/\S/)) { return lineStartSmart(cm, range.head) } return pos }, sel_move); }, goLineUp: function (cm) { return cm.moveV(-1, "line"); }, goLineDown: function (cm) { return cm.moveV(1, "line"); }, goPageUp: function (cm) { return cm.moveV(-1, "page"); }, goPageDown: function (cm) { return cm.moveV(1, "page"); }, goCharLeft: function (cm) { return cm.moveH(-1, "char"); }, goCharRight: function (cm) { return cm.moveH(1, "char"); }, goColumnLeft: function (cm) { return cm.moveH(-1, "column"); }, goColumnRight: function (cm) { return cm.moveH(1, "column"); }, goWordLeft: function (cm) { return cm.moveH(-1, "word"); }, goGroupRight: function (cm) { return cm.moveH(1, "group"); }, goGroupLeft: function (cm) { return cm.moveH(-1, "group"); }, goWordRight: function (cm) { return cm.moveH(1, "word"); }, delCharBefore: function (cm) { return cm.deleteH(-1, "codepoint"); }, delCharAfter: function (cm) { return cm.deleteH(1, "char"); }, delWordBefore: function (cm) { return cm.deleteH(-1, "word"); }, delWordAfter: function (cm) { return cm.deleteH(1, "word"); }, delGroupBefore: function (cm) { return cm.deleteH(-1, "group"); }, delGroupAfter: function (cm) { return cm.deleteH(1, "group"); }, indentAuto: function (cm) { return cm.indentSelection("smart"); }, indentMore: function (cm) { return cm.indentSelection("add"); }, indentLess: function (cm) { return cm.indentSelection("subtract"); }, insertTab: function (cm) { return cm.replaceSelection("\t"); }, insertSoftTab: function (cm) { var spaces = [], ranges = cm.listSelections(), tabSize = cm.options.tabSize; for (var i = 0; i < ranges.length; i++) { var pos = ranges[i].from(); var col = countColumn(cm.getLine(pos.line), pos.ch, tabSize); spaces.push(spaceStr(tabSize - col % tabSize)); } cm.replaceSelections(spaces); }, defaultTab: function (cm) { if (cm.somethingSelected()) { cm.indentSelection("add"); } else { cm.execCommand("insertTab"); } }, // Swap the two chars left and right of each selection's head. // Move cursor behind the two swapped characters afterwards. // // Doesn't consider line feeds a character. // Doesn't scan more than one line above to find a character. // Doesn't do anything on an empty line. // Doesn't do anything with non-empty selections. transposeChars: function (cm) { return runInOp(cm, function () { var ranges = cm.listSelections(), newSel = []; for (var i = 0; i < ranges.length; i++) { if (!ranges[i].empty()) { continue } var cur = ranges[i].head, line = getLine(cm.doc, cur.line).text; if (line) { if (cur.ch == line.length) { cur = new Pos(cur.line, cur.ch - 1); } if (cur.ch > 0) { cur = new Pos(cur.line, cur.ch + 1); cm.replaceRange(line.charAt(cur.ch - 1) + line.charAt(cur.ch - 2), Pos(cur.line, cur.ch - 2), cur, "+transpose"); } else if (cur.line > cm.doc.first) { var prev = getLine(cm.doc, cur.line - 1).text; if (prev) { cur = new Pos(cur.line, 1); cm.replaceRange(line.charAt(0) + cm.doc.lineSeparator() + prev.charAt(prev.length - 1), Pos(cur.line - 1, prev.length - 1), cur, "+transpose"); } } } newSel.push(new Range(cur, cur)); } cm.setSelections(newSel); }); }, newlineAndIndent: function (cm) { return runInOp(cm, function () { var sels = cm.listSelections(); for (var i = sels.length - 1; i >= 0; i--) { cm.replaceRange(cm.doc.lineSeparator(), sels[i].anchor, sels[i].head, "+input"); } sels = cm.listSelections(); for (var i$1 = 0; i$1 < sels.length; i$1++) { cm.indentLine(sels[i$1].from().line, null, true); } ensureCursorVisible(cm); }); }, openLine: function (cm) { return cm.replaceSelection("\n", "start"); }, toggleOverwrite: function (cm) { return cm.toggleOverwrite(); } }; function lineStart(cm, lineN) { var line = getLine(cm.doc, lineN); var visual = visualLine(line); if (visual != line) { lineN = lineNo(visual); } return endOfLine(true, cm, visual, lineN, 1) } function lineEnd(cm, lineN) { var line = getLine(cm.doc, lineN); var visual = visualLineEnd(line); if (visual != line) { lineN = lineNo(visual); } return endOfLine(true, cm, line, lineN, -1) } function lineStartSmart(cm, pos) { var start = lineStart(cm, pos.line); var line = getLine(cm.doc, start.line); var order = getOrder(line, cm.doc.direction); if (!order || order[0].level == 0) { var firstNonWS = Math.max(start.ch, line.text.search(/\S/)); var inWS = pos.line == start.line && pos.ch <= firstNonWS && pos.ch; return Pos(start.line, inWS ? 0 : firstNonWS, start.sticky) } return start } // Run a handler that was bound to a key. function doHandleBinding(cm, bound, dropShift) { if (typeof bound == "string") { bound = commands[bound]; if (!bound) { return false } } // Ensure previous input has been read, so that the handler sees a // consistent view of the document cm.display.input.ensurePolled(); var prevShift = cm.display.shift, done = false; try { if (cm.isReadOnly()) { cm.state.suppressEdits = true; } if (dropShift) { cm.display.shift = false; } done = bound(cm) != Pass; } finally { cm.display.shift = prevShift; cm.state.suppressEdits = false; } return done } function lookupKeyForEditor(cm, name, handle) { for (var i = 0; i < cm.state.keyMaps.length; i++) { var result = lookupKey(name, cm.state.keyMaps[i], handle, cm); if (result) { return result } } return (cm.options.extraKeys && lookupKey(name, cm.options.extraKeys, handle, cm)) || lookupKey(name, cm.options.keyMap, handle, cm) } // Note that, despite the name, this function is also used to check // for bound mouse clicks. var stopSeq = new Delayed; function dispatchKey(cm, name, e, handle) { var seq = cm.state.keySeq; if (seq) { if (isModifierKey(name)) { return "handled" } if (/\'$/.test(name)) { cm.state.keySeq = null; } else { stopSeq.set(50, function () { if (cm.state.keySeq == seq) { cm.state.keySeq = null; cm.display.input.reset(); } }); } if (dispatchKeyInner(cm, seq + " " + name, e, handle)) { return true } } return dispatchKeyInner(cm, name, e, handle) } function dispatchKeyInner(cm, name, e, handle) { var result = lookupKeyForEditor(cm, name, handle); if (result == "multi") { cm.state.keySeq = name; } if (result == "handled") { signalLater(cm, "keyHandled", cm, name, e); } if (result == "handled" || result == "multi") { e_preventDefault(e); restartBlink(cm); } return !!result } // Handle a key from the keydown event. function handleKeyBinding(cm, e) { var name = keyName(e, true); if (!name) { return false } if (e.shiftKey && !cm.state.keySeq) { // First try to resolve full name (including 'Shift-'). Failing // that, see if there is a cursor-motion command (starting with // 'go') bound to the keyname without 'Shift-'. return dispatchKey(cm, "Shift-" + name, e, function (b) { return doHandleBinding(cm, b, true); }) || dispatchKey(cm, name, e, function (b) { if (typeof b == "string" ? /^go[A-Z]/.test(b) : b.motion) { return doHandleBinding(cm, b) } }) } else { return dispatchKey(cm, name, e, function (b) { return doHandleBinding(cm, b); }) } } // Handle a key from the keypress event function handleCharBinding(cm, e, ch) { return dispatchKey(cm, "'" + ch + "'", e, function (b) { return doHandleBinding(cm, b, true); }) } var lastStoppedKey = null; function onKeyDown(e) { var cm = this; if (e.target && e.target != cm.display.input.getField()) { return } cm.curOp.focus = activeElt(); if (signalDOMEvent(cm, e)) { return } // IE does strange things with escape. if (ie && ie_version < 11 && e.keyCode == 27) { e.returnValue = false; } var code = e.keyCode; cm.display.shift = code == 16 || e.shiftKey; var handled = handleKeyBinding(cm, e); if (presto) { lastStoppedKey = handled ? code : null; // Opera has no cut event... we try to at least catch the key combo if (!handled && code == 88 && !hasCopyEvent && (mac ? e.metaKey : e.ctrlKey)) { cm.replaceSelection("", null, "cut"); } } if (gecko && !mac && !handled && code == 46 && e.shiftKey && !e.ctrlKey && document.execCommand) { document.execCommand("cut"); } // Turn mouse into crosshair when Alt is held on Mac. if (code == 18 && !/\bCodeMirror-crosshair\b/.test(cm.display.lineDiv.className)) { showCrossHair(cm); } } function showCrossHair(cm) { var lineDiv = cm.display.lineDiv; addClass(lineDiv, "CodeMirror-crosshair"); function up(e) { if (e.keyCode == 18 || !e.altKey) { rmClass(lineDiv, "CodeMirror-crosshair"); off(document, "keyup", up); off(document, "mouseover", up); } } on(document, "keyup", up); on(document, "mouseover", up); } function onKeyUp(e) { if (e.keyCode == 16) { this.doc.sel.shift = false; } signalDOMEvent(this, e); } function onKeyPress(e) { var cm = this; if (e.target && e.target != cm.display.input.getField()) { return } if (eventInWidget(cm.display, e) || signalDOMEvent(cm, e) || e.ctrlKey && !e.altKey || mac && e.metaKey) { return } var keyCode = e.keyCode, charCode = e.charCode; if (presto && keyCode == lastStoppedKey) {lastStoppedKey = null; e_preventDefault(e); return} if ((presto && (!e.which || e.which < 10)) && handleKeyBinding(cm, e)) { return } var ch = String.fromCharCode(charCode == null ? keyCode : charCode); // Some browsers fire keypress events for backspace if (ch == "\x08") { return } if (handleCharBinding(cm, e, ch)) { return } cm.display.input.onKeyPress(e); } var DOUBLECLICK_DELAY = 400; var PastClick = function(time, pos, button) { this.time = time; this.pos = pos; this.button = button; }; PastClick.prototype.compare = function (time, pos, button) { return this.time + DOUBLECLICK_DELAY > time && cmp(pos, this.pos) == 0 && button == this.button }; var lastClick, lastDoubleClick; function clickRepeat(pos, button) { var now = +new Date; if (lastDoubleClick && lastDoubleClick.compare(now, pos, button)) { lastClick = lastDoubleClick = null; return "triple" } else if (lastClick && lastClick.compare(now, pos, button)) { lastDoubleClick = new PastClick(now, pos, button); lastClick = null; return "double" } else { lastClick = new PastClick(now, pos, button); lastDoubleClick = null; return "single" } } // A mouse down can be a single click, double click, triple click, // start of selection drag, start of text drag, new cursor // (ctrl-click), rectangle drag (alt-drag), or xwin // middle-click-paste. Or it might be a click on something we should // not interfere with, such as a scrollbar or widget. function onMouseDown(e) { var cm = this, display = cm.display; if (signalDOMEvent(cm, e) || display.activeTouch && display.input.supportsTouch()) { return } display.input.ensurePolled(); display.shift = e.shiftKey; if (eventInWidget(display, e)) { if (!webkit) { // Briefly turn off draggability, to allow widgets to do // normal dragging things. display.scroller.draggable = false; setTimeout(function () { return display.scroller.draggable = true; }, 100); } return } if (clickInGutter(cm, e)) { return } var pos = posFromMouse(cm, e), button = e_button(e), repeat = pos ? clickRepeat(pos, button) : "single"; window.focus(); // #3261: make sure, that we're not starting a second selection if (button == 1 && cm.state.selectingText) { cm.state.selectingText(e); } if (pos && handleMappedButton(cm, button, pos, repeat, e)) { return } if (button == 1) { if (pos) { leftButtonDown(cm, pos, repeat, e); } else if (e_target(e) == display.scroller) { e_preventDefault(e); } } else if (button == 2) { if (pos) { extendSelection(cm.doc, pos); } setTimeout(function () { return display.input.focus(); }, 20); } else if (button == 3) { if (captureRightClick) { cm.display.input.onContextMenu(e); } else { delayBlurEvent(cm); } } } function handleMappedButton(cm, button, pos, repeat, event) { var name = "Click"; if (repeat == "double") { name = "Double" + name; } else if (repeat == "triple") { name = "Triple" + name; } name = (button == 1 ? "Left" : button == 2 ? "Middle" : "Right") + name; return dispatchKey(cm, addModifierNames(name, event), event, function (bound) { if (typeof bound == "string") { bound = commands[bound]; } if (!bound) { return false } var done = false; try { if (cm.isReadOnly()) { cm.state.suppressEdits = true; } done = bound(cm, pos) != Pass; } finally { cm.state.suppressEdits = false; } return done }) } function configureMouse(cm, repeat, event) { var option = cm.getOption("configureMouse"); var value = option ? option(cm, repeat, event) : {}; if (value.unit == null) { var rect = chromeOS ? event.shiftKey && event.metaKey : event.altKey; value.unit = rect ? "rectangle" : repeat == "single" ? "char" : repeat == "double" ? "word" : "line"; } if (value.extend == null || cm.doc.extend) { value.extend = cm.doc.extend || event.shiftKey; } if (value.addNew == null) { value.addNew = mac ? event.metaKey : event.ctrlKey; } if (value.moveOnDrag == null) { value.moveOnDrag = !(mac ? event.altKey : event.ctrlKey); } return value } function leftButtonDown(cm, pos, repeat, event) { if (ie) { setTimeout(bind(ensureFocus, cm), 0); } else { cm.curOp.focus = activeElt(); } var behavior = configureMouse(cm, repeat, event); var sel = cm.doc.sel, contained; if (cm.options.dragDrop && dragAndDrop && !cm.isReadOnly() && repeat == "single" && (contained = sel.contains(pos)) > -1 && (cmp((contained = sel.ranges[contained]).from(), pos) < 0 || pos.xRel > 0) && (cmp(contained.to(), pos) > 0 || pos.xRel < 0)) { leftButtonStartDrag(cm, event, pos, behavior); } else { leftButtonSelect(cm, event, pos, behavior); } } // Start a text drag. When it ends, see if any dragging actually // happen, and treat as a click if it didn't. function leftButtonStartDrag(cm, event, pos, behavior) { var display = cm.display, moved = false; var dragEnd = operation(cm, function (e) { if (webkit) { display.scroller.draggable = false; } cm.state.draggingText = false; if (cm.state.delayingBlurEvent) { if (cm.hasFocus()) { cm.state.delayingBlurEvent = false; } else { delayBlurEvent(cm); } } off(display.wrapper.ownerDocument, "mouseup", dragEnd); off(display.wrapper.ownerDocument, "mousemove", mouseMove); off(display.scroller, "dragstart", dragStart); off(display.scroller, "drop", dragEnd); if (!moved) { e_preventDefault(e); if (!behavior.addNew) { extendSelection(cm.doc, pos, null, null, behavior.extend); } // Work around unexplainable focus problem in IE9 (#2127) and Chrome (#3081) if ((webkit && !safari) || ie && ie_version == 9) { setTimeout(function () {display.wrapper.ownerDocument.body.focus({preventScroll: true}); display.input.focus();}, 20); } else { display.input.focus(); } } }); var mouseMove = function(e2) { moved = moved || Math.abs(event.clientX - e2.clientX) + Math.abs(event.clientY - e2.clientY) >= 10; }; var dragStart = function () { return moved = true; }; // Let the drag handler handle this. if (webkit) { display.scroller.draggable = true; } cm.state.draggingText = dragEnd; dragEnd.copy = !behavior.moveOnDrag; on(display.wrapper.ownerDocument, "mouseup", dragEnd); on(display.wrapper.ownerDocument, "mousemove", mouseMove); on(display.scroller, "dragstart", dragStart); on(display.scroller, "drop", dragEnd); cm.state.delayingBlurEvent = true; setTimeout(function () { return display.input.focus(); }, 20); // IE's approach to draggable if (display.scroller.dragDrop) { display.scroller.dragDrop(); } } function rangeForUnit(cm, pos, unit) { if (unit == "char") { return new Range(pos, pos) } if (unit == "word") { return cm.findWordAt(pos) } if (unit == "line") { return new Range(Pos(pos.line, 0), clipPos(cm.doc, Pos(pos.line + 1, 0))) } var result = unit(cm, pos); return new Range(result.from, result.to) } // Normal selection, as opposed to text dragging. function leftButtonSelect(cm, event, start, behavior) { if (ie) { delayBlurEvent(cm); } var display = cm.display, doc = cm.doc; e_preventDefault(event); var ourRange, ourIndex, startSel = doc.sel, ranges = startSel.ranges; if (behavior.addNew && !behavior.extend) { ourIndex = doc.sel.contains(start); if (ourIndex > -1) { ourRange = ranges[ourIndex]; } else { ourRange = new Range(start, start); } } else { ourRange = doc.sel.primary(); ourIndex = doc.sel.primIndex; } if (behavior.unit == "rectangle") { if (!behavior.addNew) { ourRange = new Range(start, start); } start = posFromMouse(cm, event, true, true); ourIndex = -1; } else { var range = rangeForUnit(cm, start, behavior.unit); if (behavior.extend) { ourRange = extendRange(ourRange, range.anchor, range.head, behavior.extend); } else { ourRange = range; } } if (!behavior.addNew) { ourIndex = 0; setSelection(doc, new Selection([ourRange], 0), sel_mouse); startSel = doc.sel; } else if (ourIndex == -1) { ourIndex = ranges.length; setSelection(doc, normalizeSelection(cm, ranges.concat([ourRange]), ourIndex), {scroll: false, origin: "*mouse"}); } else if (ranges.length > 1 && ranges[ourIndex].empty() && behavior.unit == "char" && !behavior.extend) { setSelection(doc, normalizeSelection(cm, ranges.slice(0, ourIndex).concat(ranges.slice(ourIndex + 1)), 0), {scroll: false, origin: "*mouse"}); startSel = doc.sel; } else { replaceOneSelection(doc, ourIndex, ourRange, sel_mouse); } var lastPos = start; function extendTo(pos) { if (cmp(lastPos, pos) == 0) { return } lastPos = pos; if (behavior.unit == "rectangle") { var ranges = [], tabSize = cm.options.tabSize; var startCol = countColumn(getLine(doc, start.line).text, start.ch, tabSize); var posCol = countColumn(getLine(doc, pos.line).text, pos.ch, tabSize); var left = Math.min(startCol, posCol), right = Math.max(startCol, posCol); for (var line = Math.min(start.line, pos.line), end = Math.min(cm.lastLine(), Math.max(start.line, pos.line)); line <= end; line++) { var text = getLine(doc, line).text, leftPos = findColumn(text, left, tabSize); if (left == right) { ranges.push(new Range(Pos(line, leftPos), Pos(line, leftPos))); } else if (text.length > leftPos) { ranges.push(new Range(Pos(line, leftPos), Pos(line, findColumn(text, right, tabSize)))); } } if (!ranges.length) { ranges.push(new Range(start, start)); } setSelection(doc, normalizeSelection(cm, startSel.ranges.slice(0, ourIndex).concat(ranges), ourIndex), {origin: "*mouse", scroll: false}); cm.scrollIntoView(pos); } else { var oldRange = ourRange; var range = rangeForUnit(cm, pos, behavior.unit); var anchor = oldRange.anchor, head; if (cmp(range.anchor, anchor) > 0) { head = range.head; anchor = minPos(oldRange.from(), range.anchor); } else { head = range.anchor; anchor = maxPos(oldRange.to(), range.head); } var ranges$1 = startSel.ranges.slice(0); ranges$1[ourIndex] = bidiSimplify(cm, new Range(clipPos(doc, anchor), head)); setSelection(doc, normalizeSelection(cm, ranges$1, ourIndex), sel_mouse); } } var editorSize = display.wrapper.getBoundingClientRect(); // Used to ensure timeout re-tries don't fire when another extend // happened in the meantime (clearTimeout isn't reliable -- at // least on Chrome, the timeouts still happen even when cleared, // if the clear happens after their scheduled firing time). var counter = 0; function extend(e) { var curCount = ++counter; var cur = posFromMouse(cm, e, true, behavior.unit == "rectangle"); if (!cur) { return } if (cmp(cur, lastPos) != 0) { cm.curOp.focus = activeElt(); extendTo(cur); var visible = visibleLines(display, doc); if (cur.line >= visible.to || cur.line < visible.from) { setTimeout(operation(cm, function () {if (counter == curCount) { extend(e); }}), 150); } } else { var outside = e.clientY < editorSize.top ? -20 : e.clientY > editorSize.bottom ? 20 : 0; if (outside) { setTimeout(operation(cm, function () { if (counter != curCount) { return } display.scroller.scrollTop += outside; extend(e); }), 50); } } } function done(e) { cm.state.selectingText = false; counter = Infinity; // If e is null or undefined we interpret this as someone trying // to explicitly cancel the selection rather than the user // letting go of the mouse button. if (e) { e_preventDefault(e); display.input.focus(); } off(display.wrapper.ownerDocument, "mousemove", move); off(display.wrapper.ownerDocument, "mouseup", up); doc.history.lastSelOrigin = null; } var move = operation(cm, function (e) { if (e.buttons === 0 || !e_button(e)) { done(e); } else { extend(e); } }); var up = operation(cm, done); cm.state.selectingText = up; on(display.wrapper.ownerDocument, "mousemove", move); on(display.wrapper.ownerDocument, "mouseup", up); } // Used when mouse-selecting to adjust the anchor to the proper side // of a bidi jump depending on the visual position of the head. function bidiSimplify(cm, range) { var anchor = range.anchor; var head = range.head; var anchorLine = getLine(cm.doc, anchor.line); if (cmp(anchor, head) == 0 && anchor.sticky == head.sticky) { return range } var order = getOrder(anchorLine); if (!order) { return range } var index = getBidiPartAt(order, anchor.ch, anchor.sticky), part = order[index]; if (part.from != anchor.ch && part.to != anchor.ch) { return range } var boundary = index + ((part.from == anchor.ch) == (part.level != 1) ? 0 : 1); if (boundary == 0 || boundary == order.length) { return range } // Compute the relative visual position of the head compared to the // anchor (<0 is to the left, >0 to the right) var leftSide; if (head.line != anchor.line) { leftSide = (head.line - anchor.line) * (cm.doc.direction == "ltr" ? 1 : -1) > 0; } else { var headIndex = getBidiPartAt(order, head.ch, head.sticky); var dir = headIndex - index || (head.ch - anchor.ch) * (part.level == 1 ? -1 : 1); if (headIndex == boundary - 1 || headIndex == boundary) { leftSide = dir < 0; } else { leftSide = dir > 0; } } var usePart = order[boundary + (leftSide ? -1 : 0)]; var from = leftSide == (usePart.level == 1); var ch = from ? usePart.from : usePart.to, sticky = from ? "after" : "before"; return anchor.ch == ch && anchor.sticky == sticky ? range : new Range(new Pos(anchor.line, ch, sticky), head) } // Determines whether an event happened in the gutter, and fires the // handlers for the corresponding event. function gutterEvent(cm, e, type, prevent) { var mX, mY; if (e.touches) { mX = e.touches[0].clientX; mY = e.touches[0].clientY; } else { try { mX = e.clientX; mY = e.clientY; } catch(e$1) { return false } } if (mX >= Math.floor(cm.display.gutters.getBoundingClientRect().right)) { return false } if (prevent) { e_preventDefault(e); } var display = cm.display; var lineBox = display.lineDiv.getBoundingClientRect(); if (mY > lineBox.bottom || !hasHandler(cm, type)) { return e_defaultPrevented(e) } mY -= lineBox.top - display.viewOffset; for (var i = 0; i < cm.display.gutterSpecs.length; ++i) { var g = display.gutters.childNodes[i]; if (g && g.getBoundingClientRect().right >= mX) { var line = lineAtHeight(cm.doc, mY); var gutter = cm.display.gutterSpecs[i]; signal(cm, type, cm, line, gutter.className, e); return e_defaultPrevented(e) } } } function clickInGutter(cm, e) { return gutterEvent(cm, e, "gutterClick", true) } // CONTEXT MENU HANDLING // To make the context menu work, we need to briefly unhide the // textarea (making it as unobtrusive as possible) to let the // right-click take effect on it. function onContextMenu(cm, e) { if (eventInWidget(cm.display, e) || contextMenuInGutter(cm, e)) { return } if (signalDOMEvent(cm, e, "contextmenu")) { return } if (!captureRightClick) { cm.display.input.onContextMenu(e); } } function contextMenuInGutter(cm, e) { if (!hasHandler(cm, "gutterContextMenu")) { return false } return gutterEvent(cm, e, "gutterContextMenu", false) } function themeChanged(cm) { cm.display.wrapper.className = cm.display.wrapper.className.replace(/\s*cm-s-\S+/g, "") + cm.options.theme.replace(/(^|\s)\s*/g, " cm-s-"); clearCaches(cm); } var Init = {toString: function(){return "CodeMirror.Init"}}; var defaults = {}; var optionHandlers = {}; function defineOptions(CodeMirror) { var optionHandlers = CodeMirror.optionHandlers; function option(name, deflt, handle, notOnInit) { CodeMirror.defaults[name] = deflt; if (handle) { optionHandlers[name] = notOnInit ? function (cm, val, old) {if (old != Init) { handle(cm, val, old); }} : handle; } } CodeMirror.defineOption = option; // Passed to option handlers when there is no old value. CodeMirror.Init = Init; // These two are, on init, called from the constructor because they // have to be initialized before the editor can start at all. option("value", "", function (cm, val) { return cm.setValue(val); }, true); option("mode", null, function (cm, val) { cm.doc.modeOption = val; loadMode(cm); }, true); option("indentUnit", 2, loadMode, true); option("indentWithTabs", false); option("smartIndent", true); option("tabSize", 4, function (cm) { resetModeState(cm); clearCaches(cm); regChange(cm); }, true); option("lineSeparator", null, function (cm, val) { cm.doc.lineSep = val; if (!val) { return } var newBreaks = [], lineNo = cm.doc.first; cm.doc.iter(function (line) { for (var pos = 0;;) { var found = line.text.indexOf(val, pos); if (found == -1) { break } pos = found + val.length; newBreaks.push(Pos(lineNo, found)); } lineNo++; }); for (var i = newBreaks.length - 1; i >= 0; i--) { replaceRange(cm.doc, val, newBreaks[i], Pos(newBreaks[i].line, newBreaks[i].ch + val.length)); } }); option("specialChars", /[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u200b-\u200c\u200e\u200f\u2028\u2029\ufeff\ufff9-\ufffc]/g, function (cm, val, old) { cm.state.specialChars = new RegExp(val.source + (val.test("\t") ? "" : "|\t"), "g"); if (old != Init) { cm.refresh(); } }); option("specialCharPlaceholder", defaultSpecialCharPlaceholder, function (cm) { return cm.refresh(); }, true); option("electricChars", true); option("inputStyle", mobile ? "contenteditable" : "textarea", function () { throw new Error("inputStyle can not (yet) be changed in a running editor") // FIXME }, true); option("spellcheck", false, function (cm, val) { return cm.getInputField().spellcheck = val; }, true); option("autocorrect", false, function (cm, val) { return cm.getInputField().autocorrect = val; }, true); option("autocapitalize", false, function (cm, val) { return cm.getInputField().autocapitalize = val; }, true); option("rtlMoveVisually", !windows); option("wholeLineUpdateBefore", true); option("theme", "default", function (cm) { themeChanged(cm); updateGutters(cm); }, true); option("keyMap", "default", function (cm, val, old) { var next = getKeyMap(val); var prev = old != Init && getKeyMap(old); if (prev && prev.detach) { prev.detach(cm, next); } if (next.attach) { next.attach(cm, prev || null); } }); option("extraKeys", null); option("configureMouse", null); option("lineWrapping", false, wrappingChanged, true); option("gutters", [], function (cm, val) { cm.display.gutterSpecs = getGutters(val, cm.options.lineNumbers); updateGutters(cm); }, true); option("fixedGutter", true, function (cm, val) { cm.display.gutters.style.left = val ? compensateForHScroll(cm.display) + "px" : "0"; cm.refresh(); }, true); option("coverGutterNextToScrollbar", false, function (cm) { return updateScrollbars(cm); }, true); option("scrollbarStyle", "native", function (cm) { initScrollbars(cm); updateScrollbars(cm); cm.display.scrollbars.setScrollTop(cm.doc.scrollTop); cm.display.scrollbars.setScrollLeft(cm.doc.scrollLeft); }, true); option("lineNumbers", false, function (cm, val) { cm.display.gutterSpecs = getGutters(cm.options.gutters, val); updateGutters(cm); }, true); option("firstLineNumber", 1, updateGutters, true); option("lineNumberFormatter", function (integer) { return integer; }, updateGutters, true); option("showCursorWhenSelecting", false, updateSelection, true); option("resetSelectionOnContextMenu", true); option("lineWiseCopyCut", true); option("pasteLinesPerSelection", true); option("selectionsMayTouch", false); option("readOnly", false, function (cm, val) { if (val == "nocursor") { onBlur(cm); cm.display.input.blur(); } cm.display.input.readOnlyChanged(val); }); option("screenReaderLabel", null, function (cm, val) { val = (val === '') ? null : val; cm.display.input.screenReaderLabelChanged(val); }); option("disableInput", false, function (cm, val) {if (!val) { cm.display.input.reset(); }}, true); option("dragDrop", true, dragDropChanged); option("allowDropFileTypes", null); option("cursorBlinkRate", 530); option("cursorScrollMargin", 0); option("cursorHeight", 1, updateSelection, true); option("singleCursorHeightPerLine", true, updateSelection, true); option("workTime", 100); option("workDelay", 100); option("flattenSpans", true, resetModeState, true); option("addModeClass", false, resetModeState, true); option("pollInterval", 100); option("undoDepth", 200, function (cm, val) { return cm.doc.history.undoDepth = val; }); option("historyEventDelay", 1250); option("viewportMargin", 10, function (cm) { return cm.refresh(); }, true); option("maxHighlightLength", 10000, resetModeState, true); option("moveInputWithCursor", true, function (cm, val) { if (!val) { cm.display.input.resetPosition(); } }); option("tabindex", null, function (cm, val) { return cm.display.input.getField().tabIndex = val || ""; }); option("autofocus", null); option("direction", "ltr", function (cm, val) { return cm.doc.setDirection(val); }, true); option("phrases", null); } function dragDropChanged(cm, value, old) { var wasOn = old && old != Init; if (!value != !wasOn) { var funcs = cm.display.dragFunctions; var toggle = value ? on : off; toggle(cm.display.scroller, "dragstart", funcs.start); toggle(cm.display.scroller, "dragenter", funcs.enter); toggle(cm.display.scroller, "dragover", funcs.over); toggle(cm.display.scroller, "dragleave", funcs.leave); toggle(cm.display.scroller, "drop", funcs.drop); } } function wrappingChanged(cm) { if (cm.options.lineWrapping) { addClass(cm.display.wrapper, "CodeMirror-wrap"); cm.display.sizer.style.minWidth = ""; cm.display.sizerWidth = null; } else { rmClass(cm.display.wrapper, "CodeMirror-wrap"); findMaxLine(cm); } estimateLineHeights(cm); regChange(cm); clearCaches(cm); setTimeout(function () { return updateScrollbars(cm); }, 100); } // A CodeMirror instance represents an editor. This is the object // that user code is usually dealing with. function CodeMirror(place, options) { var this$1 = this; if (!(this instanceof CodeMirror)) { return new CodeMirror(place, options) } this.options = options = options ? copyObj(options) : {}; // Determine effective options based on given values and defaults. copyObj(defaults, options, false); var doc = options.value; if (typeof doc == "string") { doc = new Doc(doc, options.mode, null, options.lineSeparator, options.direction); } else if (options.mode) { doc.modeOption = options.mode; } this.doc = doc; var input = new CodeMirror.inputStyles[options.inputStyle](this); var display = this.display = new Display(place, doc, input, options); display.wrapper.CodeMirror = this; themeChanged(this); if (options.lineWrapping) { this.display.wrapper.className += " CodeMirror-wrap"; } initScrollbars(this); this.state = { keyMaps: [], // stores maps added by addKeyMap overlays: [], // highlighting overlays, as added by addOverlay modeGen: 0, // bumped when mode/overlay changes, used to invalidate highlighting info overwrite: false, delayingBlurEvent: false, focused: false, suppressEdits: false, // used to disable editing during key handlers when in readOnly mode pasteIncoming: -1, cutIncoming: -1, // help recognize paste/cut edits in input.poll selectingText: false, draggingText: false, highlight: new Delayed(), // stores highlight worker timeout keySeq: null, // Unfinished key sequence specialChars: null }; if (options.autofocus && !mobile) { display.input.focus(); } // Override magic textarea content restore that IE sometimes does // on our hidden textarea on reload if (ie && ie_version < 11) { setTimeout(function () { return this$1.display.input.reset(true); }, 20); } registerEventHandlers(this); ensureGlobalHandlers(); startOperation(this); this.curOp.forceUpdate = true; attachDoc(this, doc); if ((options.autofocus && !mobile) || this.hasFocus()) { setTimeout(function () { if (this$1.hasFocus() && !this$1.state.focused) { onFocus(this$1); } }, 20); } else { onBlur(this); } for (var opt in optionHandlers) { if (optionHandlers.hasOwnProperty(opt)) { optionHandlers[opt](this, options[opt], Init); } } maybeUpdateLineNumberWidth(this); if (options.finishInit) { options.finishInit(this); } for (var i = 0; i < initHooks.length; ++i) { initHooks[i](this); } endOperation(this); // Suppress optimizelegibility in Webkit, since it breaks text // measuring on line wrapping boundaries. if (webkit && options.lineWrapping && getComputedStyle(display.lineDiv).textRendering == "optimizelegibility") { display.lineDiv.style.textRendering = "auto"; } } // The default configuration options. CodeMirror.defaults = defaults; // Functions to run when options are changed. CodeMirror.optionHandlers = optionHandlers; // Attach the necessary event handlers when initializing the editor function registerEventHandlers(cm) { var d = cm.display; on(d.scroller, "mousedown", operation(cm, onMouseDown)); // Older IE's will not fire a second mousedown for a double click if (ie && ie_version < 11) { on(d.scroller, "dblclick", operation(cm, function (e) { if (signalDOMEvent(cm, e)) { return } var pos = posFromMouse(cm, e); if (!pos || clickInGutter(cm, e) || eventInWidget(cm.display, e)) { return } e_preventDefault(e); var word = cm.findWordAt(pos); extendSelection(cm.doc, word.anchor, word.head); })); } else { on(d.scroller, "dblclick", function (e) { return signalDOMEvent(cm, e) || e_preventDefault(e); }); } // Some browsers fire contextmenu *after* opening the menu, at // which point we can't mess with it anymore. Context menu is // handled in onMouseDown for these browsers. on(d.scroller, "contextmenu", function (e) { return onContextMenu(cm, e); }); on(d.input.getField(), "contextmenu", function (e) { if (!d.scroller.contains(e.target)) { onContextMenu(cm, e); } }); // Used to suppress mouse event handling when a touch happens var touchFinished, prevTouch = {end: 0}; function finishTouch() { if (d.activeTouch) { touchFinished = setTimeout(function () { return d.activeTouch = null; }, 1000); prevTouch = d.activeTouch; prevTouch.end = +new Date; } } function isMouseLikeTouchEvent(e) { if (e.touches.length != 1) { return false } var touch = e.touches[0]; return touch.radiusX <= 1 && touch.radiusY <= 1 } function farAway(touch, other) { if (other.left == null) { return true } var dx = other.left - touch.left, dy = other.top - touch.top; return dx * dx + dy * dy > 20 * 20 } on(d.scroller, "touchstart", function (e) { if (!signalDOMEvent(cm, e) && !isMouseLikeTouchEvent(e) && !clickInGutter(cm, e)) { d.input.ensurePolled(); clearTimeout(touchFinished); var now = +new Date; d.activeTouch = {start: now, moved: false, prev: now - prevTouch.end <= 300 ? prevTouch : null}; if (e.touches.length == 1) { d.activeTouch.left = e.touches[0].pageX; d.activeTouch.top = e.touches[0].pageY; } } }); on(d.scroller, "touchmove", function () { if (d.activeTouch) { d.activeTouch.moved = true; } }); on(d.scroller, "touchend", function (e) { var touch = d.activeTouch; if (touch && !eventInWidget(d, e) && touch.left != null && !touch.moved && new Date - touch.start < 300) { var pos = cm.coordsChar(d.activeTouch, "page"), range; if (!touch.prev || farAway(touch, touch.prev)) // Single tap { range = new Range(pos, pos); } else if (!touch.prev.prev || farAway(touch, touch.prev.prev)) // Double tap { range = cm.findWordAt(pos); } else // Triple tap { range = new Range(Pos(pos.line, 0), clipPos(cm.doc, Pos(pos.line + 1, 0))); } cm.setSelection(range.anchor, range.head); cm.focus(); e_preventDefault(e); } finishTouch(); }); on(d.scroller, "touchcancel", finishTouch); // Sync scrolling between fake scrollbars and real scrollable // area, ensure viewport is updated when scrolling. on(d.scroller, "scroll", function () { if (d.scroller.clientHeight) { updateScrollTop(cm, d.scroller.scrollTop); setScrollLeft(cm, d.scroller.scrollLeft, true); signal(cm, "scroll", cm); } }); // Listen to wheel events in order to try and update the viewport on time. on(d.scroller, "mousewheel", function (e) { return onScrollWheel(cm, e); }); on(d.scroller, "DOMMouseScroll", function (e) { return onScrollWheel(cm, e); }); // Prevent wrapper from ever scrolling on(d.wrapper, "scroll", function () { return d.wrapper.scrollTop = d.wrapper.scrollLeft = 0; }); d.dragFunctions = { enter: function (e) {if (!signalDOMEvent(cm, e)) { e_stop(e); }}, over: function (e) {if (!signalDOMEvent(cm, e)) { onDragOver(cm, e); e_stop(e); }}, start: function (e) { return onDragStart(cm, e); }, drop: operation(cm, onDrop), leave: function (e) {if (!signalDOMEvent(cm, e)) { clearDragCursor(cm); }} }; var inp = d.input.getField(); on(inp, "keyup", function (e) { return onKeyUp.call(cm, e); }); on(inp, "keydown", operation(cm, onKeyDown)); on(inp, "keypress", operation(cm, onKeyPress)); on(inp, "focus", function (e) { return onFocus(cm, e); }); on(inp, "blur", function (e) { return onBlur(cm, e); }); } var initHooks = []; CodeMirror.defineInitHook = function (f) { return initHooks.push(f); }; // Indent the given line. The how parameter can be "smart", // "add"/null, "subtract", or "prev". When aggressive is false // (typically set to true for forced single-line indents), empty // lines are not indented, and places where the mode returns Pass // are left alone. function indentLine(cm, n, how, aggressive) { var doc = cm.doc, state; if (how == null) { how = "add"; } if (how == "smart") { // Fall back to "prev" when the mode doesn't have an indentation // method. if (!doc.mode.indent) { how = "prev"; } else { state = getContextBefore(cm, n).state; } } var tabSize = cm.options.tabSize; var line = getLine(doc, n), curSpace = countColumn(line.text, null, tabSize); if (line.stateAfter) { line.stateAfter = null; } var curSpaceString = line.text.match(/^\s*/)[0], indentation; if (!aggressive && !/\S/.test(line.text)) { indentation = 0; how = "not"; } else if (how == "smart") { indentation = doc.mode.indent(state, line.text.slice(curSpaceString.length), line.text); if (indentation == Pass || indentation > 150) { if (!aggressive) { return } how = "prev"; } } if (how == "prev") { if (n > doc.first) { indentation = countColumn(getLine(doc, n-1).text, null, tabSize); } else { indentation = 0; } } else if (how == "add") { indentation = curSpace + cm.options.indentUnit; } else if (how == "subtract") { indentation = curSpace - cm.options.indentUnit; } else if (typeof how == "number") { indentation = curSpace + how; } indentation = Math.max(0, indentation); var indentString = "", pos = 0; if (cm.options.indentWithTabs) { for (var i = Math.floor(indentation / tabSize); i; --i) {pos += tabSize; indentString += "\t";} } if (pos < indentation) { indentString += spaceStr(indentation - pos); } if (indentString != curSpaceString) { replaceRange(doc, indentString, Pos(n, 0), Pos(n, curSpaceString.length), "+input"); line.stateAfter = null; return true } else { // Ensure that, if the cursor was in the whitespace at the start // of the line, it is moved to the end of that space. for (var i$1 = 0; i$1 < doc.sel.ranges.length; i$1++) { var range = doc.sel.ranges[i$1]; if (range.head.line == n && range.head.ch < curSpaceString.length) { var pos$1 = Pos(n, curSpaceString.length); replaceOneSelection(doc, i$1, new Range(pos$1, pos$1)); break } } } } // This will be set to a {lineWise: bool, text: [string]} object, so // that, when pasting, we know what kind of selections the copied // text was made out of. var lastCopied = null; function setLastCopied(newLastCopied) { lastCopied = newLastCopied; } function applyTextInput(cm, inserted, deleted, sel, origin) { var doc = cm.doc; cm.display.shift = false; if (!sel) { sel = doc.sel; } var recent = +new Date - 200; var paste = origin == "paste" || cm.state.pasteIncoming > recent; var textLines = splitLinesAuto(inserted), multiPaste = null; // When pasting N lines into N selections, insert one line per selection if (paste && sel.ranges.length > 1) { if (lastCopied && lastCopied.text.join("\n") == inserted) { if (sel.ranges.length % lastCopied.text.length == 0) { multiPaste = []; for (var i = 0; i < lastCopied.text.length; i++) { multiPaste.push(doc.splitLines(lastCopied.text[i])); } } } else if (textLines.length == sel.ranges.length && cm.options.pasteLinesPerSelection) { multiPaste = map(textLines, function (l) { return [l]; }); } } var updateInput = cm.curOp.updateInput; // Normal behavior is to insert the new text into every selection for (var i$1 = sel.ranges.length - 1; i$1 >= 0; i$1--) { var range = sel.ranges[i$1]; var from = range.from(), to = range.to(); if (range.empty()) { if (deleted && deleted > 0) // Handle deletion { from = Pos(from.line, from.ch - deleted); } else if (cm.state.overwrite && !paste) // Handle overwrite { to = Pos(to.line, Math.min(getLine(doc, to.line).text.length, to.ch + lst(textLines).length)); } else if (paste && lastCopied && lastCopied.lineWise && lastCopied.text.join("\n") == textLines.join("\n")) { from = to = Pos(from.line, 0); } } var changeEvent = {from: from, to: to, text: multiPaste ? multiPaste[i$1 % multiPaste.length] : textLines, origin: origin || (paste ? "paste" : cm.state.cutIncoming > recent ? "cut" : "+input")}; makeChange(cm.doc, changeEvent); signalLater(cm, "inputRead", cm, changeEvent); } if (inserted && !paste) { triggerElectric(cm, inserted); } ensureCursorVisible(cm); if (cm.curOp.updateInput < 2) { cm.curOp.updateInput = updateInput; } cm.curOp.typing = true; cm.state.pasteIncoming = cm.state.cutIncoming = -1; } function handlePaste(e, cm) { var pasted = e.clipboardData && e.clipboardData.getData("Text"); if (pasted) { e.preventDefault(); if (!cm.isReadOnly() && !cm.options.disableInput) { runInOp(cm, function () { return applyTextInput(cm, pasted, 0, null, "paste"); }); } return true } } function triggerElectric(cm, inserted) { // When an 'electric' character is inserted, immediately trigger a reindent if (!cm.options.electricChars || !cm.options.smartIndent) { return } var sel = cm.doc.sel; for (var i = sel.ranges.length - 1; i >= 0; i--) { var range = sel.ranges[i]; if (range.head.ch > 100 || (i && sel.ranges[i - 1].head.line == range.head.line)) { continue } var mode = cm.getModeAt(range.head); var indented = false; if (mode.electricChars) { for (var j = 0; j < mode.electricChars.length; j++) { if (inserted.indexOf(mode.electricChars.charAt(j)) > -1) { indented = indentLine(cm, range.head.line, "smart"); break } } } else if (mode.electricInput) { if (mode.electricInput.test(getLine(cm.doc, range.head.line).text.slice(0, range.head.ch))) { indented = indentLine(cm, range.head.line, "smart"); } } if (indented) { signalLater(cm, "electricInput", cm, range.head.line); } } } function copyableRanges(cm) { var text = [], ranges = []; for (var i = 0; i < cm.doc.sel.ranges.length; i++) { var line = cm.doc.sel.ranges[i].head.line; var lineRange = {anchor: Pos(line, 0), head: Pos(line + 1, 0)}; ranges.push(lineRange); text.push(cm.getRange(lineRange.anchor, lineRange.head)); } return {text: text, ranges: ranges} } function disableBrowserMagic(field, spellcheck, autocorrect, autocapitalize) { field.setAttribute("autocorrect", autocorrect ? "" : "off"); field.setAttribute("autocapitalize", autocapitalize ? "" : "off"); field.setAttribute("spellcheck", !!spellcheck); } function hiddenTextarea() { var te = elt("textarea", null, null, "position: absolute; bottom: -1em; padding: 0; width: 1px; height: 1em; outline: none"); var div = elt("div", [te], null, "overflow: hidden; position: relative; width: 3px; height: 0px;"); // The textarea is kept positioned near the cursor to prevent the // fact that it'll be scrolled into view on input from scrolling // our fake cursor out of view. On webkit, when wrap=off, paste is // very slow. So make the area wide instead. if (webkit) { te.style.width = "1000px"; } else { te.setAttribute("wrap", "off"); } // If border: 0; -- iOS fails to open keyboard (issue #1287) if (ios) { te.style.border = "1px solid black"; } disableBrowserMagic(te); return div } // The publicly visible API. Note that methodOp(f) means // 'wrap f in an operation, performed on its `this` parameter'. // This is not the complete set of editor methods. Most of the // methods defined on the Doc type are also injected into // CodeMirror.prototype, for backwards compatibility and // convenience. function addEditorMethods(CodeMirror) { var optionHandlers = CodeMirror.optionHandlers; var helpers = CodeMirror.helpers = {}; CodeMirror.prototype = { constructor: CodeMirror, focus: function(){window.focus(); this.display.input.focus();}, setOption: function(option, value) { var options = this.options, old = options[option]; if (options[option] == value && option != "mode") { return } options[option] = value; if (optionHandlers.hasOwnProperty(option)) { operation(this, optionHandlers[option])(this, value, old); } signal(this, "optionChange", this, option); }, getOption: function(option) {return this.options[option]}, getDoc: function() {return this.doc}, addKeyMap: function(map, bottom) { this.state.keyMaps[bottom ? "push" : "unshift"](getKeyMap(map)); }, removeKeyMap: function(map) { var maps = this.state.keyMaps; for (var i = 0; i < maps.length; ++i) { if (maps[i] == map || maps[i].name == map) { maps.splice(i, 1); return true } } }, addOverlay: methodOp(function(spec, options) { var mode = spec.token ? spec : CodeMirror.getMode(this.options, spec); if (mode.startState) { throw new Error("Overlays may not be stateful.") } insertSorted(this.state.overlays, {mode: mode, modeSpec: spec, opaque: options && options.opaque, priority: (options && options.priority) || 0}, function (overlay) { return overlay.priority; }); this.state.modeGen++; regChange(this); }), removeOverlay: methodOp(function(spec) { var overlays = this.state.overlays; for (var i = 0; i < overlays.length; ++i) { var cur = overlays[i].modeSpec; if (cur == spec || typeof spec == "string" && cur.name == spec) { overlays.splice(i, 1); this.state.modeGen++; regChange(this); return } } }), indentLine: methodOp(function(n, dir, aggressive) { if (typeof dir != "string" && typeof dir != "number") { if (dir == null) { dir = this.options.smartIndent ? "smart" : "prev"; } else { dir = dir ? "add" : "subtract"; } } if (isLine(this.doc, n)) { indentLine(this, n, dir, aggressive); } }), indentSelection: methodOp(function(how) { var ranges = this.doc.sel.ranges, end = -1; for (var i = 0; i < ranges.length; i++) { var range = ranges[i]; if (!range.empty()) { var from = range.from(), to = range.to(); var start = Math.max(end, from.line); end = Math.min(this.lastLine(), to.line - (to.ch ? 0 : 1)) + 1; for (var j = start; j < end; ++j) { indentLine(this, j, how); } var newRanges = this.doc.sel.ranges; if (from.ch == 0 && ranges.length == newRanges.length && newRanges[i].from().ch > 0) { replaceOneSelection(this.doc, i, new Range(from, newRanges[i].to()), sel_dontScroll); } } else if (range.head.line > end) { indentLine(this, range.head.line, how, true); end = range.head.line; if (i == this.doc.sel.primIndex) { ensureCursorVisible(this); } } } }), // Fetch the parser token for a given character. Useful for hacks // that want to inspect the mode state (say, for completion). getTokenAt: function(pos, precise) { return takeToken(this, pos, precise) }, getLineTokens: function(line, precise) { return takeToken(this, Pos(line), precise, true) }, getTokenTypeAt: function(pos) { pos = clipPos(this.doc, pos); var styles = getLineStyles(this, getLine(this.doc, pos.line)); var before = 0, after = (styles.length - 1) / 2, ch = pos.ch; var type; if (ch == 0) { type = styles[2]; } else { for (;;) { var mid = (before + after) >> 1; if ((mid ? styles[mid * 2 - 1] : 0) >= ch) { after = mid; } else if (styles[mid * 2 + 1] < ch) { before = mid + 1; } else { type = styles[mid * 2 + 2]; break } } } var cut = type ? type.indexOf("overlay ") : -1; return cut < 0 ? type : cut == 0 ? null : type.slice(0, cut - 1) }, getModeAt: function(pos) { var mode = this.doc.mode; if (!mode.innerMode) { return mode } return CodeMirror.innerMode(mode, this.getTokenAt(pos).state).mode }, getHelper: function(pos, type) { return this.getHelpers(pos, type)[0] }, getHelpers: function(pos, type) { var found = []; if (!helpers.hasOwnProperty(type)) { return found } var help = helpers[type], mode = this.getModeAt(pos); if (typeof mode[type] == "string") { if (help[mode[type]]) { found.push(help[mode[type]]); } } else if (mode[type]) { for (var i = 0; i < mode[type].length; i++) { var val = help[mode[type][i]]; if (val) { found.push(val); } } } else if (mode.helperType && help[mode.helperType]) { found.push(help[mode.helperType]); } else if (help[mode.name]) { found.push(help[mode.name]); } for (var i$1 = 0; i$1 < help._global.length; i$1++) { var cur = help._global[i$1]; if (cur.pred(mode, this) && indexOf(found, cur.val) == -1) { found.push(cur.val); } } return found }, getStateAfter: function(line, precise) { var doc = this.doc; line = clipLine(doc, line == null ? doc.first + doc.size - 1: line); return getContextBefore(this, line + 1, precise).state }, cursorCoords: function(start, mode) { var pos, range = this.doc.sel.primary(); if (start == null) { pos = range.head; } else if (typeof start == "object") { pos = clipPos(this.doc, start); } else { pos = start ? range.from() : range.to(); } return cursorCoords(this, pos, mode || "page") }, charCoords: function(pos, mode) { return charCoords(this, clipPos(this.doc, pos), mode || "page") }, coordsChar: function(coords, mode) { coords = fromCoordSystem(this, coords, mode || "page"); return coordsChar(this, coords.left, coords.top) }, lineAtHeight: function(height, mode) { height = fromCoordSystem(this, {top: height, left: 0}, mode || "page").top; return lineAtHeight(this.doc, height + this.display.viewOffset) }, heightAtLine: function(line, mode, includeWidgets) { var end = false, lineObj; if (typeof line == "number") { var last = this.doc.first + this.doc.size - 1; if (line < this.doc.first) { line = this.doc.first; } else if (line > last) { line = last; end = true; } lineObj = getLine(this.doc, line); } else { lineObj = line; } return intoCoordSystem(this, lineObj, {top: 0, left: 0}, mode || "page", includeWidgets || end).top + (end ? this.doc.height - heightAtLine(lineObj) : 0) }, defaultTextHeight: function() { return textHeight(this.display) }, defaultCharWidth: function() { return charWidth(this.display) }, getViewport: function() { return {from: this.display.viewFrom, to: this.display.viewTo}}, addWidget: function(pos, node, scroll, vert, horiz) { var display = this.display; pos = cursorCoords(this, clipPos(this.doc, pos)); var top = pos.bottom, left = pos.left; node.style.position = "absolute"; node.setAttribute("cm-ignore-events", "true"); this.display.input.setUneditable(node); display.sizer.appendChild(node); if (vert == "over") { top = pos.top; } else if (vert == "above" || vert == "near") { var vspace = Math.max(display.wrapper.clientHeight, this.doc.height), hspace = Math.max(display.sizer.clientWidth, display.lineSpace.clientWidth); // Default to positioning above (if specified and possible); otherwise default to positioning below if ((vert == 'above' || pos.bottom + node.offsetHeight > vspace) && pos.top > node.offsetHeight) { top = pos.top - node.offsetHeight; } else if (pos.bottom + node.offsetHeight <= vspace) { top = pos.bottom; } if (left + node.offsetWidth > hspace) { left = hspace - node.offsetWidth; } } node.style.top = top + "px"; node.style.left = node.style.right = ""; if (horiz == "right") { left = display.sizer.clientWidth - node.offsetWidth; node.style.right = "0px"; } else { if (horiz == "left") { left = 0; } else if (horiz == "middle") { left = (display.sizer.clientWidth - node.offsetWidth) / 2; } node.style.left = left + "px"; } if (scroll) { scrollIntoView(this, {left: left, top: top, right: left + node.offsetWidth, bottom: top + node.offsetHeight}); } }, triggerOnKeyDown: methodOp(onKeyDown), triggerOnKeyPress: methodOp(onKeyPress), triggerOnKeyUp: onKeyUp, triggerOnMouseDown: methodOp(onMouseDown), execCommand: function(cmd) { if (commands.hasOwnProperty(cmd)) { return commands[cmd].call(null, this) } }, triggerElectric: methodOp(function(text) { triggerElectric(this, text); }), findPosH: function(from, amount, unit, visually) { var dir = 1; if (amount < 0) { dir = -1; amount = -amount; } var cur = clipPos(this.doc, from); for (var i = 0; i < amount; ++i) { cur = findPosH(this.doc, cur, dir, unit, visually); if (cur.hitSide) { break } } return cur }, moveH: methodOp(function(dir, unit) { var this$1 = this; this.extendSelectionsBy(function (range) { if (this$1.display.shift || this$1.doc.extend || range.empty()) { return findPosH(this$1.doc, range.head, dir, unit, this$1.options.rtlMoveVisually) } else { return dir < 0 ? range.from() : range.to() } }, sel_move); }), deleteH: methodOp(function(dir, unit) { var sel = this.doc.sel, doc = this.doc; if (sel.somethingSelected()) { doc.replaceSelection("", null, "+delete"); } else { deleteNearSelection(this, function (range) { var other = findPosH(doc, range.head, dir, unit, false); return dir < 0 ? {from: other, to: range.head} : {from: range.head, to: other} }); } }), findPosV: function(from, amount, unit, goalColumn) { var dir = 1, x = goalColumn; if (amount < 0) { dir = -1; amount = -amount; } var cur = clipPos(this.doc, from); for (var i = 0; i < amount; ++i) { var coords = cursorCoords(this, cur, "div"); if (x == null) { x = coords.left; } else { coords.left = x; } cur = findPosV(this, coords, dir, unit); if (cur.hitSide) { break } } return cur }, moveV: methodOp(function(dir, unit) { var this$1 = this; var doc = this.doc, goals = []; var collapse = !this.display.shift && !doc.extend && doc.sel.somethingSelected(); doc.extendSelectionsBy(function (range) { if (collapse) { return dir < 0 ? range.from() : range.to() } var headPos = cursorCoords(this$1, range.head, "div"); if (range.goalColumn != null) { headPos.left = range.goalColumn; } goals.push(headPos.left); var pos = findPosV(this$1, headPos, dir, unit); if (unit == "page" && range == doc.sel.primary()) { addToScrollTop(this$1, charCoords(this$1, pos, "div").top - headPos.top); } return pos }, sel_move); if (goals.length) { for (var i = 0; i < doc.sel.ranges.length; i++) { doc.sel.ranges[i].goalColumn = goals[i]; } } }), // Find the word at the given position (as returned by coordsChar). findWordAt: function(pos) { var doc = this.doc, line = getLine(doc, pos.line).text; var start = pos.ch, end = pos.ch; if (line) { var helper = this.getHelper(pos, "wordChars"); if ((pos.sticky == "before" || end == line.length) && start) { --start; } else { ++end; } var startChar = line.charAt(start); var check = isWordChar(startChar, helper) ? function (ch) { return isWordChar(ch, helper); } : /\s/.test(startChar) ? function (ch) { return /\s/.test(ch); } : function (ch) { return (!/\s/.test(ch) && !isWordChar(ch)); }; while (start > 0 && check(line.charAt(start - 1))) { --start; } while (end < line.length && check(line.charAt(end))) { ++end; } } return new Range(Pos(pos.line, start), Pos(pos.line, end)) }, toggleOverwrite: function(value) { if (value != null && value == this.state.overwrite) { return } if (this.state.overwrite = !this.state.overwrite) { addClass(this.display.cursorDiv, "CodeMirror-overwrite"); } else { rmClass(this.display.cursorDiv, "CodeMirror-overwrite"); } signal(this, "overwriteToggle", this, this.state.overwrite); }, hasFocus: function() { return this.display.input.getField() == activeElt() }, isReadOnly: function() { return !!(this.options.readOnly || this.doc.cantEdit) }, scrollTo: methodOp(function (x, y) { scrollToCoords(this, x, y); }), getScrollInfo: function() { var scroller = this.display.scroller; return {left: scroller.scrollLeft, top: scroller.scrollTop, height: scroller.scrollHeight - scrollGap(this) - this.display.barHeight, width: scroller.scrollWidth - scrollGap(this) - this.display.barWidth, clientHeight: displayHeight(this), clientWidth: displayWidth(this)} }, scrollIntoView: methodOp(function(range, margin) { if (range == null) { range = {from: this.doc.sel.primary().head, to: null}; if (margin == null) { margin = this.options.cursorScrollMargin; } } else if (typeof range == "number") { range = {from: Pos(range, 0), to: null}; } else if (range.from == null) { range = {from: range, to: null}; } if (!range.to) { range.to = range.from; } range.margin = margin || 0; if (range.from.line != null) { scrollToRange(this, range); } else { scrollToCoordsRange(this, range.from, range.to, range.margin); } }), setSize: methodOp(function(width, height) { var this$1 = this; var interpret = function (val) { return typeof val == "number" || /^\d+$/.test(String(val)) ? val + "px" : val; }; if (width != null) { this.display.wrapper.style.width = interpret(width); } if (height != null) { this.display.wrapper.style.height = interpret(height); } if (this.options.lineWrapping) { clearLineMeasurementCache(this); } var lineNo = this.display.viewFrom; this.doc.iter(lineNo, this.display.viewTo, function (line) { if (line.widgets) { for (var i = 0; i < line.widgets.length; i++) { if (line.widgets[i].noHScroll) { regLineChange(this$1, lineNo, "widget"); break } } } ++lineNo; }); this.curOp.forceUpdate = true; signal(this, "refresh", this); }), operation: function(f){return runInOp(this, f)}, startOperation: function(){return startOperation(this)}, endOperation: function(){return endOperation(this)}, refresh: methodOp(function() { var oldHeight = this.display.cachedTextHeight; regChange(this); this.curOp.forceUpdate = true; clearCaches(this); scrollToCoords(this, this.doc.scrollLeft, this.doc.scrollTop); updateGutterSpace(this.display); if (oldHeight == null || Math.abs(oldHeight - textHeight(this.display)) > .5 || this.options.lineWrapping) { estimateLineHeights(this); } signal(this, "refresh", this); }), swapDoc: methodOp(function(doc) { var old = this.doc; old.cm = null; // Cancel the current text selection if any (#5821) if (this.state.selectingText) { this.state.selectingText(); } attachDoc(this, doc); clearCaches(this); this.display.input.reset(); scrollToCoords(this, doc.scrollLeft, doc.scrollTop); this.curOp.forceScroll = true; signalLater(this, "swapDoc", this, old); return old }), phrase: function(phraseText) { var phrases = this.options.phrases; return phrases && Object.prototype.hasOwnProperty.call(phrases, phraseText) ? phrases[phraseText] : phraseText }, getInputField: function(){return this.display.input.getField()}, getWrapperElement: function(){return this.display.wrapper}, getScrollerElement: function(){return this.display.scroller}, getGutterElement: function(){return this.display.gutters} }; eventMixin(CodeMirror); CodeMirror.registerHelper = function(type, name, value) { if (!helpers.hasOwnProperty(type)) { helpers[type] = CodeMirror[type] = {_global: []}; } helpers[type][name] = value; }; CodeMirror.registerGlobalHelper = function(type, name, predicate, value) { CodeMirror.registerHelper(type, name, value); helpers[type]._global.push({pred: predicate, val: value}); }; } // Used for horizontal relative motion. Dir is -1 or 1 (left or // right), unit can be "codepoint", "char", "column" (like char, but // doesn't cross line boundaries), "word" (across next word), or // "group" (to the start of next group of word or // non-word-non-whitespace chars). The visually param controls // whether, in right-to-left text, direction 1 means to move towards // the next index in the string, or towards the character to the right // of the current position. The resulting position will have a // hitSide=true property if it reached the end of the document. function findPosH(doc, pos, dir, unit, visually) { var oldPos = pos; var origDir = dir; var lineObj = getLine(doc, pos.line); var lineDir = visually && doc.direction == "rtl" ? -dir : dir; function findNextLine() { var l = pos.line + lineDir; if (l < doc.first || l >= doc.first + doc.size) { return false } pos = new Pos(l, pos.ch, pos.sticky); return lineObj = getLine(doc, l) } function moveOnce(boundToLine) { var next; if (unit == "codepoint") { var ch = lineObj.text.charCodeAt(pos.ch + (dir > 0 ? 0 : -1)); if (isNaN(ch)) { next = null; } else { var astral = dir > 0 ? ch >= 0xD800 && ch < 0xDC00 : ch >= 0xDC00 && ch < 0xDFFF; next = new Pos(pos.line, Math.max(0, Math.min(lineObj.text.length, pos.ch + dir * (astral ? 2 : 1))), -dir); } } else if (visually) { next = moveVisually(doc.cm, lineObj, pos, dir); } else { next = moveLogically(lineObj, pos, dir); } if (next == null) { if (!boundToLine && findNextLine()) { pos = endOfLine(visually, doc.cm, lineObj, pos.line, lineDir); } else { return false } } else { pos = next; } return true } if (unit == "char" || unit == "codepoint") { moveOnce(); } else if (unit == "column") { moveOnce(true); } else if (unit == "word" || unit == "group") { var sawType = null, group = unit == "group"; var helper = doc.cm && doc.cm.getHelper(pos, "wordChars"); for (var first = true;; first = false) { if (dir < 0 && !moveOnce(!first)) { break } var cur = lineObj.text.charAt(pos.ch) || "\n"; var type = isWordChar(cur, helper) ? "w" : group && cur == "\n" ? "n" : !group || /\s/.test(cur) ? null : "p"; if (group && !first && !type) { type = "s"; } if (sawType && sawType != type) { if (dir < 0) {dir = 1; moveOnce(); pos.sticky = "after";} break } if (type) { sawType = type; } if (dir > 0 && !moveOnce(!first)) { break } } } var result = skipAtomic(doc, pos, oldPos, origDir, true); if (equalCursorPos(oldPos, result)) { result.hitSide = true; } return result } // For relative vertical movement. Dir may be -1 or 1. Unit can be // "page" or "line". The resulting position will have a hitSide=true // property if it reached the end of the document. function findPosV(cm, pos, dir, unit) { var doc = cm.doc, x = pos.left, y; if (unit == "page") { var pageSize = Math.min(cm.display.wrapper.clientHeight, window.innerHeight || document.documentElement.clientHeight); var moveAmount = Math.max(pageSize - .5 * textHeight(cm.display), 3); y = (dir > 0 ? pos.bottom : pos.top) + dir * moveAmount; } else if (unit == "line") { y = dir > 0 ? pos.bottom + 3 : pos.top - 3; } var target; for (;;) { target = coordsChar(cm, x, y); if (!target.outside) { break } if (dir < 0 ? y <= 0 : y >= doc.height) { target.hitSide = true; break } y += dir * 5; } return target } // CONTENTEDITABLE INPUT STYLE var ContentEditableInput = function(cm) { this.cm = cm; this.lastAnchorNode = this.lastAnchorOffset = this.lastFocusNode = this.lastFocusOffset = null; this.polling = new Delayed(); this.composing = null; this.gracePeriod = false; this.readDOMTimeout = null; }; ContentEditableInput.prototype.init = function (display) { var this$1 = this; var input = this, cm = input.cm; var div = input.div = display.lineDiv; disableBrowserMagic(div, cm.options.spellcheck, cm.options.autocorrect, cm.options.autocapitalize); function belongsToInput(e) { for (var t = e.target; t; t = t.parentNode) { if (t == div) { return true } if (/\bCodeMirror-(?:line)?widget\b/.test(t.className)) { break } } return false } on(div, "paste", function (e) { if (!belongsToInput(e) || signalDOMEvent(cm, e) || handlePaste(e, cm)) { return } // IE doesn't fire input events, so we schedule a read for the pasted content in this way if (ie_version <= 11) { setTimeout(operation(cm, function () { return this$1.updateFromDOM(); }), 20); } }); on(div, "compositionstart", function (e) { this$1.composing = {data: e.data, done: false}; }); on(div, "compositionupdate", function (e) { if (!this$1.composing) { this$1.composing = {data: e.data, done: false}; } }); on(div, "compositionend", function (e) { if (this$1.composing) { if (e.data != this$1.composing.data) { this$1.readFromDOMSoon(); } this$1.composing.done = true; } }); on(div, "touchstart", function () { return input.forceCompositionEnd(); }); on(div, "input", function () { if (!this$1.composing) { this$1.readFromDOMSoon(); } }); function onCopyCut(e) { if (!belongsToInput(e) || signalDOMEvent(cm, e)) { return } if (cm.somethingSelected()) { setLastCopied({lineWise: false, text: cm.getSelections()}); if (e.type == "cut") { cm.replaceSelection("", null, "cut"); } } else if (!cm.options.lineWiseCopyCut) { return } else { var ranges = copyableRanges(cm); setLastCopied({lineWise: true, text: ranges.text}); if (e.type == "cut") { cm.operation(function () { cm.setSelections(ranges.ranges, 0, sel_dontScroll); cm.replaceSelection("", null, "cut"); }); } } if (e.clipboardData) { e.clipboardData.clearData(); var content = lastCopied.text.join("\n"); // iOS exposes the clipboard API, but seems to discard content inserted into it e.clipboardData.setData("Text", content); if (e.clipboardData.getData("Text") == content) { e.preventDefault(); return } } // Old-fashioned briefly-focus-a-textarea hack var kludge = hiddenTextarea(), te = kludge.firstChild; cm.display.lineSpace.insertBefore(kludge, cm.display.lineSpace.firstChild); te.value = lastCopied.text.join("\n"); var hadFocus = document.activeElement; selectInput(te); setTimeout(function () { cm.display.lineSpace.removeChild(kludge); hadFocus.focus(); if (hadFocus == div) { input.showPrimarySelection(); } }, 50); } on(div, "copy", onCopyCut); on(div, "cut", onCopyCut); }; ContentEditableInput.prototype.screenReaderLabelChanged = function (label) { // Label for screenreaders, accessibility if(label) { this.div.setAttribute('aria-label', label); } else { this.div.removeAttribute('aria-label'); } }; ContentEditableInput.prototype.prepareSelection = function () { var result = prepareSelection(this.cm, false); result.focus = document.activeElement == this.div; return result }; ContentEditableInput.prototype.showSelection = function (info, takeFocus) { if (!info || !this.cm.display.view.length) { return } if (info.focus || takeFocus) { this.showPrimarySelection(); } this.showMultipleSelections(info); }; ContentEditableInput.prototype.getSelection = function () { return this.cm.display.wrapper.ownerDocument.getSelection() }; ContentEditableInput.prototype.showPrimarySelection = function () { var sel = this.getSelection(), cm = this.cm, prim = cm.doc.sel.primary(); var from = prim.from(), to = prim.to(); if (cm.display.viewTo == cm.display.viewFrom || from.line >= cm.display.viewTo || to.line < cm.display.viewFrom) { sel.removeAllRanges(); return } var curAnchor = domToPos(cm, sel.anchorNode, sel.anchorOffset); var curFocus = domToPos(cm, sel.focusNode, sel.focusOffset); if (curAnchor && !curAnchor.bad && curFocus && !curFocus.bad && cmp(minPos(curAnchor, curFocus), from) == 0 && cmp(maxPos(curAnchor, curFocus), to) == 0) { return } var view = cm.display.view; var start = (from.line >= cm.display.viewFrom && posToDOM(cm, from)) || {node: view[0].measure.map[2], offset: 0}; var end = to.line < cm.display.viewTo && posToDOM(cm, to); if (!end) { var measure = view[view.length - 1].measure; var map = measure.maps ? measure.maps[measure.maps.length - 1] : measure.map; end = {node: map[map.length - 1], offset: map[map.length - 2] - map[map.length - 3]}; } if (!start || !end) { sel.removeAllRanges(); return } var old = sel.rangeCount && sel.getRangeAt(0), rng; try { rng = range(start.node, start.offset, end.offset, end.node); } catch(e) {} // Our model of the DOM might be outdated, in which case the range we try to set can be impossible if (rng) { if (!gecko && cm.state.focused) { sel.collapse(start.node, start.offset); if (!rng.collapsed) { sel.removeAllRanges(); sel.addRange(rng); } } else { sel.removeAllRanges(); sel.addRange(rng); } if (old && sel.anchorNode == null) { sel.addRange(old); } else if (gecko) { this.startGracePeriod(); } } this.rememberSelection(); }; ContentEditableInput.prototype.startGracePeriod = function () { var this$1 = this; clearTimeout(this.gracePeriod); this.gracePeriod = setTimeout(function () { this$1.gracePeriod = false; if (this$1.selectionChanged()) { this$1.cm.operation(function () { return this$1.cm.curOp.selectionChanged = true; }); } }, 20); }; ContentEditableInput.prototype.showMultipleSelections = function (info) { removeChildrenAndAdd(this.cm.display.cursorDiv, info.cursors); removeChildrenAndAdd(this.cm.display.selectionDiv, info.selection); }; ContentEditableInput.prototype.rememberSelection = function () { var sel = this.getSelection(); this.lastAnchorNode = sel.anchorNode; this.lastAnchorOffset = sel.anchorOffset; this.lastFocusNode = sel.focusNode; this.lastFocusOffset = sel.focusOffset; }; ContentEditableInput.prototype.selectionInEditor = function () { var sel = this.getSelection(); if (!sel.rangeCount) { return false } var node = sel.getRangeAt(0).commonAncestorContainer; return contains(this.div, node) }; ContentEditableInput.prototype.focus = function () { if (this.cm.options.readOnly != "nocursor") { if (!this.selectionInEditor() || document.activeElement != this.div) { this.showSelection(this.prepareSelection(), true); } this.div.focus(); } }; ContentEditableInput.prototype.blur = function () { this.div.blur(); }; ContentEditableInput.prototype.getField = function () { return this.div }; ContentEditableInput.prototype.supportsTouch = function () { return true }; ContentEditableInput.prototype.receivedFocus = function () { var input = this; if (this.selectionInEditor()) { this.pollSelection(); } else { runInOp(this.cm, function () { return input.cm.curOp.selectionChanged = true; }); } function poll() { if (input.cm.state.focused) { input.pollSelection(); input.polling.set(input.cm.options.pollInterval, poll); } } this.polling.set(this.cm.options.pollInterval, poll); }; ContentEditableInput.prototype.selectionChanged = function () { var sel = this.getSelection(); return sel.anchorNode != this.lastAnchorNode || sel.anchorOffset != this.lastAnchorOffset || sel.focusNode != this.lastFocusNode || sel.focusOffset != this.lastFocusOffset }; ContentEditableInput.prototype.pollSelection = function () { if (this.readDOMTimeout != null || this.gracePeriod || !this.selectionChanged()) { return } var sel = this.getSelection(), cm = this.cm; // On Android Chrome (version 56, at least), backspacing into an // uneditable block element will put the cursor in that element, // and then, because it's not editable, hide the virtual keyboard. // Because Android doesn't allow us to actually detect backspace // presses in a sane way, this code checks for when that happens // and simulates a backspace press in this case. if (android && chrome && this.cm.display.gutterSpecs.length && isInGutter(sel.anchorNode)) { this.cm.triggerOnKeyDown({type: "keydown", keyCode: 8, preventDefault: Math.abs}); this.blur(); this.focus(); return } if (this.composing) { return } this.rememberSelection(); var anchor = domToPos(cm, sel.anchorNode, sel.anchorOffset); var head = domToPos(cm, sel.focusNode, sel.focusOffset); if (anchor && head) { runInOp(cm, function () { setSelection(cm.doc, simpleSelection(anchor, head), sel_dontScroll); if (anchor.bad || head.bad) { cm.curOp.selectionChanged = true; } }); } }; ContentEditableInput.prototype.pollContent = function () { if (this.readDOMTimeout != null) { clearTimeout(this.readDOMTimeout); this.readDOMTimeout = null; } var cm = this.cm, display = cm.display, sel = cm.doc.sel.primary(); var from = sel.from(), to = sel.to(); if (from.ch == 0 && from.line > cm.firstLine()) { from = Pos(from.line - 1, getLine(cm.doc, from.line - 1).length); } if (to.ch == getLine(cm.doc, to.line).text.length && to.line < cm.lastLine()) { to = Pos(to.line + 1, 0); } if (from.line < display.viewFrom || to.line > display.viewTo - 1) { return false } var fromIndex, fromLine, fromNode; if (from.line == display.viewFrom || (fromIndex = findViewIndex(cm, from.line)) == 0) { fromLine = lineNo(display.view[0].line); fromNode = display.view[0].node; } else { fromLine = lineNo(display.view[fromIndex].line); fromNode = display.view[fromIndex - 1].node.nextSibling; } var toIndex = findViewIndex(cm, to.line); var toLine, toNode; if (toIndex == display.view.length - 1) { toLine = display.viewTo - 1; toNode = display.lineDiv.lastChild; } else { toLine = lineNo(display.view[toIndex + 1].line) - 1; toNode = display.view[toIndex + 1].node.previousSibling; } if (!fromNode) { return false } var newText = cm.doc.splitLines(domTextBetween(cm, fromNode, toNode, fromLine, toLine)); var oldText = getBetween(cm.doc, Pos(fromLine, 0), Pos(toLine, getLine(cm.doc, toLine).text.length)); while (newText.length > 1 && oldText.length > 1) { if (lst(newText) == lst(oldText)) { newText.pop(); oldText.pop(); toLine--; } else if (newText[0] == oldText[0]) { newText.shift(); oldText.shift(); fromLine++; } else { break } } var cutFront = 0, cutEnd = 0; var newTop = newText[0], oldTop = oldText[0], maxCutFront = Math.min(newTop.length, oldTop.length); while (cutFront < maxCutFront && newTop.charCodeAt(cutFront) == oldTop.charCodeAt(cutFront)) { ++cutFront; } var newBot = lst(newText), oldBot = lst(oldText); var maxCutEnd = Math.min(newBot.length - (newText.length == 1 ? cutFront : 0), oldBot.length - (oldText.length == 1 ? cutFront : 0)); while (cutEnd < maxCutEnd && newBot.charCodeAt(newBot.length - cutEnd - 1) == oldBot.charCodeAt(oldBot.length - cutEnd - 1)) { ++cutEnd; } // Try to move start of change to start of selection if ambiguous if (newText.length == 1 && oldText.length == 1 && fromLine == from.line) { while (cutFront && cutFront > from.ch && newBot.charCodeAt(newBot.length - cutEnd - 1) == oldBot.charCodeAt(oldBot.length - cutEnd - 1)) { cutFront--; cutEnd++; } } newText[newText.length - 1] = newBot.slice(0, newBot.length - cutEnd).replace(/^\u200b+/, ""); newText[0] = newText[0].slice(cutFront).replace(/\u200b+$/, ""); var chFrom = Pos(fromLine, cutFront); var chTo = Pos(toLine, oldText.length ? lst(oldText).length - cutEnd : 0); if (newText.length > 1 || newText[0] || cmp(chFrom, chTo)) { replaceRange(cm.doc, newText, chFrom, chTo, "+input"); return true } }; ContentEditableInput.prototype.ensurePolled = function () { this.forceCompositionEnd(); }; ContentEditableInput.prototype.reset = function () { this.forceCompositionEnd(); }; ContentEditableInput.prototype.forceCompositionEnd = function () { if (!this.composing) { return } clearTimeout(this.readDOMTimeout); this.composing = null; this.updateFromDOM(); this.div.blur(); this.div.focus(); }; ContentEditableInput.prototype.readFromDOMSoon = function () { var this$1 = this; if (this.readDOMTimeout != null) { return } this.readDOMTimeout = setTimeout(function () { this$1.readDOMTimeout = null; if (this$1.composing) { if (this$1.composing.done) { this$1.composing = null; } else { return } } this$1.updateFromDOM(); }, 80); }; ContentEditableInput.prototype.updateFromDOM = function () { var this$1 = this; if (this.cm.isReadOnly() || !this.pollContent()) { runInOp(this.cm, function () { return regChange(this$1.cm); }); } }; ContentEditableInput.prototype.setUneditable = function (node) { node.contentEditable = "false"; }; ContentEditableInput.prototype.onKeyPress = function (e) { if (e.charCode == 0 || this.composing) { return } e.preventDefault(); if (!this.cm.isReadOnly()) { operation(this.cm, applyTextInput)(this.cm, String.fromCharCode(e.charCode == null ? e.keyCode : e.charCode), 0); } }; ContentEditableInput.prototype.readOnlyChanged = function (val) { this.div.contentEditable = String(val != "nocursor"); }; ContentEditableInput.prototype.onContextMenu = function () {}; ContentEditableInput.prototype.resetPosition = function () {}; ContentEditableInput.prototype.needsContentAttribute = true; function posToDOM(cm, pos) { var view = findViewForLine(cm, pos.line); if (!view || view.hidden) { return null } var line = getLine(cm.doc, pos.line); var info = mapFromLineView(view, line, pos.line); var order = getOrder(line, cm.doc.direction), side = "left"; if (order) { var partPos = getBidiPartAt(order, pos.ch); side = partPos % 2 ? "right" : "left"; } var result = nodeAndOffsetInLineMap(info.map, pos.ch, side); result.offset = result.collapse == "right" ? result.end : result.start; return result } function isInGutter(node) { for (var scan = node; scan; scan = scan.parentNode) { if (/CodeMirror-gutter-wrapper/.test(scan.className)) { return true } } return false } function badPos(pos, bad) { if (bad) { pos.bad = true; } return pos } function domTextBetween(cm, from, to, fromLine, toLine) { var text = "", closing = false, lineSep = cm.doc.lineSeparator(), extraLinebreak = false; function recognizeMarker(id) { return function (marker) { return marker.id == id; } } function close() { if (closing) { text += lineSep; if (extraLinebreak) { text += lineSep; } closing = extraLinebreak = false; } } function addText(str) { if (str) { close(); text += str; } } function walk(node) { if (node.nodeType == 1) { var cmText = node.getAttribute("cm-text"); if (cmText) { addText(cmText); return } var markerID = node.getAttribute("cm-marker"), range; if (markerID) { var found = cm.findMarks(Pos(fromLine, 0), Pos(toLine + 1, 0), recognizeMarker(+markerID)); if (found.length && (range = found[0].find(0))) { addText(getBetween(cm.doc, range.from, range.to).join(lineSep)); } return } if (node.getAttribute("contenteditable") == "false") { return } var isBlock = /^(pre|div|p|li|table|br)$/i.test(node.nodeName); if (!/^br$/i.test(node.nodeName) && node.textContent.length == 0) { return } if (isBlock) { close(); } for (var i = 0; i < node.childNodes.length; i++) { walk(node.childNodes[i]); } if (/^(pre|p)$/i.test(node.nodeName)) { extraLinebreak = true; } if (isBlock) { closing = true; } } else if (node.nodeType == 3) { addText(node.nodeValue.replace(/\u200b/g, "").replace(/\u00a0/g, " ")); } } for (;;) { walk(from); if (from == to) { break } from = from.nextSibling; extraLinebreak = false; } return text } function domToPos(cm, node, offset) { var lineNode; if (node == cm.display.lineDiv) { lineNode = cm.display.lineDiv.childNodes[offset]; if (!lineNode) { return badPos(cm.clipPos(Pos(cm.display.viewTo - 1)), true) } node = null; offset = 0; } else { for (lineNode = node;; lineNode = lineNode.parentNode) { if (!lineNode || lineNode == cm.display.lineDiv) { return null } if (lineNode.parentNode && lineNode.parentNode == cm.display.lineDiv) { break } } } for (var i = 0; i < cm.display.view.length; i++) { var lineView = cm.display.view[i]; if (lineView.node == lineNode) { return locateNodeInLineView(lineView, node, offset) } } } function locateNodeInLineView(lineView, node, offset) { var wrapper = lineView.text.firstChild, bad = false; if (!node || !contains(wrapper, node)) { return badPos(Pos(lineNo(lineView.line), 0), true) } if (node == wrapper) { bad = true; node = wrapper.childNodes[offset]; offset = 0; if (!node) { var line = lineView.rest ? lst(lineView.rest) : lineView.line; return badPos(Pos(lineNo(line), line.text.length), bad) } } var textNode = node.nodeType == 3 ? node : null, topNode = node; if (!textNode && node.childNodes.length == 1 && node.firstChild.nodeType == 3) { textNode = node.firstChild; if (offset) { offset = textNode.nodeValue.length; } } while (topNode.parentNode != wrapper) { topNode = topNode.parentNode; } var measure = lineView.measure, maps = measure.maps; function find(textNode, topNode, offset) { for (var i = -1; i < (maps ? maps.length : 0); i++) { var map = i < 0 ? measure.map : maps[i]; for (var j = 0; j < map.length; j += 3) { var curNode = map[j + 2]; if (curNode == textNode || curNode == topNode) { var line = lineNo(i < 0 ? lineView.line : lineView.rest[i]); var ch = map[j] + offset; if (offset < 0 || curNode != textNode) { ch = map[j + (offset ? 1 : 0)]; } return Pos(line, ch) } } } } var found = find(textNode, topNode, offset); if (found) { return badPos(found, bad) } // FIXME this is all really shaky. might handle the few cases it needs to handle, but likely to cause problems for (var after = topNode.nextSibling, dist = textNode ? textNode.nodeValue.length - offset : 0; after; after = after.nextSibling) { found = find(after, after.firstChild, 0); if (found) { return badPos(Pos(found.line, found.ch - dist), bad) } else { dist += after.textContent.length; } } for (var before = topNode.previousSibling, dist$1 = offset; before; before = before.previousSibling) { found = find(before, before.firstChild, -1); if (found) { return badPos(Pos(found.line, found.ch + dist$1), bad) } else { dist$1 += before.textContent.length; } } } // TEXTAREA INPUT STYLE var TextareaInput = function(cm) { this.cm = cm; // See input.poll and input.reset this.prevInput = ""; // Flag that indicates whether we expect input to appear real soon // now (after some event like 'keypress' or 'input') and are // polling intensively. this.pollingFast = false; // Self-resetting timeout for the poller this.polling = new Delayed(); // Used to work around IE issue with selection being forgotten when focus moves away from textarea this.hasSelection = false; this.composing = null; }; TextareaInput.prototype.init = function (display) { var this$1 = this; var input = this, cm = this.cm; this.createField(display); var te = this.textarea; display.wrapper.insertBefore(this.wrapper, display.wrapper.firstChild); // Needed to hide big blue blinking cursor on Mobile Safari (doesn't seem to work in iOS 8 anymore) if (ios) { te.style.width = "0px"; } on(te, "input", function () { if (ie && ie_version >= 9 && this$1.hasSelection) { this$1.hasSelection = null; } input.poll(); }); on(te, "paste", function (e) { if (signalDOMEvent(cm, e) || handlePaste(e, cm)) { return } cm.state.pasteIncoming = +new Date; input.fastPoll(); }); function prepareCopyCut(e) { if (signalDOMEvent(cm, e)) { return } if (cm.somethingSelected()) { setLastCopied({lineWise: false, text: cm.getSelections()}); } else if (!cm.options.lineWiseCopyCut) { return } else { var ranges = copyableRanges(cm); setLastCopied({lineWise: true, text: ranges.text}); if (e.type == "cut") { cm.setSelections(ranges.ranges, null, sel_dontScroll); } else { input.prevInput = ""; te.value = ranges.text.join("\n"); selectInput(te); } } if (e.type == "cut") { cm.state.cutIncoming = +new Date; } } on(te, "cut", prepareCopyCut); on(te, "copy", prepareCopyCut); on(display.scroller, "paste", function (e) { if (eventInWidget(display, e) || signalDOMEvent(cm, e)) { return } if (!te.dispatchEvent) { cm.state.pasteIncoming = +new Date; input.focus(); return } // Pass the `paste` event to the textarea so it's handled by its event listener. var event = new Event("paste"); event.clipboardData = e.clipboardData; te.dispatchEvent(event); }); // Prevent normal selection in the editor (we handle our own) on(display.lineSpace, "selectstart", function (e) { if (!eventInWidget(display, e)) { e_preventDefault(e); } }); on(te, "compositionstart", function () { var start = cm.getCursor("from"); if (input.composing) { input.composing.range.clear(); } input.composing = { start: start, range: cm.markText(start, cm.getCursor("to"), {className: "CodeMirror-composing"}) }; }); on(te, "compositionend", function () { if (input.composing) { input.poll(); input.composing.range.clear(); input.composing = null; } }); }; TextareaInput.prototype.createField = function (_display) { // Wraps and hides input textarea this.wrapper = hiddenTextarea(); // The semihidden textarea that is focused when the editor is // focused, and receives input. this.textarea = this.wrapper.firstChild; }; TextareaInput.prototype.screenReaderLabelChanged = function (label) { // Label for screenreaders, accessibility if(label) { this.textarea.setAttribute('aria-label', label); } else { this.textarea.removeAttribute('aria-label'); } }; TextareaInput.prototype.prepareSelection = function () { // Redraw the selection and/or cursor var cm = this.cm, display = cm.display, doc = cm.doc; var result = prepareSelection(cm); // Move the hidden textarea near the cursor to prevent scrolling artifacts if (cm.options.moveInputWithCursor) { var headPos = cursorCoords(cm, doc.sel.primary().head, "div"); var wrapOff = display.wrapper.getBoundingClientRect(), lineOff = display.lineDiv.getBoundingClientRect(); result.teTop = Math.max(0, Math.min(display.wrapper.clientHeight - 10, headPos.top + lineOff.top - wrapOff.top)); result.teLeft = Math.max(0, Math.min(display.wrapper.clientWidth - 10, headPos.left + lineOff.left - wrapOff.left)); } return result }; TextareaInput.prototype.showSelection = function (drawn) { var cm = this.cm, display = cm.display; removeChildrenAndAdd(display.cursorDiv, drawn.cursors); removeChildrenAndAdd(display.selectionDiv, drawn.selection); if (drawn.teTop != null) { this.wrapper.style.top = drawn.teTop + "px"; this.wrapper.style.left = drawn.teLeft + "px"; } }; // Reset the input to correspond to the selection (or to be empty, // when not typing and nothing is selected) TextareaInput.prototype.reset = function (typing) { if (this.contextMenuPending || this.composing) { return } var cm = this.cm; if (cm.somethingSelected()) { this.prevInput = ""; var content = cm.getSelection(); this.textarea.value = content; if (cm.state.focused) { selectInput(this.textarea); } if (ie && ie_version >= 9) { this.hasSelection = content; } } else if (!typing) { this.prevInput = this.textarea.value = ""; if (ie && ie_version >= 9) { this.hasSelection = null; } } }; TextareaInput.prototype.getField = function () { return this.textarea }; TextareaInput.prototype.supportsTouch = function () { return false }; TextareaInput.prototype.focus = function () { if (this.cm.options.readOnly != "nocursor" && (!mobile || activeElt() != this.textarea)) { try { this.textarea.focus(); } catch (e) {} // IE8 will throw if the textarea is display: none or not in DOM } }; TextareaInput.prototype.blur = function () { this.textarea.blur(); }; TextareaInput.prototype.resetPosition = function () { this.wrapper.style.top = this.wrapper.style.left = 0; }; TextareaInput.prototype.receivedFocus = function () { this.slowPoll(); }; // Poll for input changes, using the normal rate of polling. This // runs as long as the editor is focused. TextareaInput.prototype.slowPoll = function () { var this$1 = this; if (this.pollingFast) { return } this.polling.set(this.cm.options.pollInterval, function () { this$1.poll(); if (this$1.cm.state.focused) { this$1.slowPoll(); } }); }; // When an event has just come in that is likely to add or change // something in the input textarea, we poll faster, to ensure that // the change appears on the screen quickly. TextareaInput.prototype.fastPoll = function () { var missed = false, input = this; input.pollingFast = true; function p() { var changed = input.poll(); if (!changed && !missed) {missed = true; input.polling.set(60, p);} else {input.pollingFast = false; input.slowPoll();} } input.polling.set(20, p); }; // Read input from the textarea, and update the document to match. // When something is selected, it is present in the textarea, and // selected (unless it is huge, in which case a placeholder is // used). When nothing is selected, the cursor sits after previously // seen text (can be empty), which is stored in prevInput (we must // not reset the textarea when typing, because that breaks IME). TextareaInput.prototype.poll = function () { var this$1 = this; var cm = this.cm, input = this.textarea, prevInput = this.prevInput; // Since this is called a *lot*, try to bail out as cheaply as // possible when it is clear that nothing happened. hasSelection // will be the case when there is a lot of text in the textarea, // in which case reading its value would be expensive. if (this.contextMenuPending || !cm.state.focused || (hasSelection(input) && !prevInput && !this.composing) || cm.isReadOnly() || cm.options.disableInput || cm.state.keySeq) { return false } var text = input.value; // If nothing changed, bail. if (text == prevInput && !cm.somethingSelected()) { return false } // Work around nonsensical selection resetting in IE9/10, and // inexplicable appearance of private area unicode characters on // some key combos in Mac (#2689). if (ie && ie_version >= 9 && this.hasSelection === text || mac && /[\uf700-\uf7ff]/.test(text)) { cm.display.input.reset(); return false } if (cm.doc.sel == cm.display.selForContextMenu) { var first = text.charCodeAt(0); if (first == 0x200b && !prevInput) { prevInput = "\u200b"; } if (first == 0x21da) { this.reset(); return this.cm.execCommand("undo") } } // Find the part of the input that is actually new var same = 0, l = Math.min(prevInput.length, text.length); while (same < l && prevInput.charCodeAt(same) == text.charCodeAt(same)) { ++same; } runInOp(cm, function () { applyTextInput(cm, text.slice(same), prevInput.length - same, null, this$1.composing ? "*compose" : null); // Don't leave long text in the textarea, since it makes further polling slow if (text.length > 1000 || text.indexOf("\n") > -1) { input.value = this$1.prevInput = ""; } else { this$1.prevInput = text; } if (this$1.composing) { this$1.composing.range.clear(); this$1.composing.range = cm.markText(this$1.composing.start, cm.getCursor("to"), {className: "CodeMirror-composing"}); } }); return true }; TextareaInput.prototype.ensurePolled = function () { if (this.pollingFast && this.poll()) { this.pollingFast = false; } }; TextareaInput.prototype.onKeyPress = function () { if (ie && ie_version >= 9) { this.hasSelection = null; } this.fastPoll(); }; TextareaInput.prototype.onContextMenu = function (e) { var input = this, cm = input.cm, display = cm.display, te = input.textarea; if (input.contextMenuPending) { input.contextMenuPending(); } var pos = posFromMouse(cm, e), scrollPos = display.scroller.scrollTop; if (!pos || presto) { return } // Opera is difficult. // Reset the current text selection only if the click is done outside of the selection // and 'resetSelectionOnContextMenu' option is true. var reset = cm.options.resetSelectionOnContextMenu; if (reset && cm.doc.sel.contains(pos) == -1) { operation(cm, setSelection)(cm.doc, simpleSelection(pos), sel_dontScroll); } var oldCSS = te.style.cssText, oldWrapperCSS = input.wrapper.style.cssText; var wrapperBox = input.wrapper.offsetParent.getBoundingClientRect(); input.wrapper.style.cssText = "position: static"; te.style.cssText = "position: absolute; width: 30px; height: 30px;\n top: " + (e.clientY - wrapperBox.top - 5) + "px; left: " + (e.clientX - wrapperBox.left - 5) + "px;\n z-index: 1000; background: " + (ie ? "rgba(255, 255, 255, .05)" : "transparent") + ";\n outline: none; border-width: 0; outline: none; overflow: hidden; opacity: .05; filter: alpha(opacity=5);"; var oldScrollY; if (webkit) { oldScrollY = window.scrollY; } // Work around Chrome issue (#2712) display.input.focus(); if (webkit) { window.scrollTo(null, oldScrollY); } display.input.reset(); // Adds "Select all" to context menu in FF if (!cm.somethingSelected()) { te.value = input.prevInput = " "; } input.contextMenuPending = rehide; display.selForContextMenu = cm.doc.sel; clearTimeout(display.detectingSelectAll); // Select-all will be greyed out if there's nothing to select, so // this adds a zero-width space so that we can later check whether // it got selected. function prepareSelectAllHack() { if (te.selectionStart != null) { var selected = cm.somethingSelected(); var extval = "\u200b" + (selected ? te.value : ""); te.value = "\u21da"; // Used to catch context-menu undo te.value = extval; input.prevInput = selected ? "" : "\u200b"; te.selectionStart = 1; te.selectionEnd = extval.length; // Re-set this, in case some other handler touched the // selection in the meantime. display.selForContextMenu = cm.doc.sel; } } function rehide() { if (input.contextMenuPending != rehide) { return } input.contextMenuPending = false; input.wrapper.style.cssText = oldWrapperCSS; te.style.cssText = oldCSS; if (ie && ie_version < 9) { display.scrollbars.setScrollTop(display.scroller.scrollTop = scrollPos); } // Try to detect the user choosing select-all if (te.selectionStart != null) { if (!ie || (ie && ie_version < 9)) { prepareSelectAllHack(); } var i = 0, poll = function () { if (display.selForContextMenu == cm.doc.sel && te.selectionStart == 0 && te.selectionEnd > 0 && input.prevInput == "\u200b") { operation(cm, selectAll)(cm); } else if (i++ < 10) { display.detectingSelectAll = setTimeout(poll, 500); } else { display.selForContextMenu = null; display.input.reset(); } }; display.detectingSelectAll = setTimeout(poll, 200); } } if (ie && ie_version >= 9) { prepareSelectAllHack(); } if (captureRightClick) { e_stop(e); var mouseup = function () { off(window, "mouseup", mouseup); setTimeout(rehide, 20); }; on(window, "mouseup", mouseup); } else { setTimeout(rehide, 50); } }; TextareaInput.prototype.readOnlyChanged = function (val) { if (!val) { this.reset(); } this.textarea.disabled = val == "nocursor"; this.textarea.readOnly = !!val; }; TextareaInput.prototype.setUneditable = function () {}; TextareaInput.prototype.needsContentAttribute = false; function fromTextArea(textarea, options) { options = options ? copyObj(options) : {}; options.value = textarea.value; if (!options.tabindex && textarea.tabIndex) { options.tabindex = textarea.tabIndex; } if (!options.placeholder && textarea.placeholder) { options.placeholder = textarea.placeholder; } // Set autofocus to true if this textarea is focused, or if it has // autofocus and no other element is focused. if (options.autofocus == null) { var hasFocus = activeElt(); options.autofocus = hasFocus == textarea || textarea.getAttribute("autofocus") != null && hasFocus == document.body; } function save() {textarea.value = cm.getValue();} var realSubmit; if (textarea.form) { on(textarea.form, "submit", save); // Deplorable hack to make the submit method do the right thing. if (!options.leaveSubmitMethodAlone) { var form = textarea.form; realSubmit = form.submit; try { var wrappedSubmit = form.submit = function () { save(); form.submit = realSubmit; form.submit(); form.submit = wrappedSubmit; }; } catch(e) {} } } options.finishInit = function (cm) { cm.save = save; cm.getTextArea = function () { return textarea; }; cm.toTextArea = function () { cm.toTextArea = isNaN; // Prevent this from being ran twice save(); textarea.parentNode.removeChild(cm.getWrapperElement()); textarea.style.display = ""; if (textarea.form) { off(textarea.form, "submit", save); if (!options.leaveSubmitMethodAlone && typeof textarea.form.submit == "function") { textarea.form.submit = realSubmit; } } }; }; textarea.style.display = "none"; var cm = CodeMirror(function (node) { return textarea.parentNode.insertBefore(node, textarea.nextSibling); }, options); return cm } function addLegacyProps(CodeMirror) { CodeMirror.off = off; CodeMirror.on = on; CodeMirror.wheelEventPixels = wheelEventPixels; CodeMirror.Doc = Doc; CodeMirror.splitLines = splitLinesAuto; CodeMirror.countColumn = countColumn; CodeMirror.findColumn = findColumn; CodeMirror.isWordChar = isWordCharBasic; CodeMirror.Pass = Pass; CodeMirror.signal = signal; CodeMirror.Line = Line; CodeMirror.changeEnd = changeEnd; CodeMirror.scrollbarModel = scrollbarModel; CodeMirror.Pos = Pos; CodeMirror.cmpPos = cmp; CodeMirror.modes = modes; CodeMirror.mimeModes = mimeModes; CodeMirror.resolveMode = resolveMode; CodeMirror.getMode = getMode; CodeMirror.modeExtensions = modeExtensions; CodeMirror.extendMode = extendMode; CodeMirror.copyState = copyState; CodeMirror.startState = startState; CodeMirror.innerMode = innerMode; CodeMirror.commands = commands; CodeMirror.keyMap = keyMap; CodeMirror.keyName = keyName; CodeMirror.isModifierKey = isModifierKey; CodeMirror.lookupKey = lookupKey; CodeMirror.normalizeKeyMap = normalizeKeyMap; CodeMirror.StringStream = StringStream; CodeMirror.SharedTextMarker = SharedTextMarker; CodeMirror.TextMarker = TextMarker; CodeMirror.LineWidget = LineWidget; CodeMirror.e_preventDefault = e_preventDefault; CodeMirror.e_stopPropagation = e_stopPropagation; CodeMirror.e_stop = e_stop; CodeMirror.addClass = addClass; CodeMirror.contains = contains; CodeMirror.rmClass = rmClass; CodeMirror.keyNames = keyNames; } // EDITOR CONSTRUCTOR defineOptions(CodeMirror); addEditorMethods(CodeMirror); // Set up methods on CodeMirror's prototype to redirect to the editor's document. var dontDelegate = "iter insert remove copy getEditor constructor".split(" "); for (var prop in Doc.prototype) { if (Doc.prototype.hasOwnProperty(prop) && indexOf(dontDelegate, prop) < 0) { CodeMirror.prototype[prop] = (function(method) { return function() {return method.apply(this.doc, arguments)} })(Doc.prototype[prop]); } } eventMixin(Doc); CodeMirror.inputStyles = {"textarea": TextareaInput, "contenteditable": ContentEditableInput}; // Extra arguments are stored as the mode's dependencies, which is // used by (legacy) mechanisms like loadmode.js to automatically // load a mode. (Preferred mechanism is the require/define calls.) CodeMirror.defineMode = function(name/*, mode, …*/) { if (!CodeMirror.defaults.mode && name != "null") { CodeMirror.defaults.mode = name; } defineMode.apply(this, arguments); }; CodeMirror.defineMIME = defineMIME; // Minimal default mode. CodeMirror.defineMode("null", function () { return ({token: function (stream) { return stream.skipToEnd(); }}); }); CodeMirror.defineMIME("text/plain", "null"); // EXTENSIONS CodeMirror.defineExtension = function (name, func) { CodeMirror.prototype[name] = func; }; CodeMirror.defineDocExtension = function (name, func) { Doc.prototype[name] = func; }; CodeMirror.fromTextArea = fromTextArea; addLegacyProps(CodeMirror); CodeMirror.version = "5.59.2"; return CodeMirror; }))); xknx-3.6.0/docs/config-converter/lib/codemirror/mode/000077500000000000000000000000001475530762600225705ustar00rootroot00000000000000xknx-3.6.0/docs/config-converter/lib/codemirror/mode/yaml/000077500000000000000000000000001475530762600235325ustar00rootroot00000000000000xknx-3.6.0/docs/config-converter/lib/codemirror/mode/yaml/yaml.js000066400000000000000000000072251475530762600250400ustar00rootroot00000000000000// CodeMirror, copyright (c) by Marijn Haverbeke and others // Distributed under an MIT license: https://codemirror.net/LICENSE (function(mod) { if (typeof exports == "object" && typeof module == "object") // CommonJS mod(require("../../lib/codemirror")); else if (typeof define == "function" && define.amd) // AMD define(["../../lib/codemirror"], mod); else // Plain browser env mod(CodeMirror); })(function(CodeMirror) { "use strict"; CodeMirror.defineMode("yaml", function() { var cons = ['true', 'false', 'on', 'off', 'yes', 'no']; var keywordRegex = new RegExp("\\b(("+cons.join(")|(")+"))$", 'i'); return { token: function(stream, state) { var ch = stream.peek(); var esc = state.escaped; state.escaped = false; /* comments */ if (ch == "#" && (stream.pos == 0 || /\s/.test(stream.string.charAt(stream.pos - 1)))) { stream.skipToEnd(); return "comment"; } if (stream.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/)) return "string"; if (state.literal && stream.indentation() > state.keyCol) { stream.skipToEnd(); return "string"; } else if (state.literal) { state.literal = false; } if (stream.sol()) { state.keyCol = 0; state.pair = false; state.pairStart = false; /* document start */ if(stream.match('---')) { return "def"; } /* document end */ if (stream.match('...')) { return "def"; } /* array list item */ if (stream.match(/\s*-\s+/)) { return 'meta'; } } /* inline pairs/lists */ if (stream.match(/^(\{|\}|\[|\])/)) { if (ch == '{') state.inlinePairs++; else if (ch == '}') state.inlinePairs--; else if (ch == '[') state.inlineList++; else state.inlineList--; return 'meta'; } /* list separator */ if (state.inlineList > 0 && !esc && ch == ',') { stream.next(); return 'meta'; } /* pairs separator */ if (state.inlinePairs > 0 && !esc && ch == ',') { state.keyCol = 0; state.pair = false; state.pairStart = false; stream.next(); return 'meta'; } /* start of value of a pair */ if (state.pairStart) { /* block literals */ if (stream.match(/^\s*(\||\>)\s*/)) { state.literal = true; return 'meta'; }; /* references */ if (stream.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i)) { return 'variable-2'; } /* numbers */ if (state.inlinePairs == 0 && stream.match(/^\s*-?[0-9\.\,]+\s?$/)) { return 'number'; } if (state.inlinePairs > 0 && stream.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/)) { return 'number'; } /* keywords */ if (stream.match(keywordRegex)) { return 'keyword'; } } /* pairs (associative arrays) -> key */ if (!state.pair && stream.match(/^\s*(?:[,\[\]{}&*!|>'"%@`][^\s'":]|[^,\[\]{}#&*!|>'"%@`])[^#]*?(?=\s*:($|\s))/)) { state.pair = true; state.keyCol = stream.indentation(); return "atom"; } if (state.pair && stream.match(/^:\s*/)) { state.pairStart = true; return 'meta'; } /* nothing found, continue */ state.pairStart = false; state.escaped = (ch == '\\'); stream.next(); return null; }, startState: function() { return { pair: false, pairStart: false, keyCol: 0, inlinePairs: 0, inlineList: 0, literal: false, escaped: false }; }, lineComment: "#", fold: "indent" }; }); CodeMirror.defineMIME("text/x-yaml", "yaml"); CodeMirror.defineMIME("text/yaml", "yaml"); }); xknx-3.6.0/docs/config-converter/lib/converter.js000066400000000000000000000510661475530762600220540ustar00rootroot00000000000000'use strict'; let source, result, wrap_knx, sort_keys; window.onload = function () { source = CodeMirror.fromTextArea(document.getElementById('source'), { mode: 'yaml', lineNumbers: true }); result = CodeMirror.fromTextArea(document.getElementById('result'), { mode: 'yaml', lineNumbers: true }); wrap_knx = document.getElementById('wrap_knx'); sort_keys = document.getElementById('sort_keys'); var timer; source.on('change', function () { clearTimeout(timer); timer = setTimeout(parse, 500); }); wrap_knx.addEventListener('change', (event) => { clearTimeout(timer); timer = setTimeout(parse, 500); }) sort_keys.addEventListener('change', (event) => { clearTimeout(timer); timer = setTimeout(parse, 500); }) } function parse() { var newObj; let oldStr, oldObj, newYaml; let wrap_knx_checked = document.getElementById('wrap_knx').checked; let sort_keys_checked = document.getElementById('sort_keys').checked; try { oldStr = source.getValue(); oldObj = jsyaml.load(oldStr); newObj = parseOldConfig(oldObj); if (Object.keys(newObj).length === 0) { result.setOption('mode', 'yaml'); result.setValue(""); return; } if (wrap_knx_checked) { newObj = { knx: newObj }; } newYaml = jsyaml.dump(newObj, { 'styles': { '!!null': 'canonical' // dump null as ~ }, 'sortKeys': sort_keys_checked // sort object keys }); result.setOption('mode', 'yaml'); result.setValue(newYaml); } catch (err) { result.setOption('mode', 'text/plain'); result.setValue(err.message || String(err)); } } function parseOldConfig(oldConfig) { var newConfig = {} var invalid = [] let info_text = document.getElementById('info_text') if (typeof oldConfig === 'string') { info_text.value = "No key found in source."; return {}; } for (let key in oldConfig) { switch (key) { case "general": parseGeneral(oldConfig.general, newConfig, invalid); break; case "connection": parseConnection(oldConfig.connection, newConfig, invalid); break; case "groups": parseGroups(oldConfig.groups, newConfig, invalid); break; default: invalid.push(key); } } console.log(newConfig); console.log(invalid); if (invalid.length) { invalid.unshift("Invalid keys found. They are removed form converted config.\nInvalid config keys have been ignored by xknx so your new config should work just like before.") for (let item of invalid) { if (item.endsWith("actions")) { invalid[0] += "\nNOTE: Actions in BinarySensors are not supported anymore. Use a HomeAssistant Automation instead."; break; } } info_text.value = invalid.join("\n - "); } else { info_text.value = "Conversion succeded."; } return newConfig; } function parseGeneral(generalConfig, newConfig, invalid) { for (let general_key in generalConfig) { switch (general_key) { case "own_address": newConfig.individual_address = generalConfig.own_address; break; case "rate_limit": newConfig.rate_limit = generalConfig.rate_limit; break; case "multicast_group": newConfig.multicast_group = generalConfig.multicast_group; break; case "multicast_port": newConfig.multicast_port = generalConfig.multicast_port; break; default: invalid.push("general: " + general_key); } } } function parseConnection(connectionConfig, newConfig, invalid) { for (var connection_key in connectionConfig) { switch (connection_key) { case "tunneling": newConfig.tunneling = {}; for (let tunneling_key in connectionConfig.tunneling) { switch (tunneling_key) { case "gateway_ip": newConfig.tunneling.host = connectionConfig.tunneling.gateway_ip; break; case "gateway_port": newConfig.tunneling.port = connectionConfig.tunneling.gateway_port; break; case "local_ip": newConfig.tunneling.local_ip = connectionConfig.tunneling.local_ip; break; default: invalid.push("connection: tunneling: " + tunneling_key); } } break; case "routing": newConfig.routing = null; for (let routing_key in connectionConfig.routing) { switch (routing_key) { case "local_ip": newConfig.routing = {}; newConfig.routing.local_ip = connectionConfig.routing.local_ip; break; default: invalid.push("connection: routing: " + routing_key) } } break; case "auto": // ignore auto break; default: invalid.push("connection: " + connection_key); } } } function parseGroups(groups, newConfig, invalid) { var platforms = { expose: [], binary_sensor: [], climate: [], cover: [], fan: [], light: [], notify: [], scene: [], sensor: [], switch: [], weather: [], } for (let group in groups) { if (group.startsWith("binary_sensor")) { for (let device in groups[group]) { platforms.binary_sensor.push(parseBinarySensor(device, groups[group][device], invalid, group)) } } else if (group.startsWith("climate")) { for (let device in groups[group]) { platforms.climate.push(parseClimate(device, groups[group][device], invalid, group)) } } else if (group.startsWith("cover")) { for (let device in groups[group]) { platforms.cover.push(parseCover(device, groups[group][device], invalid, group)) } } else if (group.startsWith("datetime")) { for (let device in groups[group]) { platforms.expose.push(parseDateTime(device, groups[group][device], invalid, group)) } } else if (group.startsWith("fan")) { for (let device in groups[group]) { platforms.fan.push(parseFan(device, groups[group][device], invalid, group)) } } else if (group.startsWith("light")) { for (let device in groups[group]) { platforms.light.push(parseLight(device, groups[group][device], invalid, group)) } } else if (group.startsWith("notification")) { for (let device in groups[group]) { platforms.notify.push(parseNotify(device, groups[group][device], invalid, group)) } } else if (group.startsWith("scene")) { for (let device in groups[group]) { platforms.scene.push(parseScene(device, groups[group][device], invalid, group)) } } else if (group.startsWith("sensor")) { for (let device in groups[group]) { platforms.sensor.push(parseSensor(device, groups[group][device], invalid, group)) } } else if (group.startsWith("switch")) { for (let device in groups[group]) { platforms.switch.push(parseSwitch(device, groups[group][device], invalid, group)) } } else if (group.startsWith("weather")) { for (let device in groups[group]) { platforms.weather.push(parseWeather(device, groups[group][device], invalid, group)) } } else { invalid.push("groups: " + group); } } for (let platform in platforms) { if (platforms[platform].length) { newConfig[platform] = platforms[platform]; } } } ///////////// // PLATFORMS //////////// function parseBinarySensor(name, device, invalid, groupname) { let entity = { name: name } for (let conf in device) { switch (conf) { case "group_address_state": entity.state_address = device[conf] break; case "sync_state": entity[conf] = device[conf] break; case "invert": entity[conf] = device[conf] break; case "ignore_internal_state": entity[conf] = device[conf] break; case "context_timeout": entity[conf] = device[conf] break; case "reset_after": entity[conf] = device[conf] break; case "device_class": entity[conf] = device[conf] break; default: invalid.push("groups: " + groupname + ": " + name + ": " + conf); } } return entity } function parseClimate(name, device, invalid, groupname) { let entity = { name: name } for (let conf in device) { switch (conf) { case "group_address_temperature": entity.temperature_address = device[conf]; break; case "group_address_target_temperature": entity.target_temperature_address = device[conf]; break; case "group_address_target_temperature_state": entity.target_temperature_state_address = device[conf]; break; case "group_address_setpoint_shift": entity.setpoint_shift_address = device[conf]; break; case "group_address_setpoint_shift_state": entity.setpoint_shift_state_address = device[conf]; break; case "setpoint_shift_mode": entity[conf] = device[conf]; break; case "setpoint_shift_max": entity[conf] = device[conf]; break; case "setpoint_shift_min": entity[conf] = device[conf]; break; case "temperature_step": entity[conf] = device[conf]; break; case "group_address_on_off": entity.on_off_address = device[conf]; break; case "group_address_on_off_state": entity.on_off_state_address = device[conf]; break; case "on_off_invert": entity[conf] = device[conf]; break; case "min_temp": entity[conf] = device[conf]; break; case "max_temp": entity[conf] = device[conf]; break; case "mode": for (let mode_conf in device.mode) { switch (mode_conf) { case "group_address_operation_mode": entity.operation_mode_address = device.mode[mode_conf]; break; case "group_address_operation_mode_state": entity.operation_mode_state_address = device.mode[mode_conf]; break; case "group_address_operation_mode_protection": entity.operation_mode_frost_protection_address = device.mode[mode_conf]; break; case "group_address_operation_mode_night": entity.operation_mode_night_address = device.mode[mode_conf]; break; case "group_address_operation_mode_comfort": entity.operation_mode_comfort_address = device.mode[mode_conf]; break; case "group_address_operation_mode_standby": entity.operation_mode_standby_address = device.mode[mode_conf]; break; case "group_address_controller_status": entity.controller_status_address = device.mode[mode_conf]; break; case "group_address_controller_status_state": entity.controller_status_state_address = device.mode[mode_conf]; break; case "group_address_controller_mode": entity.controller_mode_address = device.mode[mode_conf]; break; case "group_address_controller_mode_state": entity.controller_mode_state_address = device.mode[mode_conf]; break; case "group_address_heat_cool": entity.heat_cool_address = device.mode[mode_conf]; break; case "group_address_heat_cool_state": entity.heat_cool_state_address = device.mode[mode_conf]; break; default: invalid.push("groups: " + groupname + ": " + name + ": mode: " + mode_conf); } } break; default: invalid.push("groups: " + groupname + ": " + name + ": " + conf); } } return entity } function parseCover(name, device, invalid, groupname) { let entity = { name: name } for (let conf in device) { switch (conf) { case "group_address_long": entity.move_long_address = device[conf] break; case "group_address_short": entity.move_short_address = device[conf] break; case "group_address_stop": entity.stop_address = device[conf] break; case "group_address_position": entity.position_address = device[conf] break; case "group_address_position_state": entity.position_state_address = device[conf] break; case "group_address_angle": entity.angle_address = device[conf] break; case "group_address_angle_state": entity.angle_state_address = device[conf] break; case "travel_time_down": entity.travelling_time_down = device[conf] break; case "travel_time_up": entity.travelling_time_up = device[conf] break; case "invert_position": entity[conf] = device[conf] break; case "invert_angle": entity[conf] = device[conf] break; case "device_class": entity[conf] = device[conf] break; default: invalid.push("groups: " + groupname + ": " + name + ": " + conf); } } return entity } function parseDateTime(name, device, invalid, groupname) { let entity = { type: "time" } for (let conf in device) { switch (conf) { case "group_address": entity.address = device[conf] break; case "broadcast_type": entity.type = device[conf] break; default: invalid.push("groups: " + groupname + ": " + name + ": " + conf); } } return entity } function parseFan(name, device, invalid, groupname) { let entity = { name: name } for (let conf in device) { switch (conf) { case "group_address_speed": entity.address = device[conf] break; case "group_address_speed_state": entity.state_address = device[conf] break; case "group_address_oscillation": entity.oscillation_address = device[conf] break; case "group_address_oscillation_state": entity.oscillation_state_address = device[conf] break; case "max_step": entity[conf] = device[conf] break; default: invalid.push("groups: " + groupname + ": " + name + ": " + conf); } } return entity } function parseLight(name, device, invalid, groupname) { let entity = { name: name } for (let conf in device) { switch (conf) { case "group_address_switch": entity.address = device[conf] break; case "group_address_switch_state": entity.state_address = device[conf] break; case "group_address_brightness": entity.brightness_address = device[conf] break; case "group_address_brightness_state": entity.brightness_state_address = device[conf] break; case "group_address_color": entity.color_address = device[conf] break; case "group_address_color_state": entity.color_state_address = device[conf] break; case "group_address_rgbw": entity.rgbw_address = device[conf] break; case "group_address_rgbw_state": entity.rgbw_state_address = device[conf] break; case "group_address_tunable_white": entity.color_temperature_address = device[conf] entity.color_temperature_mode = "relative" break; case "group_address_tunable_white_state": entity.color_temperature_state_address = device[conf] break; case "group_address_color_temperature": entity.color_temperature_address = device[conf] entity.color_temperature_mode = "absolute" break; case "group_address_color_temperature_state": entity.color_temperature_state_address = device[conf] break; case "min_kelvin": entity[conf] = device[conf] break; case "max_kelvin": entity[conf] = device[conf] break; case "individual_colors": entity.individual_colors = {} for (let color in device.individual_colors) { entity.individual_colors[color] = {} for (let color_config in device.individual_colors[color]) { switch (color_config) { case "group_address_switch": entity.individual_colors[color]["address"] = device.individual_colors[color][color_config] break; case "group_address_switch_state": entity.individual_colors[color]["state_address"] = device.individual_colors[color][color_config] break; case "group_address_brightness": entity.individual_colors[color]["brightness_address"] = device.individual_colors[color][color_config] break; case "group_address_brightness_state": entity.individual_colors[color]["brightness_state_address"] = device.individual_colors[color][color_config] break; } } } break; default: invalid.push("groups: " + groupname + ": " + name + ": " + conf); } } return entity } function parseNotify(name, device, invalid, groupname) { let entity = { name: name } for (let conf in device) { switch (conf) { case "group_address": entity.address = device[conf] break; default: invalid.push("groups: " + groupname + ": " + name + ": " + conf); } } return entity } function parseScene(name, device, invalid, groupname) { let entity = { name: name } for (let conf in device) { switch (conf) { case "group_address": entity.address = device[conf] break; case "scene_number": entity[conf] = device[conf] break; default: invalid.push("groups: " + groupname + ": " + name + ": " + conf); } } return entity } function parseSensor(name, device, invalid, groupname) { let entity = { name: name } for (let conf in device) { switch (conf) { case "group_address_state": entity.state_address = device[conf] break; case "value_type": entity.type = device[conf] break; case "sync_state": entity[conf] = device[conf] break; case "always_callback": entity[conf] = device[conf] break; default: invalid.push("groups: " + groupname + ": " + name + ": " + conf); } } return entity } function parseSwitch(name, device, invalid, groupname) { let entity = { name: name } for (let conf in device) { switch (conf) { case "group_address": entity.address = device[conf] break; case "group_address_state": entity.state_address = device[conf] break; case "invert": entity[conf] = device[conf] break; default: invalid.push("groups: " + groupname + ": " + name + ": " + conf); } } return entity } function parseWeather(name, device, invalid, groupname) { let entity = { name: name } for (let conf in device) { switch (conf) { case "group_address_temperature": entity.address_temperature = device[conf] break; case "group_address_brightness_south": entity.address_brightness_south = device[conf] break; case "group_address_brightness_north": entity.address_brightness_north = device[conf] break; case "group_address_brightness_west": entity.address_brightness_west = device[conf] break; case "group_address_brightness_east": entity.address_brightness_east = device[conf] break; case "group_address_wind_speed": entity.address_wind_speed = device[conf] break; case "group_address_wind_bearing": entity.address_wind_bearing = device[conf] break; case "group_address_rain_alarm": entity.address_rain_alarm = device[conf] break; case "group_address_frost_alarm": entity.address_frost_alarm = device[conf] break; case "group_address_wind_alarm": entity.address_wind_alarm = device[conf] break; case "group_address_day_night": entity.address_day_night = device[conf] break; case "group_address_air_pressure": entity.address_air_pressure = device[conf] break; case "group_address_humidity": entity.address_humidity = device[conf] break; case "expose_sensors": entity.create_sensors = device[conf] break; case "sync_state": entity[conf] = device[conf] break; default: invalid.push("groups: " + groupname + ": " + name + ": " + conf); } } return entity } xknx-3.6.0/docs/config-converter/lib/js-yaml/000077500000000000000000000000001475530762600210535ustar00rootroot00000000000000xknx-3.6.0/docs/config-converter/lib/js-yaml/js-yaml.min.js000077500000000000000000001146321475530762600235610ustar00rootroot00000000000000/*! js-yaml 4.0.0 https://github.com/nodeca/js-yaml @license MIT */ !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).jsyaml={})}(this,(function(e){"use strict";function t(e){return null==e}var n={isNothing:t,isObject:function(e){return"object"==typeof e&&null!==e},toArray:function(e){return Array.isArray(e)?e:t(e)?[]:[e]},repeat:function(e,t){var n,i="";for(n=0;nl&&(t=i-l+(o=" ... ").length),n-i>l&&(n=i+l-(a=" ...").length),{str:o+e.slice(t,n).replace(/\t/g,"→")+a,pos:i-t+o.length}}function l(e,t){return n.repeat(" ",t-e.length)+e}var c=function(e,t){if(t=Object.create(t||null),!e.buffer)return null;t.maxLength||(t.maxLength=79),"number"!=typeof t.indent&&(t.indent=1),"number"!=typeof t.linesBefore&&(t.linesBefore=3),"number"!=typeof t.linesAfter&&(t.linesAfter=2);for(var i,r=/\r?\n|\r|\0/g,o=[0],c=[],s=-1;i=r.exec(e.buffer);)c.push(i.index),o.push(i.index+i[0].length),e.position<=i.index&&s<0&&(s=o.length-2);s<0&&(s=o.length-1);var u,p,f="",d=Math.min(e.line+t.linesAfter,c.length).toString().length,h=t.maxLength-(t.indent+d+3);for(u=1;u<=t.linesBefore&&!(s-u<0);u++)p=a(e.buffer,o[s-u],c[s-u],e.position-(o[s]-o[s-u]),h),f=n.repeat(" ",t.indent)+l((e.line-u+1).toString(),d)+" | "+p.str+"\n"+f;for(p=a(e.buffer,o[s],c[s],e.position,h),f+=n.repeat(" ",t.indent)+l((e.line+1).toString(),d)+" | "+p.str+"\n",f+=n.repeat("-",t.indent+d+3+p.pos)+"^\n",u=1;u<=t.linesAfter&&!(s+u>=c.length);u++)p=a(e.buffer,o[s+u],c[s+u],e.position-(o[s]-o[s+u]),h),f+=n.repeat(" ",t.indent)+l((e.line+u+1).toString(),d)+" | "+p.str+"\n";return f.replace(/\n$/,"")},s=["kind","multi","resolve","construct","instanceOf","predicate","represent","representName","defaultStyle","styleAliases"],u=["scalar","sequence","mapping"];var p=function(e,t){if(t=t||{},Object.keys(t).forEach((function(t){if(-1===s.indexOf(t))throw new o('Unknown option "'+t+'" is met in definition of "'+e+'" YAML type.')})),this.tag=e,this.kind=t.kind||null,this.resolve=t.resolve||function(){return!0},this.construct=t.construct||function(e){return e},this.instanceOf=t.instanceOf||null,this.predicate=t.predicate||null,this.represent=t.represent||null,this.representName=t.representName||null,this.defaultStyle=t.defaultStyle||null,this.multi=t.multi||!1,this.styleAliases=function(e){var t={};return null!==e&&Object.keys(e).forEach((function(n){e[n].forEach((function(e){t[String(e)]=n}))})),t}(t.styleAliases||null),-1===u.indexOf(this.kind))throw new o('Unknown kind "'+this.kind+'" is specified for "'+e+'" YAML type.')};function f(e,t,n){var i=[];return e[t].forEach((function(e){n.forEach((function(t,n){t.tag===e.tag&&t.kind===e.kind&&t.multi===e.multi&&i.push(n)})),n.push(e)})),n.filter((function(e,t){return-1===i.indexOf(t)}))}function d(e){return this.extend(e)}d.prototype.extend=function(e){var t=[],n=[];if(e instanceof p)n.push(e);else if(Array.isArray(e))n=n.concat(e);else{if(!e||!Array.isArray(e.implicit)&&!Array.isArray(e.explicit))throw new o("Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })");e.implicit&&(t=t.concat(e.implicit)),e.explicit&&(n=n.concat(e.explicit))}t.forEach((function(e){if(!(e instanceof p))throw new o("Specified list of YAML types (or a single Type object) contains a non-Type object.");if(e.loadKind&&"scalar"!==e.loadKind)throw new o("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.");if(e.multi)throw new o("There is a multi type in the implicit list of a schema. Multi tags can only be listed as explicit.")})),n.forEach((function(e){if(!(e instanceof p))throw new o("Specified list of YAML types (or a single Type object) contains a non-Type object.")}));var i=Object.create(d.prototype);return i.implicit=(this.implicit||[]).concat(t),i.explicit=(this.explicit||[]).concat(n),i.compiledImplicit=f(i,"implicit",[]),i.compiledExplicit=f(i,"explicit",[]),i.compiledTypeMap=function(){var e,t,n={scalar:{},sequence:{},mapping:{},fallback:{},multi:{scalar:[],sequence:[],mapping:[],fallback:[]}};function i(e){e.multi?(n.multi[e.kind].push(e),n.multi.fallback.push(e)):n[e.kind][e.tag]=n.fallback[e.tag]=e}for(e=0,t=arguments.length;e=0?"0b"+e.toString(2):"-0b"+e.toString(2).slice(1)},octal:function(e){return e>=0?"0o"+e.toString(8):"-0o"+e.toString(8).slice(1)},decimal:function(e){return e.toString(10)},hexadecimal:function(e){return e>=0?"0x"+e.toString(16).toUpperCase():"-0x"+e.toString(16).toUpperCase().slice(1)}},defaultStyle:"decimal",styleAliases:{binary:[2,"bin"],octal:[8,"oct"],decimal:[10,"dec"],hexadecimal:[16,"hex"]}}),w=new RegExp("^(?:[-+]?(?:[0-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$");var k=/^[-+]?[0-9]+e/;var C=new p("tag:yaml.org,2002:float",{kind:"scalar",resolve:function(e){return null!==e&&!(!w.test(e)||"_"===e[e.length-1])},construct:function(e){var t,n;return n="-"===(t=e.replace(/_/g,"").toLowerCase())[0]?-1:1,"+-".indexOf(t[0])>=0&&(t=t.slice(1)),".inf"===t?1===n?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:".nan"===t?NaN:n*parseFloat(t,10)},predicate:function(e){return"[object Number]"===Object.prototype.toString.call(e)&&(e%1!=0||n.isNegativeZero(e))},represent:function(e,t){var i;if(isNaN(e))switch(t){case"lowercase":return".nan";case"uppercase":return".NAN";case"camelcase":return".NaN"}else if(Number.POSITIVE_INFINITY===e)switch(t){case"lowercase":return".inf";case"uppercase":return".INF";case"camelcase":return".Inf"}else if(Number.NEGATIVE_INFINITY===e)switch(t){case"lowercase":return"-.inf";case"uppercase":return"-.INF";case"camelcase":return"-.Inf"}else if(n.isNegativeZero(e))return"-0.0";return i=e.toString(10),k.test(i)?i.replace("e",".e"):i},defaultStyle:"lowercase"}),x=g.extend({implicit:[m,y,v,C]}),I=x,S=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"),O=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$");var j=new p("tag:yaml.org,2002:timestamp",{kind:"scalar",resolve:function(e){return null!==e&&(null!==S.exec(e)||null!==O.exec(e))},construct:function(e){var t,n,i,r,o,a,l,c,s=0,u=null;if(null===(t=S.exec(e))&&(t=O.exec(e)),null===t)throw new Error("Date resolve error");if(n=+t[1],i=+t[2]-1,r=+t[3],!t[4])return new Date(Date.UTC(n,i,r));if(o=+t[4],a=+t[5],l=+t[6],t[7]){for(s=t[7].slice(0,3);s.length<3;)s+="0";s=+s}return t[9]&&(u=6e4*(60*+t[10]+ +(t[11]||0)),"-"===t[9]&&(u=-u)),c=new Date(Date.UTC(n,i,r,o,a,l,s)),u&&c.setTime(c.getTime()-u),c},instanceOf:Date,represent:function(e){return e.toISOString()}});var T=new p("tag:yaml.org,2002:merge",{kind:"scalar",resolve:function(e){return"<<"===e||null===e}}),N="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r";var F=new p("tag:yaml.org,2002:binary",{kind:"scalar",resolve:function(e){if(null===e)return!1;var t,n,i=0,r=e.length,o=N;for(n=0;n64)){if(t<0)return!1;i+=6}return i%8==0},construct:function(e){var t,n,i=e.replace(/[\r\n=]/g,""),r=i.length,o=N,a=0,l=[];for(t=0;t>16&255),l.push(a>>8&255),l.push(255&a)),a=a<<6|o.indexOf(i.charAt(t));return 0===(n=r%4*6)?(l.push(a>>16&255),l.push(a>>8&255),l.push(255&a)):18===n?(l.push(a>>10&255),l.push(a>>2&255)):12===n&&l.push(a>>4&255),new Uint8Array(l)},predicate:function(e){return"[object Uint8Array]"===Object.prototype.toString.call(e)},represent:function(e){var t,n,i="",r=0,o=e.length,a=N;for(t=0;t>18&63],i+=a[r>>12&63],i+=a[r>>6&63],i+=a[63&r]),r=(r<<8)+e[t];return 0===(n=o%3)?(i+=a[r>>18&63],i+=a[r>>12&63],i+=a[r>>6&63],i+=a[63&r]):2===n?(i+=a[r>>10&63],i+=a[r>>4&63],i+=a[r<<2&63],i+=a[64]):1===n&&(i+=a[r>>2&63],i+=a[r<<4&63],i+=a[64],i+=a[64]),i}}),E=Object.prototype.hasOwnProperty,M=Object.prototype.toString;var L=new p("tag:yaml.org,2002:omap",{kind:"sequence",resolve:function(e){if(null===e)return!0;var t,n,i,r,o,a=[],l=e;for(t=0,n=l.length;t>10),56320+(e-65536&1023))}for(var ee=new Array(256),te=new Array(256),ne=0;ne<256;ne++)ee[ne]=z(ne)?1:0,te[ne]=z(ne);function ie(e,t){this.input=e,this.filename=t.filename||null,this.schema=t.schema||q,this.onWarning=t.onWarning||null,this.legacy=t.legacy||!1,this.json=t.json||!1,this.listener=t.listener||null,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=e.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.firstTabInLine=-1,this.documents=[]}function re(e,t){var n={name:e.filename,buffer:e.input.slice(0,-1),position:e.position,line:e.line,column:e.position-e.lineStart};return n.snippet=c(n),new o(t,n)}function oe(e,t){throw re(e,t)}function ae(e,t){e.onWarning&&e.onWarning.call(null,re(e,t))}var le={YAML:function(e,t,n){var i,r,o;null!==e.version&&oe(e,"duplication of %YAML directive"),1!==n.length&&oe(e,"YAML directive accepts exactly one argument"),null===(i=/^([0-9]+)\.([0-9]+)$/.exec(n[0]))&&oe(e,"ill-formed argument of the YAML directive"),r=parseInt(i[1],10),o=parseInt(i[2],10),1!==r&&oe(e,"unacceptable YAML version of the document"),e.version=n[0],e.checkLineBreaks=o<2,1!==o&&2!==o&&ae(e,"unsupported YAML version of the document")},TAG:function(e,t,n){var i,r;2!==n.length&&oe(e,"TAG directive accepts exactly two arguments"),i=n[0],r=n[1],W.test(i)||oe(e,"ill-formed tag handle (first argument) of the TAG directive"),R.call(e.tagMap,i)&&oe(e,'there is a previously declared suffix for "'+i+'" tag handle'),H.test(r)||oe(e,"ill-formed tag prefix (second argument) of the TAG directive");try{r=decodeURIComponent(r)}catch(t){oe(e,"tag prefix is malformed: "+r)}e.tagMap[i]=r}};function ce(e,t,n,i){var r,o,a,l;if(t1&&(e.result+=n.repeat("\n",t-1))}function ge(e,t){var n,i,r=e.tag,o=e.anchor,a=[],l=!1;if(-1!==e.firstTabInLine)return!1;for(null!==e.anchor&&(e.anchorMap[e.anchor]=a),i=e.input.charCodeAt(e.position);0!==i&&(-1!==e.firstTabInLine&&(e.position=e.firstTabInLine,oe(e,"tab characters must not be used in indentation")),45===i)&&Z(e.input.charCodeAt(e.position+1));)if(l=!0,e.position++,fe(e,!0,-1)&&e.lineIndent<=t)a.push(null),i=e.input.charCodeAt(e.position);else if(n=e.line,be(e,t,3,!1,!0),a.push(e.result),fe(e,!0,-1),i=e.input.charCodeAt(e.position),(e.line===n||e.lineIndent>t)&&0!==i)oe(e,"bad indentation of a sequence entry");else if(e.lineIndentt?g=1:e.lineIndent===t?g=0:e.lineIndentt?g=1:e.lineIndent===t?g=0:e.lineIndentt)&&(y&&(a=e.line,l=e.lineStart,c=e.position),be(e,t,4,!0,r)&&(y?g=e.result:m=e.result),y||(ue(e,f,d,h,g,m,a,l,c),h=g=m=null),fe(e,!0,-1),s=e.input.charCodeAt(e.position)),(e.line===o||e.lineIndent>t)&&0!==s)oe(e,"bad indentation of a mapping entry");else if(e.lineIndent=0))break;0===o?oe(e,"bad explicit indentation width of a block scalar; it cannot be less than one"):u?oe(e,"repeat of an indentation width identifier"):(p=t+o-1,u=!0)}if(V(a)){do{a=e.input.charCodeAt(++e.position)}while(V(a));if(35===a)do{a=e.input.charCodeAt(++e.position)}while(!G(a)&&0!==a)}for(;0!==a;){for(pe(e),e.lineIndent=0,a=e.input.charCodeAt(e.position);(!u||e.lineIndentp&&(p=e.lineIndent),G(a))f++;else{if(e.lineIndent0){for(r=a,o=0;r>0;r--)(a=Q(l=e.input.charCodeAt(++e.position)))>=0?o=(o<<4)+a:oe(e,"expected hexadecimal character");e.result+=X(o),e.position++}else oe(e,"unknown escape sequence");n=i=e.position}else G(l)?(ce(e,n,i,!0),he(e,fe(e,!1,t)),n=i=e.position):e.position===e.lineStart&&de(e)?oe(e,"unexpected end of the document within a double quoted scalar"):(e.position++,i=e.position)}oe(e,"unexpected end of the stream within a double quoted scalar")}(e,d)?y=!0:!function(e){var t,n,i;if(42!==(i=e.input.charCodeAt(e.position)))return!1;for(i=e.input.charCodeAt(++e.position),t=e.position;0!==i&&!Z(i)&&!J(i);)i=e.input.charCodeAt(++e.position);return e.position===t&&oe(e,"name of an alias node must contain at least one character"),n=e.input.slice(t,e.position),R.call(e.anchorMap,n)||oe(e,'unidentified alias "'+n+'"'),e.result=e.anchorMap[n],fe(e,!0,-1),!0}(e)?function(e,t,n){var i,r,o,a,l,c,s,u,p=e.kind,f=e.result;if(Z(u=e.input.charCodeAt(e.position))||J(u)||35===u||38===u||42===u||33===u||124===u||62===u||39===u||34===u||37===u||64===u||96===u)return!1;if((63===u||45===u)&&(Z(i=e.input.charCodeAt(e.position+1))||n&&J(i)))return!1;for(e.kind="scalar",e.result="",r=o=e.position,a=!1;0!==u;){if(58===u){if(Z(i=e.input.charCodeAt(e.position+1))||n&&J(i))break}else if(35===u){if(Z(e.input.charCodeAt(e.position-1)))break}else{if(e.position===e.lineStart&&de(e)||n&&J(u))break;if(G(u)){if(l=e.line,c=e.lineStart,s=e.lineIndent,fe(e,!1,-1),e.lineIndent>=t){a=!0,u=e.input.charCodeAt(e.position);continue}e.position=o,e.line=l,e.lineStart=c,e.lineIndent=s;break}}a&&(ce(e,r,o,!1),he(e,e.line-l),r=o=e.position,a=!1),V(u)||(o=e.position+1),u=e.input.charCodeAt(++e.position)}return ce(e,r,o,!1),!!e.result||(e.kind=p,e.result=f,!1)}(e,d,1===i)&&(y=!0,null===e.tag&&(e.tag="?")):(y=!0,null===e.tag&&null===e.anchor||oe(e,"alias node should not have any properties")),null!==e.anchor&&(e.anchorMap[e.anchor]=e.result)):0===g&&(y=c&&ge(e,h))),null===e.tag)null!==e.anchor&&(e.anchorMap[e.anchor]=e.result);else if("?"===e.tag){for(null!==e.result&&"scalar"!==e.kind&&oe(e,'unacceptable node kind for ! tag; it should be "scalar", not "'+e.kind+'"'),s=0,u=e.implicitTypes.length;s"),null!==e.result&&f.kind!==e.kind&&oe(e,"unacceptable node kind for !<"+e.tag+'> tag; it should be "'+f.kind+'", not "'+e.kind+'"'),f.resolve(e.result,e.tag)?(e.result=f.construct(e.result,e.tag),null!==e.anchor&&(e.anchorMap[e.anchor]=e.result)):oe(e,"cannot resolve a node with !<"+e.tag+"> explicit tag")}return null!==e.listener&&e.listener("close",e),null!==e.tag||null!==e.anchor||y}function Ae(e){var t,n,i,r,o=e.position,a=!1;for(e.version=null,e.checkLineBreaks=e.legacy,e.tagMap=Object.create(null),e.anchorMap=Object.create(null);0!==(r=e.input.charCodeAt(e.position))&&(fe(e,!0,-1),r=e.input.charCodeAt(e.position),!(e.lineIndent>0||37!==r));){for(a=!0,r=e.input.charCodeAt(++e.position),t=e.position;0!==r&&!Z(r);)r=e.input.charCodeAt(++e.position);for(i=[],(n=e.input.slice(t,e.position)).length<1&&oe(e,"directive name must not be less than one character in length");0!==r;){for(;V(r);)r=e.input.charCodeAt(++e.position);if(35===r){do{r=e.input.charCodeAt(++e.position)}while(0!==r&&!G(r));break}if(G(r))break;for(t=e.position;0!==r&&!Z(r);)r=e.input.charCodeAt(++e.position);i.push(e.input.slice(t,e.position))}0!==r&&pe(e),R.call(le,n)?le[n](e,n,i):ae(e,'unknown document directive "'+n+'"')}fe(e,!0,-1),0===e.lineIndent&&45===e.input.charCodeAt(e.position)&&45===e.input.charCodeAt(e.position+1)&&45===e.input.charCodeAt(e.position+2)?(e.position+=3,fe(e,!0,-1)):a&&oe(e,"directives end mark is expected"),be(e,e.lineIndent-1,4,!1,!0),fe(e,!0,-1),e.checkLineBreaks&&K.test(e.input.slice(o,e.position))&&ae(e,"non-ASCII line breaks are interpreted as content"),e.documents.push(e.result),e.position===e.lineStart&&de(e)?46===e.input.charCodeAt(e.position)&&(e.position+=3,fe(e,!0,-1)):e.position=55296&&i<=56319&&t+1=56320&&n<=57343?1024*(i-55296)+n-56320+65536:i}function Ue(e){return/^\n* /.test(e)}function Ye(e,t,n,i,r,o,a,l){var c,s,u=0,p=null,f=!1,d=!1,h=-1!==i,g=-1,m=Me(s=De(e,0))&&s!==xe&&!Ee(s)&&45!==s&&63!==s&&58!==s&&44!==s&&91!==s&&93!==s&&123!==s&&125!==s&&35!==s&&38!==s&&42!==s&&33!==s&&124!==s&&61!==s&&62!==s&&39!==s&&34!==s&&37!==s&&64!==s&&96!==s&&function(e){return!Ee(e)&&58!==e}(De(e,e.length-1));if(t||a)for(c=0;c=65536?c+=2:c++){if(!Me(u=De(e,c)))return 5;m=m&&_e(u,p,l),p=u}else{for(c=0;c=65536?c+=2:c++){if(10===(u=De(e,c)))f=!0,h&&(d=d||c-g-1>i&&" "!==e[g+1],g=c);else if(!Me(u))return 5;m=m&&_e(u,p,l),p=u}d=d||h&&c-g-1>i&&" "!==e[g+1]}return f||d?n>9&&Ue(e)?5:a?2===o?5:2:d?4:3:!m||a||r(e)?2===o?5:2:1}function qe(e,t,n,i,r){e.dump=function(){if(0===t.length)return 2===e.quotingType?'""':"''";if(!e.noCompatMode&&(-1!==Se.indexOf(t)||Oe.test(t)))return 2===e.quotingType?'"'+t+'"':"'"+t+"'";var a=e.indent*Math.max(1,n),l=-1===e.lineWidth?-1:Math.max(Math.min(e.lineWidth,40),e.lineWidth-a),c=i||e.flowLevel>-1&&n>=e.flowLevel;switch(Ye(t,c,e.indent,l,(function(t){return function(e,t){var n,i;for(n=0,i=e.implicitTypes.length;n"+Re(t,e.indent)+Be(Ne(function(e,t){var n,i,r=/(\n+)([^\n]*)/g,o=(l=e.indexOf("\n"),l=-1!==l?l:e.length,r.lastIndex=l,Ke(e.slice(0,l),t)),a="\n"===e[0]||" "===e[0];var l;for(;i=r.exec(e);){var c=i[1],s=i[2];n=" "===s[0],o+=c+(a||n||""===s?"":"\n")+Ke(s,t),a=n}return o}(t,l),a));case 5:return'"'+function(e){for(var t,n="",i=0,r=0;r=65536?r+=2:r++)i=De(e,r),!(t=Ie[i])&&Me(i)?(n+=e[r],i>=65536&&(n+=e[r+1])):n+=t||je(i);return n}(t)+'"';default:throw new o("impossible error: invalid scalar style")}}()}function Re(e,t){var n=Ue(e)?String(t):"",i="\n"===e[e.length-1];return n+(i&&("\n"===e[e.length-2]||"\n"===e)?"+":i?"":"-")+"\n"}function Be(e){return"\n"===e[e.length-1]?e.slice(0,-1):e}function Ke(e,t){if(""===e||" "===e[0])return e;for(var n,i,r=/ [^ ]/g,o=0,a=0,l=0,c="";n=r.exec(e);)(l=n.index)-o>t&&(i=a>o?a:l,c+="\n"+e.slice(o,i),o=i+1),a=l;return c+="\n",e.length-o>t&&a>o?c+=e.slice(o,a)+"\n"+e.slice(a+1):c+=e.slice(o),c.slice(1)}function Pe(e,t,n,i){var r,o,a,l="",c=e.tag;for(r=0,o=n.length;r tag resolver accepts not "'+s+'" style');i=c.represent[s](t,s)}e.dump=i}return!0}return!1}function He(e,t,n,i,r,a,l){e.tag=null,e.dump=n,We(e,n,!1)||We(e,n,!0);var c,s=ke.call(e.dump),u=i;i&&(i=e.flowLevel<0||e.flowLevel>t);var p,f,d="[object Object]"===s||"[object Array]"===s;if(d&&(f=-1!==(p=e.duplicates.indexOf(n))),(null!==e.tag&&"?"!==e.tag||f||2!==e.indent&&t>0)&&(r=!1),f&&e.usedDuplicates[p])e.dump="*ref_"+p;else{if(d&&f&&!e.usedDuplicates[p]&&(e.usedDuplicates[p]=!0),"[object Object]"===s)i&&0!==Object.keys(e.dump).length?(!function(e,t,n,i){var r,a,l,c,s,u,p="",f=e.tag,d=Object.keys(n);if(!0===e.sortKeys)d.sort();else if("function"==typeof e.sortKeys)d.sort(e.sortKeys);else if(e.sortKeys)throw new o("sortKeys must be a boolean or a function");for(r=0,a=d.length;r1024)&&(e.dump&&10===e.dump.charCodeAt(0)?u+="?":u+="? "),u+=e.dump,s&&(u+=Fe(e,t)),He(e,t+1,c,!0,s)&&(e.dump&&10===e.dump.charCodeAt(0)?u+=":":u+=": ",p+=u+=e.dump));e.tag=f,e.dump=p||"{}"}(e,t,e.dump,r),f&&(e.dump="&ref_"+p+e.dump)):(!function(e,t,n){var i,r,o,a,l,c="",s=e.tag,u=Object.keys(n);for(i=0,r=u.length;i1024&&(l+="? "),l+=e.dump+(e.condenseFlow?'"':"")+":"+(e.condenseFlow?"":" "),He(e,t,a,!1,!1)&&(c+=l+=e.dump));e.tag=s,e.dump="{"+c+"}"}(e,t,e.dump),f&&(e.dump="&ref_"+p+" "+e.dump));else if("[object Array]"===s)i&&0!==e.dump.length?(e.noArrayIndent&&!l&&t>0?Pe(e,t-1,e.dump,r):Pe(e,t,e.dump,r),f&&(e.dump="&ref_"+p+e.dump)):(!function(e,t,n){var i,r,o,a="",l=e.tag;for(i=0,r=n.length;i",e.dump=c+" "+e.dump)}return!0}function $e(e,t){var n,i,r=[],o=[];for(Ge(e,r,o),n=0,i=o.length;n>> light_s = Light( ... xknx, ... name="light with state address", ... group_address_switch="0/2/2", ... group_address_switch_state="0/3/3", ... ) >>> light_s.switch.group_address # this is used to send payloads to the bus GroupAddress("0/2/2") >>> light_s.switch.group_address_state # group_address_*_state is used to send GroupValueRead requests to (from `sync()` or StateUpdater) GroupAddress("0/3/3") >>> light_s.switch.passive_group_addresses # none configured [] >>> >>> light_p = Light( ... xknx, ... name="light with state and passive addresses", ... group_address_switch=["1/2/2", "4/2/10", "4/2/20"], ... group_address_switch_state=["1/3/3", "4/3/10", "4/3/20"], ... ) >>> light_p.switch.group_address # this is used to send payloads to the bus GroupAddress("1/2/2") >>> light_p.switch.group_address_state # group_address_*_state is used for reading state from the bus GroupAddress("1/3/3") >>> light_p.switch.passive_group_addresses # these are only listening [GroupAddress("4/2/10"), GroupAddress("4/2/20"), GroupAddress("4/3/10"), GroupAddress("4/3/20")] ``` ## [](#header-2)Addresses `GroupAddress` classes are initialized with strings or integers in the format “1/2/3” for 3-level GA-structure, “1/2” for 2-level GA-structure or “1” for free GA-structure. `InternalGroupAddress` classes are initialized by prepending "i", "i-" or "i_" to any string. These can be used to connect xknx devices without sending telegrams to the KNX/IP interface. Addresses passed to devices as arguments are initialized by `xknx.telegram.address.parse_device_group_address()` to create the according address class. ```python >>> s = Switch(xknx, ... name="Switch", ... group_address=["1/2/3", "1/2/100", "i-🤖⚡️"], ... ) >>> s.switch.group_address GroupAddress("1/2/3") >>> s.switch.passive_group_addresses [GroupAddress("1/2/100"), InternalGroupAddress("i-🤖⚡️")] ``` ## [](#header-2)Device classes The following pages will give you an overview over the available devices within XKNX. xknx-3.6.0/docs/fan.md000066400000000000000000000044071475530762600145520ustar00rootroot00000000000000--- layout: default title: Fan parent: Devices nav_order: 4 --- # [](#header-1)Fans ## [](#header-2)Overview Fans are simple representations of KNX controlled fans. They support switching on/off, setting the speed and the oscillation. ## [](#header-2)Interface - `xknx` XKNX object. - `name` name of the device. - `group_address_speed` is the KNX group address of the fan speed. Used for sending. If no `group_address_switch` is provided, it will implicitly control switching the fan on/off as well. *DPT 5.001 / 5.010* - `group_address_speed_state` is the KNX group address of the fan speed state. Used for updating and reading state. *DPT 5.001 / 5.010* - `group_address_oscillation` is the KNX group address of the oscillation. Used for sending. *DPT 1.001* - `group_address_oscillation_state` is the KNX group address of the fan oscillation state. Used for updating and reading state. *DPT 1.001* - `group_address_switch` is the KNX group address of the fan on/off state. If not used, on/off will implicitly be controlled via `group_address_speed` instead. Used for sending. *DPT 1.001* - `group_address_switch_state` is the KNX group address of the fan on/off state. Used for updating and reading state. *DPT 1.001* - `sync_state` defines if and how often the value should be actively read from the bus. If `False` no GroupValueRead telegrams will be sent to its group address. Defaults to `True` - `device_updated_cb` Callback for each update. - `max_step` Maximum step amount for fans which are controlled with steps and not percentage. If this attribute is set, the fan is controlled by sending the step value in the range `0` and `max_step`. In that case, the group address DPT changes from *DPT 5.001* to *DPT 5.010*. Default: None ## [](#header-2)Example ```python fan = Fan( xknx, 'TestFan', group_address_speed='1/2/1', group_address_speed_state='1/2/2', group_address_oscillation='1/2/3', group_address_oscillation_state='1/2/4' ) xknx.devices.async_add(fan) # Set the fan speed await fan.set_speed(50) # Accessing speed print(fan.current_speed) # Set the oscillation await fan.set_oscillation(True) # Accessing oscillation print(fan.current_oscillation) # Accessing on/off state print(fan.is_on) # Requesting state via KNX GroupValueRead await fan.sync() ``` xknx-3.6.0/docs/favicon.ico000066400000000000000000000021761475530762600156060ustar00rootroot00000000000000 h(    ~~~___CCC~~~uuuqqq999nnn}}}qqquuueeeaaaeeexknx-3.6.0/docs/home_assistant.md000066400000000000000000000030501475530762600170200ustar00rootroot00000000000000--- layout: default title: Home Assistant Integration has_children: true nav_order: 13 --- Home Assistant KNX Integration ======================== XKNX is shipped within [Home Assistant (HA)](https://www.home-assistant.io/), the great solution for home automation, in the form of the included [KNX integration](https://www.home-assistant.io/integrations/knx/). Running HA with local XKNX library ------------------------------------ When running HA with the KNX integrated component once, HA will automatically install a `xknx` library version within `[hass-dependency-directory]/lib/python[python-version]/site-packages` via pip. In order to test new features before a release you can run HA with a local xknx installation as follows: Delete the automatically installed version of the library: ```bash rm [hass-dependency-directory]/lib/python[python-version]/site-packages/xknx* ``` Note: `[hass-dependency-directory]` is platform dependent (e.g. `/usr/local` for Docker image, `~/.homeassistant/deps` for macOS or `/srv/homeassistant` for Debian). Ideally start HA from command line. Export the environment variable PYTHONPATH to your local `xknx` checkout: ```bash export PYTHONPATH=$HOME/xknx hass ``` Starting via service is also possible, but you have to change the configuration to make sure PYTHONPATH [is set correctly](https://stackoverflow.com/questions/45374910/how-to-pass-environment-variables-to-a-service-started-by-systemd). Help ---- If you have problems, join the [XKNX chat on Discord](https://discord.gg/EuAQDXU). We are happy to help :-) xknx-3.6.0/docs/index.md000066400000000000000000000052541475530762600151160ustar00rootroot00000000000000--- layout: default title: Asynchronous Python Library for KNX nav_exclude: true --- # [](#header-1)Asynchronous Python Library for KNX XKNX is an Asynchronous Python library for reading and writing [KNX]()/IP packets. [![Coverage Status](https://coveralls.io/repos/github/XKNX/xknx/badge.svg?branch=main)](https://coveralls.io/github/XKNX/xknx?branch=main) ## [](#header-2)Overview XKNX... - ... does [cooperative multitasking via asyncio](https://github.com/XKNX/xknx/blob/main/examples/example_light_state.py) and is 100% thread safe. - ... provides support for KNX/IP [routing](https://github.com/XKNX/xknx/blob/main/xknx/io/routing.py) _and_ [tunneling](https://github.com/XKNX/xknx/blob/main/xknx/io/tunnel.py) devices. - ... supports KNX IP Secure - via tunneling or routing. - ... supports KNX Data Secure group communication. - ... has strong coverage with [unit tests](https://github.com/XKNX/xknx/tree/main/test). - ... automatically updates and synchronizes all devices in the background periodically. - ... listens for all updates of all devices on the KNX bus and updates the corresponding internal objects. - ... has a clear abstraction of data/network/logic-layer. - ... does clean [connect](https://github.com/XKNX/xknx/blob/main/xknx/io/connect.py) and [disconnect](https://github.com/XKNX/xknx/blob/main/xknx/io/disconnect.py) requests to the tunneling device and reconnects if KNX/IP connection failed. - ... ships with [Home Assistant](https://home-assistant.io/). ## [](#header-2)Installation XKNX depends on Python >= 3.10 You can install XKNX as Python package via pip: ```bash pip install xknx ``` ## [](#header-2)Hello World ```python import asyncio from xknx import XKNX from xknx.tools import group_value_write async def main(): async with XKNX() as xknx: # send a binary Telegram group_value_write(xknx, "1/2/3", True) # send a generic 1-byte Telegram group_value_write(xknx, "1/2/4", [0x80]) # send a Telegram with an encoded value group_value_write(xknx, "1/2/4", 50, value_type="percent") asyncio.run(main()) ``` For more examples please check out the [examples page](https://github.com/XKNX/xknx/tree/main/examples) # [](#header-1)Getting Help For questions, feature requests, bugreports either join the [XKNX chat on Discord](https://discord.gg/EuAQDXU), open an issue at [GitHub](https://github.com/XKNX/xknx) or write an [email](mailto:xknx@xknx.io). # [](#header-1)Attributions Many thanks to [MDT technologies GmbH](https://www.mdt.de) and [Weinzierl Engineering GmbH](https://weinzierl.de) for providing us each an IP Secure Router to support testing and development of xknx. xknx-3.6.0/docs/introduction.md000066400000000000000000000026111475530762600165220ustar00rootroot00000000000000--- layout: default title: Introduction nav_order: 1 --- # [](#header-1)Introduction # [](#header-2)Simple Example: XKNX is using [asyncio](https://www.python.org/dev/peps/pep-3156/) for single-threaded concurrent code using coroutines. This allows concurrency in a thread safe manner. ```python import asyncio from xknx import XKNX async def main(): xknx = XKNX() await xknx.start() # USING THE XKNX OBJECT, e.g. for # controlling lights, dimmers, shutters await xknx.stop() asyncio.run(main()) ``` # [](#header-2)Explanation En Dé­tail: ```python async def main(): ``` `main()` function. The `async` qualifier marks the function asy an asyncio function. See [asyncio](https://www.python.org/dev/peps/pep-3156/) documentation for details. ```python xknx = XKNX() ``` Initialization of XKNX object. Constructor may take several arguments like a reference to the asyncio-loop or various callbacks for device updates or telegram received. See [XKNX object documentation](/xknx) for details. ```python await xknx.start() ``` Asynchronous start of the XKNX object. `xknx.start()` will connect to a KNX/IP device and either build a tunnel or connect through Multicast UDP. ```python await xknx.stop() ``` Asynchronous stop of the XKNX object. `xknx.stop()` will disconnect from Tunnels - which is important bc most of the devices have a limited amount of channels. xknx-3.6.0/docs/light.md000066400000000000000000000166131475530762600151170ustar00rootroot00000000000000--- layout: default title: Lights / Dimmer parent: Devices nav_order: 5 --- # [](#header-1)Light & Dimmer ## [](#header-2)Overview The Light object is either a representation of a binary or dimm actor, LED-controller or DALI-gateway. ## [](#header-2)Interface - `xknx` XKNX object. - `name` name of the device. - `group_address_switch` KNX group address to switch the light. *DPT 1.001* - `group_address_switch_state` KNX group address for the state of the light. *DPT 1.001* - `group_address_brightness` KNX group address to set the brightness. *DPT 5.001* - `group_address_brightness_state` KNX group address for the current brightness state. *DPT 5.001* - `group_address_color` KNX group address to set the RGB color. *DPT 232.600* - `group_address_color_state` KNX group address for the current RGB color. *DPT 232.600* - `group_address_rgbw` KNX group address to set the RGBW color. *DPT 251.600* - `group_address_rgbw_state` KNX group address for the current RGBW color. *DPT 251.600* - `group_address_hue` KNX group address to set the current hue. *DPT 5.003* - `group_address_hue_state` KNX group address for the current hue. *DPT 5.003* - `group_address_saturation` KNX group address to set the current saturation. *DPT 5.001* - `group_address_saturation_state` KNX group address for the current saturation. *DPT 5.001* - `group_address_xyy_color`: KNX group address to set the xyY color. *DPT 242.600* - `group_address_xyy_color_state`: KNX group address for the current xyY color. *DPT 242.600* - `group_address_tunable_white` KNX group address to set relative color temperature. *DPT 5.001* - `group_address_tunable_white_state` KNX group address for the current relative color temperature. *DPT 5.001* - `group_address_color_temperature` KNX group address to set absolute color temperature. *DPT 7.600 or 9* - `group_address_color_temperature_state` KNX group address for the current absolute color temperature. *DPT 7.600 or 9* - `group_address_switch_red` KNX group address to switch the red component. *DPT 1.001* - `group_address_switch_red_state` KNX group address for the state of the red component. *DPT 1.001* - `group_address_brightness_red` KNX group address to set the brightness of the red component. *DPT 5.001* - `group_address_brightness_red_state` KNX group address for the current brightness of the red component. *DPT 5.001* - `group_address_switch_green` KNX group address to switch the green component. *DPT 1.001* - `group_address_switch_green_state` KNX group address for the state of the green component. *DPT 1.001* - `group_address_brightness_green` KNX group address to set the brightness of the green component. *DPT 5.001* - `group_address_brightness_green_state` KNX group address for the current brightness of the green component. *DPT 5.001* - `group_address_switch_blue` KNX group address to switch the blue component. *DPT 1.001* - `group_address_switch_blue_state` KNX group address for the state of the blue component. *DPT 1.001* - `group_address_brightness_blue` KNX group address to set the brightness of the blue component. *DPT 5.001* - `group_address_brightness_blue_state` KNX group address for the current brightness of the blue component. *DPT 5.001* - `group_address_switch_white` KNX group address to switch the white component. *DPT 1.001* - `group_address_switch_white_state` KNX group address for the state of the white component. *DPT 1.001* - `group_address_brightness_white` KNX group address to set the brightness of the white component. *DPT 5.001* - `group_address_brightness_white_state` KNX group address for the current brightness of the white component. *DPT 5.001* - `color_temperature_type` defines if DPT 7.600 or 9 is used for absolute color temperature. Default is `ColorTemperatureType.UINT_2_BYTE` (7.600) - `sync_state` defines if and how often the value should be actively read from the bus. If `False` no GroupValueRead telegrams will be sent to its group address. Defaults to `True` - `min_kelvin` lowest possible color temperature in Kelvin. Default: 2700 - `max_kelvin` highest possible color temperature in Kelvin. Default: 6000 - `device_updated_cb` Callback for each update. ## [](#header-2)Example ```python light = Light( xknx, name='TestLight', group_address_switch='1/2/3', group_address_switch_state='1/2/4', group_address_brightness='1/2/5', group_address_brightness_state='1/2/6', group_address_color='1/2/7', group_address_color_state='1/2/8', group_address_rgbw='1/2/13', group_address_rgbw_state='1/2/14', group_address_tunable_white='1/2/9', group_address_tunable_white_state='1/2/10', group_address_color_temperature='1/2/11', group_address_color_temperature_state='1/2/12' ) xknx.devices.async_add(light) # Switching light on await light.set_on() # Switching light off await light.set_off() # Set brightness await light.set_brightness(23) # Set color await light.set_color((20, 70,200)) # Set rgbw color await light.set_color((20,70,200), 30) # Set relative color temperature (percent) await set_tunable_white(25) # Set absolute color temperature (Kelvin) await set_color_temperature(3300) # Update current state via KNX GroupValueRead await light.sync(wait_for_result=True) # Accessing state print(light.state) print(light.supports_brightness) print(light.current_brightness) print(light.supports_color) print(light.current_color) print(light.supports_rgbw) print(light.supports_tunable_white) print(light.current_tunable_white) print(light.supports_color_temperature) print(light.current_color_temperature) ``` ## [](#header-2)Example: RGBW light with individual group addresses for red, green, blue and white ```python light = Light( xknx, name='TestRGBWLight', group_address_switch_red="1/1/1", group_address_switch_red_state="1/1/2", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_switch_green="1/1/5", group_address_switch_green_state="1/1/6", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_switch_blue="1/1/9", group_address_switch_blue_state="1/1/10", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", group_address_switch_white="1/1/13", group_address_switch_white_state="1/1/14", group_address_brightness_white="1/1/15", group_address_brightness_white_state="1/1/16" ) xknx.devices.async_add(light) # Switching light on await light.set_on() # Switching light off await light.set_off() # Set color await light.set_color((20, 70,200)) # Set rgbw color await light.set_color((20,70,200), 30) # Accessing state print(light.state) print(light.supports_brightness) print(light.supports_color) print(light.supports_rgbw) print(light.current_color) print(light.supports_tunable_white) print(light.supports_color_temperature) # Requesting current state via KNX GroupValueRead for all _state addresses await light.sync() ``` ## [](#header-2)Example: HSV-color light ```python light = Light( xknx, "Hue and saturation", group_address_switch="1/1/1", group_address_switch_state='1/2/1', group_address_brightness='1/1/2', group_address_brightness_state='1/2/2', group_address_hue="1/1/3", group_address_hue_state="1/2/3", group_address_saturation="1/1/4", group_address_saturation_state="1/2/4", ) xknx.devices.async_add(light) print(light.supports_brightness) print(light.supports_hs_color) await light.set_hs_color((25,40)) ``` xknx-3.6.0/docs/numeric_value.md000066400000000000000000000033521475530762600166420ustar00rootroot00000000000000--- layout: default title: NumericValue parent: Devices nav_order: 6 --- # [](#header-1)NumericValue - Send and receive numeric values over KNX ## [](#header-2)Overview NumericValue devices send values to the KNX bus. Received values update the devices state. Optionally the current state can be read from the KNX bus. ## [](#header-2)Interface - `xknx` is the XKNX object. - `name` is the name of the object. - `group_address` is the KNX group address of the numeric value device. Used for sending. - `group_address_state` is the KNX group address of the numeric value device. - `respond_to_read` if `True` GroupValueRead requests to the `group_address` are answered. Defaults to `False` - `sync_state` defines if and how often the value should be actively read from the bus. If `False` no GroupValueRead telegrams will be sent to its group address. Defaults to `True` - `value_type` controls how the value should be encoded / decoded. The attribute may have may have parseable value types representing numeric values. - `always_callback` defines if a callback shall be triggered for consecutive GroupValueWrite telegrams with same payload. Defaults to `False` - `device_updated_cb` Callback for each update. ## [](#header-2)Example ```python value = NumericValue( xknx=xknx, name='Temperature limit', group_address='6/2/1', respond_to_read=True, value_type='temperature' ) xknx.devices.async_add(value) # Set a value without sending to the bus value.sensor_value.value = 23.0 # Send a new value to the bus await value.set(24.0) # Returns the value of in a human readable way value.resolve_state() # Returns the unit of the value as string value.unit_of_measurement() # Returns the last received telegram or None value.last_telegram ``` xknx-3.6.0/docs/raw_value.md000066400000000000000000000030671475530762600157740ustar00rootroot00000000000000--- layout: default title: RawValue parent: Devices nav_order: 7 --- # [](#header-1)RawValue - Send and receive raw values over KNX ## [](#header-2)Overview RawValue devices send uint values to the KNX bus. Received values update the devices state. Optionally the current state can be read from the KNX bus. ## [](#header-2)Interface - `xknx` is the XKNX object. - `name` is the name of the object. - `payload_length` is the appended byte size to a CEMI-Frame. `0` for DPT 1, 2 and 3. - `group_address` is the KNX group address of the raw value device. Used for sending. - `group_address_state` is the KNX group address of the raw value device. - `respond_to_read` if `True` GroupValueRead requests to the `group_address` are answered. Defaults to `False` - `sync_state` defines if and how often the value should be actively read from the bus. If `False` no GroupValueRead telegrams will be sent to its group address. Defaults to `True` - `always_callback` defines if a callback shall be triggered for consecutive GroupValueWrite telegrams with same payload. Defaults to `False` - `device_updated_cb` Callback for each update. ## [](#header-2)Example ```python value = RawValue( xknx=xknx, name='Raw', payload_length=2, group_address='6/2/1', respond_to_read=True, ) xknx.devices.async_add(value) # Set a value without sending to the bus value.remote_value.value = 23.0 # Send a new value to the bus await value.set(24.0) # Returns the value of in a human readable way value.resolve_state() # Returns the last received telegram or None value.last_telegram ``` xknx-3.6.0/docs/sensor.md000066400000000000000000000027511475530762600153170ustar00rootroot00000000000000--- layout: default title: Sensor parent: Devices nav_order: 8 --- # [](#header-1)Sensor - Monitor values of KNX ## [](#header-2)Overview Sensors are monitoring temperature, air humidity, pressure etc. from KNX bus. ## [](#header-2)Interface - `xknx` is the XKNX object. - `name` is the name of the object. - `group_address_state` is the KNX group address of the sensor device. - `sync_state` defines if the value should be actively read from the bus. If `False` no GroupValueRead telegrams will be sent to its group address. Defaults to `True` - `always_callback` defines if a callback shall be triggered for consecutive GroupValueWrite telegrams with same payload. Defaults to `False` - `value_type` controls how the value should be rendered in a human readable representation. The attribute may have may have the values `percent`, `temperature`, `illuminance`, `speed_ms` or `current`. - `device_updated_cb` Callback for each update. ## [](#header-2)Example ```python sensor = Sensor( xknx=xknx, name='DiningRoom.Temperature.Sensor', always_callback=False, group_address_state='6/2/1', sync_state=True, value_type='temperature' ) xknx.devices.async_add(sensor) # Requesting current state via KNX GroupValueRead from the bus await sensor.sync(wait_for_result=True) # Returns the value of in a human readable way sensor.resolve_state() # Returns the unit of the value as string sensor.unit_of_measurement() # Returns the last received telegram or None sensor.last_telegram ``` xknx-3.6.0/docs/switch.md000066400000000000000000000027061475530762600153070ustar00rootroot00000000000000--- layout: default title: Switches parent: Devices nav_order: 9 --- # [](#header-1)Switches ## [](#header-2)Overview Switches are simple representations of binary actors. They mainly support switching on and off. ## [](#header-2)Interface - `xknx` is the XKNX object. - `name` is the name of the object. - `group_address` is the KNX group address of the switch device. Used for sending. - `group_address_state` is the KNX group address of the switch state. Used for updating and reading state. - `respond_to_read` if `True` GroupValueRead requests to the `group_address` are answered. Defaults to `False` - `sync_state` defines if and how often the value should be actively read from the bus. If `False` no GroupValueRead telegrams will be sent to its group address. Defaults to `True` - `invert` inverts the payload so state "on" is represented by 0 on bus and "off" by 1. Defaults to `False` - `reset_after` may be used to reset the switch to `OFF` again after given time in sec. Defaults to `None` - `device_updated_cb` Callback for each update. ## [](#header-2)Example ```python switch = Switch(xknx, 'TestOutlet', group_address='1/2/3') xknx.devices.async_add(switch) # Accessing switch via xknx.devices await xknx.devices['TestOutlet'].set_on() # Switching switch on await switch.set_on() # Switching switch off await switch.set_off() # Accessing state print(switch.state) # Requesting state via KNX GroupValueRead await switch.sync(wait_for_result=True) ``` xknx-3.6.0/docs/time.md000066400000000000000000000036751475530762600147520ustar00rootroot00000000000000--- layout: default title: Time parent: Devices nav_order: 10 --- # [](#header-1)Time ## [](#header-2)Overview XKNX provides the possibility to send the local time, date or both combined to the KNX bus in regular intervals with `TimeDevice`, `DateDevice` or `DateTimeDevice`. ## [](#header-2)Example ```python time_device = TimeDevice( xknx, 'TimeTest', group_address='1/2/3', localtime=True ) xknx.devices.async_add(time_device) # `sync()` doesn't send a GroupValueRead when localtime is True but sends the current time to KNX bus await xknx.devices['TimeTest'].sync() ``` * `xknx` is the XKNX object. * `name` is the name of the object. * `group_address` is the KNX group address of the sensor device. * `localtime` If set `True` sync() and GroupValueRead requests always return the current systems local time and it is also sent every 60 minutes. Same if set to a `datetime.tzinfo` object, but the time for that timezone information will be used. On `False` the set value will be sent, no automatic sending will be scheduled. Default: `True` * `device_updated_cb` Callback for each update. ## [](#header-2)Local time When XKNX is started, a DateDevice, DateTimeDevice or TimeDevice will automatically send the time to the KNX bus every hour. This can be disabled by setting `localtime=False`. ```python import asyncio from xknx import XKNX from xknx.devices import DateTimeDevice async def main(): async with XKNX(daemon_mode=True) as xknx: dt_device = DateTimeDevice(xknx, 'TimeTest', group_address='1/2/3') xknx.devices.async_add(dt_device) print("Sending datetime object to KNX bus every hour") asyncio.run(main()) ``` ## [](#header-2)Interface ```python from xknx import XKNX from xknx.devices import DateDevice xknx = XKNX() date_device = DateDevice(xknx, 'TimeTest', group_address='1/2/3') xknx.devices.async_add(date_device) await xknx.start() # Sending Time to KNX bus await time_device.broadcast_localtime() ``` xknx-3.6.0/docs/weather.md000066400000000000000000000054631475530762600154500ustar00rootroot00000000000000--- layout: default title: Weather parent: Devices nav_order: 11 --- # [](#header-1)Weather device ## [](#header-2)Overview The weather device is basically a set of sensors that you can obtain from your weather station. ## [](#header-2)Example ```python async with XKNX() as xknx: weather = Weather( xknx, 'Home', group_address_temperature='7/0/1', group_address_brightness_south='7/0/5', group_address_brightness_east='7/0/4', group_address_brightness_west='7/0/3', group_address_wind_speed='7/0/2', group_address_wind_bearing='7/0/6', group_address_day_night='7/0/7', group_address_rain_alarm='7/0/0' ) xknx.devices.async_add(weather) await weather.sync(wait_for_result=True) print(weather) ``` ## [](#header-2)Interface - **xknx** is the XKNX object. - **name** is the name of the object. - **group_address_temperature** KNX address of current outside temperature. - **group_address_brightness_south** KNX address for the brightness to south **DPT 9.004**. - **group_address_brightness_west** KNX address for the brightness to west **DPT 9.004**. - **group_address_brightness_east** KNX address for the brightness to east **DPT 9.004**. - **group_address_wind_speed** KNX address for current wind speed. **DPT 9.005** - **group_address_wind_bearing** KNX address for current wind bearing. **DPT 5.003** - **group_address_rain_alarm** KNX address for reading if rain alarm is on/off. - **group_address_wind_alarm** KNX address for reading if wind alarm is on/off. - **group_address_frost_alarm** KNX address for reading if frost alarm is on/off. - **group_address_day_night** KNX address for reading a day/night object. - **group_address_air_pressure** KNX address reading current air pressure. **DPT 9.006 or 14.058** - **group_address_humidity** KNX address for reading current humidity. **DPT 9.007** - **sync_state** Periodically sync the state. - **device_updated_cb** Callback for each update. ```python async with XKNX() as xknx: weather = Weather( xknx, 'Home', group_address_temperature='7/0/1', group_address_brightness_south='7/0/5', group_address_brightness_east='7/0/4', group_address_brightness_west='7/0/3', group_address_wind_speed='7/0/2', group_address_day_night='7/0/7', group_address_rain_alarm='7/0/0' ) xknx.devices.async_add(weather) await weather.sync(wait_for_result=True) print(weather.humidity) # get humidity print(weather.ha_current_state()) # get the current state mapped as a WeatherCondition enum value. (for HA mainly) print(weather.wind_speed) # get the current wind speed in m/s ``` xknx-3.6.0/docs/xknx.md000066400000000000000000000161301475530762600147720ustar00rootroot00000000000000--- layout: default title: XKNX Object nav_order: 3 --- # [](#header-1)The XKNX Object # [](#header-2)Overview The `XKNX()` object is the core element of any XKNX installation. It should be only initialized once per implementation. The XKNX object is responsible for: - connecting to a KNX/IP device and managing the connection - processing all incoming KNX telegrams - organizing all connected devices and keeping their state - updating all connected devices from time to time - keeping the global configuration # [](#header-2)Initialization ```python xknx = XKNX( address_format=GroupAddressType.LONG, connection_state_changed_cb=None, telegram_received_cb=None, device_updated_cb=None, rate_limit=0, multicast_group=DEFAULT_MCAST_GRP, multicast_port=DEFAULT_MCAST_PORT, log_directory=None, state_updater=False, daemon_mode=False, connection_config=ConnectionConfig() ) ``` The constructor of the XKNX object takes several parameters: - `address_format` may be used to specify the type of group addresses to use. Possible values are: - FREE: integer or hex representation - SHORT: representation like '1/34' without middle groups - LONG: representation like '1/2/34' with middle groups - `connection_state_changed_cb` is a callback which is called every time the connection state to the gateway changes. See [callbacks](#callbacks) documentation for details. - `telegram_received_cb` is a callback which is called after every received KNX telegram. See [callbacks](#callbacks) documentation for details. - `device_updated_cb` is a callback after a [XKNX device](#devices) was updated. See [callbacks](#callbacks) documentation for details. - `rate_limit` in telegrams per second - can be used to limit the outgoing traffic to the KNX/IP interface by the telegram queue. `0` disables rate limiter. Disabled by default. - `multicast_group` is the multicast group used for discovery - can be used to override the default multicast address (`224.0.23.12`) - `multicast_port` is the multicast port used for discovery - can be used to override the default multicast port (`3671`) - `log_directory` is the path to the log directory - when set to a valid directory we log to a dedicated file in this directory called `xknx.log`. The log files are rotated each night and will exist for 7 days. After that the oldest one will be deleted. - `state_updater` is used to set the default state-updating mechanism used by devices. `False` to disable state-updating by default, `True` to use default 60 minutes expire-interval, a number between 2 to 1440 to configure expire-time or a string "expire 50", "every 90" for strict periodically update or "init" for update when a connection is established. Default: `False`. - if `daemon_mode` is set, start will only stop if Control-X is pressed. This function is useful for using XKNX as a daemon, e.g. for using the callback functions or using the internal action logic. - `connection_config` replaces a ConnectionConfig() that was read from a yaml config file. # [](#header-2)Connection configuration ```python from xknx.io import ConnectionConfig, ConnectionType, SecureConfig secure_config = SecureConfig( knxkeys_file_path="/Users/me/xknx/Keyfile.knxkeys", knxkeys_password="secret", ) connection_config = ConnectionConfig( connection_type=ConnectionType.TUNNELING_TCP_SECURE, gateway_ip="10.1.0.123", individual_address="1.0.240", secure_config=secure_config, ) xknx = XKNX(connection_config=connection_config) ``` An explicit connection configuration can be used. In this case a `connection_type` other than `ConnectionType.AUTOMATIC` shall be passed. KNX Data Secure credentials are sourced from a keyfile exported from ETS. IP Secure keys can be configured directly or sourced from a keyfile. A specific tunnel endpoint can be requested by setting `individual_address`. For AUTOMATIC connections this setting selects a host from a given keyfile. For TCP TUNNELING connections this setting requests a tunnel to that individual address. For SECURE tunnels this setting selects an interface from a given keyfile. # [](#header-2)Starting ```python await xknx.start() ``` `xknx.start()` will search for KNX/IP devices in the network and either build a KNX/IP-Tunnel or open a multicast KNX/IP-Routing connection. `start()` will not take any parameters. # [](#header-2)Stopping ```python await xknx.stop() ``` Will disconnect from tunneling devices and stop the different queues. # [](#header-2)Using XKNX as an asynchronous context manager You can also use an asynchronous context manager instead of calling `xknx.start()` and `xknx.stop()`: ```python import asyncio async def main(): async with XKNX() as xknx: switch = Switch(xknx, name='TestSwitch', group_address='1/1/11' ) xknx.devices.async_add(switch) await switch.set_on() asyncio.run(main()) ``` # [](#header-2)Devices To attach a device to XKNX call `xknx.devices.async_add(device)`. Added devices may be accessed by their name: `xknx.devices['NameOfDevice']`. When an update via KNX GroupValueWrite or GroupValueResponse was received devices will be updated accordingly. To remove a device from XKNX call `xknx.devices.async_remove(device)`. Removed devices can be re-added at a later point. This cancels background tasks of that device and disconnects it from receiving new telegrams. Example: ```python switch = Switch( xknx, name='TestSwitch', group_address='1/1/11' ) xknx.devices.async_add(switch) await xknx.devices['TestSwitch'].set_on() await xknx.devices['TestSwitch'].set_off() ``` # [](#header-2)Callbacks A callback `telegram_received_cb` will be called for each KNX telegram received by the XKNX daemon. Example: ```python import asyncio from xknx import XKNX from xknx.telegram import Telegram def telegram_received_cb(telegram: Telegram): print("Telegram received: {0}".format(telegram)) async def main(): xknx = XKNX(telegram_received_cb=telegram_received_cb, daemon_mode=True) await xknx.start() await xknx.stop() asyncio.run(main()) ``` For all devices stored in the `devices` storage (see [above](#devices)) a callback for each update may be defined: ```python import asyncio from xknx import XKNX from xknx.devices import Device, Switch def device_updated_cb(device: Device): print("Callback received from {0}".format(device.name)) async def main(): xknx = XKNX(device_updated_cb=device_updated_cb, daemon_mode=True) switch = Switch(xknx, name='TestSwitch', group_address='1/1/11') await xknx.start() await xknx.stop() asyncio.run(main()) ``` A callback `connection_state_changed_cb` will be called every time the connection state to the gateway changes. Example: ```python import asyncio from xknx import XKNX from xknx.core import XknxConnectionState def connection_state_changed_cb(state: XknxConnectionState): print("Callback received with state {0}".format(state.name)) async def main(): xknx = XKNX(connection_state_changed_cb=connection_state_changed_cb, daemon_mode=True) await xknx.start() await xknx.stop() asyncio.run(main()) ``` xknx-3.6.0/examples/000077500000000000000000000000001475530762600143455ustar00rootroot00000000000000xknx-3.6.0/examples/example_callback.py000066400000000000000000000017601475530762600201720ustar00rootroot00000000000000"""Example for running a callback when a devices state changed.""" import asyncio from xknx import XKNX from xknx.devices import Light from xknx.io import ConnectionConfig, ConnectionType def light_callback(light: Light) -> None: """Run callback when the light changed any of its state.""" print(f"{light.name} - {light.state}") async def main() -> None: """Connect to KNX/IP bus, turn on Light device and wait.""" xknx = XKNX( connection_config=ConnectionConfig( gateway_ip="10.1.0.41", connection_type=ConnectionType.TUNNELING_TCP, ), ) await xknx.start() light = Light( xknx, name="TestLight", group_address_switch="1/1/45", group_address_switch_state="1/0/45", device_updated_cb=light_callback, ) xknx.devices.async_add(light) # turn on light and listen for 10 seconds for changes await light.set_on() await asyncio.sleep(10) await xknx.stop() asyncio.run(main()) xknx-3.6.0/examples/example_climate.py000066400000000000000000000010221475530762600200430ustar00rootroot00000000000000"""Example for Climate device.""" import asyncio from xknx import XKNX from xknx.devices import Climate async def main() -> None: """Connect to KNX/IP and read the state of a Climate device.""" xknx = XKNX() async with xknx: climate = Climate(xknx, "TestClimate", group_address_temperature="6/2/1") xknx.devices.async_add(climate) await climate.sync(wait_for_result=True) # Will print out state of climate including current temperature: print(climate) asyncio.run(main()) xknx-3.6.0/examples/example_daemon.py000066400000000000000000000011771475530762600177030ustar00rootroot00000000000000"""Example for daemon mode within XKNX.""" import asyncio from xknx import XKNX from xknx.devices import Device, Switch def device_updated_cb(device: Device) -> None: """Do something with the updated device.""" print(f"Callback received from {device.name}") async def main() -> None: """Connect to KNX/IP device and listen if a switch was updated via KNX bus.""" xknx = XKNX(device_updated_cb=device_updated_cb, daemon_mode=True) xknx.devices.async_add(Switch(xknx, name="TestOutlet", group_address="1/1/11")) await xknx.start() # Wait until Ctrl-C was pressed await xknx.stop() asyncio.run(main()) xknx-3.6.0/examples/example_datetime.py000066400000000000000000000006541475530762600202330ustar00rootroot00000000000000"""Example for Date and Time devices.""" import asyncio from xknx import XKNX from xknx.devices import TimeDevice async def main() -> None: """Connect to KNX/IP device and broadcast time.""" xknx = XKNX(daemon_mode=True) xknx.devices.async_add(TimeDevice(xknx, "TimeTest", group_address="1/2/3")) print("Sending time to KNX bus every hour") await xknx.start() await xknx.stop() asyncio.run(main()) xknx-3.6.0/examples/example_devices.py000066400000000000000000000010111475530762600200450ustar00rootroot00000000000000"""Example for internal devices storage.""" import asyncio from xknx import XKNX from xknx.devices import Switch async def main() -> None: """Add test Switch to devices storage and access it by name.""" xknx = XKNX() await xknx.start() switch = Switch(xknx, name="TestOutlet", group_address="1/1/11") xknx.devices.async_add(switch) await xknx.devices["TestOutlet"].set_on() await asyncio.sleep(2) await xknx.devices["TestOutlet"].set_off() await xknx.stop() asyncio.run(main()) xknx-3.6.0/examples/example_disconnect.py000066400000000000000000000026201475530762600205630ustar00rootroot00000000000000"""Example on how to disconnect/reset all available tunneling channels.""" import asyncio from xknx import XKNX from xknx.io import GatewayScanner from xknx.io.request_response import ConnectionState, Disconnect from xknx.io.transport import UDPTransport from xknx.knxip import HPAI async def main() -> None: """Search for a Tunnelling device, walk through all possible channels and disconnect them.""" xknx = XKNX() gatewayscanner = GatewayScanner(xknx) gateways = await gatewayscanner.scan() if not gateways: print("No Gateways found") return gateway = gateways[0] if not gateway.supports_tunnelling: print("Gateway does not support tunneling") return udp_transport = UDPTransport((gateway.local_ip, 0), (gateway.ip_addr, gateway.port)) await udp_transport.connect() local_hpai = HPAI(*udp_transport.getsockname()) for i in range(255): conn_state = ConnectionState( udp_transport, communication_channel_id=i, local_hpai=local_hpai ) await conn_state.start() if conn_state.success: print("Disconnecting ", i) disconnect = Disconnect( udp_transport, communication_channel_id=i, local_hpai=local_hpai ) await disconnect.start() if disconnect.success: print("Disconnected ", i) asyncio.run(main()) xknx-3.6.0/examples/example_fan_percent_mode.py000066400000000000000000000013131475530762600217200ustar00rootroot00000000000000"""Example for Fan device.""" import asyncio from xknx import XKNX from xknx.devices import Fan async def main() -> None: """Connect to KNX/IP bus, control a fan, and turn it off afterwards.""" xknx = XKNX() await xknx.start() fan = Fan( xknx, name="TestFan", group_address_switch="1/0/12", group_address_speed="1/0/14", max_step=3, ) xknx.devices.async_add(fan) # Turn on the fan await fan.turn_on() # Set fan speed to different levels for speed in [0, 33, 66, 100]: await fan.set_speed(speed) await asyncio.sleep(1) # Turn off the fan await fan.turn_off() await xknx.stop() asyncio.run(main()) xknx-3.6.0/examples/example_fan_step_mode.py000066400000000000000000000013101475530762600212300ustar00rootroot00000000000000"""Example for Fan device.""" import asyncio from xknx import XKNX from xknx.devices import Fan async def main() -> None: """Connect to KNX/IP bus, control a fan, and turn it off afterwards.""" xknx = XKNX() await xknx.start() fan = Fan( xknx, name="TestFan", group_address_switch="1/0/12", group_address_speed="1/0/14", max_step=3, ) xknx.devices.async_add(fan) # Turn on the fan await fan.turn_on() # Set fan speed in steps for step in range(1, fan.max_step + 1): await fan.set_speed(step) await asyncio.sleep(1) # Turn off the fan await fan.turn_off() await xknx.stop() asyncio.run(main()) xknx-3.6.0/examples/example_gatewayscanner.py000066400000000000000000000021211475530762600214410ustar00rootroot00000000000000"""Example for GatewayScanner.""" import asyncio from xknx import XKNX from xknx.io import GatewayScanner async def main() -> None: """Search for available KNX/IP devices with GatewayScanner and print out result if a device was found.""" xknx = XKNX() gatewayscanner = GatewayScanner(xknx) async for gateway in gatewayscanner.async_scan(): print(f"{gateway.individual_address} {gateway.name}") print(f" {gateway.ip_addr}:{gateway.port}") tunnelling = ( "Secure" if gateway.tunnelling_requires_secure else "TCP" if gateway.supports_tunnelling_tcp else "UDP" if gateway.supports_tunnelling else "No" ) print(f" Tunnelling: {tunnelling}") routing = ( "Secure" if gateway.routing_requires_secure else "Yes" if gateway.supports_routing else "No" ) print(f" Routing: {routing}") if not gatewayscanner.found_gateways: print("No Gateways found") asyncio.run(main()) xknx-3.6.0/examples/example_light_dimm.py000066400000000000000000000012131475530762600205440ustar00rootroot00000000000000"""Example for switching a light on and off.""" import asyncio from xknx import XKNX from xknx.devices import Light async def main() -> None: """Connect to KNX/IP bus, slowly dimm on light, set it off again afterwards.""" xknx = XKNX() await xknx.start() light = Light( xknx, name="TestLight2", group_address_switch="1/0/12", group_address_brightness="1/0/14", ) xknx.devices.async_add(light) for i in [0, 31, 63, 95, 127, 159, 191, 223, 255]: await light.set_brightness(i) await asyncio.sleep(1) await light.set_off() await xknx.stop() asyncio.run(main()) xknx-3.6.0/examples/example_light_rgbw.py000066400000000000000000000024401475530762600205620ustar00rootroot00000000000000"""Example for setting different colors on a RGBW remote value.""" import asyncio from xknx import XKNX from xknx.dpt import RGBWColor from xknx.remote_value import RemoteValueColorRGBW async def main() -> None: """Connect to KNX/IP bus and set different colors.""" xknx = XKNX() await xknx.start() rgbw = RemoteValueColorRGBW( xknx, group_address="1/1/40", group_address_state="1/1/41", device_name="RGBWLight", ) rgbw.set(RGBWColor(255, 255, 255, 0)) # cold-white await asyncio.sleep(1) rgbw.set(RGBWColor(0, 0, 0, 255)) # warm-white await asyncio.sleep(1) rgbw.set(RGBWColor(0, 0, 0, 0)) # off await asyncio.sleep(1) rgbw.set(RGBWColor(255, 0, 0, 0)) # red await asyncio.sleep(1) rgbw.set(RGBWColor(0, 255, 0, 0)) # green await asyncio.sleep(1) rgbw.set(RGBWColor(0, 0, 255, 0)) # blue await asyncio.sleep(1) rgbw.set(RGBWColor(0, 0, 0, 0)) # off await asyncio.sleep(1) rgbw.set(RGBWColor(255, 255, 0, 0)) await asyncio.sleep(1) rgbw.set(RGBWColor(0, 255, 255, 0)) await asyncio.sleep(1) rgbw.set(RGBWColor(255, 0, 255, 0)) await asyncio.sleep(1) rgbw.set(RGBWColor(0, 0, 0, 0)) # off await asyncio.sleep(1) await xknx.stop() asyncio.run(main()) xknx-3.6.0/examples/example_light_state.py000066400000000000000000000012541475530762600207430ustar00rootroot00000000000000"""Example for reading the state from the KNX bus.""" import asyncio from xknx import XKNX from xknx.devices import Light async def main() -> None: """Connect to KNX/IP bus and read the state of a Light device.""" async with XKNX() as xknx: light = Light( xknx, name="TestLight2", group_address_switch="1/0/12", group_address_brightness="1/0/14", ) xknx.devices.async_add(light) await light.set_brightness(80) # Will do a GroupValueRead for both addresses and block until a result is received await light.sync(wait_for_result=True) print(light) asyncio.run(main()) xknx-3.6.0/examples/example_light_switch.py000066400000000000000000000007721475530762600211300ustar00rootroot00000000000000"""Example for switching a light on and off.""" import asyncio from xknx import XKNX from xknx.devices import Light async def main() -> None: """Connect to KNX/IP bus, switch on light, wait 2 seconds and switch of off again.""" xknx = XKNX() await xknx.start() light = Light(xknx, name="TestLight", group_address_switch="1/0/9") xknx.devices.async_add(light) await light.set_on() await asyncio.sleep(2) await light.set_off() await xknx.stop() asyncio.run(main()) xknx-3.6.0/examples/example_manual_connection.py000066400000000000000000000017061475530762600221320ustar00rootroot00000000000000"""Example for connecting to a specific KNX interface.""" import asyncio import logging import time from xknx import XKNX from xknx.io import ConnectionConfig, ConnectionType from xknx.tools import read_group_value logging.basicConfig(level=logging.INFO) logging.getLogger("xknx.log").level = logging.DEBUG logging.getLogger("xknx.knx").level = logging.DEBUG async def main() -> None: """Connect to specific tunnelling server, time a request for a group address value.""" connection_config = ConnectionConfig( connection_type=ConnectionType.TUNNELING, gateway_ip="10.1.0.40", # local_ip="10.1.0.123", # route_back=True, ) xknx = XKNX(connection_config=connection_config) async with xknx: start_time = time.time() result = await read_group_value(xknx, "5/1/20", value_type="temperature") print(f"Value: {result} - took {(time.time() - start_time):0.3f} seconds") asyncio.run(main()) xknx-3.6.0/examples/example_powermeter_mqtt.py000066400000000000000000000164521475530762600217000ustar00rootroot00000000000000#!/usr/bin/env python """ Example of a daemon listening for values from my main power-meter and resend them on a MQTT bus. This example will not be able to run as it is - but it will hopefully give you some ideas to how you can define DPT, and get their converted values, and even send them to MQTT in a tested topic-format. I have a Mosquitto MQTT Server - and running the Paho Python client. I use some external MQTT libraries as well to handle the MQTT Topic-creation. The data published on the MQTT bus can be fetched and stored in InfluxDB for graphing, or monitored by other listeners - that triggers different events. Please join XKNX on Discord (https://discord.gg/EuAQDXU) and chat with JohanElmis for specific questions. """ import asyncio import re import sys from xknx.devices import Device try: # The following library is not included. from myhouse_sensors_mqtt import MetricType, SensorClientMqtt # import time import paho.mqtt.client as mqtt from xknx import XKNX from xknx.devices import Sensor # import pprint except ImportError as import_err: err_list = str(import_err).split(" ") print(f"Unable to import module: {err_list[3]}") print(f"Please install the {err_list[3]} module for Python.") sys.exit() BROKER_ADDRESS = "127.0.0.1" # Give this client a name on the MQTT bus. mqttc = mqtt.Client("main_power_central") # Library to deal with verification of values. # It also triggers and send values to the MQTT bus if the values has changed. # With no changes it can go up to max_interval_seconds before it's sent. # This allows me to fetch fast changes without storing all data at a 15s interval. mh_sensor = SensorClientMqtt( change_trigger_percent=5, max_interval_seconds=600, metric_class="sensor", debug=True, ) # Pre-compile some regexp filters that will be used to catch the different types. RE_METER_READING = re.compile("MeterReading_") RE_ACTIVE_POWER = re.compile("ActivePower") RE_REACTIVE_POWER = re.compile("ReactivePower") RE_APPARENT_POWER = re.compile("ApparentPower") RE_VOLTAGE = re.compile("Voltage_") RE_CURRENT = re.compile("Current_") RE_FREQUENCY = re.compile("Frequency_") def device_updated_cb(device: Device) -> None: """Do something with the updated device.""" # print(device.name + ' ' + str(device.resolve_state()) + ' ' + device.unit_of_measurement()) value = None topic = None if re.search("^EL-T-O_", device.name): metric = str(device.name)[7:] value = device.resolve_state() if RE_ACTIVE_POWER.search(metric): topic = mh_sensor.get_mqtt_sensor_metric( MetricType.WATT, "main_power_central", metric ) elif RE_REACTIVE_POWER.search(metric): topic = mh_sensor.get_mqtt_sensor_metric( MetricType.VAR, "main_power_central", metric ) elif RE_APPARENT_POWER.search(metric): topic = mh_sensor.get_mqtt_sensor_metric( MetricType.VA, "main_power_central", metric ) elif RE_VOLTAGE.search(metric): topic = mh_sensor.get_mqtt_sensor_metric( MetricType.VOLTAGE, "main_power_central", metric ) elif RE_CURRENT.search(metric): topic = mh_sensor.get_mqtt_sensor_metric( MetricType.CURRENT, "main_power_central", metric ) elif RE_METER_READING.search(metric): topic = mh_sensor.get_mqtt_sensor_metric( MetricType.KWH, "main_power_central", metric ) elif RE_FREQUENCY: topic = mh_sensor.get_mqtt_sensor_metric( MetricType.CUSTOM, "main_power_central", metric ) else: print( f"Uncatched metric: {device.name} {device.resolve_state()!s} {device.unit_of_measurement()}" ) if topic and value: # This will create a topic like: # myhouse/sensor/KWH/main_power_central/MeterReading_ActiveEnergy 103150280 # myhouse/sensor/WATT/main_power_central/ActivePower_L2 1028.1099853515625 # myhouse/sensor/VAR/main_power_central/ReactivePower_L3 136.3699951171875 # myhouse/sensor/VA/main_power_central/ApparentPower_L3 1449.919921875 # myhouse/sensor/CURRENT/main_power_central/Current_L3 6.239999294281006 # When storing it to InfluxDB I have a DB named 'myhouse' where all these values will be stored. # Then I use the next two fields as metric name (lowercase): sensor/current # break out main_power_central as a tag 'location', and the last field is tagged as the # 'sensor': ApparentPower_L3 # This makes it really easy to graph in for example Grafana. # My latest version of the library doesn't send the value after the MQTT Topic, but a JSON structure # that also contains time. print(f"{topic} {value!s}") # ts = int(time.time() * 1000) mqttc.publish(topic, value) async def main() -> None: """ KNX device objects are created and the MQTT server connection is established. Then the XKNX Daemon will be started. Then everything else happens in the device_updated-function above as it is triggered when we receive data. """ # Connect to KNX/IP device and listen if a switch was updated via KNX bus. xknx = XKNX(device_updated_cb=device_updated_cb, daemon_mode=True) # The KNX addresses to monitor are defined below, but is normally placed in an external # file that is loaded in on start. # Generic Types not specifically supported by XKNX Sensor( xknx, "EL-T-O_MeterReading_ActiveEnergy", group_address_state="5/6/11", value_type="DPT-13", ) Sensor( xknx, "EL-T-O_MeterReading_ReactiveEnergy", group_address_state="5/6/16", value_type="DPT-13", ) # Active Power Sensor( xknx, "EL-T-O_TotalActivePower", group_address_state="5/6/24", value_type="power", ) Sensor( xknx, "EL-T-O_ActivePower_L1", group_address_state="5/6/25", value_type="power" ) # ... # Reactive Power Sensor( xknx, "EL-T-O_TotalReactivePower", group_address_state="5/6/28", value_type="power", ) Sensor( xknx, "EL-T-O_ReactivePower_L1", group_address_state="5/6/29", value_type="power", ) # ... # Apparent Power Sensor( xknx, "EL-T-O_TotalReactivePower", group_address_state="5/6/32", value_type="power", ) Sensor( xknx, "EL-T-O_ApparentPower_L1", group_address_state="5/6/33", value_type="power", ) # ... # Current Sensor( xknx, "EL-T-O_Current_L1", group_address_state="5/6/45", value_type="electric_current", ) # ... # Voltage Sensor( xknx, "EL-T-O_Voltage_L1-N", group_address_state="5/6/48", value_type="electric_potential", ) # ... # Frequency Sensor( xknx, "EL-T-O_Frequency", group_address_state="5/6/53", value_type="frequency" ) mqttc.connect(BROKER_ADDRESS, 8883, 60) mqttc.loop_start() # Wait until Ctrl-C was pressed await xknx.start() await xknx.stop() await mqttc.loop_stop() await mqttc.disconnect() asyncio.run(main()) xknx-3.6.0/examples/example_restart.py000066400000000000000000000010541475530762600201160ustar00rootroot00000000000000"""Example on how to connect to restart a KNX device.""" import asyncio import sys from xknx import XKNX from xknx.management.procedures import dm_restart from xknx.telegram import IndividualAddress async def main(argv: list[str]) -> int: """Restart a KNX device.""" if len(argv) != 2: print(f"{argv[0]}: missing target address.") return 1 address = IndividualAddress(argv[1]) async with XKNX() as xknx: await dm_restart(xknx, address) return 0 if __name__ == "__main__": asyncio.run(main(sys.argv)) xknx-3.6.0/examples/example_scene.py000066400000000000000000000006141475530762600175300ustar00rootroot00000000000000"""Example for switching a light on and off.""" import asyncio from xknx import XKNX from xknx.devices import Scene async def main() -> None: """Connect to KNX/IP bus and run scene.""" async with XKNX() as xknx: scene = Scene(xknx, name="Romantic", group_address="7/0/9", scene_number=23) xknx.devices.async_add(scene) await scene.run() asyncio.run(main()) xknx-3.6.0/examples/example_secure.py000066400000000000000000000017501475530762600177230ustar00rootroot00000000000000"""Example for connecting to a KNX IP Secure Tunnel.""" import asyncio import logging from xknx import XKNX from xknx.io import ConnectionConfig, ConnectionType, SecureConfig from xknx.tools import group_value_write logging.basicConfig(level=logging.INFO) logging.getLogger("xknx.log").level = logging.DEBUG logging.getLogger("xknx.knx").level = logging.DEBUG async def main() -> None: """Test connection with IP secure tunnelling.""" connection_config = ConnectionConfig( connection_type=ConnectionType.TUNNELING_TCP_SECURE, gateway_ip="192.168.1.188", individual_address="1.0.11", secure_config=SecureConfig( knxkeys_file_path="/home/marvin/testcase.knxkeys", knxkeys_password="password", ), ) xknx = XKNX(connection_config=connection_config) await xknx.start() print("Tunnel connected") group_value_write(xknx, "1/0/32", True) await asyncio.sleep(5) await xknx.stop() asyncio.run(main()) xknx-3.6.0/examples/example_send_telegrams.py000066400000000000000000000012351475530762600214270ustar00rootroot00000000000000"""Example for sending generic telegrams to the KNX bus.""" import asyncio import logging from xknx import XKNX from xknx.tools import group_value_response, group_value_write logging.basicConfig(level=logging.INFO) logging.getLogger("xknx.log").level = logging.DEBUG logging.getLogger("xknx.tools").level = logging.DEBUG async def main() -> None: """Connect to KNX bus and send telegrams.""" async with XKNX() as xknx: # send a DPT 9.001 temperature value group_value_write(xknx, "5/1/20", 21.7, value_type="temperature") # send a response DPT 1 binary value group_value_response(xknx, "5/1/20", True) asyncio.run(main()) xknx-3.6.0/examples/example_sensor.py000066400000000000000000000015531475530762600177470ustar00rootroot00000000000000"""Example for Sensor device. See docs/sensor.md and docs/binary_sensor.md for a detailed explanation.""" import asyncio from xknx import XKNX from xknx.devices import BinarySensor, Sensor async def main() -> None: """Connect to KNX/IP device and read the value of a temperature and a motion sensor.""" xknx = XKNX() await xknx.start() sensor1 = BinarySensor( xknx, "DiningRoom.Motion.Sensor", group_address_state="6/0/2", ) xknx.devices.async_add(sensor1) await sensor1.sync(wait_for_result=True) print(sensor1) sensor2 = Sensor( xknx, "DiningRoom.Temperature.Sensor", group_address_state="6/2/1", value_type="temperature", ) xknx.devices.async_add(sensor2) await sensor2.sync(wait_for_result=True) print(sensor2) await xknx.stop() asyncio.run(main()) xknx-3.6.0/examples/example_switch.py000066400000000000000000000007571475530762600177440ustar00rootroot00000000000000"""Example for Switch device.""" import asyncio from xknx import XKNX from xknx.devices import Switch async def main() -> None: """Connect to KNX/IP device, switch on outlet, wait 2 seconds and switch of off again.""" xknx = XKNX() await xknx.start() switch = Switch(xknx, name="TestOutlet", group_address="1/1/11") xknx.devices.async_add(switch) await switch.set_on() await asyncio.sleep(2) await switch.set_off() await xknx.stop() asyncio.run(main()) xknx-3.6.0/examples/example_telegram_monitor.py000066400000000000000000000032121475530762600217770ustar00rootroot00000000000000"""Example for the telegram monitor callback.""" import asyncio import getopt # pylint: disable=deprecated-module import sys from xknx import XKNX from xknx.telegram import AddressFilter, Telegram def telegram_received_cb(telegram: Telegram) -> None: """Do something with the received telegram.""" print(f"Telegram received: {telegram}") def show_help() -> None: """Print Help.""" print("Telegram filter.") print() print("Usage:") print() print(__file__, " Listen to all telegrams") print( __file__, "-f --filter 1/2/*,1/4/[5-6] Filter for specific group addresses" ) print(__file__, "-h --help Print help") print() async def monitor(address_filters: list[AddressFilter] | None) -> None: """Set telegram_received_cb within XKNX and connect to KNX/IP device in daemon mode.""" xknx = XKNX(daemon_mode=True) xknx.telegram_queue.register_telegram_received_cb( telegram_received_cb, address_filters ) await xknx.start() await xknx.stop() async def main(argv: list[str]) -> None: """Parse command line arguments and start monitor.""" try: opts, _ = getopt.getopt(argv, "hf:", ["help", "filter="]) except getopt.GetoptError: show_help() sys.exit(2) address_filters = None for opt, arg in opts: if opt in ["-h", "--help"]: show_help() sys.exit() if opt in ["-f", "--filter"]: address_filters = list(map(AddressFilter, arg.split(","))) await monitor(address_filters) if __name__ == "__main__": asyncio.run(main(sys.argv[1:])) xknx-3.6.0/examples/example_value_reader.py000066400000000000000000000012001475530762600210610ustar00rootroot00000000000000"""Example on how to read a value from KNX bus.""" import asyncio from xknx import XKNX from xknx.core import ValueReader from xknx.telegram import GroupAddress from xknx.tools import read_group_value async def main() -> None: """Connect and read value from KNX bus.""" async with XKNX() as xknx: # get the value only (can be decoded when passing `value_type`) result = await read_group_value(xknx, "5/1/20") print(f"Value: {result}") # get the whole telegram telegram = await ValueReader(xknx, GroupAddress("5/1/20")).read() print(f"Telegram: {telegram}") asyncio.run(main()) xknx-3.6.0/examples/example_weather.py000066400000000000000000000017471475530762600201020ustar00rootroot00000000000000"""Example for Weather device. See docs/weather.md for a detailed explanation.""" import asyncio import logging from xknx import XKNX from xknx.devices import Weather logging.basicConfig(level=logging.WARNING) async def main() -> None: """Connect to KNX/IP device and create a weather device and read its sensors.""" xknx = XKNX() await xknx.start() weather = Weather( xknx, "Home", group_address_temperature="7/0/1", group_address_brightness_south="7/0/5", group_address_brightness_east="7/0/4", group_address_brightness_west="7/0/3", group_address_wind_speed="7/0/2", group_address_wind_bearing="7/0/6", group_address_day_night="7/0/7", group_address_rain_alarm="7/0/0", ) xknx.devices.async_add(weather) await weather.sync(wait_for_result=True) print(weather.max_brightness) print(weather.ha_current_state()) print(weather) await xknx.stop() asyncio.run(main()) xknx-3.6.0/examples/example_write_individual_address.py000066400000000000000000000014621475530762600235040ustar00rootroot00000000000000"""Example for writing an individual address to a KNX device.""" import asyncio import sys from xknx import XKNX from xknx.management import procedures from xknx.telegram import IndividualAddress async def main(argv: list[str]) -> int: """ Write the individual address to a device in programming mode. This fails if multiple devices are in programming mode and/or when there is no device found in programming mode. """ if len(argv) != 2: print(f"{argv[0]}: missing target address.") return 1 address = IndividualAddress(argv[1]) async with XKNX() as xknx: individual_address = IndividualAddress(address) await procedures.nm_individual_address_write(xknx, individual_address) return 0 if __name__ == "__main__": asyncio.run(main(sys.argv)) xknx-3.6.0/examples/example_write_serial_number.py000066400000000000000000000015341475530762600224760ustar00rootroot00000000000000"""Example for writing an individual address to a KNX device.""" import asyncio import sys from xknx import XKNX from xknx.management import procedures from xknx.telegram import IndividualAddress async def main(argv: list[str]) -> int: """Write the individual address to a device.""" if len(argv) != 3: print( f"{argv[0]}: required arguments: serial number formatted as f0:18:fa:23:d2:00 and individual address." ) return 1 serial = bytes.fromhex(argv[1].replace(":", "")) address = IndividualAddress(argv[2]) print(f"Setting address {address} to device with serial {argv[1]}") async with XKNX() as xknx: await procedures.nm_individual_address_serial_number_write( xknx, serial, address ) return 0 if __name__ == "__main__": asyncio.run(main(sys.argv)) xknx-3.6.0/examples/ip_secure_calculations.py000066400000000000000000000257341475530762600214510ustar00rootroot00000000000000"""Cryptographical calculations of KNX specification example frames.""" from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric.x25519 import ( X25519PrivateKey, X25519PublicKey, ) from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC def bytes_xor(a: bytes, b: bytes) -> bytes: # pylint: disable=invalid-name """XOR two bytes values.""" return (int.from_bytes(a, "big") ^ int.from_bytes(b, "big")).to_bytes(len(a), "big") def byte_pad(data: bytes, block_size: int) -> bytes: """Pad data with 0x00 until its length is a multiple of block_size.""" if remainder := len(data) % block_size: return data + bytes(block_size - remainder) return data def sha256_hash(data: bytes) -> bytes: """Calculate SHA256 hash of data.""" digest = hashes.Hash(hashes.SHA256()) digest.update(data) return digest.finalize() def calculate_message_authentication_code_cbc( key: bytes, additional_data: bytes, payload: bytes = b"", block_0: bytes = bytes(16), # counter_0: bytes = bytes(16), ) -> bytes: """Calculate the message authentication code (MAC) for a message with AES-CBC.""" blocks = ( block_0 + len(additional_data).to_bytes(2, "big") + additional_data + payload ) y_cipher = Cipher(algorithms.AES(key), modes.CBC(bytes(16))) y_encryptor = y_cipher.encryptor() y_blocks = ( y_encryptor.update(byte_pad(blocks, block_size=16)) + y_encryptor.finalize() ) # only calculate, no ctr encryption return y_blocks[-16:] # s_cipher = Cipher(algorithms.AES(key), modes.CTR(counter_0)) # s_encryptor = s_cipher.encryptor() # return s_encryptor.update(y_blocks[-16:]) + s_encryptor.finalize() def encrypt_data_ctr( key: bytes, mac_cbc: bytes, payload: bytes = b"", counter_0: bytes = bytes(16), ) -> bytes: """ Encrypt data with AES-CTR. Payload is optional; expected plain KNX/IP frame bytes. MAC shall be encrypted with counter 0, KNXnet/IP frame with incremented counters. Encrypted MAC is appended to the end of encrypted payload data (if there is any). """ s_cipher = Cipher(algorithms.AES(key), modes.CTR(counter_0)) s_encryptor = s_cipher.encryptor() mac = s_encryptor.update(mac_cbc) data = s_encryptor.update(payload) + s_encryptor.finalize() return data + mac def decrypt_ctr( session_key: bytes, payload: bytes, counter_0: bytes = bytes(16), ) -> bytes: """ Decrypt data from SecureWrapper. MAC is expected to be the last 16 octets of the payload. This will be sliced and decoded first with counter 0. Returns a tuple of (KNX/IP frame bytes, MAC TR for verification). """ cipher = Cipher(algorithms.AES(session_key), modes.CTR(counter_0)) decryptor = cipher.decryptor() mac_tr = decryptor.update(payload[-16:]) # MAC is encrypted with counter 0 decrypted_data = decryptor.update(payload[:-16]) + decryptor.finalize() return (decrypted_data, mac_tr) def calculate_wrapper( session_key: bytes, encapsulated_frame: bytes, secure_session_id: bytes = bytes.fromhex("00 01"), sequence_number: bytes = bytes.fromhex("00 00 00 00 00 00"), serial_number: bytes = bytes.fromhex("00 fa 12 34 56 78"), message_tag: bytes = bytes.fromhex("af fe"), ) -> bytes: """Calculate the payload and mac for a secure wrapper.""" print("# SecureWrapper") total_length = ( 6 # KNX/IP Header + len(secure_session_id) + len(sequence_number) + len(serial_number) + len(message_tag) + len(encapsulated_frame) + 16 # MAC ) wrapper_header = bytes.fromhex("06 10 09 50") + total_length.to_bytes(2, "big") a_data = wrapper_header + secure_session_id p_data = encapsulated_frame q_payload_length = len(p_data).to_bytes(2, "big") b_0_secure_wrapper = ( sequence_number + serial_number + message_tag + q_payload_length ) ctr_0_secure_wrapper = ( sequence_number + serial_number + message_tag + bytes.fromhex("ff") + bytes(1) ) # last octet is the counter to increment by 1 each step mac_cbc = calculate_message_authentication_code_cbc( session_key, additional_data=a_data, payload=p_data, block_0=b_0_secure_wrapper, ) encrypted_data = encrypt_data_ctr( session_key, mac_cbc=mac_cbc, payload=p_data, counter_0=ctr_0_secure_wrapper, ) # encrypted data # ctr_1_secure_wrapper = (int.from_bytes(ctr_0_secure_wrapper, "big") + 1).to_bytes( # 16, "big" # ) # cipher = Cipher(algorithms.AES(session_key), modes.CTR(ctr_1_secure_wrapper)) # encryptor = cipher.encryptor() # enc_frame = encryptor.update(p_data) + encryptor.finalize() print(f"encrypted_data: {encrypted_data[16:].hex()}") dec_frame, mac_tr = decrypt_ctr( session_key, payload=encrypted_data, counter_0=ctr_0_secure_wrapper, ) assert dec_frame == p_data assert mac_tr == mac_cbc # verification of MAC return encrypted_data def main() -> None: """Recalculate KNX specification example frames.""" ################ # SessionRequest ################ print("# SessionRequest") client_private_key = X25519PrivateKey.from_private_bytes( bytes.fromhex( "b8 fa bd 62 66 5d 8b 9e 8a 9d 8b 1f 4b ca 42 c8 c2 78 9a 61 10 f5 0e 9d d7 85 b3 ed e8 83 f3 78" ) ) client_public_key_raw = client_private_key.public_key().public_bytes( serialization.Encoding.Raw, serialization.PublicFormat.Raw ) # append to SessionRequest (15-46) print(f"Public key: {client_public_key_raw.hex()}") ################# # SessionResponse ################# print("# SessionResponse") peer_public_key = X25519PublicKey.from_public_bytes( bytes.fromhex( "bd f0 99 90 99 23 14 3e f0 a5 de 0b 3b e3 68 7b c5 bd 3c f5 f9 e6 f9 01 69 9c d8 70 ec 1f f8 24" ) ) pub_keys_xor = bytes_xor( client_public_key_raw, peer_public_key.public_bytes( encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw, ), ) peer_device_authentication_password = "trustme" kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=16, salt=b"device-authentication-code.1.secure.ip.knx.org", iterations=65536, ) # TODO: is encoding "latin-1" correct? (also used for device names in DIBs) peer_device_authentication_code = kdf.derive( peer_device_authentication_password.encode("latin-1") ) assert peer_device_authentication_code == bytes.fromhex( "e1 58 e4 01 20 47 bd 6c c4 1a af bc 5c 04 c1 fc" ) _a_data = bytes.fromhex( "06 10 09 52 00 38 00 01 b7 52 be 24 64 59 26 0f 6b 0c 48 01 fb d5 a6 75 99 f8 3b 40 57 b3 ef 1e 79 e4 69 ac 17 23 4e 15" ) ctr_0_session_response = ( b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\x00" ) message_authentication_code_cbc = calculate_message_authentication_code_cbc( peer_device_authentication_code, additional_data=_a_data[:8] + pub_keys_xor, # knx_ip_header + secure_session_id + bytes_xor(client_pub_key, server_pub_key) ) message_authentication_code = encrypt_data_ctr( peer_device_authentication_code, mac_cbc=message_authentication_code_cbc, counter_0=ctr_0_session_response, ) assert message_authentication_code == bytes.fromhex( "a9 22 50 5a aa 43 61 63 57 0b d5 49 4c 2d f2 a3" ) ecdh_shared_secret = client_private_key.exchange(peer_public_key) print(f"ECDH shared secret: {ecdh_shared_secret.hex()}") session_key = sha256_hash(ecdh_shared_secret)[:16] print(f"Session key: {session_key.hex()}") _, mac_tr = decrypt_ctr( peer_device_authentication_code, payload=message_authentication_code, counter_0=ctr_0_session_response, ) assert mac_tr == message_authentication_code_cbc # verification of MAC ##################### # SessionAuthenticate ##################### # shall be wrapped in SecureWrapper print("# SessionAuthenticate") password_string = "secret" kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=16, salt=b"user-password.1.secure.ip.knx.org", iterations=65536, ) password_hash = kdf.derive(password_string.encode("latin-1")) print(f"Password hash: {password_hash.hex(' ')}") assert password_hash == bytes.fromhex( "03 fc ed b6 66 60 25 1e c8 1a 1a 71 69 01 69 6a" ) authenticate_wrapper = bytes.fromhex( "06 10 09 50 00 3e 00 01 00 00 00 00 00 00 00 fa 12 34 56 78 af fe" "79 15 a4 f3 6e 6e 42 08" "d2 8b 4a 20 7d 8f 35 c0" "d1 38 c2 6a 7b 5e 71 69" "52 db a8 e7 e4 bd 80 bd" "7d 86 8a 3a e7 87 49 de" ) session_authenticate = bytes.fromhex( "06 10 09 53 00 18 00 01" + "1f 1d 59 ea 9f 12 a1 52 e5 d9 72 7f 08 46 2c de" # MAC ) mac_cbc_authenticate = calculate_message_authentication_code_cbc( password_hash, additional_data=session_authenticate[:8] + pub_keys_xor, block_0=bytes(16), ) ctr_0_session_authenticate = ( b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\x00" ) assert ( encrypt_data_ctr( password_hash, mac_cbc=mac_cbc_authenticate, counter_0=ctr_0_session_authenticate, ) == session_authenticate[8:] ) assert ( calculate_wrapper( session_key, encapsulated_frame=session_authenticate, secure_session_id=authenticate_wrapper[6:8], sequence_number=authenticate_wrapper[8:14], serial_number=authenticate_wrapper[14:20], message_tag=authenticate_wrapper[20:22], ) == authenticate_wrapper[22:] ) # verify MAC _, mac_tr = decrypt_ctr( password_hash, payload=session_authenticate[8:], counter_0=ctr_0_session_authenticate, ) assert mac_tr == mac_cbc_authenticate ############### # SessionStatus ############### # shall be wrapped in SecureWrapper print("# SessionStatus") status_wrapper = bytes.fromhex( "06 10 09 50 00 2e 00 01 00 00 00 00 00 00 00 fa aa aa aa aa af fe" "26 15 6d b5 c7 49 88 8f" "a3 73 c3 e0 b4 bd e4 49" "7c 39 5e 4b 1c 2f 46 a1" ) session_status = bytes.fromhex("06 10 09 54 00 08 00 00") assert ( calculate_wrapper( session_key, encapsulated_frame=session_status, secure_session_id=status_wrapper[6:8], sequence_number=status_wrapper[8:14], serial_number=status_wrapper[14:20], message_tag=status_wrapper[20:22], ) == status_wrapper[22:] ) if __name__ == "__main__": main() xknx-3.6.0/examples/readme.md000066400000000000000000000035241475530762600161300ustar00rootroot00000000000000# Example section ## General |Example|Description| |-|-| |[Callback](./example_callback.py)|Example for running a callback when a devices state changed| |[Connection](./example_manual_connection.py)|Example for specifying connection parameters| |[Daemon](./example_daemon.py)|Example for daemon mode within XKNX| |[Disconnect](./example_disconnect.py)|Example on how to disconnect/reset all available tunneling channels| |[Gateway scanner](./example_gatewayscanner.py)|Example for GatewayScanner| |[Secure Tunnel](./example_secure.py)|Example for connecting to a KNX IP Secure Tunnel| |[Send telegrams](./example_send_telegrams.py)|Example for sending generic telegrams| |[Telegram monitor](./example_telegram_monitor.py)|An example telegram monitor| |[Value reader](./example_value_reader.py)|Example on how to read a value from KNX bus| |[MQTT powermeter](./example_powermeter_mqtt.py)|Example of a daemon listening for values from my main power-meter and resend them on a MQTT bus| ## Devices |Example|Description| |-|-| |[Climate Control](./example_climate.py)|Example for controlling climate device| |[Datetime](./example_datetime.py)|Example for Date and Time devices| |[Devices](./example_devices.py)|Example for internal devices storage| |[Dimm light](./example_light_dimm.py)|Example for switching a light on and off| |[State of light](./example_light_state.py)|Example for reading the state from the KNX bus| |[Switch light](./example_light_switch.py)|Example for switching a light on and off| |[Sensor](./example_sensor.py)|Example for Sensor device| |[Switch](./example_switch.py)|Example for Switch device| |[Scene](./example_scene.py)|Example for calling a scene| |[Weather](./example_weather.py)|Example for reading a Weather devices data| ## Low-level |Example|Description| |-|-| |[Restart](./example_restart.py)|Example on how to restart an KNX device| xknx-3.6.0/pyproject.toml000066400000000000000000000107631475530762600154520ustar00rootroot00000000000000[build-system] requires = ["setuptools>=62.3"] build-backend = "setuptools.build_meta" [project] name = "xknx" authors = [ { name = "Julius Mittenzwei", email = "julius@mittenzwei.com" }, { name = "Matthias Alphart", email = "farmio@alphart.net" }, { name = "Marvin Wichmann", email = "me@marvin-wichmann.de" }, ] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: System :: Hardware :: Hardware Drivers", ] dependencies = [ "async_timeout>=4.0.0; python_version<'3.11'", "typing_extensions; python_version<'3.11'", "cryptography>=35.0.0", "ifaddr>=0.1.7", ] description = "An Asynchronous Library for the KNX protocol. Documentation: https://xknx.io/" dynamic = ["version"] keywords = ["KNX", "EIB", "Home Assistant", "home automation"] license = { text = "MIT" } readme = "README.md" requires-python = ">=3.10.0" [project.urls] homepage = "https://xknx.io/" repository = "https://github.com/XKNX/xknx.git" [tool.setuptools.dynamic] version = { attr = "xknx.__version__.__version__" } [tool.setuptools.packages.find] include = ["xknx*"] [tool.codespell] skip = '*.min.js,docs/config-converter/lib/*,*.lock' ignore-words-list = 'hass' quiet-level = 2 [tool.mypy] pretty = true python_version = "3.10" show_error_codes = true strict = true # additional flags to strict mode ignore_missing_imports = true implicit_reexport = true warn_unreachable = true [tool.pylint.master] init-hook = 'import sys; sys.path.append(".")' persistent = "no" reports = "no" allow-reexport-from-package = true jobs = 0 [tool.pylint.message_control] # Reasons disabled: # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* disable = [ "cyclic-import", # doesn't test if both import on load "duplicate-code", # unavoidable "fixme", # TODO "format", # handled by ruff "inconsistent-return-statements", # doesn't handle raise "locally-disabled", # it spams too much "no-member", # handled by mypy - pylint has false positives sometimes "too-few-public-methods", "too-many-ancestors", # it's too strict. "too-many-arguments", "too-many-boolean-expressions", "too-many-branches", "too-many-instance-attributes", "too-many-lines", "too-many-locals", "too-many-positional-arguments", "too-many-public-methods", "too-many-return-statements", "too-many-statements", "ungrouped-imports", # handled by ruff "unused-argument", # generic callbacks and setup methods create a lot of warnings "wrong-import-order", # handled by ruff ] # disabled for tests via command line options in tox.ini: # - protected-access # - abstract-class-instantiated enable = ["use-symbolic-message-instead"] [tool.pylint.format] expected-line-ending-format = "LF" [tool.pylint.reports] score = "no" output-format = "colorized" [tool.pytest.ini_options] testpaths = "test" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" addopts = "--durations=10" [tool.ruff] lint.select = [ "A", # builtins shadowing "ANN", # annotations "ASYNC", # async "B", # bugbear "C4", # comprehensions "D", # pydocstyle "E", # pycodestyle "F", # pyflakes "FLY", # flynt "FURB", # refurb "G", # logging "I", # isort "LOG", # logging "PTH", # pathlib "RUF", # ruff specific "SLF", # private member access "SIM", # simplify "T20", # print "UP", # pyupgrade "W", # pydocstyle warning ] lint.ignore = [ "ANN401", # any-type - mypy should handle this where necessary "D202", "D203", "D212", "E501", # line too long "SIM102", # collapsible-if "SIM105", #suppressible-exception ] extend-exclude = ["script"] [tool.ruff.lint.isort] force-sort-within-sections = true combine-as-imports = true [tool.ruff.lint.per-file-ignores] "examples/*" = ["T20"] # print-used "test/*" = [ "RUF012", "SLF", # private member access ] # Mutable class attributes should be annotated with `typing.ClassVar` [tool.ruff.lint.flake8-builtins] builtins-allowed-modules = [ # been there before ruff check A005 was added - backwards compatibility "datetime", "io", "profile", "typing", ] xknx-3.6.0/requirements/000077500000000000000000000000001475530762600152525ustar00rootroot00000000000000xknx-3.6.0/requirements/production.txt000066400000000000000000000001671475530762600202050ustar00rootroot00000000000000async-timeout==5.0.1;python_version<"3.11" typing_extensions; python_version<"3.11" cryptography==44.0.1 ifaddr==0.2.0 xknx-3.6.0/requirements/testing.txt000066400000000000000000000003331475530762600174670ustar00rootroot00000000000000-r production.txt freezegun==1.5.1 mypy==1.15.0 pre-commit==4.1.0 pylint==3.3.4 pytest==8.3.4 pytest-asyncio==0.25.3 pytest-cov==6.0.0 pytest-icdiff==0.9 ruff==0.9.6 setuptools==75.8.0 tox==4.24.1 tox-gh-actions==3.2.0 xknx-3.6.0/script/000077500000000000000000000000001475530762600140335ustar00rootroot00000000000000xknx-3.6.0/script/run-in-env.sh000077500000000000000000000006771475530762600164020ustar00rootroot00000000000000#!/usr/bin/env sh set -eu # Activate pyenv and virtualenv if present, then run the specified command # pyenv, pyenv-virtualenv if [ -s .python-version ]; then PYENV_VERSION=$(head -n 1 .python-version) export PYENV_VERSION fi # other common virtualenvs my_path=$(git rev-parse --show-toplevel) for venv in venv .venv .; do if [ -f "${my_path}/${venv}/bin/activate" ]; then . "${my_path}/${venv}/bin/activate" fi done exec "$@"xknx-3.6.0/script/sensor_table_generator.py000066400000000000000000000125601475530762600211370ustar00rootroot00000000000000"""Generate a markdown table that can be copied to home-assistant.io sensor.knx documentation.""" try: from xknx.dpt import DPTBase except ModuleNotFoundError: exit( "Add the `xknx` directory to python path via `export PYTHONPATH=$HOME/directory/to/xknx`" ) # Defines the column order of the printed table. # ["dpt_number", "value_type", "dpt_size", "unit", "dpt_range"] COLUMN_ORDER = ["dpt_number", "value_type", "dpt_size", "dpt_range", "unit"] # Defines the column adjustment of the printed table. # "left", "right" or "center" COLUMN_ADJUSTMENT = { "value_type": "left", "unit": "left", "dpt_number": "right", "dpt_size": "right", "dpt_range": "center", } class Row: """A row in the table. Table header text is defined in __init__ defaults.""" column_width = {} def __init__( self, value_type="type", unit="unit", dpt_number="KNX DPT", dpt_size="size in byte", dpt_range="range", ): self.value_type = value_type self._update_column_width("value_type", value_type) self.unit = unit self._update_column_width("unit", unit) self.dpt_number = dpt_number self._update_column_width("dpt_number", dpt_number) self.dpt_size = dpt_size self._update_column_width("dpt_size", dpt_size) self.dpt_range = dpt_range self._update_column_width("dpt_range", dpt_range) def _update_column_width(self, index, text: str): try: Row.column_width[index] = max(Row.column_width[index], len(text)) except KeyError: # index is not yet available Row.column_width[index] = len(text) def __repr__(self): def _format_column_ljust(index): content = getattr(self, index) return f"| {content.ljust(Row.column_width[index] + 1)}" _row = "" for column in COLUMN_ORDER: _row += _format_column_ljust(column) _row += "|" return _row class DPTRow(Row): """A row holding information for a DPT.""" def __init__(self, dpt_class: DPTBase): dpt_range = "" if hasattr(dpt_class, "value_min") and hasattr(dpt_class, "value_max"): dpt_range = f"{dpt_class.value_min} ... {dpt_class.value_max}" dpt_number_str = self._get_dpt_number_from_docstring(dpt_class) self.dpt_number_sort = self._dpt_number_sort(dpt_number_str) dpt_number = self._dpt_number_str_repr(dpt_number_str) super().__init__( value_type=dpt_class.value_type, unit=dpt_class.unit, dpt_number=dpt_number, dpt_size=str(dpt_class.payload_length), dpt_range=dpt_range, ) def _get_dpt_number_from_docstring(self, dpt_class: DPTBase): """Extract dpt number from class docstring.""" docstring = dpt_class.__doc__ try: for line in docstring.splitlines(): text = line.strip() if text.startswith("DPT"): return text.split()[1] except IndexError: print(f"Error: Could not read docstring for: {dpt_class}") print(f"Error: Could not find DPT in docstring for: {dpt_class}") raise ValueError def _dpt_number_sort(self, dpt_str: str) -> int: """Return dpt number as integer (for sorting). "xxx" is treated as 0.""" try: dpt_major, dpt_minor = dpt_str.split(".") if dpt_minor in ("x", "xxx", "*", "***"): dpt_minor = -1 elif dpt_minor in ("?", "???"): dpt_minor = 99999 return (int(dpt_major) * 100000) + int(dpt_minor) except ValueError: print( f"Error: Could not parse dpt_number: '{self.dpt_number}' in '{self.value_type}'" ) def _dpt_number_str_repr(self, dpt_str: str) -> str: dpt_major, dpt_minor = dpt_str.split(".") if dpt_minor in ("x", "xxx", "*", "***"): return dpt_major return dpt_str def table_delimiter(): """Build a row of table delimiters.""" def table_delimiter_ljust(width): return "|-" + "-" * width + "-" def table_delimiter_center(width): return "|:" + "-" * width + ":" def table_delimiter_rjust(width): return "|-" + "-" * width + ":" _row = "" for column in COLUMN_ORDER: _cell_width = Row.column_width[column] if COLUMN_ADJUSTMENT[column] == "left": _row += table_delimiter_ljust(_cell_width) elif COLUMN_ADJUSTMENT[column] == "right": _row += table_delimiter_rjust(_cell_width) elif COLUMN_ADJUSTMENT[column] == "center": _row += table_delimiter_center(_cell_width) _row += "|" return _row def print_table(): """Read the values and print the table to stdout.""" rows = [] for dpt in DPTBase.__recursive_subclasses__(): if dpt.has_distinct_value_type(): try: row = DPTRow(dpt_class=dpt) except ValueError: continue else: rows.append(row) rows.sort(key=lambda row: row.dpt_number_sort) table_header = Row() rows.insert(0, table_header) # Insert at last to have correct column_widths. rows.insert(1, table_delimiter()) for row in rows: print(row) if __name__ == "__main__": print_table() xknx-3.6.0/test/000077500000000000000000000000001475530762600135065ustar00rootroot00000000000000xknx-3.6.0/test/__init__.py000066400000000000000000000000261475530762600156150ustar00rootroot00000000000000"""Tests for XKNX.""" xknx-3.6.0/test/cemi_tests/000077500000000000000000000000001475530762600156455ustar00rootroot00000000000000xknx-3.6.0/test/cemi_tests/__init__.py000066400000000000000000000000461475530762600177560ustar00rootroot00000000000000"""Unit tests for the CEMI module.""" xknx-3.6.0/test/cemi_tests/cemi_frame_test.py000066400000000000000000000355411475530762600213550ustar00rootroot00000000000000"""Tests for the CEMIFrame object.""" import pytest from xknx.cemi import ( CEMIFlags, CEMIFrame, CEMILData, CEMIMessageCode, CEMIMPropReadRequest, CEMIMPropReadResponse, CEMIMPropWriteRequest, CEMIMPropWriteResponse, ) from xknx.cemi.const import CEMIErrorCode from xknx.exceptions import ConversionError, CouldNotParseCEMI, UnsupportedCEMIMessage from xknx.profile.const import ResourceKNXNETIPPropertyId, ResourceObjectType from xknx.telegram import GroupAddress, IndividualAddress, Telegram from xknx.telegram.apci import GroupValueRead from xknx.telegram.tpci import TConnect, TDataBroadcast, TDataGroup def get_data( code: int, adil: int, flags: int, src: int, dst: int, npdu_len: int, tpci_apci: int, payload: list[int], ) -> bytes: """Encode to cemi data raw bytes.""" return bytes( [ code, adil, # adil (flags >> 8) & 255, # flags flags & 255, # flags (src >> 8) & 255, # src src & 255, # src (dst >> 8) & 255, # dst dst & 255, # dst npdu_len, # npdu_len (tpci_apci >> 8) & 255, # tpci_apci tpci_apci & 255, # tpci_apci *payload, # payload ] ) def test_valid_command() -> None: """Test for valid frame parsing.""" raw = get_data(0x29, 0, 0x0080, 1, 1, 1, 0, []) frame = CEMIFrame.from_knx(raw) assert frame.code == CEMIMessageCode.L_DATA_IND assert isinstance(frame.data, CEMILData) assert frame.data.flags == 0x0080 assert frame.data.hops == 0 assert frame.data.src_addr == IndividualAddress(1) assert frame.data.dst_addr == GroupAddress(1) assert frame.data.payload == GroupValueRead() assert frame.data.tpci == TDataGroup() assert frame.calculated_length() == 11 assert frame.to_knx() == raw def test_valid_tpci_control() -> None: """Test for valid tpci control.""" raw = bytes((0x29, 0, 0, 0, 0, 0, 0, 0, 0, 0x80)) frame = CEMIFrame.from_knx(raw) assert frame.code == CEMIMessageCode.L_DATA_IND assert isinstance(frame.data, CEMILData) assert frame.data.flags == 0 assert frame.data.hops == 0 assert frame.data.payload is None assert frame.data.src_addr == IndividualAddress(0) assert frame.data.dst_addr == IndividualAddress(0) assert frame.data.tpci == TConnect() assert frame.calculated_length() == 10 assert frame.to_knx() == raw @pytest.mark.parametrize( "raw,err_msg", [ ( get_data(0x29, 0, 0, 0, 0, 1, 0xFFC0, []), r".*Invalid length for control TPDU.*", ), ], ) def test_invalid_tpci_apci(raw: bytes, err_msg: str) -> None: """Test for invalid APCIService.""" with pytest.raises(CouldNotParseCEMI, match=err_msg): CEMIFrame.from_knx(raw) @pytest.mark.parametrize( "raw,err_msg", [ ( get_data(0x29, 0, 0, 0, 0, 1, 0x08C0, []), r".*TPCI not supported.*", ), ( get_data(0x29, 0, 0, 0, 0, 1, 0x03C0, []), r".*APDU not supported*", ), ], ) def test_unsupported_tpci_apci(raw: bytes, err_msg: str) -> None: """Test for invalid APCIService.""" with pytest.raises(UnsupportedCEMIMessage, match=err_msg): CEMIFrame.from_knx(raw) def test_invalid_apdu_len() -> None: """Test for invalid apdu len.""" with pytest.raises(CouldNotParseCEMI, match=r".*APDU LEN should be .*"): CEMIFrame.from_knx(get_data(0x29, 0, 0, 0, 0, 2, 0, [])) def test_invalid_payload() -> None: """Test for having wrong payload set.""" frame = CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=CEMILData( flags=0, src_addr=IndividualAddress(0), dst_addr=IndividualAddress(0), tpci=TDataGroup(), payload=None, ), ) with pytest.raises(TypeError): frame.calculated_length() with pytest.raises(ConversionError): frame.to_knx() def test_missing_data() -> None: """Test for having no data set.""" frame = CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=None, ) with pytest.raises(UnsupportedCEMIMessage): frame.calculated_length() with pytest.raises(UnsupportedCEMIMessage): frame.to_knx() def test_from_knx_with_not_handleable_cemi() -> None: """Test for having unhandlebale cemi set.""" with pytest.raises( UnsupportedCEMIMessage, match=r".*CEMIMessageCode not implemented:.*" ): CEMIFrame.from_knx(get_data(0x30, 0, 0, 0, 0, 2, 0, [])) def test_from_knx_with_not_implemented_cemi() -> None: """Test for having not implemented CEMI set.""" with pytest.raises( UnsupportedCEMIMessage, match=r".*Could not handle CEMIMessageCode:.*" ): CEMIFrame.from_knx( get_data(CEMIMessageCode.L_BUSMON_IND.value, 0, 0, 0, 0, 2, 0, []) ) def test_invalid_invalid_len() -> None: """Test for invalid cemi len.""" with pytest.raises(CouldNotParseCEMI, match=r".*CEMI too small.*"): CEMIFrame.from_knx(get_data(0x29, 0, 0, 0, 0, 2, 0, [])[:5]) def test_from_knx_group_address() -> None: """Test conversion for a cemi with a group address as destination.""" frame = CEMIFrame.from_knx(get_data(0x29, 0, 0x80, 0, 0, 1, 0, [])) assert isinstance(frame.data, CEMILData) assert frame.data.dst_addr == GroupAddress(0) def test_from_knx_individual_address() -> None: """Test conversion for a cemi with a individual address as destination.""" frame = CEMIFrame.from_knx(get_data(0x29, 0, 0x00, 0, 0, 1, 0, [])) assert isinstance(frame.data, CEMILData) assert frame.data.dst_addr == IndividualAddress(0) def test_telegram_group_address() -> None: """Test telegram conversion flags with a group address.""" _telegram = Telegram(destination_address=GroupAddress(1)) frame = CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=CEMILData.init_from_telegram(_telegram), ) assert isinstance(frame.data, CEMILData) assert frame.data.flags & 0x0080 == CEMIFlags.DESTINATION_GROUP_ADDRESS assert frame.data.flags & 0x0C00 == CEMIFlags.PRIORITY_LOW # test CEMIFrame.telegram property assert frame.data.telegram() == _telegram def test_telegram_broadcast() -> None: """Test telegram conversion flags with a group address.""" _telegram = Telegram(destination_address=GroupAddress(0)) frame = CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=CEMILData.init_from_telegram(_telegram), ) assert isinstance(frame.data, CEMILData) assert frame.data.flags & 0x0080 == CEMIFlags.DESTINATION_GROUP_ADDRESS assert frame.data.flags & 0x0C00 == CEMIFlags.PRIORITY_SYSTEM assert frame.data.tpci == TDataBroadcast() # test CEMIFrame.telegram property assert frame.data.telegram() == _telegram def test_telegram_individual_address() -> None: """Test telegram conversion flags with a individual address.""" _telegram = Telegram(destination_address=IndividualAddress(0), tpci=TConnect()) frame = CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=CEMILData.init_from_telegram(_telegram), ) assert isinstance(frame.data, CEMILData) assert frame.data.flags & 0x0080 == CEMIFlags.DESTINATION_INDIVIDUAL_ADDRESS assert frame.data.flags & 0x0C00 == CEMIFlags.PRIORITY_SYSTEM assert frame.data.flags & 0x0200 == CEMIFlags.NO_ACK_REQUESTED # test CEMIFrame.telegram property assert frame.data.telegram() == _telegram def test_telegram_unsupported_address() -> None: """Test telegram conversion flags with an unsupported address.""" with pytest.raises(TypeError): CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=CEMILData.init_from_telegram(Telegram(destination_address=object())), ) def get_prop( code: int, obj_id: int, obj_inst: int, prop_id: int, num: int, six: int, payload: list[int], ) -> bytes: """Encode to cemi prop raw bytes.""" return bytes( [ code, (obj_id >> 8) & 255, # Interface Object Type obj_id & 255, # Interface Object Type obj_inst & 255, # Object instance prop_id & 255, # Property ID (num << 4) | (six >> 8), # Number of Elements (4bit) Start index (hsb 4bit) six & 255, # Start index (lsb 8bit) *payload, # payload ] ) def test_valid_read_req() -> None: """Test for valid frame parsing.""" raw = get_prop(0xFC, 0x000B, 1, 52, 1, 1, []) frame = CEMIFrame.from_knx(raw) assert frame.code == CEMIMessageCode.M_PROP_READ_REQ assert isinstance(frame.data, CEMIMPropReadRequest) assert ( frame.data.property_info.object_type == ResourceObjectType.OBJECT_KNXNETIP_PARAMETER ) assert frame.data.property_info.object_instance == 1 assert ( frame.data.property_info.property_id == ResourceKNXNETIPPropertyId.PID_KNX_INDIVIDUAL_ADDRESS ) assert frame.data.property_info.number_of_elements == 1 assert frame.data.property_info.start_index == 1 assert frame.calculated_length() == 7 assert frame.to_knx() == raw with pytest.raises(AttributeError): frame.data.telegram() def test_valid_read_con() -> None: """Test for valid frame parsing.""" raw = get_prop(0xFB, 0x000B, 1, 52, 1, 1, [0x12, 0x03]) frame = CEMIFrame.from_knx(raw) assert frame.code == CEMIMessageCode.M_PROP_READ_CON assert isinstance(frame.data, CEMIMPropReadResponse) assert ( frame.data.property_info.object_type == ResourceObjectType.OBJECT_KNXNETIP_PARAMETER ) assert frame.data.property_info.object_instance == 1 assert ( frame.data.property_info.property_id == ResourceKNXNETIPPropertyId.PID_KNX_INDIVIDUAL_ADDRESS ) assert frame.data.property_info.number_of_elements == 1 assert frame.data.property_info.start_index == 1 assert frame.data.error_code is None assert IndividualAddress.from_knx(frame.data.data) == IndividualAddress("1.2.3") assert frame.calculated_length() == 9 assert frame.to_knx() == raw def test_valid_error_read_con() -> None: """Test for valid frame parsing.""" raw = get_prop(0xFB, 0x000B, 1, 52, 0, 1, [0x07]) frame = CEMIFrame.from_knx(raw) assert frame.code == CEMIMessageCode.M_PROP_READ_CON assert isinstance(frame.data, CEMIMPropReadResponse) assert ( frame.data.property_info.object_type == ResourceObjectType.OBJECT_KNXNETIP_PARAMETER ) assert frame.data.property_info.object_instance == 1 assert ( frame.data.property_info.property_id == ResourceKNXNETIPPropertyId.PID_KNX_INDIVIDUAL_ADDRESS ) assert frame.data.property_info.number_of_elements == 0 assert frame.data.property_info.start_index == 1 assert frame.data.error_code == CEMIErrorCode.CEMI_ERROR_VOID_DP assert frame.calculated_length() == 8 assert frame.to_knx() == raw def test_valid_write_req() -> None: """Test for valid frame parsing.""" raw = get_prop(0xF6, 0x000B, 1, 52, 1, 1, [0x12, 0x03]) frame = CEMIFrame.from_knx(raw) assert frame.code == CEMIMessageCode.M_PROP_WRITE_REQ assert isinstance(frame.data, CEMIMPropWriteRequest) assert ( frame.data.property_info.object_type == ResourceObjectType.OBJECT_KNXNETIP_PARAMETER ) assert frame.data.property_info.object_instance == 1 assert ( frame.data.property_info.property_id == ResourceKNXNETIPPropertyId.PID_KNX_INDIVIDUAL_ADDRESS ) assert frame.data.property_info.number_of_elements == 1 assert frame.data.property_info.start_index == 1 assert IndividualAddress.from_knx(frame.data.data) == IndividualAddress("1.2.3") assert frame.calculated_length() == 9 assert frame.to_knx() == raw def test_valid_empty_write_con() -> None: """Test for valid frame parsing.""" raw = get_prop(0xF5, 0x000B, 1, 52, 1, 1, []) frame = CEMIFrame.from_knx(raw) assert frame.code == CEMIMessageCode.M_PROP_WRITE_CON assert isinstance(frame.data, CEMIMPropWriteResponse) assert ( frame.data.property_info.object_type == ResourceObjectType.OBJECT_KNXNETIP_PARAMETER ) assert frame.data.property_info.object_instance == 1 assert ( frame.data.property_info.property_id == ResourceKNXNETIPPropertyId.PID_KNX_INDIVIDUAL_ADDRESS ) assert frame.data.property_info.number_of_elements == 1 assert frame.data.property_info.start_index == 1 assert frame.data.error_code is None assert frame.calculated_length() == 7 assert frame.to_knx() == raw def test_valid_error_write_con() -> None: """Test for valid frame parsing.""" raw = get_prop(0xF5, 0x000B, 1, 52, 0, 1, [0x07]) frame = CEMIFrame.from_knx(raw) assert frame.code == CEMIMessageCode.M_PROP_WRITE_CON assert isinstance(frame.data, CEMIMPropWriteResponse) assert ( frame.data.property_info.object_type == ResourceObjectType.OBJECT_KNXNETIP_PARAMETER ) assert frame.data.property_info.object_instance == 1 assert ( frame.data.property_info.property_id == ResourceKNXNETIPPropertyId.PID_KNX_INDIVIDUAL_ADDRESS ) assert frame.data.property_info.number_of_elements == 0 assert frame.data.property_info.start_index == 1 assert frame.data.error_code == CEMIErrorCode.CEMI_ERROR_VOID_DP assert frame.calculated_length() == 8 assert frame.to_knx() == raw @pytest.mark.parametrize( "raw,err_msg", [ ( get_prop(0xFC, 0x000B, 1, 52, 1, 1, [])[:5], r".*Invalid CEMI length:*", ), ( get_prop(0xFB, 0x000B, 1, 52, 1, 1, [])[:5], r".*CEMI Property Read Response too small.*", ), ( get_prop(0xFB, 0x000B, 1, 52, 0, 1, [0x07, 0x00]), r".*Invalid CEMI error response length:.*", ), ( get_prop(0xF6, 0x000B, 1, 52, 1, 1, [])[:5], r".*CEMI Property Write Request too small.*", ), ( get_prop(0xF5, 0x000B, 1, 52, 1, 1, [])[:5], r".*CEMI Property Write Response too small.*", ), ( get_prop(0xF5, 0x000B, 1, 52, 0, 1, [0x07, 0x00]), r".*Invalid CEMI error response length:.*", ), ( get_prop(0xF5, 0x000B, 1, 52, 1, 1, [0x07]), r".*Invalid CEMI response length:.*", ), ], ) def test_invalid_length(raw: bytes, err_msg: str) -> None: """Test for invalid frame parsing.""" with pytest.raises(CouldNotParseCEMI, match=err_msg): CEMIFrame.from_knx(raw) def test_invalid_resource_object() -> None: """Test for invalid frame parsing.""" with pytest.raises( UnsupportedCEMIMessage, match=r".*CEMIMProp Object Type not supported:.*" ): CEMIFrame.from_knx(get_prop(0xFC, 0x1234, 1, 52, 1, 1, [])) xknx-3.6.0/test/cemi_tests/cemi_handler_test.py000066400000000000000000000151031475530762600216700ustar00rootroot00000000000000"""Test for CEMIHandler.""" import asyncio from unittest.mock import AsyncMock, patch import pytest from xknx import XKNX from xknx.cemi import CEMIFrame, CEMILData, CEMIMessageCode from xknx.dpt import DPTArray from xknx.exceptions import ConfirmationError from xknx.telegram import GroupAddress, IndividualAddress, Telegram, apci, tpci from ..conftest import EventLoopClockAdvancer async def test_wait_for_l2_confirmation(time_travel: EventLoopClockAdvancer) -> None: """Test waiting for L_DATA.con before sending another L_DATA.req.""" xknx = XKNX() xknx.knxip_interface = AsyncMock() test_telegram = Telegram( destination_address=GroupAddress(1), payload=apci.GroupValueWrite(DPTArray((1,))), ) test_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_REQ, data=CEMILData.init_from_telegram(test_telegram), ) test_cemi_confirmation = CEMIFrame( code=CEMIMessageCode.L_DATA_CON, data=CEMILData.init_from_telegram( test_telegram, ), ) task = asyncio.create_task(xknx.cemi_handler.send_telegram(test_telegram)) await time_travel(0) xknx.knxip_interface.send_cemi.assert_called_once_with(test_cemi) assert xknx.connection_manager.cemi_count_outgoing == 0 assert not task.done() xknx.cemi_handler.handle_cemi_frame(test_cemi_confirmation) await time_travel(0) assert task.done() await task assert xknx.connection_manager.cemi_count_outgoing == 1 assert xknx.connection_manager.cemi_count_outgoing_error == 0 # no L_DATA.con received -> raise ConfirmationError xknx.knxip_interface.send_cemi.reset_mock() task = asyncio.create_task(xknx.cemi_handler.send_telegram(test_telegram)) await time_travel(0) xknx.knxip_interface.send_cemi.assert_called_once_with(test_cemi) with pytest.raises(ConfirmationError): await time_travel(3) assert task.done() await task assert xknx.connection_manager.cemi_count_outgoing == 1 assert xknx.connection_manager.cemi_count_outgoing_error == 1 def test_incoming_cemi() -> None: """Test incoming CEMI.""" xknx = XKNX() xknx.current_address = IndividualAddress("1.1.1") # TDataGroup Telegram test_telegram = Telegram( destination_address=GroupAddress(1), payload=apci.GroupValueWrite(DPTArray((1,))), ) test_group_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=CEMILData.init_from_telegram(test_telegram), ) xknx.cemi_handler.handle_cemi_frame(test_group_cemi) assert xknx.telegrams.qsize() == 1 # L_DATA_CON and L_DATA_REQ should not be forwarded to the telegram queue or management with patch.object(xknx.cemi_handler, "telegram_received") as mock_telegram_received: test_incoming_l_data_con = CEMIFrame( code=CEMIMessageCode.L_DATA_CON, data=CEMILData.init_from_telegram(test_telegram), ) xknx.cemi_handler.handle_cemi_frame(test_incoming_l_data_con) mock_telegram_received.assert_not_called() test_incoming_l_data_req = CEMIFrame( code=CEMIMessageCode.L_DATA_REQ, data=CEMILData.init_from_telegram(test_telegram), ) xknx.cemi_handler.handle_cemi_frame(test_incoming_l_data_req) mock_telegram_received.assert_not_called() assert xknx.connection_manager.cemi_count_incoming == 1 @pytest.mark.parametrize( "telegram", [ Telegram( destination_address=GroupAddress(0), tpci=tpci.TDataBroadcast(), ), Telegram( destination_address=IndividualAddress("1.1.1"), tpci=tpci.TConnect(), ), Telegram( destination_address=IndividualAddress("1.1.1"), tpci=tpci.TDataIndividual(), ), ], ) def test_incoming_management_telegram(telegram: Telegram) -> None: """Test incoming management CEMI.""" xknx = XKNX() xknx.current_address = IndividualAddress("1.1.1") with patch.object(xknx.management, "process") as mock_management_process: test_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=CEMILData.init_from_telegram(telegram), ) xknx.cemi_handler.handle_cemi_frame(test_cemi) mock_management_process.assert_called_once() assert xknx.telegrams.qsize() == 0 assert xknx.connection_manager.cemi_count_incoming == 1 @pytest.mark.parametrize( "raw", [ # # communication_channel_id: 0x02 sequence_counter: 0x81 bytes.fromhex("2900b06010fa10ff00"), ], ) def test_invalid_cemi(raw: bytes) -> None: """Test incoming invalid CEMI Frames.""" xknx = XKNX() with ( patch("logging.Logger.warning") as mock_info, patch.object(xknx.cemi_handler, "handle_cemi_frame") as mock_handle_cemi_frame, ): xknx.cemi_handler.handle_raw_cemi(raw) mock_info.assert_called_once() mock_handle_cemi_frame.assert_not_called() assert xknx.connection_manager.cemi_count_incoming_error == 1 @pytest.mark.parametrize( "raw", [ # LDataInd Unsupported Extended APCI from 0.0.1 to 0/0/0 broadcast # bytes.fromhex("2900b0d0000100000103f8"), ], ) def test_unsupported_cemi(raw: bytes) -> None: """Test incoming unsupported CEMI Frames.""" xknx = XKNX() with ( patch("logging.Logger.info") as mock_info, patch.object(xknx.cemi_handler, "handle_cemi_frame") as mock_handle_cemi_frame, ): xknx.cemi_handler.handle_raw_cemi(raw) mock_info.assert_called_once() mock_handle_cemi_frame.assert_not_called() assert xknx.connection_manager.cemi_count_incoming_error == 1 def test_incoming_from_own_ia() -> None: """Test incoming CEMI from own IA.""" xknx = XKNX() xknx.current_address = IndividualAddress("1.1.22") # L_Data.ind GroupValueWrite from 1.1.22 to to 5/1/22 with DPT9 payload 0C 3F raw = bytes.fromhex("2900bcd011162916030080 0c 3f") with ( patch("logging.Logger.debug") as mock_debug, patch.object(xknx.cemi_handler, "telegram_received") as mock_telegram_received, ): xknx.cemi_handler.handle_raw_cemi(raw) mock_debug.assert_called_once() mock_telegram_received.assert_called_once() assert xknx.connection_manager.cemi_count_incoming == 1 assert xknx.connection_manager.cemi_count_incoming_error == 0 xknx-3.6.0/test/conftest.py000066400000000000000000000042571475530762600157150ustar00rootroot00000000000000"""Conftest for XKNX.""" import asyncio from unittest.mock import AsyncMock, Mock, patch import pytest from xknx import XKNX class EventLoopClockAdvancer: """Allow advancing of loop time.""" # thanks to @dermotduffy for his asyncio.sleep mock # https://github.com/dermotduffy/hyperion-py/blob/main/tests/client_test.py#L273 __slots__ = ("_base_time", "loop", "offset") def __init__(self, loop: asyncio.AbstractEventLoop) -> None: """Initialize.""" self.offset = 0.0 self._base_time = loop.time self.loop = loop # incorporate offset timing into the event loop self.loop.time = self.time # type: ignore[assignment] def time(self) -> float: """Return loop time adjusted by offset.""" return self._base_time() + self.offset async def _exhaust_callbacks(self) -> None: """Run the loop until all ready callbacks are executed.""" while self.loop._ready: # noqa: ASYNC110 # type: ignore[attr-defined] await asyncio.sleep(0) async def __call__(self, seconds: float) -> None: """Advance time by a given offset in seconds.""" # Exhaust all callbacks. await self._exhaust_callbacks() if seconds > 0: # advance the clock by the given offset self.offset += seconds # Once the clock is adjusted, new tasks may have just been # scheduled for running in the next pass through the event loop await asyncio.sleep(0) await self._exhaust_callbacks() @pytest.fixture def time_travel(event_loop: asyncio.AbstractEventLoop) -> EventLoopClockAdvancer: """Advance loop time and run callbacks.""" return EventLoopClockAdvancer(event_loop) @pytest.fixture def xknx_no_interface() -> XKNX: """Return XKNX instance without KNX/IP interface.""" def knx_ip_interface_mock() -> Mock: """Create a xknx knx ip interface mock.""" mock = Mock() mock.start = AsyncMock() mock.stop = AsyncMock() mock.send_cemi = AsyncMock() return mock with patch("xknx.xknx.knx_interface_factory", return_value=knx_ip_interface_mock()): return XKNX() xknx-3.6.0/test/core_tests/000077500000000000000000000000001475530762600156605ustar00rootroot00000000000000xknx-3.6.0/test/core_tests/__init__.py000066400000000000000000000000461475530762600177710ustar00rootroot00000000000000"""Unit tests for the Core Module.""" xknx-3.6.0/test/core_tests/connection_manager_test.py000066400000000000000000000135071475530762600231300ustar00rootroot00000000000000"""Unit test for connection manager.""" import asyncio from datetime import datetime import threading from typing import Any from unittest.mock import Mock, patch from xknx import XKNX from xknx.core import XknxConnectionState, XknxConnectionType from xknx.io import ConnectionConfig from xknx.util import asyncio_timeout class TestConnectionManager: """Test class for connection manager.""" # # TEST REGISTER/UNREGISTER # async def test_register(self) -> None: """Test connection_state_changed after register.""" xknx = XKNX() connection_state_changed_cb = Mock() xknx.connection_manager.register_connection_state_changed_cb( connection_state_changed_cb ) xknx.connection_manager.connection_state_changed( XknxConnectionState.CONNECTED, XknxConnectionType.ROUTING_SECURE ) connection_state_changed_cb.assert_called_once_with( XknxConnectionState.CONNECTED ) async def test_unregister(self) -> None: """Test unregister after register.""" xknx = XKNX() connection_state_changed_cb = Mock() xknx.connection_manager.register_connection_state_changed_cb( connection_state_changed_cb ) xknx.connection_manager.unregister_connection_state_changed_cb( connection_state_changed_cb ) xknx.connection_manager.connection_state_changed(XknxConnectionState.CONNECTED) connection_state_changed_cb.assert_not_called() # # TEST PROCESS # async def test_state_return(self) -> None: """Test should return if current state equals parameter.""" xknx = XKNX() connection_state_changed_cb = Mock() xknx.connection_manager.register_connection_state_changed_cb( connection_state_changed_cb ) assert xknx.connection_manager.state == XknxConnectionState.DISCONNECTED xknx.connection_manager.connection_state_changed( XknxConnectionState.DISCONNECTED ) connection_state_changed_cb.assert_not_called() # # TEST CONNECTED # async def test_connected_event(self) -> None: """Test connected event callback.""" xknx = XKNX() connection_state_changed_cb = Mock() xknx.connection_manager.register_connection_state_changed_cb( connection_state_changed_cb ) assert not xknx.connection_manager.connected.is_set() xknx.connection_manager.connection_state_changed(XknxConnectionState.CONNECTED) connection_state_changed_cb.assert_called_once_with( XknxConnectionState.CONNECTED ) assert xknx.connection_manager.connected.is_set() async def test_threaded_connection(self) -> None: """Test starting threaded connection.""" # pylint: disable=attribute-defined-outside-init self.main_thread = threading.get_ident() xknx = XKNX(connection_config=ConnectionConfig(threaded=True)) def assert_main_thread(*args: Any, **kwargs: dict[str, Any]) -> None: """Test callback is done by main thread.""" assert self.main_thread == threading.get_ident() xknx.connection_manager.register_connection_state_changed_cb(assert_main_thread) async def set_connected() -> None: """Set connected state.""" xknx.connection_manager.connection_state_changed( XknxConnectionState.CONNECTED ) assert self.main_thread != threading.get_ident() with patch("xknx.io.KNXIPInterface._start", side_effect=set_connected): await xknx.start() # wait for side_effect to finish async with asyncio_timeout(1): await xknx.connection_manager.connected.wait() await asyncio.sleep(0) await xknx.stop() async def test_connection_information(self) -> None: """Test connection information.""" xknx = XKNX() assert xknx.connection_manager.connected_since is None assert ( xknx.connection_manager.connection_type is XknxConnectionType.NOT_CONNECTED ) xknx.connection_manager.cemi_count_incoming = 5 xknx.connection_manager.cemi_count_incoming_error = 5 xknx.connection_manager.cemi_count_outgoing = 5 xknx.connection_manager.cemi_count_outgoing_error = 5 # reset counters on new connection xknx.connection_manager.connection_state_changed( XknxConnectionState.CONNECTED, XknxConnectionType.TUNNEL_TCP ) assert xknx.connection_manager.cemi_count_incoming == 0 assert xknx.connection_manager.cemi_count_incoming_error == 0 assert xknx.connection_manager.cemi_count_outgoing == 0 assert xknx.connection_manager.cemi_count_outgoing_error == 0 assert isinstance(xknx.connection_manager.connected_since, datetime) assert xknx.connection_manager.connection_type is XknxConnectionType.TUNNEL_TCP xknx.connection_manager.cemi_count_incoming = 5 xknx.connection_manager.cemi_count_incoming_error = 5 xknx.connection_manager.cemi_count_outgoing = 5 xknx.connection_manager.cemi_count_outgoing_error = 5 # keep values until new connection; set connection timestamp to None xknx.connection_manager.connection_state_changed( XknxConnectionState.DISCONNECTED ) assert xknx.connection_manager.cemi_count_incoming == 5 assert xknx.connection_manager.cemi_count_incoming_error == 5 assert xknx.connection_manager.cemi_count_outgoing == 5 assert xknx.connection_manager.cemi_count_outgoing_error == 5 assert xknx.connection_manager.connected_since is None assert ( xknx.connection_manager.connection_type is XknxConnectionType.NOT_CONNECTED ) xknx-3.6.0/test/core_tests/exceptions_test.py000066400000000000000000000030261475530762600214530ustar00rootroot00000000000000"""Unit tests for exceptions.""" import pytest from xknx.exceptions import ( ConversionError, CouldNotParseAddress, CouldNotParseKNXIP, CouldNotParseTelegram, DeviceIllegalValue, XKNXException, ) @pytest.mark.parametrize( "base,equal,diff", [ ( ConversionError("desc1"), ConversionError("desc1"), ConversionError("desc2"), ), ( CouldNotParseAddress(123), CouldNotParseAddress(123), CouldNotParseAddress(321), ), ( CouldNotParseKNXIP("desc1"), CouldNotParseKNXIP("desc1"), CouldNotParseKNXIP("desc2"), ), ( CouldNotParseTelegram("desc", arg1=1, arg2=2), CouldNotParseTelegram("desc", arg1=1, arg2=2), CouldNotParseTelegram("desc", arg1=2, arg2=1), ), ( DeviceIllegalValue("value1", "desc"), DeviceIllegalValue("value1", "desc"), DeviceIllegalValue("value1", "desc2"), ), ( XKNXException("desc1"), XKNXException("desc1"), XKNXException("desc2"), ), ], ) def test_exceptions( base: XKNXException, equal: XKNXException, diff: XKNXException, ) -> None: """Test hashability and repr of exceptions.""" assert hash(base) == hash(equal) assert hash(base) != hash(diff) assert base == equal assert base != diff assert repr(base) == repr(equal) assert repr(base) != repr(diff) xknx-3.6.0/test/core_tests/group_address_dpt_test.py000066400000000000000000000124571475530762600230120ustar00rootroot00000000000000"""Test for group address dpt.""" from unittest.mock import Mock, patch from xknx import XKNX from xknx.dpt import DPTArray, DPTHumidity, DPTScaling, DPTTemperature from xknx.telegram import GroupAddress, Telegram, TelegramDirection, apci async def test_group_address_dpt_in_telegram_queue(xknx_no_interface: XKNX) -> None: """Test group address dpt.""" xknx = xknx_no_interface telegram = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=apci.GroupValueWrite(DPTArray((0x7F,))), ) read_telegram = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=apci.GroupValueRead(), ) telegram_callback = Mock() xknx.telegram_queue.register_telegram_received_cb(telegram_callback) async with xknx: xknx.telegrams.put_nowait(telegram) await xknx.telegrams.join() assert telegram_callback.call_count == 1 test_telegram = telegram_callback.call_args[0][0] assert test_telegram.decoded_data is None xknx.group_address_dpt.set({GroupAddress("1/2/3"): {"main": 5, "sub": 1}}) xknx.telegrams.put_nowait(telegram) await xknx.telegrams.join() assert telegram_callback.call_count == 2 test_telegram = telegram_callback.call_args[0][0] assert test_telegram.decoded_data is not None assert test_telegram.decoded_data.transcoder is DPTScaling assert test_telegram.decoded_data.value == 50 # do nothing for read-telegrams xknx.telegrams.put_nowait(read_telegram) await xknx.telegrams.join() assert telegram_callback.call_count == 3 test_telegram = telegram_callback.call_args[0][0] assert test_telegram.decoded_data is None @patch("logging.Logger.warning") @patch("logging.Logger.debug") async def test_get_invalid_payload( logger_debug_mock: Mock, logger_warning_mock: Mock, xknx_no_interface: XKNX ) -> None: """Test processing when DPT doesn't fit payload.""" xknx = xknx_no_interface xknx.group_address_dpt.set({GroupAddress("1/2/3"): {"main": 5}}) telegram_callback = Mock() xknx.telegram_queue.register_telegram_received_cb(telegram_callback) telegram = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=apci.GroupValueWrite(DPTArray((0x01, 0x02))), # wrong payload size ) async with xknx: logger_debug_mock.reset_mock() logger_warning_mock.reset_mock() assert len(xknx.group_address_dpt.ga_decoding_error) == 0 xknx.telegrams.put_nowait(telegram) await xknx.telegrams.join() assert len(xknx.group_address_dpt.ga_decoding_error) == 1 assert telegram_callback.call_count == 1 test_telegram = telegram_callback.call_args[0][0] assert test_telegram.decoded_data is None assert logger_debug_mock.call_count == 1 # telegram log assert logger_warning_mock.call_count == 1 # first occurrence goes to warning assert "DPT decoding error" in logger_warning_mock.call_args_list[0][0][0] # second telegram with same GA should go to debug logger_debug_mock.reset_mock() logger_warning_mock.reset_mock() xknx.telegrams.put_nowait(telegram) await xknx.telegrams.join() assert len(xknx.group_address_dpt.ga_decoding_error) == 1 # still 1 - same GA assert logger_warning_mock.call_count == 0 assert logger_debug_mock.call_count == 2 # second occurrence goes to debug assert "DPT decoding error" in logger_debug_mock.call_args_list[0][0][0] assert xknx.group_address_dpt.ga_decoding_error == {GroupAddress("1/2/3")} def test_set(xknx_no_interface: XKNX) -> None: """Test set.""" xknx = xknx_no_interface xknx.group_address_dpt.set( { GroupAddress("1/2/3"): {"main": 5, "sub": 1}, 1: "temperature", "i-internal": "9.007", } ) assert xknx.group_address_dpt._ga_dpts == { 2563: DPTScaling, 1: DPTTemperature, "i-internal": DPTHumidity, } assert xknx.group_address_dpt.get(GroupAddress("0/0/1")) is DPTTemperature @patch("logging.Logger.warning") @patch("logging.Logger.debug") def test_set_invalid( logger_debug_mock: Mock, logger_warning_mock: Mock, xknx_no_interface: XKNX ) -> None: """Test set invalid dpts.""" xknx = xknx_no_interface xknx.group_address_dpt.set( { GroupAddress("0/0/1"): {"main": None}, 2: "invalid", 0: "temperature", # 0 is not a valid GA } ) assert logger_warning_mock.call_count == 1 assert "Invalid group address" in logger_warning_mock.call_args[0][0] assert logger_debug_mock.call_count == 1 assert "No transcoder found for DPTs" in logger_debug_mock.call_args[0][0] assert not xknx.group_address_dpt._ga_dpts def test_clear(xknx_no_interface: XKNX) -> None: """Test clear.""" xknx = xknx_no_interface xknx.group_address_dpt.set( { GroupAddress("1/2/3"): {"main": 5, "sub": 1}, 1: "temperature", } ) assert len(xknx.group_address_dpt._ga_dpts) == 2 xknx.group_address_dpt.clear() assert len(xknx.group_address_dpt._ga_dpts) == 0 xknx-3.6.0/test/core_tests/state_updater_test.py000066400000000000000000000245341475530762600221450ustar00rootroot00000000000000"""Unit test for StateUpdater.""" from typing import Any from unittest.mock import Mock, patch import pytest from xknx import XKNX from xknx.core import XknxConnectionState from xknx.core.state_updater import StateTrackerType, _StateTracker from xknx.remote_value import RemoteValue from xknx.telegram import GroupAddress @patch.multiple(RemoteValue, __abstractmethods__=set()) class TestStateUpdater: """Test class for state updater.""" def test_register_unregister(self) -> None: """Test register and unregister.""" xknx = XKNX() assert len(xknx.state_updater._workers) == 0 # register when state address and sync_state is set remote_value_1: RemoteValue[Any] = RemoteValue( xknx, sync_state=True, group_address_state=GroupAddress("1/1/1") ) assert len(xknx.state_updater._workers) == 0 remote_value_1.register_state_updater() assert len(xknx.state_updater._workers) == 1 # don't register when sync_state is False remote_value_2: RemoteValue[Any] = RemoteValue( xknx, sync_state=False, group_address_state=GroupAddress("1/1/1") ) assert len(xknx.state_updater._workers) == 1 remote_value_2.register_state_updater() assert len(xknx.state_updater._workers) == 1 # manual registration xknx.state_updater.register_remote_value(remote_value_2) assert len(xknx.state_updater._workers) == 2 # manual unregister xknx.state_updater.unregister_remote_value(remote_value_1) # only remote_value_2 remaining assert len(xknx.state_updater._workers) == 1 assert next(iter(xknx.state_updater._workers.keys())) == id(remote_value_2) # unregister remote_value_2.unregister_state_updater() assert len(xknx.state_updater._workers) == 0 def test_tracker_parser(self) -> None: """Test parsing tracker options.""" xknx = XKNX() def _get_only_tracker() -> _StateTracker: # _workers is unordered so it just works with 1 item assert len(xknx.state_updater._workers) == 1 _tracker = next(iter(xknx.state_updater._workers.values())) return _tracker # INIT remote_value_init: RemoteValue[Any] = RemoteValue( xknx, sync_state="init", group_address_state=GroupAddress("1/1/1") ) remote_value_init.register_state_updater() assert _get_only_tracker().tracker_type == StateTrackerType.INIT remote_value_init.unregister_state_updater() # DEFAULT with int remote_value_expire: RemoteValue[Any] = RemoteValue( xknx, sync_state=2, group_address_state=GroupAddress("1/1/1") ) remote_value_expire.register_state_updater() assert _get_only_tracker().tracker_type == StateTrackerType.EXPIRE assert _get_only_tracker().update_interval == 2 * 60 remote_value_expire.unregister_state_updater() # DEFAULT with float remote_value_expire = RemoteValue( xknx, sync_state=6.9, group_address_state=GroupAddress("1/1/1") ) remote_value_expire.register_state_updater() assert _get_only_tracker().tracker_type == StateTrackerType.EXPIRE assert _get_only_tracker().update_interval == 6.9 * 60 remote_value_expire.unregister_state_updater() # EXPIRE with default time remote_value_expire = RemoteValue( xknx, sync_state="expire", group_address_state=GroupAddress("1/1/1") ) remote_value_expire.register_state_updater() assert _get_only_tracker().tracker_type == StateTrackerType.EXPIRE assert _get_only_tracker().update_interval == 60 * 60 remote_value_expire.unregister_state_updater() # EXPIRE with 30 minutes remote_value_expire = RemoteValue( xknx, sync_state="expire 30", group_address_state=GroupAddress("1/1/1") ) remote_value_expire.register_state_updater() assert _get_only_tracker().tracker_type == StateTrackerType.EXPIRE assert _get_only_tracker().update_interval == 30 * 60 remote_value_expire.unregister_state_updater() # PERIODICALLY with default time remote_value_every: RemoteValue[Any] = RemoteValue( xknx, sync_state="every", group_address_state=GroupAddress("1/1/1") ) remote_value_every.register_state_updater() assert _get_only_tracker().tracker_type == StateTrackerType.PERIODICALLY assert _get_only_tracker().update_interval == 60 * 60 remote_value_every.unregister_state_updater() # PERIODICALLY 10 * 60 seconds remote_value_every = RemoteValue( xknx, sync_state="every 10", group_address_state=GroupAddress("1/1/1") ) remote_value_every.register_state_updater() assert _get_only_tracker().tracker_type == StateTrackerType.PERIODICALLY assert _get_only_tracker().update_interval == 10 * 60 remote_value_every.unregister_state_updater() @patch("logging.Logger.warning") def test_tracker_parser_invalid_options(self, logging_warning_mock: Mock) -> None: """Test parsing invalid tracker options.""" xknx = XKNX() def _get_only_tracker() -> _StateTracker: # _workers is unordered so it just works with 1 item assert len(xknx.state_updater._workers) == 1 _tracker = next(iter(xknx.state_updater._workers.values())) return _tracker # INVALID string remote_value_invalid: RemoteValue[Any] = RemoteValue( xknx, sync_state="invalid", group_address_state=GroupAddress("1/1/1") ) remote_value_invalid.register_state_updater() logging_warning_mock.assert_called_once_with( 'Could not parse StateUpdater tracker_options "%s" for %s. Using default %s %s minutes.', "invalid", str(remote_value_invalid), StateTrackerType.EXPIRE, 60, ) assert _get_only_tracker().tracker_type == StateTrackerType.EXPIRE assert _get_only_tracker().update_interval == 60 * 60 remote_value_invalid.unregister_state_updater() logging_warning_mock.reset_mock() # interval too long remote_value_long: RemoteValue[Any] = RemoteValue( xknx, sync_state=1441, group_address_state=GroupAddress("1/1/1") ) remote_value_long.register_state_updater() logging_warning_mock.assert_called_once_with( "StateUpdater interval of %s to long for %s. Using maximum of %s minutes (1 day)", 1441, str(remote_value_long), 1440, ) remote_value_long.unregister_state_updater() def test_state_updater_start_update_stop(self) -> None: """Test start, update_received and stop of StateUpdater.""" xknx = XKNX() remote_value_1: RemoteValue[Any] = RemoteValue( xknx, sync_state=True, group_address_state=GroupAddress("1/1/1") ) remote_value_2: RemoteValue[Any] = RemoteValue( xknx, sync_state=True, group_address_state=GroupAddress("1/1/2") ) xknx.state_updater._workers[id(remote_value_1)] = Mock() xknx.state_updater._workers[id(remote_value_2)] = Mock() assert not xknx.state_updater.started xknx.connection_manager._state = XknxConnectionState.CONNECTED xknx.state_updater.start() assert xknx.state_updater.started # start xknx.state_updater._workers[id(remote_value_1)].start.assert_called_once_with() xknx.state_updater._workers[id(remote_value_2)].start.assert_called_once_with() # update xknx.state_updater.update_received(remote_value_2) xknx.state_updater._workers[ id(remote_value_1) ].update_received.assert_not_called() xknx.state_updater._workers[ id(remote_value_2) ].update_received.assert_called_once_with() # stop xknx.state_updater.stop() assert not xknx.state_updater.started xknx.state_updater._workers[id(remote_value_1)].stop.assert_called_once_with() xknx.state_updater._workers[id(remote_value_2)].stop.assert_called_once_with() # don't update when not started xknx.state_updater.update_received(remote_value_1) xknx.state_updater._workers[ id(remote_value_1) ].update_received.assert_not_called() async def test_stop_start_state_updater_when_reconnecting(self) -> None: """Test start/stop state updater after reconnect.""" xknx = XKNX() assert not xknx.state_updater.started xknx.connection_manager._state = XknxConnectionState.CONNECTED xknx.state_updater.start() assert xknx.state_updater.started xknx.connection_manager.connection_state_changed( XknxConnectionState.DISCONNECTED ) assert not xknx.state_updater.started xknx.connection_manager.connection_state_changed(XknxConnectionState.CONNECTED) assert xknx.state_updater.started @pytest.mark.parametrize( ("default", "sync_state_value", "expected_interval", "expected_tracker_type"), [ (90, True, 90, StateTrackerType.EXPIRE), (False, True, 60, StateTrackerType.EXPIRE), (True, None, 60, StateTrackerType.EXPIRE), (40, None, 40, StateTrackerType.EXPIRE), ("every 70", None, 70, StateTrackerType.PERIODICALLY), ("init", True, 60, StateTrackerType.INIT), ("every 80", "expire 20", 20, StateTrackerType.EXPIRE), ], ) async def test_stat_updater_default( self, default: int | str | bool, sync_state_value: int | str | bool | None, expected_interval: int, expected_tracker_type: StateTrackerType, ) -> None: """Test setting a default for StateUpdater.""" xknx = XKNX(state_updater=default) remote_value: RemoteValue[Any] = RemoteValue( xknx, sync_state=sync_state_value, group_address_state=GroupAddress("1/1/1") ) remote_value.register_state_updater() assert ( xknx.state_updater._workers[id(remote_value)].update_interval == expected_interval * 60 ) assert ( xknx.state_updater._workers[id(remote_value)].tracker_type == expected_tracker_type ) xknx-3.6.0/test/core_tests/task_registry_test.py000066400000000000000000000106401475530762600221640ustar00rootroot00000000000000"""Unit test for task registry.""" import asyncio import sys from xknx import XKNX from xknx.core import XknxConnectionState from ..conftest import EventLoopClockAdvancer class TestTaskRegistry: """Test class for task registry.""" # # TEST REGISTER/UNREGISTER # async def test_register(self) -> None: """Test register.""" xknx = XKNX() async def callback() -> None: """Reset tasks.""" xknx.task_registry.tasks = [] task = xknx.task_registry.register( name="test", async_func=callback, ) assert len(xknx.task_registry.tasks) == 1 task.start() assert not task.done() await xknx.task_registry.block_till_done() assert task.done() assert len(xknx.task_registry.tasks) == 0 async def test_unregister(self) -> None: """Test unregister after register.""" xknx = XKNX() async def callback() -> None: """Do nothing.""" task = xknx.task_registry.register( name="test", async_func=callback, ) assert len(xknx.task_registry.tasks) == 1 task.start() xknx.task_registry.unregister(task.name) assert len(xknx.task_registry.tasks) == 0 assert task.done() # # TEST START/STOP # async def test_stop(self) -> None: """Test stop.""" xknx = XKNX() async def callback() -> None: """Reset tasks.""" await asyncio.sleep(100) task = xknx.task_registry.register( name="test", async_func=callback, ) assert len(xknx.task_registry.tasks) == 1 task.start() xknx.task_registry.stop() assert len(xknx.task_registry.tasks) == 0 # # TEST CONNECTION HANDLING # async def test_reconnect_handling( self, time_travel: EventLoopClockAdvancer ) -> None: """Test reconnect handling.""" xknx = XKNX() xknx.task_registry.start() assert len(xknx.connection_manager._connection_state_changed_cbs) == 1 xknx.connection_manager.connection_state_changed(XknxConnectionState.CONNECTED) # pylint: disable=attribute-defined-outside-init self.test = 0 async def callback() -> None: """Reset tasks.""" try: while True: await asyncio.sleep(100) self.test += 1 except asyncio.CancelledError: self.test -= 1 task = xknx.task_registry.register( name="test", async_func=callback, restart_after_reconnect=True ) assert len(xknx.task_registry.tasks) == 1 task.start() assert task._task is not None await time_travel(100) assert self.test == 1 xknx.connection_manager.connection_state_changed( XknxConnectionState.DISCONNECTED ) await asyncio.sleep(0) # iterate loop to cancel task assert task._task is None assert self.test == 0 xknx.connection_manager.connection_state_changed(XknxConnectionState.CONNECTED) assert task._task is not None assert self.test == 0 await time_travel(100) assert self.test == 1 assert len(xknx.task_registry.tasks) == 1 xknx.task_registry.stop() assert len(xknx.task_registry.tasks) == 0 assert task._task is None await asyncio.sleep(0) # iterate loop to cancel task assert self.test == 0 assert len(xknx.connection_manager._connection_state_changed_cbs) == 0 async def test_background(self, time_travel: EventLoopClockAdvancer) -> None: """Test running background task.""" test_time = 10 async def callback() -> None: """Do nothing.""" await asyncio.sleep(test_time) xknx = XKNX() xknx.task_registry.background(callback()) assert len(xknx.task_registry._background_task) == 1 task = next(iter(xknx.task_registry._background_task)) refs = sys.getrefcount(task) assert refs == 4 assert not task.done() # after task is finished it should remove itself from the background registry await time_travel(test_time) assert len(xknx.task_registry._background_task) == 0 assert task.done() refs = sys.getrefcount(task) assert refs == 2 xknx-3.6.0/test/core_tests/telegram_queue_test.py000066400000000000000000000425171475530762600223060ustar00rootroot00000000000000"""Unit test for telegram received callback.""" import asyncio from unittest.mock import AsyncMock, MagicMock, Mock, call, patch import pytest from xknx import XKNX from xknx.dpt import DPTArray, DPTBinary from xknx.exceptions import CommunicationError, CouldNotParseTelegram from xknx.telegram import AddressFilter, Telegram, TelegramDirection from xknx.telegram.address import GroupAddress, InternalGroupAddress from xknx.telegram.apci import GroupValueWrite class TestTelegramQueue: """Test class for telegram queue.""" # # TEST START, RUN, STOP # async def test_start(self) -> None: """Test start, run and stop.""" xknx = XKNX() telegram_in = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=GroupValueWrite(DPTBinary(1)), ) await xknx.telegram_queue.start() assert not xknx.telegram_queue._consumer_task.done() # queue shall now consume telegrams from xknx.telegrams assert xknx.telegrams.qsize() == 0 xknx.telegrams.put_nowait(telegram_in) xknx.telegrams.put_nowait(telegram_in) assert xknx.telegrams.qsize() == 2 # wait until telegrams are consumed await xknx.telegrams.join() assert xknx.telegrams.qsize() == 0 await xknx.telegrams.join() assert xknx.telegrams.qsize() == 0 # stop run() task with stop() await xknx.telegram_queue.stop() assert xknx.telegram_queue._consumer_task.done() @patch("asyncio.sleep", new_callable=AsyncMock) async def test_rate_limit(self, async_sleep_mock: AsyncMock) -> None: """Test rate limit.""" xknx = XKNX( rate_limit=20, # 50 ms per outgoing telegram ) sleep_time = 0.05 # 1 / 20 telegram_in = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=GroupValueWrite(DPTBinary(1)), ) telegram_out = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.OUTGOING, payload=GroupValueWrite(DPTBinary(1)), ) telegram_internal = Telegram( direction=TelegramDirection.OUTGOING, payload=GroupValueWrite(DPTBinary(1)), destination_address=InternalGroupAddress("i-test"), ) await xknx.telegram_queue.start() # no sleep for incoming telegrams xknx.telegrams.put_nowait(telegram_in) xknx.telegrams.put_nowait(telegram_in) await xknx.telegrams.join() assert async_sleep_mock.call_count == 0 # sleep for outgoing telegrams xknx.telegrams.put_nowait(telegram_out) xknx.telegrams.put_nowait(telegram_out) await xknx.telegrams.join() assert async_sleep_mock.call_count == 2 async_sleep_mock.assert_called_with(sleep_time) async_sleep_mock.reset_mock() # no sleep for internal group address telegrams xknx.telegrams.put_nowait(telegram_internal) xknx.telegrams.put_nowait(telegram_internal) await xknx.telegrams.join() async_sleep_mock.assert_not_called() await xknx.telegram_queue.stop() # # TEST REGISTER # async def test_register(self) -> None: """Test telegram_received_callback after state of switch was changed.""" xknx = XKNX() telegram_received_cb = Mock() xknx.telegram_queue.register_telegram_received_cb(telegram_received_cb) telegram = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=GroupValueWrite(DPTBinary(1)), ) await xknx.telegram_queue.process_telegram_incoming(telegram) telegram_received_cb.assert_called_once_with(telegram) async def test_register_with_outgoing_telegrams(self) -> None: """Test telegram_received_callback with outgoing telegrams.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() telegram_received_cb = Mock() xknx.telegram_queue.register_telegram_received_cb( telegram_received_cb, None, None, True ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.OUTGOING, payload=GroupValueWrite(DPTBinary(1)), ) await xknx.telegram_queue.process_telegram_outgoing(telegram) telegram_received_cb.assert_called_once_with(telegram) async def test_register_with_outgoing_telegrams_does_not_trigger(self) -> None: """Test telegram_received_callback with outgoing telegrams.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() telegram_received_cb = Mock() xknx.telegram_queue.register_telegram_received_cb(telegram_received_cb) telegram = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.OUTGOING, payload=GroupValueWrite(DPTBinary(1)), ) await xknx.telegram_queue.process_telegram_outgoing(telegram) telegram_received_cb.assert_not_called() # # TEST UNREGISTER # async def test_unregister(self) -> None: """Test telegram_received_callback after state of switch was changed.""" xknx = XKNX() telegram_received_cb = Mock() callback = xknx.telegram_queue.register_telegram_received_cb( telegram_received_cb ) xknx.telegram_queue.unregister_telegram_received_cb(callback) telegram = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=GroupValueWrite(DPTBinary(1)), ) await xknx.telegram_queue.process_telegram_incoming(telegram) telegram_received_cb.assert_not_called() # # TEST PROCESS # @patch("xknx.devices.Devices.devices_by_group_address") async def test_process_to_device(self, devices_by_ga_mock: Mock) -> None: """Test process_telegram_incoming for forwarding telegram to a device.""" xknx = XKNX() test_device = Mock() devices_by_ga_mock.return_value = [test_device] telegram = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=GroupValueWrite(DPTBinary(1)), ) await xknx.telegram_queue.process_telegram_incoming(telegram) devices_by_ga_mock.assert_called_once_with(GroupAddress("1/2/3")) test_device.process.assert_called_once_with(telegram) @patch("xknx.devices.Devices.process") async def test_process_to_callback(self, devices_process: MagicMock) -> None: """Test process_telegram_incoming with callback.""" xknx = XKNX() telegram_received_cb = Mock() xknx.telegram_queue.register_telegram_received_cb(telegram_received_cb) telegram = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=GroupValueWrite(DPTBinary(1)), ) await xknx.telegram_queue.process_telegram_incoming(telegram) telegram_received_cb.assert_called_once_with(telegram) devices_process.assert_called_once_with(telegram) async def test_callback_decoded_telegram_data(self) -> None: """Test telegram_received_callback having decoded telegram data.""" xknx = XKNX() xknx.group_address_dpt.set({"1/2/3": {"main": 5, "sub": 1}}) telegram_received_cb = Mock() xknx.telegram_queue.register_telegram_received_cb(telegram_received_cb) telegram = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=GroupValueWrite( DPTArray( 0x7F, ) ), ) await xknx.telegram_queue.start() xknx.telegrams.put_nowait(telegram) await xknx.telegrams.join() await xknx.telegram_queue.stop() assert telegram_received_cb.call_count == 1 received = telegram_received_cb.call_args_list[0][0][0] assert received == telegram assert received.decoded_data is not None assert received.decoded_data.value == 50 async def test_outgoing(self) -> None: """Test outgoing telegrams in telegram queue.""" xknx = XKNX() telegram = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.OUTGOING, payload=GroupValueWrite(DPTBinary(1)), ) # log a warning if there is no KNXIP interface instantiated with pytest.raises(CommunicationError): await xknx.telegram_queue.process_telegram_outgoing(telegram) # if we have an interface send the telegram (doesn't raise) xknx.cemi_handler.send_telegram = AsyncMock() await xknx.telegram_queue.process_telegram_outgoing(telegram) xknx.cemi_handler.send_telegram.assert_called_once_with(telegram) @patch("logging.Logger.error") @patch("xknx.core.TelegramQueue.process_telegram_incoming", new_callable=MagicMock) async def test_process_exception( self, process_tg_in_mock: MagicMock, logging_error_mock: MagicMock ) -> None: """Test process_telegram exception handling.""" xknx = XKNX() async def process_exception() -> None: raise CouldNotParseTelegram( "Something went wrong when receiving the telegram." ) process_tg_in_mock.return_value = asyncio.ensure_future(process_exception()) telegram = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=GroupValueWrite(DPTBinary(1)), ) xknx.telegrams.put_nowait(telegram) await xknx.telegram_queue._process_all_telegrams() logging_error_mock.assert_called_once_with( "Error while processing telegram %s", CouldNotParseTelegram("Something went wrong when receiving the telegram."), ) @patch("xknx.core.TelegramQueue.process_telegram_outgoing", new_callable=AsyncMock) @patch("xknx.core.TelegramQueue.process_telegram_incoming", new_callable=AsyncMock) async def test_process_all_telegrams( self, process_telegram_incoming_mock: AsyncMock, process_telegram_outgoing_mock: AsyncMock, ) -> None: """Test _process_all_telegrams for clearing the queue.""" xknx = XKNX() telegram_in = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=GroupValueWrite(DPTBinary(1)), ) telegram_out = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.OUTGOING, payload=GroupValueWrite(DPTBinary(1)), ) xknx.telegrams.put_nowait(telegram_in) xknx.telegrams.put_nowait(telegram_out) await xknx.telegram_queue._process_all_telegrams() process_telegram_incoming_mock.assert_called_once() process_telegram_outgoing_mock.assert_called_once() # # TEST NO FILTERS # async def test_callback_no_filters(self) -> None: """Test telegram_received_callback after state of switch was changed.""" xknx = XKNX() telegram_received_cb = Mock() xknx.telegram_queue.register_telegram_received_cb(telegram_received_cb) telegram = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=GroupValueWrite(DPTBinary(1)), ) xknx.telegrams.put_nowait(telegram) await xknx.telegram_queue._process_all_telegrams() telegram_received_cb.assert_called_with(telegram) # # TEST POSITIVE FILTERS # async def test_callback_positive_address_filters(self) -> None: """Test telegram_received_callback after state of switch was changed.""" xknx = XKNX() telegram_received_cb = Mock() xknx.telegram_queue.register_telegram_received_cb( telegram_received_cb, address_filters=[AddressFilter("2/4-8/*"), AddressFilter("1/2/-8")], ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=GroupValueWrite(DPTBinary(1)), ) xknx.telegrams.put_nowait(telegram) await xknx.telegram_queue._process_all_telegrams() telegram_received_cb.assert_called_with(telegram) # # TEST NEGATIVE FILTERS # async def test_callback_negative_address_filters(self) -> None: """Test telegram_received_callback after state of switch was changed.""" xknx = XKNX() telegram_received_cb = Mock() xknx.telegram_queue.register_telegram_received_cb( telegram_received_cb, address_filters=[AddressFilter("2/4-8/*"), AddressFilter("1/2/8-")], ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=GroupValueWrite(DPTBinary(1)), ) xknx.telegrams.put_nowait(telegram) await xknx.telegram_queue._process_all_telegrams() telegram_received_cb.assert_not_called() async def test_callback_group_addresses(self) -> None: """Test telegram_received_callback after state of switch was changed.""" xknx = XKNX() telegram_received_cb_one = Mock() telegram_received_cb_two = Mock() callback_one = xknx.telegram_queue.register_telegram_received_cb( telegram_received_cb_one, address_filters=[], group_addresses=[GroupAddress("1/2/3")], ) callback_two = xknx.telegram_queue.register_telegram_received_cb( telegram_received_cb_two, address_filters=[], group_addresses=[] ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=GroupValueWrite(DPTBinary(1)), ) await xknx.telegram_queue.process_telegram_incoming(telegram) telegram_received_cb_one.assert_called_once_with(telegram) telegram_received_cb_two.assert_not_called() telegram_received_cb_one.reset_mock() # modify the filters - add/remove a GroupAddress callback_one.group_addresses.remove(GroupAddress("1/2/3")) callback_two.group_addresses.append(GroupAddress("1/2/3")) await xknx.telegram_queue.process_telegram_incoming(telegram) telegram_received_cb_one.assert_not_called() telegram_received_cb_two.assert_called_once_with(telegram) # # TEST EXCEPTION HANDLING # @patch("logging.Logger.exception") @patch("xknx.xknx.Devices.process", side_effect=Exception) async def test_process_raising( self, process_mock: MagicMock, logging_exception_mock: MagicMock ) -> None: """Test unexpected exception handling in telegram queues.""" xknx = XKNX() telegram_in = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=GroupValueWrite(DPTBinary(1)), ) # InternalGroupAddress to avoid CommunicationError for missing knxip_interface telegram_out = Telegram( destination_address=InternalGroupAddress("i-outgoing"), direction=TelegramDirection.OUTGOING, payload=GroupValueWrite(DPTBinary(0)), ) xknx.telegrams.put_nowait(telegram_in) xknx.telegrams.put_nowait(telegram_out) await xknx.telegram_queue.start() await xknx.telegram_queue.stop() log_calls = [ call( "Unexpected error while processing incoming telegram %s", telegram_in, ), call( "Unexpected error while processing outgoing telegram %s", telegram_out, ), ] logging_exception_mock.assert_has_calls(log_calls) @patch("logging.Logger.exception") async def test_callback_raising(self, logging_exception_mock: MagicMock) -> None: """Test telegram_received_callback raising an exception.""" xknx = XKNX() good_callback_1 = Mock() bad_callback = Mock(side_effect=Exception("Boom")) good_callback_2 = Mock() xknx.telegram_queue.register_telegram_received_cb(good_callback_1) xknx.telegram_queue.register_telegram_received_cb(bad_callback) xknx.telegram_queue.register_telegram_received_cb(good_callback_2) telegram = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=GroupValueWrite(DPTBinary(1)), ) await xknx.telegram_queue.process_telegram_incoming(telegram) good_callback_1.assert_called_once_with(telegram) bad_callback.assert_called_once_with(telegram) good_callback_2.assert_called_once_with(telegram) logging_exception_mock.assert_called_once_with( "Unexpected error while processing telegram_received_cb for %s", telegram, ) xknx-3.6.0/test/core_tests/value_reader_test.py000066400000000000000000000127131475530762600217330ustar00rootroot00000000000000"""Unit test for value reader.""" import asyncio from unittest.mock import MagicMock, patch import pytest from xknx import XKNX from xknx.core import ValueReader from xknx.dpt import DPTBinary from xknx.telegram import GroupAddress, Telegram, TelegramDirection from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite class TestValueReader: """Test class for value reader.""" async def test_value_reader_read_success(self) -> None: """Test value reader: successful read.""" xknx = XKNX() test_group_address = GroupAddress("0/0/1") response_telegram = Telegram( destination_address=test_group_address, direction=TelegramDirection.INCOMING, payload=GroupValueResponse(DPTBinary(1)), ) value_reader = ValueReader(xknx, test_group_address) # receive the response value_reader.telegram_received(response_telegram) # and yield the result successful_read = await value_reader.read() # GroupValueRead telegram is still in the queue because we are not actually processing it assert xknx.telegrams.qsize() == 1 # Callback was removed again assert not xknx.telegram_queue.telegram_received_cbs # Telegram was received assert value_reader.received_telegram == response_telegram # successful read() returns the telegram assert successful_read == response_telegram @patch("logging.Logger.warning") async def test_value_reader_read_timeout( self, logger_warning_mock: MagicMock ) -> None: """Test value reader: read timeout.""" xknx = XKNX() value_reader = ValueReader(xknx, GroupAddress("0/0/1")) value_reader.response_received_event.wait = MagicMock( side_effect=asyncio.TimeoutError() ) timed_out_read = await value_reader.read() # GroupValueRead telegram is still in the queue because we are not actually processing it assert xknx.telegrams.qsize() == 1 # Warning was logged logger_warning_mock.assert_called_once_with( "Error: KNX bus did not respond in time (%s secs) to GroupValueRead request for: %s", 2.0, GroupAddress("0/0/1"), ) # Callback was removed again assert not xknx.telegram_queue.telegram_received_cbs # No telegram was received assert value_reader.received_telegram is None # Unsuccessful read() returns None assert timed_out_read is None async def test_value_reader_read_cancelled(self) -> None: """Test value reader: read cancelled.""" xknx = XKNX() value_reader = ValueReader(xknx, GroupAddress("0/0/1")) value_reader.response_received_event.wait = MagicMock( side_effect=asyncio.CancelledError() ) with pytest.raises(asyncio.CancelledError): await value_reader.read() # GroupValueRead telegram is still in the queue because we are not actually processing it assert xknx.telegrams.qsize() == 1 # Callback was removed again assert not xknx.telegram_queue.telegram_received_cbs # No telegram was received assert value_reader.received_telegram is None async def test_value_reader_send_group_read(self) -> None: """Test value reader: send_group_read.""" xknx = XKNX() value_reader = ValueReader(xknx, GroupAddress("0/0/1")) value_reader.send_group_read() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("0/0/1"), payload=GroupValueRead() ) async def test_value_reader_telegram_received(self) -> None: """Test value reader: telegram_received.""" xknx = XKNX() test_group_address = GroupAddress("0/0/1") expected_telegram_1 = Telegram( destination_address=test_group_address, direction=TelegramDirection.INCOMING, payload=GroupValueResponse(DPTBinary(1)), ) expected_telegram_2 = Telegram( destination_address=test_group_address, direction=TelegramDirection.INCOMING, payload=GroupValueWrite(DPTBinary(1)), ) telegram_wrong_address = Telegram( destination_address=GroupAddress("0/0/2"), direction=TelegramDirection.INCOMING, payload=GroupValueResponse(DPTBinary(1)), ) telegram_wrong_type = Telegram( destination_address=test_group_address, direction=TelegramDirection.INCOMING, payload=GroupValueRead(), ) value_reader = ValueReader(xknx, test_group_address) value_reader.telegram_received(telegram_wrong_address) assert value_reader.received_telegram is None assert not value_reader.response_received_event.is_set() value_reader.telegram_received(telegram_wrong_type) assert value_reader.received_telegram is None assert not value_reader.response_received_event.is_set() value_reader.telegram_received(expected_telegram_1) assert value_reader.received_telegram == expected_telegram_1 assert value_reader.response_received_event.is_set() value_reader.telegram_received(expected_telegram_2) assert value_reader.received_telegram == expected_telegram_2 assert value_reader.response_received_event.is_set() xknx-3.6.0/test/devices_tests/000077500000000000000000000000001475530762600163525ustar00rootroot00000000000000xknx-3.6.0/test/devices_tests/__init__.py000066400000000000000000000000511475530762600204570ustar00rootroot00000000000000"""Unit tests for the Devices module.""" xknx-3.6.0/test/devices_tests/binary_sensor_test.py000066400000000000000000000272431475530762600226500ustar00rootroot00000000000000"""Unit test for BinarySensor objects.""" from unittest.mock import Mock, patch from xknx import XKNX from xknx.devices import BinarySensor from xknx.dpt import DPTArray, DPTBinary from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueResponse, GroupValueWrite from ..conftest import EventLoopClockAdvancer class TestBinarySensor: """Test class for BinarySensor objects.""" # # TEST PROCESS # async def test_process(self) -> None: """Test process / reading telegrams from telegram queue.""" xknx = XKNX() binaryinput = BinarySensor(xknx, "TestInput", "1/2/3") assert binaryinput.state is None telegram_on = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) binaryinput.process(telegram_on) assert binaryinput.state is True telegram_off = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(0)), ) binaryinput.process(telegram_off) assert binaryinput.state is False binaryinput2 = BinarySensor(xknx, "TestInput", "1/2/4") assert binaryinput2.state is None telegram_off2 = Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueWrite(DPTBinary(0)), ) binaryinput2.process(telegram_off2) assert binaryinput2.last_telegram == telegram_off2 assert binaryinput2.state is False async def test_process_invert(self) -> None: """Test process / reading telegrams from telegram queue.""" xknx = XKNX() bs_invert = BinarySensor(xknx, "TestInput", "1/2/3", invert=True) assert bs_invert.state is None telegram_on = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(0)), ) bs_invert.process(telegram_on) assert bs_invert.state is True telegram_off = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) bs_invert.process(telegram_off) assert bs_invert.state is False async def test_process_reset_after( self, time_travel: EventLoopClockAdvancer ) -> None: """Test process / reading telegrams from telegram queue.""" xknx = XKNX() reset_after_sec = 1 after_update_callback = Mock() binaryinput = BinarySensor( xknx, "TestInput", "1/2/3", reset_after=reset_after_sec, device_updated_cb=after_update_callback, ) telegram_on = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) binaryinput.process(telegram_on) assert binaryinput.state await time_travel(reset_after_sec) assert not binaryinput.state # once for 'on' and once for 'off' assert after_update_callback.call_count == 2 after_update_callback.reset_mock() # multiple telegrams during reset_after time period shall reset timer binaryinput.process(telegram_on) after_update_callback.assert_called_once() binaryinput.process(telegram_on) binaryinput.process(telegram_on) # second and third telegram resets timer but doesn't run callback after_update_callback.assert_called_once() assert binaryinput.state await time_travel(reset_after_sec) assert not binaryinput.state # once for 'on' and once for 'off' assert after_update_callback.call_count == 2 async def test_process_wrong_payload(self) -> None: """Test process wrong telegram (wrong payload type).""" xknx = XKNX() binary_sensor = BinarySensor(xknx, "Warning", group_address_state="1/2/3") telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x1, 0x2, 0x3))), ) with patch("logging.Logger.warning") as log_mock: binary_sensor.process(telegram) log_mock.assert_called_once() assert binary_sensor.state is None # # TEST SWITCHED ON # def test_is_on(self) -> None: """Test is_on() and is_off() of a BinarySensor with state 'on'.""" xknx = XKNX() binaryinput = BinarySensor(xknx, "TestInput", "1/2/3") assert not binaryinput.is_on() assert binaryinput.is_off() binaryinput._set_internal_state(True) assert binaryinput.is_on() assert not binaryinput.is_off() # # TEST SWITCHED OFF # def test_is_off(self) -> None: """Test is_on() and is_off() of a BinarySensor with state 'off'.""" xknx = XKNX() binaryinput = BinarySensor(xknx, "TestInput", "1/2/3") binaryinput._set_internal_state(False) assert not binaryinput.is_on() assert binaryinput.is_off() # # TEST PROCESS CALLBACK # async def test_process_callback(self) -> None: """Test after_update_callback after state of switch was changed.""" xknx = XKNX() switch = BinarySensor( xknx, "TestInput", group_address_state="1/2/3", ignore_internal_state=False ) after_update_callback = Mock() switch.register_device_updated_cb(after_update_callback) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) switch.process(telegram) # no _context_task started because ignore_internal_state is False assert switch._context_task is None after_update_callback.assert_called_once_with(switch) after_update_callback.reset_mock() # send same telegram again switch.process(telegram) after_update_callback.assert_not_called() async def test_process_callback_ignore_internal_state(self) -> None: """Test after_update_callback after state of switch was changed.""" xknx = XKNX() switch = BinarySensor( xknx, "TestInput", group_address_state="1/2/3", ignore_internal_state=True, context_timeout=0.001, ) after_update_callback = Mock() switch.register_device_updated_cb(after_update_callback) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) assert switch.counter == 0 switch.process(telegram) after_update_callback.assert_not_called() assert switch.counter == 1 await switch._context_task after_update_callback.assert_called_with(switch) # once with counter 1 and once with counter 0 assert after_update_callback.call_count == 2 after_update_callback.reset_mock() # send same telegram again switch.process(telegram) assert switch.counter == 1 switch.process(telegram) assert switch.counter == 2 after_update_callback.assert_not_called() await switch._context_task after_update_callback.assert_called_with(switch) # once with counter 2 and once with counter 0 assert after_update_callback.call_count == 2 assert switch.counter == 0 async def test_process_callback_ignore_internal_state_no_counter(self) -> None: """Test after_update_callback after state of switch was changed.""" xknx = XKNX() switch = BinarySensor( xknx, "TestInput", group_address_state="1/2/3", ignore_internal_state=True, context_timeout=0, ) after_update_callback = Mock() switch.register_device_updated_cb(after_update_callback) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) switch.process(telegram) # no _context_task started because context_timeout is False assert switch._context_task is None after_update_callback.assert_called_once_with(switch) after_update_callback.reset_mock() # send same telegram again switch.process(telegram) after_update_callback.assert_called_once_with(switch) async def test_process_group_value_response(self) -> None: """Test process of GroupValueResponse telegrams.""" xknx = XKNX() switch = BinarySensor( xknx, "TestInput", group_address_state="1/2/3", ignore_internal_state=True, ) after_update_callback = Mock() switch.register_device_updated_cb(after_update_callback) write_telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) response_telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueResponse( DPTBinary(1), ), ) assert switch.state is None # initial GroupValueResponse changes state and runs callback switch.process(response_telegram) assert switch.state after_update_callback.assert_called_once_with(switch) # GroupValueWrite with same payload runs callback because of `ignore_internal_state` after_update_callback.reset_mock() switch.process(write_telegram) assert switch.state after_update_callback.assert_called_once_with(switch) # GroupValueResponse should not run callback when state has not changed after_update_callback.reset_mock() switch.process(response_telegram) after_update_callback.assert_not_called() # # TEST COUNTER # def test_counter(self) -> None: """Test counter functionality.""" xknx = XKNX() switch = BinarySensor( xknx, "TestInput", group_address_state="1/2/3", context_timeout=1 ) with patch("time.time") as mock_time: mock_time.return_value = 1517000000.0 assert switch.bump_and_get_counter(True) == 1 mock_time.return_value = 1517000000.1 assert switch.bump_and_get_counter(True) == 2 mock_time.return_value = 1517000000.2 assert switch.bump_and_get_counter(False) == 1 mock_time.return_value = 1517000000.3 assert switch.bump_and_get_counter(True) == 3 mock_time.return_value = 1517000000.4 assert switch.bump_and_get_counter(False) == 2 mock_time.return_value = 1517000002.0 # TIME OUT ... assert switch.bump_and_get_counter(True) == 1 mock_time.return_value = 1517000004.1 # TIME OUT ... assert switch.bump_and_get_counter(False) == 1 async def test_remove_tasks(self, xknx_no_interface: XKNX) -> None: """Test remove tasks.""" xknx = xknx_no_interface switch = BinarySensor( xknx, "TestInput", group_address_state="1/2/3", context_timeout=1, reset_after=10, ) xknx.devices.async_add(switch) async with xknx: write_telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) switch.process(write_telegram) assert switch._context_task assert switch._reset_task xknx.devices.async_remove(switch) assert switch._context_task is None assert switch._reset_task is None xknx-3.6.0/test/devices_tests/climate_test.py000066400000000000000000002031041475530762600214010ustar00rootroot00000000000000"""Unit test for Climate objects.""" from unittest.mock import Mock, patch import pytest from xknx import XKNX from xknx.devices import Climate, ClimateMode from xknx.devices.climate import FanSpeedMode, SetpointShiftMode from xknx.dpt import ( DPT2ByteFloat, DPTArray, DPTBinary, DPTHumidity, DPTHVACContrMode, DPTHVACMode, DPTHVACStatus, DPTTemperature, DPTValue1Count, ) from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode, HVACStatus from xknx.exceptions import ConversionError, DeviceIllegalValue from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueRead, GroupValueWrite DPT_20102_MODES = [ HVACOperationMode.AUTO, HVACOperationMode.COMFORT, HVACOperationMode.STANDBY, HVACOperationMode.ECONOMY, HVACOperationMode.BUILDING_PROTECTION, ] class TestClimate: """Test class for Climate objects.""" # # SUPPORTS TEMPERATURE / SETPOINT # def test_support_temperature(self) -> None: """Test supports_temperature flag.""" xknx = XKNX() climate = Climate(xknx, "TestClimate", group_address_temperature="1/2/3") assert climate.temperature.initialized assert not climate.target_temperature.initialized def test_support_target_temperature(self) -> None: """Test supports_target__temperature flag.""" xknx = XKNX() climate = Climate(xknx, "TestClimate", group_address_target_temperature="1/2/3") assert not climate.temperature.initialized assert climate.target_temperature.initialized def test_support_operation_mode(self) -> None: """Test supports_supports_operation_mode flag. One group address for all modes.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode="1/2/4" ) assert climate_mode.supports_operation_mode def test_support_operation_mode2(self) -> None: """Test supports_supports_operation_mode flag. Split group addresses for each mode.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode_protection="1/2/4" ) assert climate_mode.supports_operation_mode # # TEST HAS GROUP ADDRESS # def test_has_group_address(self) -> None: """Test if has_group_address function works.""" xknx = XKNX() climate = Climate( xknx, "TestClimate", group_address_temperature="1/2/1", group_address_target_temperature="1/2/2", group_address_setpoint_shift="1/2/3", group_address_setpoint_shift_state="1/2/4", group_address_on_off="1/2/11", group_address_on_off_state="1/2/12", group_address_humidity_state="1/2/16", ) assert climate.has_group_address(GroupAddress("1/2/1")) assert climate.has_group_address(GroupAddress("1/2/2")) assert climate.has_group_address(GroupAddress("1/2/4")) assert climate.has_group_address(GroupAddress("1/2/11")) assert climate.has_group_address(GroupAddress("1/2/12")) assert climate.has_group_address(GroupAddress("1/2/16")) assert not climate.has_group_address(GroupAddress("1/2/99")) # # TEST HAS GROUP ADDRESS # def test_has_group_address_mode(self) -> None: """Test if has_group_address function works.""" xknx = XKNX() climate_mode = ClimateMode( xknx, name=None, group_address_operation_mode="1/2/4", group_address_operation_mode_state="1/2/5", group_address_operation_mode_protection="1/2/6", group_address_operation_mode_economy="1/2/7", group_address_operation_mode_comfort="1/2/8", group_address_operation_mode_standby="1/2/9", group_address_controller_status="1/2/10", group_address_controller_status_state="1/2/11", group_address_controller_mode="1/2/12", group_address_controller_mode_state="1/2/13", group_address_heat_cool="1/2/14", group_address_heat_cool_state="1/2/15", ) climate = Climate(xknx, name="TestClimate", mode=climate_mode) assert climate.has_group_address(GroupAddress("1/2/4")) assert climate.has_group_address(GroupAddress("1/2/5")) assert climate.has_group_address(GroupAddress("1/2/6")) assert climate.has_group_address(GroupAddress("1/2/7")) assert climate.has_group_address(GroupAddress("1/2/8")) assert climate.has_group_address(GroupAddress("1/2/9")) assert climate.has_group_address(GroupAddress("1/2/10")) assert climate.has_group_address(GroupAddress("1/2/11")) assert climate.has_group_address(GroupAddress("1/2/12")) assert climate.has_group_address(GroupAddress("1/2/13")) assert climate.has_group_address(GroupAddress("1/2/14")) assert climate.has_group_address(GroupAddress("1/2/15")) assert not climate.has_group_address(GroupAddress("1/2/99")) # # TEST CALLBACK # async def test_process_callback(self) -> None: """Test if after_update_callback is called after update of Climate object was changed.""" xknx = XKNX() climate = Climate( xknx, "TestClimate", group_address_target_temperature="1/2/2", group_address_setpoint_shift="1/2/3", group_address_setpoint_shift_state="1/2/4", setpoint_shift_mode=SetpointShiftMode.DPT6010, ) after_update_callback = Mock() climate.register_device_updated_cb(after_update_callback) xknx.devices.async_add(climate) climate.target_temperature.set(23.00) xknx.devices.process(xknx.telegrams.get_nowait()) after_update_callback.assert_called_with(climate) after_update_callback.reset_mock() await climate.set_setpoint_shift(-2) xknx.devices.process(xknx.telegrams.get_nowait()) after_update_callback.assert_called_with(climate) after_update_callback.reset_mock() async def test_process_callback_mode(self) -> None: """Test if after_update_callback is called after update of Climate object was changed.""" xknx = XKNX() after_update_callback = Mock() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode="1/2/5", device_updated_cb=after_update_callback, ) await climate_mode.set_operation_mode(HVACOperationMode.COMFORT) after_update_callback.assert_called_with(climate_mode) after_update_callback.reset_mock() await climate_mode.set_operation_mode(HVACOperationMode.COMFORT) after_update_callback.assert_not_called() after_update_callback.reset_mock() await climate_mode.set_operation_mode(HVACOperationMode.BUILDING_PROTECTION) after_update_callback.assert_called_with(climate_mode) after_update_callback.reset_mock() async def test_process_callback_updated_via_telegram(self) -> None: """Test if after_update_callback is called after update of Climate object.""" xknx = XKNX() climate = Climate( xknx, "TestClimate", group_address_temperature="1/2/1", group_address_target_temperature="1/2/2", group_address_setpoint_shift="1/2/3", ) after_update_callback = Mock() climate.register_device_updated_cb(after_update_callback) telegram = Telegram( destination_address=GroupAddress("1/2/1"), payload=GroupValueWrite(DPTTemperature.to_knx(23)), ) climate.process(telegram) after_update_callback.assert_called_with(climate) after_update_callback.reset_mock() telegram = Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPTTemperature.to_knx(23)), ) climate.process(telegram) after_update_callback.assert_called_with(climate) after_update_callback.reset_mock() telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTValue1Count.to_knx(-4)), ) climate.process(telegram) after_update_callback.assert_called_with(climate) after_update_callback.reset_mock() async def test_climate_mode_process_callback_updated_via_telegram(self) -> None: """Test if after_update_callback is called after update of ClimateMode object.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimateMode", group_address_operation_mode="1/2/4" ) after_update_callback = Mock() climate = Climate(xknx, "TestClimate", mode=climate_mode) climate_mode.register_device_updated_cb(after_update_callback) # Note: the climate object processes the telegram, but the cb # is called with the climate_mode object. telegram = Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray(1)), ) climate.process(telegram) after_update_callback.assert_called_with(climate_mode) after_update_callback.reset_mock() # # TEST SET OPERATION MODE # async def test_set_operation_mode(self) -> None: """Test set_operation_mode.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode="1/2/4" ) for operation_mode in DPT_20102_MODES: await climate_mode.set_operation_mode(operation_mode) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueWrite(DPTHVACMode.to_knx(operation_mode)), ) async def test_set_controller_operation_mode(self) -> None: """Test set_operation_mode with DPT20.105 controller.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_controller_mode="1/2/4" ) for controller_mode in DPTHVACContrMode.get_valid_values(): await climate_mode.set_controller_mode(controller_mode) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueWrite(DPTHVACContrMode.to_knx(controller_mode)), ) async def test_set_operation_mode_not_supported(self) -> None: """Test set_operation_mode but not supported.""" xknx = XKNX() climate_mode = ClimateMode(xknx, "TestClimate") with pytest.raises(DeviceIllegalValue): await climate_mode.set_operation_mode(HVACOperationMode.AUTO) async def test_set_controller_mode_not_supported(self) -> None: """Test set_controller_mode but not supported.""" xknx = XKNX() climate_mode = ClimateMode(xknx, "TestClimate") with pytest.raises(DeviceIllegalValue): await climate_mode.set_controller_mode(HVACControllerMode.HEAT) async def test_set_operation_mode_with_controller_status(self) -> None: """Test set_operation_mode with controller status address defined.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_controller_status="1/2/4" ) # needs to be initialized before it can be sent with pytest.raises(ConversionError): await climate_mode.set_controller_mode(HVACControllerMode.HEAT) climate_mode.process( Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray((0x01,))), ) ) assert climate_mode.controller_mode == HVACControllerMode.COOL assert climate_mode.operation_mode == HVACOperationMode.COMFORT # controller mode await climate_mode.set_controller_mode(HVACControllerMode.HEAT) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray((0x21,))), ) climate_mode.process(telegram) # process to have internal value updated # operation mode await climate_mode.set_operation_mode(HVACOperationMode.STANDBY) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray((0x22,))), ) async def test_set_operation_mode_with_separate_addresses(self) -> None: """Test set_operation_mode with combined and separated group addresses defined.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode="1/2/4", group_address_operation_mode_protection="1/2/5", group_address_operation_mode_economy="1/2/6", group_address_operation_mode_comfort="1/2/7", ) await climate_mode.set_operation_mode(HVACOperationMode.COMFORT) assert xknx.telegrams.qsize() == 4 telegrams = [xknx.telegrams.get_nowait() for _ in range(4)] test_telegrams = [ Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray(1)), ), Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTBinary(False)), ), Telegram( destination_address=GroupAddress("1/2/6"), payload=GroupValueWrite(DPTBinary(False)), ), Telegram( destination_address=GroupAddress("1/2/7"), payload=GroupValueWrite(DPTBinary(True)), ), ] for test_item in test_telegrams: assert test_item in telegrams async def test_set_heat_cool_binary(self) -> None: """Test set_operation_mode with binary heat/cool group addresses defined.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_heat_cool="1/2/14", group_address_heat_cool_state="1/2/15", ) await climate_mode.set_controller_mode(HVACControllerMode.HEAT) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/14"), payload=GroupValueWrite(DPTBinary(True)), ) await climate_mode.set_controller_mode(HVACControllerMode.COOL) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/14"), payload=GroupValueWrite(DPTBinary(False)), ) async def test_set_multiple_mode(self) -> None: """Test if set operation or controller mode with multiple mode types.""" xknx = XKNX() climate_mode = ClimateMode( xknx, name=None, group_address_operation_mode="1/2/4", group_address_operation_mode_state="1/2/5", group_address_operation_mode_protection="1/2/6", group_address_operation_mode_economy="1/2/7", group_address_operation_mode_comfort="1/2/8", group_address_operation_mode_standby="1/2/9", group_address_controller_status="1/2/10", group_address_controller_status_state="1/2/11", group_address_controller_mode="1/2/12", group_address_controller_mode_state="1/2/13", group_address_heat_cool="1/2/14", group_address_heat_cool_state="1/2/15", ) def _process_all_telegrams() -> None: """Process all telegrams in the queue in ClimateMode device.""" for _ in range(xknx.telegrams.qsize()): telegram = xknx.telegrams.get_nowait() climate_mode.process(telegram) # HVACStatus needs to be initialized before it can be sent climate_mode.process( Telegram( destination_address=GroupAddress("1/2/10"), payload=GroupValueWrite(DPTArray((0b10000000,))), ) ) await climate_mode.set_controller_mode(HVACControllerMode.HEAT) assert xknx.telegrams.qsize() == 3 _process_all_telegrams() await climate_mode.set_operation_mode(HVACOperationMode.COMFORT) assert xknx.telegrams.qsize() == 6 _process_all_telegrams() await climate_mode.set_controller_mode(HVACControllerMode.NODEM) assert xknx.telegrams.qsize() == 1 # only supported by controller mode _process_all_telegrams() assert climate_mode.operation_mode == HVACOperationMode.COMFORT assert climate_mode.controller_mode == HVACControllerMode.NODEM await climate_mode.set_controller_mode(HVACControllerMode.COOL) assert xknx.telegrams.qsize() == 3 _process_all_telegrams() await climate_mode.set_operation_mode(HVACOperationMode.ECONOMY) assert xknx.telegrams.qsize() == 6 _process_all_telegrams() assert climate_mode.operation_mode == HVACOperationMode.ECONOMY assert climate_mode.controller_mode == HVACControllerMode.COOL # # TEST initialized_for_setpoint_shift_calculations # async def test_initialized_for_setpoint_shift_calculations(self) -> None: """Test initialized_for_setpoint_shift_calculations method.""" xknx = XKNX() climate1 = Climate(xknx, "TestClimate") xknx.devices.async_add(climate1) assert not climate1.initialized_for_setpoint_shift_calculations climate2 = Climate( xknx, "TestClimate", group_address_setpoint_shift="1/2/3", setpoint_shift_mode=SetpointShiftMode.DPT6010, ) xknx.devices.async_add(climate2) assert not climate2.initialized_for_setpoint_shift_calculations await climate2.set_setpoint_shift(4) xknx.devices.process(xknx.telegrams.get_nowait()) assert not climate2.initialized_for_setpoint_shift_calculations climate3 = Climate( xknx, "TestClimate", group_address_target_temperature="1/2/2", group_address_setpoint_shift="1/2/3", setpoint_shift_mode=SetpointShiftMode.DPT6010, ) xknx.devices.async_add(climate3) await climate3.set_setpoint_shift(4) xknx.devices.process(xknx.telegrams.get_nowait()) assert not climate3.initialized_for_setpoint_shift_calculations climate3.target_temperature.set(23.00) xknx.devices.process(xknx.telegrams.get_nowait()) assert climate3.initialized_for_setpoint_shift_calculations async def test_setpoint_shift_mode_autosensing(self) -> None: """Test autosensing setpoint_shift_mode.""" xknx = XKNX() climate_dpt6 = Climate( xknx, "TestClimate", group_address_temperature="1/2/1", group_address_target_temperature_state="1/2/2", group_address_setpoint_shift="1/2/3", ) climate_dpt6.target_temperature.value = 23.00 # uninitialized with pytest.raises(ConversionError): await climate_dpt6.set_setpoint_shift(1) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTValue1Count.to_knx(-4)), ) climate_dpt6.process(telegram) assert climate_dpt6.initialized_for_setpoint_shift_calculations await climate_dpt6.set_setpoint_shift(1) _telegram = xknx.telegrams.get_nowait() # DPTValue1Count is used for outgoing assert _telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(10)), # 1 / 0.1 setpoint_shift_step ) climate_dpt9 = Climate( xknx, "TestClimate", group_address_temperature="1/2/1", group_address_target_temperature_state="1/2/2", group_address_setpoint_shift="1/2/3", ) climate_dpt9.target_temperature.value = 23.00 # uninitialized with pytest.raises(ConversionError): await climate_dpt9.set_setpoint_shift(1) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTTemperature.to_knx(-4)), ) climate_dpt9.process(telegram) assert climate_dpt9.initialized_for_setpoint_shift_calculations await climate_dpt9.set_setpoint_shift(1) _telegram = xknx.telegrams.get_nowait() # DPTTemperature is used for outgoing assert _telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTTemperature.to_knx(1)), ) # # TEST for uninitialized target_temperature_min/target_temperature_max # def test_uninitalized_for_target_temperature_min_max(self) -> None: """Test if target_temperature_min/target_temperature_max return non if not initialized.""" xknx = XKNX() climate = Climate(xknx, "TestClimate") assert climate.target_temperature_min is None assert climate.target_temperature_max is None # # TEST for uninitialized target_temperature_min/target_temperature_max but with overridden max and min temperature # def test_uninitalized_for_target_temperature_min_max_can_be_overridden( self, ) -> None: """Test if target_temperature_min/target_temperature_max return overridden value if specified.""" xknx = XKNX() climate = Climate(xknx, "TestClimate", min_temp="7", max_temp="35") assert climate.target_temperature_min == "7" assert climate.target_temperature_max == "35" # # TEST for overridden max and min temp do have precedence over setpoint shift calculations # async def test_overridden_max_min_temperature_has_priority(self) -> None: """Test that the overridden min and max temp always have precedence over setpoint shift calculations.""" xknx = XKNX() climate = Climate( xknx, "TestClimate", group_address_target_temperature="1/2/2", group_address_setpoint_shift="1/2/3", setpoint_shift_mode=SetpointShiftMode.DPT6010, max_temp="42", min_temp="3", ) xknx.devices.async_add(climate) await climate.set_setpoint_shift(4) xknx.devices.process(xknx.telegrams.get_nowait()) assert not climate.initialized_for_setpoint_shift_calculations climate.target_temperature.set(23.00) xknx.devices.process(xknx.telegrams.get_nowait()) assert climate.initialized_for_setpoint_shift_calculations assert climate.target_temperature_min == "3" assert climate.target_temperature_max == "42" # # TEST TARGET TEMPERATURE # async def test_target_temperature_up(self) -> None: """Test increase target temperature.""" xknx = XKNX() climate = Climate( xknx, "TestClimate", group_address_target_temperature="1/2/2", group_address_setpoint_shift="1/2/3", setpoint_shift_mode=SetpointShiftMode.DPT6010, ) xknx.devices.async_add(climate) await climate.set_setpoint_shift(3) assert xknx.telegrams.qsize() == 1 _telegram = xknx.telegrams.get_nowait() # DEFAULT_TEMPERATURE_STEP is 0.1 -> payload = setpoint_shift * 10 assert _telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(30)), ) xknx.devices.process(_telegram) climate.target_temperature.set(23.00) assert xknx.telegrams.qsize() == 1 _telegram = xknx.telegrams.get_nowait() assert _telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPT2ByteFloat().to_knx(23.00)), ) xknx.devices.process(_telegram) assert climate.base_temperature == 20 # First change await climate.set_target_temperature(24.00) assert xknx.telegrams.qsize() == 2 _telegram = xknx.telegrams.get_nowait() assert _telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(40)), ) xknx.devices.process(_telegram) _telegram = xknx.telegrams.get_nowait() assert _telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPT2ByteFloat().to_knx(24.00)), ) xknx.devices.process(_telegram) assert climate.target_temperature.value == 24.00 # Second change await climate.set_target_temperature(23.50) assert xknx.telegrams.qsize() == 2 _telegram = xknx.telegrams.get_nowait() assert _telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(35)), ) xknx.devices.process(_telegram) _telegram = xknx.telegrams.get_nowait() assert _telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPT2ByteFloat().to_knx(23.50)), ) xknx.devices.process(_telegram) assert climate.target_temperature.value == 23.50 # Test max target temperature # Base (20) - setpoint_shift_max (6) assert climate.target_temperature_max == 26.00 # third change - limit exceeded, setting to max await climate.set_target_temperature(26.50) assert xknx.telegrams.qsize() == 2 xknx.devices.process(xknx.telegrams.get_nowait()) xknx.devices.process(xknx.telegrams.get_nowait()) assert climate.target_temperature_max == 26.00 assert climate.setpoint_shift == 6 async def test_target_temperature_down(self) -> None: """Test decrease target temperature.""" xknx = XKNX() climate = Climate( xknx, "TestClimate", group_address_target_temperature="1/2/2", group_address_setpoint_shift="1/2/3", setpoint_shift_mode=SetpointShiftMode.DPT6010, ) xknx.devices.async_add(climate) await climate.set_setpoint_shift(1) assert xknx.telegrams.qsize() == 1 _telegram = xknx.telegrams.get_nowait() # DEFAULT_TEMPERATURE_STEP is 0.1 -> payload = setpoint_shift * 10 assert _telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(10)), ) xknx.devices.process(_telegram) climate.target_temperature.set(23.00) assert xknx.telegrams.qsize() == 1 _telegram = xknx.telegrams.get_nowait() assert _telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPT2ByteFloat().to_knx(23.00)), ) xknx.devices.process(_telegram) assert climate.base_temperature == 22.0 # First change await climate.set_target_temperature(20.50) assert xknx.telegrams.qsize() == 2 _telegram = xknx.telegrams.get_nowait() assert _telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(0xF1)), ) # -15 xknx.devices.process(_telegram) _telegram = xknx.telegrams.get_nowait() assert _telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPT2ByteFloat().to_knx(20.50)), ) xknx.devices.process(_telegram) assert climate.target_temperature.value == 20.50 # Second change await climate.set_target_temperature(19.00) assert xknx.telegrams.qsize() == 2 _telegram = xknx.telegrams.get_nowait() assert _telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(0xE2)), ) # -30 xknx.devices.process(_telegram) _telegram = xknx.telegrams.get_nowait() assert _telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPT2ByteFloat().to_knx(19.00)), ) xknx.devices.process(_telegram) assert climate.target_temperature.value == 19.00 # Test min target temperature # Base (22) - setpoint_shift_min (6) assert climate.target_temperature_min == 16.00 # third change - limit exceeded, setting to min await climate.set_target_temperature(15.50) assert xknx.telegrams.qsize() == 2 xknx.devices.process(xknx.telegrams.get_nowait()) xknx.devices.process(xknx.telegrams.get_nowait()) assert climate.target_temperature_min == 16.00 assert climate.setpoint_shift == -6 async def test_target_temperature_modified_step(self) -> None: """Test increase target temperature with modified step size.""" xknx = XKNX() climate = Climate( xknx, "TestClimate", group_address_target_temperature="1/2/2", group_address_setpoint_shift="1/2/3", setpoint_shift_mode=SetpointShiftMode.DPT6010, temperature_step=0.5, setpoint_shift_max=10, setpoint_shift_min=-10, ) xknx.devices.async_add(climate) await climate.set_setpoint_shift(3) assert xknx.telegrams.qsize() == 1 _telegram = xknx.telegrams.get_nowait() xknx.devices.process(_telegram) # temperature_step is 0.5 -> payload = setpoint_shift * 2 assert _telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(6)), ) climate.target_temperature.set(23.00) assert xknx.telegrams.qsize() == 1 _telegram = xknx.telegrams.get_nowait() xknx.devices.process(_telegram) assert _telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPT2ByteFloat().to_knx(23.00)), ) assert climate.base_temperature == 20.00 await climate.set_target_temperature(24.00) assert xknx.telegrams.qsize() == 2 _telegram = xknx.telegrams.get_nowait() xknx.devices.process(_telegram) assert _telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(8)), ) _telegram = xknx.telegrams.get_nowait() xknx.devices.process(_telegram) assert _telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPT2ByteFloat().to_knx(24.00)), ) assert climate.target_temperature.value == 24.00 # Test max/min target temperature assert climate.target_temperature_max == 30.00 assert climate.target_temperature_min == 10.00 # # TEST BASE TEMPERATURE # async def test_base_temperature(self) -> None: """Test base temperature.""" xknx = XKNX() climate = Climate( xknx, "TestClimate", group_address_target_temperature_state="1/2/1", group_address_target_temperature="1/2/2", group_address_setpoint_shift="1/2/3", setpoint_shift_mode=SetpointShiftMode.DPT6010, ) xknx.devices.async_add(climate) await climate.set_target_temperature(21.00) assert xknx.telegrams.qsize() == 1 _telegram = xknx.telegrams.get_nowait() xknx.devices.process(_telegram) assert _telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPT2ByteFloat().to_knx(21.00)), ) assert not climate.initialized_for_setpoint_shift_calculations assert climate.base_temperature is None # setpoint_shift initialized after target_temperature (no temperature change) await climate.set_setpoint_shift(1) assert xknx.telegrams.qsize() == 1 _telegram = xknx.telegrams.get_nowait() xknx.devices.process(_telegram) # DEFAULT_TEMPERATURE_STEP is 0.1 -> payload = setpoint_shift * 10 assert _telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(10)), ) assert climate.initialized_for_setpoint_shift_calculations assert climate.base_temperature == 20.00 # setpoint_shift changed after initialisation await climate.set_setpoint_shift(2) # setpoint_shift and target_temperature are sent to the bus assert xknx.telegrams.qsize() == 2 _telegram = xknx.telegrams.get_nowait() xknx.devices.process(_telegram) # DEFAULT_TEMPERATURE_STEP is 0.1 -> payload = setpoint_shift * 10 assert _telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(20)), ) _telegram = xknx.telegrams.get_nowait() xknx.devices.process(_telegram) assert climate.initialized_for_setpoint_shift_calculations assert climate.base_temperature == 20.00 assert climate.target_temperature.value == 22 async def test_target_temperature_step_mode_9002(self) -> None: """Test increase target temperature with modified step size.""" xknx = XKNX() climate = Climate( xknx, "TestClimate", group_address_target_temperature_state="1/2/2", group_address_setpoint_shift="1/2/3", setpoint_shift_mode=SetpointShiftMode.DPT9002, setpoint_shift_max=10, setpoint_shift_min=-10, ) xknx.devices.async_add(climate) # base temperature is 20 °C climate.target_temperature.process( Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPT2ByteFloat().to_knx(20.00)), ) ) await climate.set_setpoint_shift(0) assert xknx.telegrams.qsize() == 1 _telegram = xknx.telegrams.get_nowait() xknx.devices.process(_telegram) assert climate.initialized_for_setpoint_shift_calculations assert climate.base_temperature == 20.00 assert _telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x00, 0x00))), ) # 0 # - 0.6 °C = 19.4 await climate.set_target_temperature(19.40) assert xknx.telegrams.qsize() == 1 _telegram = xknx.telegrams.get_nowait() xknx.devices.process(_telegram) assert _telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x87, 0xC4))), ) # -0.6 # simulate incoming new target temperature for next calculation climate.target_temperature.process( Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPT2ByteFloat().to_knx(19.40)), ) ) # + 3.5 °C = 23.5 await climate.set_target_temperature(23.50) assert xknx.telegrams.qsize() == 1 _telegram = xknx.telegrams.get_nowait() xknx.devices.process(_telegram) assert _telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x01, 0x5E))), ) # +3.5 # # TEST TEMPERATURE STEP # async def test_temperature_step(self) -> None: """Test base temperature step.""" xknx = XKNX() climate = Climate( xknx, "TestClimate", group_address_target_temperature_state="1/2/1", group_address_target_temperature="1/2/2", ) await climate.set_target_temperature(21.00) # default temperature_step for non setpoint_shift assert climate.temperature_step == 0.1 climate = Climate( xknx, "TestClimate", group_address_target_temperature_state="1/2/1", group_address_target_temperature="1/2/2", group_address_setpoint_shift="1/2/3", ) # default temperature_step for setpoint_shift assert climate.temperature_step == 0.1 climate = Climate( xknx, "TestClimate", group_address_target_temperature_state="1/2/1", group_address_target_temperature="1/2/2", group_address_setpoint_shift="1/2/3", temperature_step=0.3, ) assert climate.temperature_step == 0.3 # # TEST SYNC # async def test_sync(self) -> None: """Test sync function / sending group reads to KNX bus.""" xknx = XKNX() climate = Climate(xknx, "TestClimate", group_address_temperature="1/2/3") await climate.sync() assert xknx.telegrams.qsize() == 1 telegram1 = xknx.telegrams.get_nowait() assert telegram1 == Telegram(GroupAddress("1/2/3"), payload=GroupValueRead()) async def test_sync_operation_mode(self) -> None: """Test sync function / sending group reads to KNX bus for operation mode.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode="1/2/3", group_address_operation_mode_state="1/2/4", ) await climate_mode.sync() assert xknx.telegrams.qsize() == 1 telegram1 = xknx.telegrams.get_nowait() assert telegram1 == Telegram(GroupAddress("1/2/4"), payload=GroupValueRead()) async def test_sync_controller_status(self) -> None: """Test sync function / sending group reads to KNX bus for controller status.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode="1/2/23", group_address_controller_status_state="1/2/24", ) await climate_mode.sync() assert xknx.telegrams.qsize() == 1 telegram1 = xknx.telegrams.get_nowait() assert telegram1 == Telegram(GroupAddress("1/2/24"), payload=GroupValueRead()) async def test_sync_controller_mode(self) -> None: """Test sync function / sending group reads to KNX bus for controller mode.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_controller_mode="1/2/13", group_address_controller_mode_state="1/2/14", ) await climate_mode.sync() assert xknx.telegrams.qsize() == 1 telegram1 = xknx.telegrams.get_nowait() assert telegram1 == Telegram(GroupAddress("1/2/14"), payload=GroupValueRead()) async def test_sync_operation_mode_state(self) -> None: """Test sync function / sending group reads to KNX bus for multiple mode addresses.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode="1/2/3", group_address_operation_mode_state="1/2/5", group_address_controller_status="1/2/4", group_address_controller_status_state="1/2/6", group_address_controller_mode="1/2/13", group_address_controller_mode_state="1/2/14", ) await climate_mode.sync() assert xknx.telegrams.qsize() == 3 telegrams = [xknx.telegrams.get_nowait() for _ in range(3)] assert telegrams == [ Telegram(GroupAddress("1/2/5"), payload=GroupValueRead()), Telegram(GroupAddress("1/2/14"), payload=GroupValueRead()), Telegram(GroupAddress("1/2/6"), payload=GroupValueRead()), ] async def test_sync_heat_cool(self) -> None: """Test sync function / sending group reads to KNX bus for heat/cool.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_heat_cool="1/2/14", group_address_heat_cool_state="1/2/15", ) await climate_mode.sync() assert xknx.telegrams.qsize() == 1 telegram1 = xknx.telegrams.get_nowait() assert telegram1 == Telegram(GroupAddress("1/2/15"), payload=GroupValueRead()) async def test_sync_mode_from_climate(self) -> None: """Test sync function / propagating to mode.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimateMode", group_address_operation_mode_state="1/2/4" ) climate = Climate(xknx, "TestClimate", mode=climate_mode) await climate.sync() assert xknx.telegrams.qsize() == 1 telegram1 = xknx.telegrams.get_nowait() assert telegram1 == Telegram(GroupAddress("1/2/4"), payload=GroupValueRead()) async def test_sync_humidity(self) -> None: """Test sync function / sending group reads to KNX bus for humidity.""" xknx = XKNX() climate = Climate(xknx, "TestClimate", group_address_humidity_state="1/2/16") await climate.sync() assert xknx.telegrams.qsize() == 1 telegram1 = xknx.telegrams.get_nowait() assert telegram1 == Telegram(GroupAddress("1/2/16"), payload=GroupValueRead()) # # TEST PROCESS # async def test_process_temperature(self) -> None: """Test process / reading telegrams from telegram queue. Test if temperature is processed correctly.""" xknx = XKNX() climate = Climate(xknx, "TestClimate", group_address_temperature="1/2/3") telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTTemperature().to_knx(21.34)), ) climate.process(telegram) assert climate.temperature.value == 21.34 async def test_process_operation_mode(self) -> None: """Test process / reading telegrams from telegram queue. Test if operation mode is processed correctly.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode="1/2/5", group_address_controller_status="1/2/3", ) for operation_mode in DPT_20102_MODES: telegram = Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTHVACMode.to_knx(operation_mode)), ) climate_mode.process(telegram) assert climate_mode.operation_mode == operation_mode for operation_mode in DPT_20102_MODES: if operation_mode == HVACOperationMode.AUTO: continue telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite( DPTHVACStatus.to_knx( HVACStatus( mode=operation_mode, dew_point=False, heat_cool=HVACControllerMode.HEAT, inactive=False, frost_alarm=False, ) ) ), ) climate_mode.process(telegram) assert climate_mode.operation_mode == operation_mode async def test_process_controller_mode(self) -> None: """Test process / reading telegrams from telegram queue. Test if DPT20.105 controller mode is set correctly.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_controller_mode="1/2/5" ) for controller_mode in DPTHVACContrMode.get_valid_values(): telegram = Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTHVACContrMode.to_knx(controller_mode)), ) climate_mode.process(telegram) assert climate_mode.controller_mode == controller_mode async def test_process_controller_status_wrong_payload(self) -> None: """Test process wrong telegram for controller status (wrong payload type).""" xknx = XKNX() updated_cb = Mock() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode="1/2/5", group_address_controller_status="1/2/3", device_updated_cb=updated_cb, ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) with patch("logging.Logger.warning") as log_mock: climate_mode.process(telegram) log_mock.assert_called_once() updated_cb.assert_not_called() async def test_process_controller_status_payload_invalid_length(self) -> None: """Test process wrong telegram for controller status (wrong payload length).""" xknx = XKNX() updated_cb = Mock() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode="1/2/5", group_address_controller_status="1/2/3", device_updated_cb=updated_cb, ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((23, 24))), ) with patch("logging.Logger.warning") as log_mock: climate_mode.process(telegram) log_mock.assert_called_once() updated_cb.assert_not_called() async def test_process_operation_mode_wrong_payload(self) -> None: """Test process wrong telegram for operation mode (wrong payload type).""" xknx = XKNX() updated_cb = Mock() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode="1/2/5", group_address_controller_status="1/2/3", device_updated_cb=updated_cb, ) telegram = Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTBinary(1)), ) with patch("logging.Logger.warning") as log_mock: climate_mode.process(telegram) log_mock.assert_called_once() updated_cb.assert_not_called() async def test_process_operation_mode_payload_invalid_length(self) -> None: """Test process wrong telegram for operation mode (wrong payload length).""" xknx = XKNX() updated_cb = Mock() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode="1/2/5", group_address_controller_status="1/2/3", device_updated_cb=updated_cb, ) telegram = Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray((23, 24))), ) with patch("logging.Logger.warning") as log_mock: climate_mode.process(telegram) log_mock.assert_called_once() updated_cb.assert_not_called() async def test_process_callback_temp(self) -> None: """Test process / reading telegrams from telegram queue. Test if callback is executed when receiving temperature.""" xknx = XKNX() climate = Climate(xknx, "TestClimate", group_address_temperature="1/2/3") after_update_callback = Mock() climate.register_device_updated_cb(after_update_callback) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTTemperature().to_knx(21.34)), ) climate.process(telegram) after_update_callback.assert_called_with(climate) async def test_process_heat_cool(self) -> None: """Test process / reading telegrams from telegram queue. Test if heat/cool is set correctly.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode="i-op-mode", group_address_heat_cool="1/2/14", group_address_heat_cool_state="1/2/15", ) telegram = Telegram( destination_address=GroupAddress("1/2/14"), payload=GroupValueWrite(DPTBinary(False)), ) climate_mode.process(telegram) assert climate_mode.controller_mode == HVACControllerMode.COOL telegram = Telegram( destination_address=GroupAddress("1/2/14"), payload=GroupValueWrite(DPTBinary(True)), ) climate_mode.process(telegram) assert climate_mode.controller_mode == HVACControllerMode.HEAT async def test_process_humidity(self) -> None: """Test process / reading telegrams from telegram queue. Test if humidity is processed correctly.""" xknx = XKNX() climate = Climate(xknx, "TestClimate", group_address_humidity_state="1/2/16") after_update_callback = Mock() climate.register_device_updated_cb(after_update_callback) telegram = Telegram( destination_address=GroupAddress("1/2/16"), payload=GroupValueWrite(DPTHumidity.to_knx(45.6)), ) climate.process(telegram) assert climate.humidity.value == 45.6 after_update_callback.assert_called_with(climate) # # SUPPORTED OPERATION MODES # def test_supported_operation_modes(self) -> None: """Test get_supported_operation_modes with combined group address.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode="1/2/5" ) assert set(climate_mode.operation_modes) == { HVACOperationMode.AUTO, HVACOperationMode.COMFORT, HVACOperationMode.STANDBY, HVACOperationMode.ECONOMY, HVACOperationMode.BUILDING_PROTECTION, } def test_supported_modes_controller_status(self) -> None: """Test supported modes with HVAC status group address.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_controller_status="1/2/5" ) assert set(climate_mode.operation_modes) == { HVACOperationMode.COMFORT, HVACOperationMode.STANDBY, HVACOperationMode.ECONOMY, HVACOperationMode.BUILDING_PROTECTION, } assert set(climate_mode.controller_modes) == { HVACControllerMode.HEAT, HVACControllerMode.COOL, } def test_supported_operation_modes_no_mode(self) -> None: """Test get_supported_operation_modes no operation_modes supported.""" xknx = XKNX() climate_mode = ClimateMode(xknx, "TestClimate") assert not climate_mode.operation_modes def test_supported_operation_modes_with_separate_addresses(self) -> None: """Test get_supported_operation_modes with separated group addresses.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode_protection="1/2/5", group_address_operation_mode_economy="1/2/6", group_address_operation_mode_comfort="1/2/7", ) assert set(climate_mode.operation_modes) == { HVACOperationMode.COMFORT, HVACOperationMode.ECONOMY, HVACOperationMode.BUILDING_PROTECTION, HVACOperationMode.STANDBY, } def test_supported_operation_modes_only_economy(self) -> None: """Test get_supported_operation_modes with only economy mode supported.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode_economy="1/2/7" ) # If one binary climate object is set, this mode and Standby are supported. # All binary modes off -> Standby according to MDT heating actuator assert set(climate_mode.operation_modes) == { HVACOperationMode.ECONOMY, HVACOperationMode.STANDBY, } def test_supported_operation_modes_heat_cool(self) -> None: """Test supported controller_modes with heat_cool address.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_heat_cool="1/2/14", group_address_heat_cool_state="1/2/15", ) assert climate_mode.supports_controller_mode assert set(climate_mode.controller_modes) == { HVACControllerMode.HEAT, HVACControllerMode.COOL, } def test_supported_operation_modes_heat_cool_read_only(self) -> None: """Test supported controller_modes with heat_cool_state address.""" xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_heat_cool_state="1/2/15", ) assert climate_mode.supports_controller_mode assert climate_mode.controller_modes == [] # only writable modes assert set(climate_mode.gather_controller_modes(only_writable=False)) == { HVACControllerMode.HEAT, HVACControllerMode.COOL, } def test_custom_supported_operation_modes(self) -> None: """Test get_supported_operation_modes with custom mode.""" modes = [HVACOperationMode.STANDBY, HVACOperationMode.ECONOMY] xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode="1/2/7", operation_modes=modes, ) assert climate_mode.operation_modes == modes def test_custom_supported_operation_modes_as_str(self) -> None: """Test get_supported_operation_modes with custom mode as str list.""" str_modes = ["Standby", "Building Protection"] modes = [HVACOperationMode.STANDBY, HVACOperationMode.BUILDING_PROTECTION] xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode="1/2/7", operation_modes=str_modes, ) assert climate_mode.operation_modes == modes def test_custom_supported_operation_modes_only_valid(self) -> None: """Test get_supported_operation_modes with custom mode as str list.""" str_modes = [ "Standby", "Building Protection", "Comfort", # Comfort can't be set - no address set ] modes = [HVACOperationMode.STANDBY, HVACOperationMode.BUILDING_PROTECTION] xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_operation_mode_standby="1/2/7", group_address_operation_mode_protection="1/2/8", operation_modes=str_modes, ) assert climate_mode.operation_modes == modes def test_custom_supported_controller_modes_as_str(self) -> None: """Test get_supported_operation_modes with custom mode as str list.""" str_modes = ["Heat", "Cool", HVACControllerMode.NODEM] modes = [ HVACControllerMode.HEAT, HVACControllerMode.COOL, HVACControllerMode.NODEM, ] xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", group_address_controller_mode="1/2/7", controller_modes=str_modes, ) assert climate_mode.controller_modes == modes def test_custom_supported_controller_modes_when_controller_mode_unsupported( self, ) -> None: """Test get_supported_operation_modes with custom mode as str list.""" str_modes = ["Heat", "Cool"] modes = [] xknx = XKNX() climate_mode = ClimateMode( xknx, "TestClimate", controller_modes=str_modes, ) assert climate_mode.controller_modes == modes async def test_process_power_status(self) -> None: """Test process / reading telegrams from telegram queue. Test if DPT20.105 controller mode is set correctly.""" xknx = XKNX() climate = Climate(xknx, "TestClimate", group_address_on_off="1/2/2") telegram = Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPTBinary(1)), ) climate.process(telegram) assert climate.is_on is True climate_inv = Climate( xknx, "TestClimate", group_address_on_off="1/2/2", on_off_invert=True ) telegram = Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPTBinary(1)), ) climate_inv.process(telegram) assert climate_inv.is_on is False async def test_power_on_off(self) -> None: """Test turn_on and turn_off functions.""" xknx = XKNX() climate = Climate(xknx, "TestClimate", group_address_on_off="1/2/2") xknx.devices.async_add(climate) await climate.turn_on() _telegram = xknx.telegrams.get_nowait() xknx.devices.process(_telegram) assert climate.is_on is True assert _telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPTBinary(True)), ) await climate.turn_off() _telegram = xknx.telegrams.get_nowait() xknx.devices.process(_telegram) assert climate.is_on is False assert _telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPTBinary(False)), ) climate_inv = Climate( xknx, "TestClimate", group_address_on_off="1/2/2", on_off_invert=True ) xknx.devices.async_add(climate_inv) await climate_inv.turn_on() _telegram = xknx.telegrams.get_nowait() xknx.devices.process(_telegram) assert climate_inv.is_on is True assert _telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPTBinary(False)), ) await climate_inv.turn_off() _telegram = xknx.telegrams.get_nowait() xknx.devices.process(_telegram) assert climate_inv.is_on is False assert _telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPTBinary(True)), ) async def test_is_active(self) -> None: """Test is_active property.""" xknx = XKNX() climate_active = Climate( xknx, "TestClimate1", group_address_active_state="1/1/1" ) xknx.devices.async_add(climate_active) climate_command = Climate( xknx, "TestClimate2", group_address_command_value_state="2/2/2" ) xknx.devices.async_add(climate_command) climate_active_command = Climate( xknx, "TestClimate3", group_address_active_state="1/1/1", group_address_command_value_state="2/2/2", ) xknx.devices.async_add(climate_active_command) assert climate_active.is_active is None assert climate_command.is_active is None assert climate_active_command.is_active is None # set active to False xknx.devices.process( Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueWrite(DPTBinary(False)), ) ) assert climate_active.is_active is False assert climate_command.is_active is None assert climate_active_command.is_active is False # set command to 50% xknx.devices.process( Telegram( destination_address=GroupAddress("2/2/2"), payload=GroupValueWrite(DPTArray((128,))), ) ) assert climate_active.is_active is False assert climate_command.is_active is True assert climate_active_command.is_active is False # set active to True xknx.devices.process( Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueWrite(DPTBinary(True)), ) ) assert climate_active.is_active is True assert climate_command.is_active is True assert climate_active_command.is_active is True # set command to 0% xknx.devices.process( Telegram( destination_address=GroupAddress("2/2/2"), payload=GroupValueWrite(DPTArray((0,))), ) ) assert climate_active.is_active is True assert climate_command.is_active is False assert climate_active_command.is_active is True # only command initialized climate_active_command.active.value = None assert climate_active_command.is_active is False async def test_fan_speed(self) -> None: """Test fan speed functionality.""" xknx = XKNX() climate_step = Climate( xknx, name="TestClimate", group_address_fan_speed="1/2/3", group_address_fan_speed_state="1/2/4", fan_speed_mode=FanSpeedMode.STEP, ) xknx.devices.async_add(climate_step) climate_percent = Climate( xknx, name="TestClimate", group_address_fan_speed="1/2/5", group_address_fan_speed_state="1/2/6", ) assert climate_percent.fan_speed_mode == FanSpeedMode.PERCENT xknx.devices.async_add(climate_percent) # Test initial state assert climate_step.current_fan_speed is None assert climate_percent.current_fan_speed is None xknx.devices.process( Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(2)), ) ) assert climate_step.current_fan_speed == 2 # 140 is 55% as byte (0...255) xknx.devices.process( Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray(140)), ) ) assert climate_percent.current_fan_speed == 55 await climate_step.set_fan_speed(3) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(3)), ) await climate_percent.set_fan_speed(45) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() # 115 is 45% as byte (0...255) assert telegram == Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray(115)), ) xknx.devices.process( Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray(2)), ) ) assert climate_step.current_fan_speed == 2 # 140 is 55% as byte (0...255) xknx.devices.process( Telegram( destination_address=GroupAddress("1/2/6"), payload=GroupValueWrite(DPTArray(140)), ) ) assert climate_percent.current_fan_speed == 55 async def test_swing(self) -> None: """Test fan speed functionality.""" xknx = XKNX() climate_swing = Climate( xknx, name="TestClimate", group_address_swing="1/2/3", group_address_swing_state="1/2/4", ) xknx.devices.async_add(climate_swing) # Test initial state assert climate_swing.current_swing is None xknx.devices.process( Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(True)), ) ) assert climate_swing.current_swing is True await climate_swing.set_swing(False) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(False)), ) async def test_horizontal_swing(self) -> None: """Test fan speed functionality.""" xknx = XKNX() climate_horizontal_swing = Climate( xknx, name="TestClimate", group_address_horizontal_swing="1/2/3", group_address_horizontal_swing_state="1/2/4", ) xknx.devices.async_add(climate_horizontal_swing) # Test initial state assert climate_horizontal_swing.current_horizontal_swing is None xknx.devices.process( Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(True)), ) ) assert climate_horizontal_swing.current_horizontal_swing is True await climate_horizontal_swing.set_horizontal_swing(False) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(False)), ) xknx-3.6.0/test/devices_tests/cover_test.py000066400000000000000000001142261475530762600211070ustar00rootroot00000000000000"""Unit test for Cover objects.""" from unittest.mock import AsyncMock, Mock, patch from xknx import XKNX from xknx.devices import Cover from xknx.dpt import DPTArray, DPTBinary from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueRead, GroupValueWrite from ..conftest import EventLoopClockAdvancer class TestCover: """Test class for Cover objects.""" # # SUPPORTS STOP/POSITION/ANGLE # def test_supports_stop_true(self) -> None: """Test support_position_true.""" xknx = XKNX() cover_short_stop = Cover( xknx, "Children.Venetian", group_address_long="1/4/14", group_address_short="1/4/15", ) assert cover_short_stop.supports_stop cover_manual_stop = Cover( xknx, "Children.Venetian", group_address_long="1/4/14", group_address_stop="1/4/15", ) assert cover_manual_stop.supports_stop async def test_supports_stop_false(self) -> None: """Test support_position_true.""" xknx = XKNX() cover = Cover( xknx, "Children.Venetian", group_address_long="1/4/14", group_address_position="1/4/16", group_address_angle="1/4/18", ) assert not cover.supports_stop with patch("logging.Logger.warning") as mock_warn: await cover.stop() assert xknx.telegrams.qsize() == 0 mock_warn.assert_called_with( "Stop not supported for device %s", "Children.Venetian" ) def test_supports_position_true(self) -> None: """Test support_position_true.""" xknx = XKNX() cover = Cover( xknx, "Children.Venetian", group_address_long="1/4/14", group_address_short="1/4/15", group_address_position="1/4/16", ) assert cover.supports_position def test_supports_position_false(self) -> None: """Test support_position_true.""" xknx = XKNX() cover = Cover( xknx, "Children.Venetian", group_address_long="1/4/14", group_address_short="1/4/15", ) assert not cover.supports_position def test_supports_angle_true(self) -> None: """Test support_position_true.""" xknx = XKNX() cover = Cover( xknx, "Children.Venetian", group_address_long="1/4/14", group_address_short="1/4/15", group_address_angle="1/4/18", ) assert cover.supports_angle def test_support_angle_false(self) -> None: """Test support_position_true.""" xknx = XKNX() cover = Cover( xknx, "Children.Venetian", group_address_long="1/4/14", group_address_short="1/4/15", ) assert not cover.supports_angle # # SUPPORTS LOCKED # def test_support_locked(self) -> None: """Test support_position_true.""" xknx = XKNX() cover_locked = Cover( xknx, "Children.Venetian", group_address_locked_state="1/4/14", ) assert cover_locked.supports_locked cover_manual_stop = Cover( xknx, "Children.Venetian", group_address_long="1/4/14", group_address_stop="1/4/15", ) assert cover_manual_stop.supports_locked is False # # SYNC # async def test_sync(self) -> None: """Test sync function / sending group reads to KNX bus.""" xknx = XKNX() cover = Cover( xknx, "TestCoverSync", group_address_long="1/2/1", group_address_short="1/2/2", group_address_position_state="1/2/3", ) await cover.sync() assert xknx.telegrams.qsize() == 1 telegram1 = xknx.telegrams.get_nowait() assert telegram1 == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueRead() ) async def test_sync_state(self) -> None: """Test sync function with explicit state address.""" xknx = XKNX() cover = Cover( xknx, "TestCoverSyncState", group_address_long="1/2/1", group_address_short="1/2/2", group_address_position="1/2/3", group_address_position_state="1/2/4", ) await cover.sync() assert xknx.telegrams.qsize() == 1 telegram1 = xknx.telegrams.get_nowait() assert telegram1 == Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueRead() ) async def test_sync_angle(self) -> None: """Test sync function for cover with angle.""" xknx = XKNX() cover = Cover( xknx, "TestCoverSyncAngle", group_address_long="1/2/1", group_address_short="1/2/2", group_address_position_state="1/2/3", group_address_angle_state="1/2/4", ) await cover.sync() assert xknx.telegrams.qsize() == 2 telegram1 = xknx.telegrams.get_nowait() assert telegram1 == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueRead() ) telegram2 = xknx.telegrams.get_nowait() assert telegram2 == Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueRead() ) async def test_sync_angle_state(self) -> None: """Test sync function with angle/explicit state.""" xknx = XKNX() cover = Cover( xknx, "TestCoverSyncAngleState", group_address_long="1/2/1", group_address_short="1/2/2", group_address_angle="1/2/3", group_address_angle_state="1/2/4", ) await cover.sync() assert xknx.telegrams.qsize() == 1 telegram1 = xknx.telegrams.get_nowait() assert telegram1 == Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueRead() ) # # TEST SET UP # async def test_set_up(self) -> None: """Test moving cover to 'up' position.""" xknx = XKNX() cover = Cover( xknx, "TestCoverSetUp", group_address_long="1/2/1", group_address_short="1/2/2", group_address_position="1/2/3", group_address_position_state="1/2/4", ) await cover.set_up() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() # DPT 1.008 - 0:up 1:down assert telegram == Telegram( destination_address=GroupAddress("1/2/1"), payload=GroupValueWrite(DPTBinary(0)), ) # # TEST SET DOWN # async def test_set_short_down(self) -> None: """Test moving cover to 'down' position.""" xknx = XKNX() cover = Cover( xknx, "TestCoverShortDown", group_address_long="1/2/1", group_address_short="1/2/2", group_address_position="1/2/3", group_address_position_state="1/2/4", ) await cover.set_down() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/1"), payload=GroupValueWrite(DPTBinary(1)), ) # # TEST SET DOWN INVERTED # async def test_set_down_inverted(self) -> None: """Test moving cover to 'down' position.""" xknx = XKNX() cover = Cover( xknx, "TestCoverSetDownInverted", group_address_long="1/2/1", group_address_short="1/2/2", group_address_position="1/2/3", group_address_position_state="1/2/4", invert_updown=True, ) await cover.set_down() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/1"), payload=GroupValueWrite(DPTBinary(0)), ) # # TEST SET SHORT UP # async def test_set_short_up(self) -> None: """Test moving cover 'short up'.""" xknx = XKNX() cover = Cover( xknx, "TestCoverSetShortUp", group_address_long="1/2/1", group_address_short="1/2/2", group_address_position="1/2/3", group_address_position_state="1/2/4", ) await cover.set_short_up() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() # DPT 1.008 - 0:up 1:down assert telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPTBinary(0)), ) # # TEST SET UP INVERTED # async def test_set_up_inverted(self) -> None: """Test moving cover 'short up'.""" xknx = XKNX() cover = Cover( xknx, "TestCoverSetUpInverted", group_address_long="1/2/1", group_address_short="1/2/2", group_address_position="1/2/3", group_address_position_state="1/2/4", invert_updown=True, ) await cover.set_short_up() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() # DPT 1.008 - 0:up 1:down assert telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPTBinary(1)), ) # # TEST SET SHORT DOWN # async def test_set_down(self) -> None: """Test moving cover 'short down'.""" xknx = XKNX() cover = Cover( xknx, "TestCoverSetDown", group_address_long="1/2/1", group_address_short="1/2/2", group_address_position="1/2/3", group_address_position_state="1/2/4", ) await cover.set_short_down() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() # DPT 1.008 - 0:up 1:down assert telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPTBinary(1)), ) # # TEST STOP # async def test_stop(self) -> None: """Test stopping cover.""" xknx = XKNX() cover_short_stop = Cover( xknx, "TestCoverStop", group_address_long="1/2/1", group_address_short="1/2/2", group_address_position="1/2/3", group_address_position_state="1/2/4", ) # Attempt stopping while not actually moving await cover_short_stop.stop() assert xknx.telegrams.qsize() == 0 # Attempt stopping while moving down cover_short_stop.travelcalculator.set_position(0) await cover_short_stop.set_down() await cover_short_stop.stop() assert xknx.telegrams.qsize() == 2 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/1"), payload=GroupValueWrite(DPTBinary(1)), ) telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPTBinary(1)), ) # Attempt stopping while moving up cover_short_stop.travelcalculator.set_position(100) await cover_short_stop.set_up() await cover_short_stop.stop() assert xknx.telegrams.qsize() == 2 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/1"), payload=GroupValueWrite(DPTBinary(0)), ) telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPTBinary(0)), ) cover_manual_stop = Cover( xknx, "TestCoverManualStop", group_address_long="1/2/1", group_address_short="1/2/2", group_address_stop="1/2/0", group_address_position="1/2/3", group_address_position_state="1/2/4", ) await cover_manual_stop.stop() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/0"), payload=GroupValueWrite(DPTBinary(1)), ) async def test_stop_angle(self) -> None: """Test stopping cover during angle move / tilting.""" xknx = XKNX() cover_short_stop = Cover( xknx, "TestCoverStopAngle", group_address_long="1/2/1", group_address_short="1/2/2", group_address_angle="1/2/5", group_address_angle_state="1/2/6", ) # Attempt stopping while not actually tilting await cover_short_stop.stop() assert xknx.telegrams.qsize() == 0 # Set cover tilt to a dummy start value, since otherwise we cannot # determine later on a tilt direction and without it, stopping the # til process has no effect. cover_short_stop.angle.process( Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray(0xAA)), ) ) # Attempt stopping while tilting down await cover_short_stop.set_angle(100) await cover_short_stop.stop() assert xknx.telegrams.qsize() == 2 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray(0xFF)), ) telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPTBinary(1)), ) # Attempt stopping while tilting up await cover_short_stop.set_angle(0) await cover_short_stop.stop() assert xknx.telegrams.qsize() == 2 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray(0x00)), ) telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPTBinary(0)), ) # # TEST POSITION # async def test_position(self) -> None: """Test moving cover to absolute position.""" xknx = XKNX() cover = Cover( xknx, "TestCoverPosition", group_address_long="1/2/1", group_address_short="1/2/2", group_address_position="1/2/3", group_address_position_state="1/2/4", ) await cover.set_position(50) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(0x80)), ) await cover.stop() # clean up tasks async def test_position_without_binary(self) -> None: """Test moving cover - with no binary positioning supported.""" xknx = XKNX() cover = Cover( xknx, "TestCoverPositionWithoutBinary", group_address_position="1/2/3", ) await cover.set_down() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(0xFF)), ) await cover.set_position(50) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(0x80)), ) await cover.set_up() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(0x00)), ) async def test_position_without_position_address_up(self) -> None: """Test moving cover to absolute position - with no absolute positioning supported.""" xknx = XKNX() cover = Cover( xknx, "TestCoverPWPAD", group_address_long="1/2/1", group_address_short="1/2/2", group_address_position_state="1/2/4", ) cover.travelcalculator.set_position(60) await cover.set_position(50) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() # DPT 1.008 - 0:up 1:down assert telegram == Telegram( destination_address=GroupAddress("1/2/1"), payload=GroupValueWrite(DPTBinary(0)), ) assert cover.travelcalculator._travel_to_position == 50 assert cover.is_opening() # process the outgoing telegram to make sure it doesn't overwrite the target position cover.process(telegram) assert cover.travelcalculator._travel_to_position == 50 assert xknx.telegrams.qsize() == 0 await cover.stop() # clean up tasks async def test_position_without_position_address_down(self) -> None: """Test moving cover down - with no absolute positioning supported.""" xknx = XKNX() cover = Cover( xknx, "TestCoverPWPAD", group_address_long="1/2/1", group_address_short="1/2/2", group_address_position_state="1/2/4", ) cover.travelcalculator.set_position(70) await cover.set_position(80) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/1"), payload=GroupValueWrite(DPTBinary(1)), ) assert cover.travelcalculator._travel_to_position == 80 assert cover.is_closing() # process the outgoing telegram to make sure it doesn't overwrite the target position cover.process(telegram) assert cover.travelcalculator._travel_to_position == 80 await cover.stop() # clean up tasks async def test_position_without_position_address_uninitialized_up(self) -> None: """Test moving uninitialized cover to absolute position - with no absolute positioning supported.""" xknx = XKNX() cover = Cover( xknx, "TestCoverPWPAUU", group_address_long="1/2/1", group_address_short="1/2/2", group_address_position_state="1/2/4", ) with patch("logging.Logger.warning") as mock_warn: await cover.set_position(50) assert xknx.telegrams.qsize() == 0 mock_warn.assert_called_with( "Current position unknown. Initialize cover by moving to end position." ) await cover.set_position(0) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/1"), payload=GroupValueWrite(DPTBinary(0)), ) await cover.stop() # clean up tasks async def test_position_without_position_address_uninitialized_down(self) -> None: """Test moving uninitialized cover to absolute position - with no absolute positioning supported.""" xknx = XKNX() cover = Cover( xknx, "TestCoverPWPAUD", group_address_long="1/2/1", group_address_short="1/2/2", group_address_position_state="1/2/4", ) with patch("logging.Logger.warning") as mock_warn: await cover.set_position(50) assert xknx.telegrams.qsize() == 0 mock_warn.assert_called_with( "Current position unknown. Initialize cover by moving to end position." ) await cover.set_position(100) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/1"), payload=GroupValueWrite(DPTBinary(1)), ) await cover.stop() # clean up tasks async def test_angle(self) -> None: """Test changing angle.""" xknx = XKNX() cover = Cover( xknx, "Children.Venetian", group_address_long="1/4/14", group_address_short="1/4/15", group_address_position_state="1/4/17", group_address_position="1/4/16", group_address_angle="1/4/18", group_address_angle_state="1/4/19", ) await cover.set_angle(50) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/4/18"), payload=GroupValueWrite(DPTArray(0x80)), ) async def test_angle_not_supported(self) -> None: """Test changing angle on cover which does not support angle.""" xknx = XKNX() cover = Cover( xknx, "Children.Venetian", group_address_long="1/4/14", group_address_short="1/4/15", ) with patch("logging.Logger.warning") as mock_warn: await cover.set_angle(50) assert xknx.telegrams.qsize() == 0 mock_warn.assert_called_with( "Angle not supported for device %s", "Children.Venetian" ) # # TEST PROCESS # async def test_process_position(self) -> None: """Test process / reading telegrams from telegram queue. Test if position is processed correctly.""" xknx = XKNX() cover = Cover( xknx, "TestCoverProcessPosition", group_address_long="1/2/1", group_address_short="1/2/2", group_address_position="1/2/3", group_address_position_state="1/2/4", ) # initial position process - position is unknown so this is the new state - not moving telegram = Telegram( GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(213)) ) cover.process(telegram) assert cover.current_position() == 84 assert not cover.is_traveling() # state telegram updates current position - we are not moving so this is new state - not moving telegram = Telegram( GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray(42)) ) cover.process(telegram) assert cover.current_position() == 16 assert not cover.is_traveling() assert cover.travelcalculator._travel_to_position == 16 # new position - movement starts telegram = Telegram( GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(255)) ) cover.process(telegram) assert cover.current_position() == 16 assert cover.is_closing() assert cover.travelcalculator._travel_to_position == 100 # new state while moving - movement goes on; travelcalculator updated telegram = Telegram( GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray(213)) ) cover.process(telegram) assert cover.current_position() == 84 assert cover.is_closing() assert cover.travelcalculator._travel_to_position == 100 await cover.stop() # clean up tasks async def test_process_angle(self) -> None: """Test process / reading telegrams from telegram queue. Test if position is processed correctly.""" xknx = XKNX() cover = Cover( xknx, "TestCoverProcessAngle", group_address_long="1/2/1", group_address_short="1/2/2", group_address_angle="1/2/3", group_address_angle_state="1/2/4", ) telegram = Telegram( GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray(42)) ) cover.process(telegram) assert cover.current_angle() == 16 async def test_process_locked(self) -> None: """Test process / reading telegrams from telegram queue. Test if position is processed correctly.""" xknx = XKNX() cover = Cover( xknx, "TestCoverProcessLocked", group_address_long="1/2/1", group_address_locked_state="1/2/4", ) telegram = Telegram( GroupAddress("1/2/4"), payload=GroupValueWrite(DPTBinary(1)) ) cover.process(telegram) assert cover.is_locked() is True async def test_process_up(self) -> None: """Test process / reading telegrams from telegram queue. Test if up/down is processed correctly.""" xknx = XKNX() cover = Cover( xknx, "TestCoverProcessUp", group_address_long="1/2/1", group_address_short="1/2/2", ) cover.travelcalculator.set_position(50) assert not cover.is_traveling() telegram = Telegram( GroupAddress("1/2/1"), payload=GroupValueWrite(DPTBinary(0)) ) cover.process(telegram) assert cover.is_opening() await cover.stop() # clean up tasks async def test_process_down(self) -> None: """Test process / reading telegrams from telegram queue. Test if up/down is processed correctly.""" xknx = XKNX() cover = Cover( xknx, "TestCoverProcessDown", group_address_long="1/2/1", group_address_short="1/2/2", ) cover.travelcalculator.set_position(50) assert not cover.is_traveling() telegram = Telegram( GroupAddress("1/2/1"), payload=GroupValueWrite(DPTBinary(1)) ) cover.process(telegram) assert cover.is_closing() await cover.stop() # clean up tasks async def test_process_stop(self) -> None: """Test process / reading telegrams from telegram queue. Test if stop is processed correctly.""" xknx = XKNX() cover = Cover( xknx, "TestCoverProcessStop", group_address_long="1/2/1", group_address_stop="1/2/2", ) cover.travelcalculator.set_position(50) await cover.set_down() assert cover.is_traveling() telegram = Telegram( GroupAddress("1/2/2"), payload=GroupValueWrite(DPTBinary(1)) ) cover.process(telegram) assert not cover.is_traveling() async def test_process_short_stop(self) -> None: """Test process / reading telegrams from telegram queue. Test if stop is processed correctly.""" xknx = XKNX() cover = Cover( xknx, "TestCoverProcessShortStop", group_address_long="1/2/1", group_address_short="1/2/2", ) cover.travelcalculator.set_position(50) await cover.set_down() assert cover.is_traveling() telegram = Telegram( GroupAddress("1/2/2"), payload=GroupValueWrite(DPTBinary(1)) ) cover.process(telegram) assert not cover.is_traveling() async def test_process_callback(self) -> None: """Test process / reading telegrams from telegram queue. Test if callback is executed.""" xknx = XKNX() cover = Cover( xknx, "TestCoverProcessCallback", group_address_long="1/2/1", group_address_short="1/2/2", group_address_stop="1/2/3", group_address_position="1/2/4", group_address_position_state="1/2/5", group_address_angle="1/2/6", group_address_angle_state="1/2/7", ) after_update_callback = Mock() cover.register_device_updated_cb(after_update_callback) for address, payload, _feature in [ ("1/2/1", DPTBinary(1), "long"), ("1/2/2", DPTBinary(1), "short"), ("1/2/4", DPTArray(42), "position"), ("1/2/3", DPTBinary(1), "stop"), # call position with same value again to make sure `always_callback` is set for target position ("1/2/4", DPTArray(42), "position"), ("1/2/5", DPTArray(42), "position state"), ("1/2/6", DPTArray(42), "angle"), ("1/2/7", DPTArray(51), "angle state"), ]: telegram = Telegram( destination_address=GroupAddress(address), payload=GroupValueWrite(payload), ) cover.process(telegram) after_update_callback.assert_called_with(cover) after_update_callback.reset_mock() # Stop only when cover is travelling telegram = Telegram( GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)) ) cover.process(telegram) after_update_callback.assert_not_called() await cover.set_down() cover.process(telegram) after_update_callback.assert_called_with(cover) await cover.stop() # clean up tasks # # IS TRAVELING / IS UP / IS DOWN # async def test_is_traveling(self) -> None: """Test moving cover to absolute position.""" xknx = XKNX() cover = Cover( xknx, "TestCoverIsTraveling", group_address_long="1/2/1", group_address_stop="1/2/2", group_address_position="1/2/3", group_address_position_state="1/2/4", travel_time_down=10, travel_time_up=10, ) with patch("time.time") as mock_time: mock_time.return_value = 1517000000.0 assert not cover.is_traveling() assert not cover.is_opening() assert not cover.is_closing() assert cover.position_reached() # we start with state open covers (up) cover.travelcalculator.set_position(0) await cover.set_down() assert cover.is_traveling() assert cover.is_open() assert not cover.is_closed() assert not cover.is_opening() assert cover.is_closing() mock_time.return_value = 1517000005.0 # 5 Seconds, half way assert not cover.position_reached() assert cover.is_traveling() assert not cover.is_open() assert not cover.is_closed() assert not cover.is_opening() assert cover.is_closing() mock_time.return_value = 1517000010.0 # 10 Seconds, fully closed assert cover.position_reached() assert not cover.is_traveling() assert not cover.is_open() assert cover.is_closed() assert not cover.is_opening() assert not cover.is_closing() # up again await cover.set_up() assert not cover.position_reached() assert cover.is_traveling() assert not cover.is_open() assert cover.is_closed() assert cover.is_opening() assert not cover.is_closing() mock_time.return_value = 1517000015.0 # 15 Seconds, half way assert not cover.position_reached() assert cover.is_traveling() assert not cover.is_open() assert not cover.is_closed() assert cover.is_opening() assert not cover.is_closing() mock_time.return_value = 1517000016.0 # 16 Seconds, manual stop await cover.stop() assert cover.position_reached() assert not cover.is_traveling() assert not cover.is_open() assert not cover.is_closed() assert not cover.is_opening() assert not cover.is_closing() # # TEST TASKS # async def test_auto_stop(self, time_travel: EventLoopClockAdvancer) -> None: """Test auto stop functionality.""" xknx = XKNX() cover = Cover( xknx, "TestCoverAutoStop", group_address_long="1/2/1", group_address_stop="1/2/2", travel_time_down=10, travel_time_up=10, ) with patch("time.time") as mock_time: mock_time.return_value = 1517000000.0 # we start with state 0 - open covers (up) this is assumed immediately await cover.set_position(0) assert xknx.telegrams.qsize() == 1 _ = xknx.telegrams.get_nowait() await cover.set_position(50) await time_travel(1) mock_time.return_value = 1517000001.0 assert xknx.telegrams.qsize() == 1 telegram1 = xknx.telegrams.get_nowait() assert telegram1 == Telegram( destination_address=GroupAddress("1/2/1"), payload=GroupValueWrite(DPTBinary(True)), ) await time_travel(4) mock_time.return_value = 1517000005.0 assert xknx.telegrams.qsize() == 1 telegram1 = xknx.telegrams.get_nowait() assert telegram1 == Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPTBinary(True)), ) async def test_periodic_update(self, time_travel: EventLoopClockAdvancer) -> None: """Test periodic update functionality.""" xknx = XKNX() callback_mock = Mock() cover = Cover( xknx, "TestCoverPeriodicUpdate", group_address_long="1/2/1", group_address_stop="1/2/2", group_address_position="1/2/3", group_address_position_state="1/2/4", travel_time_down=10, travel_time_up=10, device_updated_cb=callback_mock, ) with patch("time.time") as mock_time: mock_time.return_value = 1517000000.0 # state telegram updates current position - we are not moving so this is new state - not moving telegram = Telegram( GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray(0)) ) cover.process(telegram) await time_travel(0) assert callback_mock.call_count == 1 callback_mock.reset_mock() # move to 50% telegram = Telegram( GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(125)) ) cover.process(telegram) await time_travel(0) assert callback_mock.call_count == 1 mock_time.return_value = 1517000001.0 await time_travel(1) assert callback_mock.call_count == 2 # state telegram from bus too early mock_time.return_value = 1517000001.6 await time_travel(0.6) assert callback_mock.call_count == 2 telegram = Telegram( GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray(42)) ) cover.process(telegram) assert callback_mock.call_count == 3 # next update 1 second after last received state telegram mock_time.return_value = 1517000002.0 await time_travel(0.4) assert callback_mock.call_count == 3 mock_time.return_value = 1517000002.6 await time_travel(0.6) assert callback_mock.call_count == 4 # last callback - auto updater is removed mock_time.return_value = 1517000005.0 await time_travel(2.4) assert callback_mock.call_count == 5 assert cover.position_reached() assert cover._periodic_update_task is None @patch("xknx.core.TelegramQueue.process_telegram_outgoing", new_callable=AsyncMock) async def test_remove_task_cancel( self, _outgoing: AsyncMock, xknx_no_interface: XKNX ) -> None: """Test if tasks are removed correctly when device is removed.""" xknx = xknx_no_interface cover = Cover( xknx, "TestCoverRemoveTaskCancel", group_address_long="1/2/1", group_address_stop="1/2/2", group_address_position_state="1/2/4", travel_time_down=10, travel_time_up=10, ) xknx.devices.async_add(cover) async with xknx: # state telegram updates current position - we are not moving so this is new state - not moving telegram = Telegram( GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray(0)) ) cover.process(telegram) assert cover.current_position() == 0 await cover.set_position(50) assert cover._periodic_update_task is not None assert cover._auto_stop_task is not None xknx.devices.async_remove(cover) assert cover._periodic_update_task is None assert cover._auto_stop_task is None # # HAS GROUP ADDRESS # def test_has_group_address(self) -> None: """Test sensor has group address.""" xknx = XKNX() cover = Cover( xknx, "TestCoverHasGroupAddress", group_address_long="1/2/1", group_address_short="1/2/2", group_address_position="1/2/3", group_address_position_state="1/2/4", group_address_angle="1/2/5", group_address_angle_state="1/2/6", ) assert cover.has_group_address(GroupAddress("1/2/1")) assert cover.has_group_address(GroupAddress("1/2/2")) assert cover.has_group_address(GroupAddress("1/2/3")) assert cover.has_group_address(GroupAddress("1/2/4")) assert cover.has_group_address(GroupAddress("1/2/5")) assert cover.has_group_address(GroupAddress("1/2/6")) assert not cover.has_group_address(GroupAddress("1/2/7")) xknx-3.6.0/test/devices_tests/datetime_test.py000066400000000000000000000227121475530762600215630ustar00rootroot00000000000000"""Unit test for DateTime object.""" import datetime as dt from unittest.mock import AsyncMock, patch from zoneinfo import ZoneInfo from freezegun import freeze_time import pytest from xknx import XKNX from xknx.devices.datetime import ( BROADCAST_MINUTES, DateDevice, DateTimeDevice, TimeDevice, ) from xknx.dpt import DPTArray from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite from ..conftest import EventLoopClockAdvancer class TestDateTime: """Test class for Time object.""" @pytest.mark.parametrize( ("test_cls", "dt_value", "raw"), [ (TimeDevice, dt.time(9, 13, 14), (0x9, 0xD, 0xE)), (DateDevice, dt.date(2017, 1, 7), (0x07, 0x01, 0x11)), ( DateTimeDevice, dt.datetime(2017, 1, 7, 9, 13, 14), (0x75, 0x01, 0x07, 0x09, 0x0D, 0x0E, 0x24, 0x00), ), ], ) async def test_process_set_custom_time( self, test_cls: type[DateDevice | DateTimeDevice | TimeDevice], dt_value: dt.time | dt.date | dt.datetime, raw: tuple[int], ) -> None: """Test setting a new time.""" xknx = XKNX() test_device = test_cls( xknx, "Test", group_address="1/2/3", localtime=False, ) assert test_device.value is None await test_device.set(dt_value) telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(raw)), ) test_device.process(telegram) assert test_device.value == dt_value @pytest.mark.parametrize( ("cls", "raw_length", "raw"), [ (TimeDevice, 3, (0xC9, 0xD, 0xE)), (DateDevice, 3, (0x07, 0x01, 0x11)), (DateTimeDevice, 8, (0x75, 0x01, 0x07, 0xC9, 0x0D, 0x0E, 0x20, 0xC0)), ], ) async def test_sync_localtime( self, cls: type[DateDevice | DateTimeDevice | TimeDevice], raw_length: int, raw: tuple[int], ) -> None: """Test sync function / sending group reads to KNX bus.""" xknx = XKNX() test_device = cls(xknx, "Test", group_address="1/2/3") with freeze_time("2017-01-07 09:13:14"): await test_device.sync() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram.destination_address == GroupAddress("1/2/3") assert len(telegram.payload.value.value) == raw_length assert telegram.payload.value.value == raw async def test_sync_time_custom(self) -> None: """Test sync function / sending group reads to KNX bus.""" xknx = XKNX() test_device = TimeDevice( xknx, "TestDateTime", group_address="1/2/3", group_address_state="1/2/4", localtime=False, ) assert test_device.has_group_address(GroupAddress("1/2/4")) await test_device.sync() telegram = xknx.telegrams.get_nowait() assert telegram.destination_address == GroupAddress("1/2/4") assert isinstance(telegram.payload, GroupValueRead) @pytest.mark.parametrize( ("expected", "localtime"), [ ((0xC9, 0xD, 0xE), True), ((0xCA, 0xD, 0xE), ZoneInfo("Europe/Vienna")), ], ) async def test_process_read_localtime_time( self, expected: tuple[int, int, int], localtime: bool | dt.tzinfo ) -> None: """Test test process a read telegram from KNX bus.""" xknx = XKNX() test_device = TimeDevice( xknx, "TestTime", group_address="1/2/3", localtime=localtime ) telegram_read = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueRead() ) with freeze_time("2017-01-07 09:13:14"): test_device.process(telegram_read) telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueResponse(DPTArray(expected)), ) @pytest.mark.parametrize( ("expected", "localtime"), [ ( (0x75, 0x07, 0x0B, 0x49, 0x0D, 0x0E, 0x20, 0xC0), True, ), ( (0x75, 0x07, 0x0B, 0x4B, 0x0D, 0x0E, 0x21, 0xC0), ZoneInfo("Europe/Vienna"), ), ], ) async def test_process_read_localtime_datetime( self, expected: tuple[int, int, int], localtime: bool | dt.tzinfo ) -> None: """Test test process a read telegram from KNX bus.""" xknx = XKNX() test_device = DateTimeDevice( xknx, "TestDateTime", group_address="1/2/3", localtime=localtime ) telegram_read = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueRead() ) with freeze_time("2017-07-11 09:13:14"): # summer time test_device.process(telegram_read) telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueResponse(DPTArray(expected)), ) async def test_process_read_custom_time(self) -> None: """Test test process a read telegram from KNX bus.""" xknx = XKNX() test_device = TimeDevice( xknx, "TestDateTime", group_address="1/2/3", localtime=False, respond_to_read=True, ) await test_device.set(dt.time(9, 13, 14)) telegram_set = xknx.telegrams.get_nowait() assert telegram_set == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x9, 0xD, 0xE))), ) test_device.process(telegram_set) telegram_read = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueRead() ) test_device.process(telegram_read) telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueResponse(DPTArray((0x9, 0xD, 0xE))), ) # # TEST HAS GROUP ADDRESS # async def test_has_group_address_localtime(self) -> None: """Test if has_group_address function works.""" xknx = XKNX() test_device = DateDevice( xknx, "TestDateTime", group_address="1/2/3", group_address_state="1/2/4", localtime=True, ) assert test_device.has_group_address(GroupAddress("1/2/3")) # group_address_state ignored when using localtime assert not test_device.has_group_address(GroupAddress("1/2/4")) async def test_has_group_address_custom_time(self) -> None: """Test if has_group_address function works.""" xknx = XKNX() test_device = DateDevice( xknx, "TestDateTime", group_address="1/2/3", group_address_state="1/2/4", localtime=False, ) assert test_device.has_group_address(GroupAddress("1/2/3")) assert test_device.has_group_address(GroupAddress("1/2/4")) # # TEST BACKGROUND TASK # @patch("xknx.core.TelegramQueue.process_telegram_outgoing", new_callable=AsyncMock) async def test_background_task( self, process_telegram_outgoing_mock: AsyncMock, time_travel: EventLoopClockAdvancer, xknx_no_interface: XKNX, ) -> None: """Test if background task works.""" xknx = xknx_no_interface test_device = TimeDevice(xknx, "TestDateTime", group_address="1/2/3") xknx.devices.async_add(test_device) async with xknx: # initial time telegram await time_travel(0) process_telegram_outgoing_mock.assert_called_once() process_telegram_outgoing_mock.reset_mock() # repeated time telegram await time_travel(BROADCAST_MINUTES * 60) process_telegram_outgoing_mock.assert_called_once() process_telegram_outgoing_mock.reset_mock() # remove device - no more telegrams xknx.devices.async_remove(test_device) await time_travel(BROADCAST_MINUTES * 60) process_telegram_outgoing_mock.assert_not_called() @patch("xknx.core.TelegramQueue.process_telegram_outgoing", new_callable=AsyncMock) async def test_no_background_task( self, process_telegram_outgoing_mock: AsyncMock, time_travel: EventLoopClockAdvancer, xknx_no_interface: XKNX, ) -> None: """Test if background task is not started when not using `localtime`.""" xknx = xknx_no_interface test_device = TimeDevice( xknx, "TestDateTime", group_address="1/2/3", localtime=False, ) xknx.devices.async_add(test_device) async with xknx: assert test_device._broadcast_task is None # no initial time telegram await time_travel(0) process_telegram_outgoing_mock.assert_not_called() # no repeated time telegram await time_travel(BROADCAST_MINUTES * 60) process_telegram_outgoing_mock.assert_not_called() xknx-3.6.0/test/devices_tests/device_test.py000066400000000000000000000135071475530762600212300ustar00rootroot00000000000000"""Unit test for Switch objects.""" from unittest.mock import AsyncMock, Mock, patch from xknx import XKNX from xknx.devices import Device, Sensor from xknx.dpt import DPTArray from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite @patch.multiple(Device, __abstractmethods__=set()) class TestDevice: """Test class for Switch object.""" def test_device_updated_cb(self) -> None: """Test device updated cb is added to the device.""" xknx = XKNX() after_update_callback = Mock() device = Device(xknx, "TestDevice", device_updated_cb=after_update_callback) device.after_update() after_update_callback.assert_called_with(device) def test_process_callback(self) -> None: """Test process / reading telegrams from telegram queue. Test if callback was called.""" xknx = XKNX() device = Device(xknx, "TestDevice") after_update_callback1 = Mock() after_update_callback2 = Mock() device.register_device_updated_cb(after_update_callback1) device.register_device_updated_cb(after_update_callback2) # Triggering first time. Both have to be called device.after_update() after_update_callback1.assert_called_with(device) after_update_callback2.assert_called_with(device) after_update_callback1.reset_mock() after_update_callback2.reset_mock() # Triggering 2nd time. Both have to be called device.after_update() after_update_callback1.assert_called_with(device) after_update_callback2.assert_called_with(device) after_update_callback1.reset_mock() after_update_callback2.reset_mock() # Unregistering first callback device.unregister_device_updated_cb(after_update_callback1) device.after_update() after_update_callback1.assert_not_called() after_update_callback2.assert_called_with(device) after_update_callback1.reset_mock() after_update_callback2.reset_mock() # Unregistering second callback device.unregister_device_updated_cb(after_update_callback2) device.after_update() after_update_callback1.assert_not_called() after_update_callback2.assert_not_called() @patch("logging.Logger.exception") def test_bad_callback(self, logging_exception_mock: Mock) -> None: """Test handling callback raising an exception.""" xknx = XKNX() device = Device(xknx, "TestDevice") good_callback_1 = Mock() bad_callback = Mock(side_effect=Exception("Boom")) good_callback_2 = Mock() device.register_device_updated_cb(good_callback_1) device.register_device_updated_cb(bad_callback) device.register_device_updated_cb(good_callback_2) device.after_update() good_callback_1.assert_called_with(device) bad_callback.assert_called_with(device) good_callback_2.assert_called_with(device) logging_exception_mock.assert_called_once_with( "Unexpected error while processing device_updated_cb for %s", device, ) async def test_process(self) -> None: """Test if telegram is handled by the correct process_* method.""" xknx = XKNX() device = Device(xknx, "TestDevice") with patch("xknx.devices.Device.process_group_read") as mock_group_read: telegram = Telegram( destination_address=GroupAddress("1/2/1"), payload=GroupValueRead() ) device.process(telegram) mock_group_read.assert_called_with(telegram) with patch("xknx.devices.Device.process_group_write") as mock_group_write: telegram = Telegram( destination_address=GroupAddress("1/2/1"), payload=GroupValueWrite(DPTArray((0x01, 0x02))), ) device.process(telegram) mock_group_write.assert_called_with(telegram) with patch("xknx.devices.Device.process_group_response") as mock_group_response: telegram = Telegram( destination_address=GroupAddress("1/2/1"), payload=GroupValueResponse(DPTArray((0x01, 0x02))), ) device.process(telegram) mock_group_response.assert_called_with(telegram) async def test_sync_with_wait(self) -> None: """Test sync with wait_for_result=True.""" xknx = XKNX() sensor = Sensor( xknx, "Sensor", group_address_state="1/2/3", value_type="wind_speed_ms" ) with patch( "xknx.remote_value.RemoteValue.read_state", new_callable=AsyncMock ) as read_state_mock: await sensor.sync(wait_for_result=True) read_state_mock.assert_called_with(wait_for_result=True) async def test_process_group_write(self) -> None: """Test if process_group_write. Nothing really to test here.""" xknx = XKNX() device = Device(xknx, "TestDevice") device.process_group_write(Telegram(destination_address=GroupAddress(1))) async def test_process_group_response(self) -> None: """Test if process_group_read. Testing if mapped to group_write.""" xknx = XKNX() device = Device(xknx, "TestDevice") with patch("xknx.devices.Device.process_group_write") as mock_group_write: device.process_group_response(Telegram(destination_address=GroupAddress(1))) mock_group_write.assert_called_with( Telegram(destination_address=GroupAddress(1)) ) async def test_process_group_read(self) -> None: """Test if process_group_read. Nothing really to test here.""" xknx = XKNX() device = Device(xknx, "TestDevice") device.process_group_read(Telegram(destination_address=GroupAddress(1))) xknx-3.6.0/test/devices_tests/devices_test.py000066400000000000000000000202051475530762600214040ustar00rootroot00000000000000"""Unit test for devices container within XKNX.""" from unittest.mock import AsyncMock, Mock, patch import pytest from xknx import XKNX from xknx.devices import BinarySensor, Device, Light, Switch from xknx.telegram import GroupAddress class TestDevices: """Test class for devices container within XKNX.""" # # XKNX Config # def test_get_item(self) -> None: """Test get item by name or by index.""" xknx = XKNX() light1 = Light(xknx, "Living-Room.Light_1", group_address_switch="1/6/7") xknx.devices.async_add(light1) switch1 = Switch(xknx, "TestOutlet_1", group_address="1/2/3") xknx.devices.async_add(switch1) light2 = Light(xknx, "Living-Room.Light_2", group_address_switch="1/6/8") xknx.devices.async_add(light2) switch2 = Switch(xknx, "TestOutlet_2", group_address="1/2/4") xknx.devices.async_add(switch2) assert xknx.devices["Living-Room.Light_1"] == light1 assert xknx.devices["TestOutlet_1"] == switch1 assert xknx.devices["Living-Room.Light_2"] == light2 assert xknx.devices["TestOutlet_2"] == switch2 with pytest.raises(KeyError): # pylint: disable=pointless-statement xknx.devices["TestOutlet_2sdds"] assert xknx.devices[0] == light1 assert xknx.devices[1] == switch1 assert xknx.devices[2] == light2 assert xknx.devices[3] == switch2 with pytest.raises(IndexError): # pylint: disable=pointless-statement xknx.devices[4] def test_device_by_group_address(self) -> None: """Test get devices by group address.""" xknx = XKNX() light1 = Light(xknx, "Livingroom", group_address_switch="1/6/7") sensor1 = BinarySensor(xknx, "Diningroom", group_address_state="3/0/1") sensor2 = BinarySensor(xknx, "Diningroom", group_address_state="3/0/1") light2 = Light(xknx, "Livingroom", group_address_switch="1/6/8") xknx.devices.async_add(light1) xknx.devices.async_add(sensor1) xknx.devices.async_add(sensor2) xknx.devices.async_add(light2) assert tuple(xknx.devices.devices_by_group_address(GroupAddress("1/6/7"))) == ( light1, ) assert tuple(xknx.devices.devices_by_group_address(GroupAddress("1/6/8"))) == ( light2, ) assert tuple(xknx.devices.devices_by_group_address(GroupAddress("3/0/1"))) == ( sensor1, sensor2, ) def test_iter(self) -> None: """Test __iter__() function.""" xknx = XKNX() light1 = Light(xknx, "Livingroom", group_address_switch="1/6/7") sensor1 = BinarySensor(xknx, "Diningroom", group_address_state="3/0/1") sensor2 = BinarySensor(xknx, "Diningroom", group_address_state="3/0/1") light2 = Light(xknx, "Livingroom", group_address_switch="1/6/8") xknx.devices.async_add(light1) xknx.devices.async_add(sensor1) xknx.devices.async_add(sensor2) xknx.devices.async_add(light2) assert tuple(iter(xknx.devices)) == (light1, sensor1, sensor2, light2) def test_len(self) -> None: """Test len() function.""" xknx = XKNX() assert len(xknx.devices) == 0 light = Light(xknx, "Living-Room.Light_1", group_address_switch="1/6/7") xknx.devices.async_add(light) assert len(xknx.devices) == 1 binary_sensor = BinarySensor( xknx, "DiningRoom.Motion.Sensor", group_address_state="3/0/1" ) xknx.devices.async_add(binary_sensor) assert len(xknx.devices) == 2 xknx.devices.async_remove(light) assert len(xknx.devices) == 1 xknx.devices.async_add(light) assert len(xknx.devices) == 2 def test_contains(self) -> None: """Test __contains__() function.""" xknx = XKNX() xknx.devices.async_add( Light(xknx, "Living-Room.Light_1", group_address_switch="1/6/7") ) xknx.devices.async_add( Light(xknx, "Living-Room.Light_2", group_address_switch="1/6/8") ) assert "Living-Room.Light_1" in xknx.devices assert "Living-Room.Light_2" in xknx.devices assert "Living-Room.Light_3" not in xknx.devices @patch.multiple(Device, __abstractmethods__=set()) def test_add_remove(self) -> None: """Tesst add and remove functions.""" xknx = XKNX() device1 = Device(xknx, "TestDevice1") device2 = Device(xknx, "TestDevice2") xknx.devices.async_add(device1) xknx.devices.async_add(device2) assert len(xknx.devices) == 2 xknx.devices.async_remove(device1) assert len(xknx.devices) == 1 assert "TestDevice1" not in xknx.devices xknx.devices.async_remove(device2) assert len(xknx.devices) == 0 async def test_modification_of_device(self) -> None: """Test if devices object does store references and not copies of objects.""" xknx = XKNX() light1 = Light(xknx, "Living-Room.Light_1", group_address_switch="1/6/7") xknx.devices.async_add(light1) for device in xknx.devices: await device.set_on() xknx.devices.process(xknx.telegrams.get_nowait()) assert light1.state device2 = xknx.devices["Living-Room.Light_1"] await device2.set_off() xknx.devices.process(xknx.telegrams.get_nowait()) assert not light1.state for device in xknx.devices.devices_by_group_address(GroupAddress("1/6/7")): await device.set_on() xknx.devices.process(xknx.telegrams.get_nowait()) assert light1.state # # TEST SYNC # @patch.multiple(Device, __abstractmethods__=set()) async def test_sync(self) -> None: """Test sync function.""" xknx = XKNX() xknx.devices.async_add(Device(xknx, "TestDevice1")) xknx.devices.async_add(Device(xknx, "TestDevice2")) with patch("xknx.devices.Device.sync", new_callable=AsyncMock) as mock_sync: await xknx.devices.sync() assert mock_sync.call_count == 2 # # TEST CALLBACK # @patch.multiple(Device, __abstractmethods__=set()) def test_device_updated_callback(self) -> None: """Test if device updated callback is called correctly if device was updated.""" xknx = XKNX() device1 = Device(xknx, "TestDevice1") device2 = Device(xknx, "TestDevice2") xknx.devices.async_add(device1) xknx.devices.async_add(device2) after_update_callback1 = Mock() after_update_callback2 = Mock() # Registering both callbacks xknx.devices.register_device_updated_cb(after_update_callback1) xknx.devices.register_device_updated_cb(after_update_callback2) # Triggering first device. Both callbacks to be called device1.after_update() after_update_callback1.assert_called_with(device1) after_update_callback2.assert_called_with(device1) after_update_callback1.reset_mock() after_update_callback2.reset_mock() # Triggering 2nd device. Both callbacks have to be called device2.after_update() after_update_callback1.assert_called_with(device2) after_update_callback2.assert_called_with(device2) after_update_callback1.reset_mock() after_update_callback2.reset_mock() # Unregistering first callback xknx.devices.unregister_device_updated_cb(after_update_callback1) # Triggering first device. Only second callback has to be called device1.after_update() after_update_callback1.assert_not_called() after_update_callback2.assert_called_with(device1) after_update_callback1.reset_mock() after_update_callback2.reset_mock() # Unregistering second callback xknx.devices.unregister_device_updated_cb(after_update_callback2) # Triggering second device. No callback should be called device2.after_update() after_update_callback1.assert_not_called() after_update_callback2.assert_not_called() after_update_callback1.reset_mock() after_update_callback2.reset_mock() xknx-3.6.0/test/devices_tests/expose_sensor_test.py000066400000000000000000000315651475530762600226710ustar00rootroot00000000000000"""Unit test for Sensor objects.""" from unittest.mock import AsyncMock, Mock, call from xknx import XKNX from xknx.devices import ExposeSensor from xknx.dpt import DPTArray, DPTBinary from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite from ..conftest import EventLoopClockAdvancer class TestExposeSensor: """Test class for Sensor objects.""" # # STR FUNCTIONS # async def test_str_binary(self) -> None: """Test resolve state with binary sensor.""" xknx = XKNX() expose_sensor = ExposeSensor( xknx, "TestSensor", group_address="1/2/3", value_type="binary" ) expose_sensor.process( Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(value=DPTBinary(1)), ) ) assert expose_sensor.resolve_state() is True assert expose_sensor.unit_of_measurement() is None async def test_str_percent(self) -> None: """Test resolve state with percent sensor.""" xknx = XKNX() expose_sensor = ExposeSensor( xknx, "TestSensor", group_address="1/2/3", value_type="percent" ) expose_sensor.process( Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x40,))), ) ) assert expose_sensor.resolve_state() == 25 assert expose_sensor.unit_of_measurement() == "%" async def test_str_temperature(self) -> None: """Test resolve state with temperature sensor.""" xknx = XKNX() expose_sensor = ExposeSensor( xknx, "TestSensor", group_address="1/2/3", value_type="temperature" ) expose_sensor.process( Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x0C, 0x1A))), ) ) assert expose_sensor.resolve_state() == 21.0 assert expose_sensor.unit_of_measurement() == "°C" # # TEST SET # async def test_set_binary(self) -> None: """Test set with binary sensor.""" xknx = XKNX() expose_sensor = ExposeSensor( xknx, "TestSensor", group_address="1/2/3", value_type="binary" ) await expose_sensor.set(False) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(0)), ) async def test_set_percent(self) -> None: """Test set with percent sensor.""" xknx = XKNX() expose_sensor = ExposeSensor( xknx, "TestSensor", group_address="1/2/3", value_type="percent" ) await expose_sensor.set(75) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0xBF,))), ) async def test_set_temperature(self) -> None: """Test set with temperature sensor.""" xknx = XKNX() expose_sensor = ExposeSensor( xknx, "TestSensor", group_address="1/2/3", value_type="temperature" ) await expose_sensor.set(21.0) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x0C, 0x1A))), ) # # TEST PROCESS (GROUP READ) # async def test_process_binary(self) -> None: """Test reading binary expose sensor from bus.""" xknx = XKNX() expose_sensor = ExposeSensor( xknx, "TestSensor", value_type="binary", group_address="1/2/3" ) expose_sensor.sensor_value.value = True telegram = Telegram(GroupAddress("1/2/3"), payload=GroupValueRead()) expose_sensor.process(telegram) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueResponse(DPTBinary(True)), ) async def test_process_percent(self) -> None: """Test reading percent expose sensor from bus.""" xknx = XKNX() expose_sensor = ExposeSensor( xknx, "TestSensor", value_type="percent", group_address="1/2/3" ) expose_sensor.process( Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x40,))), ) ) telegram = Telegram(GroupAddress("1/2/3"), payload=GroupValueRead()) expose_sensor.process(telegram) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueResponse(DPTArray((0x40,))), ) async def test_process_temperature(self) -> None: """Test reading temperature expose sensor from bus.""" xknx = XKNX() expose_sensor = ExposeSensor( xknx, "TestSensor", value_type="temperature", group_address="1/2/3" ) expose_sensor.sensor_value.value = 21.0 telegram = Telegram(GroupAddress("1/2/3"), payload=GroupValueRead()) expose_sensor.process(telegram) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueResponse(DPTArray((0x0C, 0x1A))), ) async def test_process_no_respond_to_read(self) -> None: """Test expose sensor with respond_to_read set to False.""" xknx = XKNX() expose_sensor = ExposeSensor( xknx, "TestSensor", value_type="temperature", group_address="1/2/3", respond_to_read=False, ) expose_sensor.sensor_value.value = 21.0 telegram = Telegram(GroupAddress("1/2/3"), payload=GroupValueRead()) expose_sensor.process(telegram) assert xknx.telegrams.qsize() == 0 # # HAS GROUP ADDRESS # def test_has_group_address(self) -> None: """Test expose sensor has group address.""" xknx = XKNX() expose_sensor = ExposeSensor( xknx, "TestSensor", value_type="temperature", group_address="1/2/3" ) assert expose_sensor.has_group_address(GroupAddress("1/2/3")) assert not expose_sensor.has_group_address(GroupAddress("1/2/4")) # # PROCESS CALLBACK # async def test_process_callback(self) -> None: """Test setting value. Test if callback is called.""" xknx = XKNX() after_update_callback = Mock() expose_sensor = ExposeSensor( xknx, "TestSensor", group_address="1/2/3", value_type="temperature" ) expose_sensor.register_device_updated_cb(after_update_callback) xknx.devices.async_add(expose_sensor) await expose_sensor.set(21.0) xknx.devices.process(xknx.telegrams.get_nowait()) after_update_callback.assert_called_with(expose_sensor) # # TEST COOLDOWN # async def test_cooldown(self, time_travel: EventLoopClockAdvancer) -> None: """Test cooldown.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() await xknx.telegram_queue.start() expose_sensor_cd = ExposeSensor( xknx, "TestSensor", group_address="1/2/3", value_type="temperature", cooldown=10, ) xknx.devices.async_add(expose_sensor_cd) expose_sensor_no_cd = ExposeSensor( xknx, "TestSensor", group_address="1/2/4", value_type="temperature", ) xknx.devices.async_add(expose_sensor_no_cd) await expose_sensor_cd.set(21.0) await expose_sensor_no_cd.set(21.0) await time_travel(0) xknx.cemi_handler.send_telegram.assert_has_calls( [ call( Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x0C, 0x1A))), ) ), call( Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray((0x0C, 0x1A))), ) ), ] ) xknx.cemi_handler.send_telegram.reset_mock() # don't send telegram with same payload twice if cooldown is active await expose_sensor_cd.set(21.0) await expose_sensor_no_cd.set(21.0) await time_travel(0) xknx.cemi_handler.send_telegram.assert_has_calls( [ call( Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray((0x0C, 0x1A))), ) ), ] ) xknx.cemi_handler.send_telegram.reset_mock() await time_travel(10) assert xknx.telegrams.qsize() == 0 xknx.cemi_handler.send_telegram.assert_not_called() # different payload after cooldown await expose_sensor_cd.set(10.0) await expose_sensor_no_cd.set(10.0) await time_travel(0) xknx.cemi_handler.send_telegram.assert_has_calls( [ call( Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x03, 0xE8))), ) ), call( Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray((0x03, 0xE8))), ) ), ] ) xknx.cemi_handler.send_telegram.reset_mock() # different payload immediately (payload of 3.111 equals 3.11) await expose_sensor_cd.set(3.111) await expose_sensor_no_cd.set(3.111) await time_travel(0) xknx.cemi_handler.send_telegram.assert_has_calls( [ call( Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray((0x01, 0x37))), ) ), ] ) xknx.cemi_handler.send_telegram.reset_mock() assert expose_sensor_cd._cooldown_latest_value == 3.111 assert expose_sensor_cd.sensor_value.value == 10 await time_travel(10) xknx.cemi_handler.send_telegram.assert_has_calls( [ call( Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x01, 0x37))), ) ), ] ) xknx.cemi_handler.send_telegram.reset_mock() assert expose_sensor_cd._cooldown_latest_value == 3.11 assert expose_sensor_cd.sensor_value.value == 3.11 await time_travel(10) xknx.cemi_handler.send_telegram.assert_not_called() # reading unsent value await expose_sensor_cd.set(10) # first send new value await time_travel(0) xknx.cemi_handler.send_telegram.assert_has_calls( [ call( Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x03, 0xE8))), ) ), ] ) xknx.cemi_handler.send_telegram.reset_mock() # in cooldown with a new value - receiving a read request await expose_sensor_cd.set(21) expose_sensor_cd.process( Telegram(GroupAddress("1/2/3"), payload=GroupValueRead()) ) await time_travel(0) xknx.cemi_handler.send_telegram.assert_has_calls( [ call( Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueResponse(DPTArray((0x0C, 0x1A))), ) ), ] ) xknx.cemi_handler.send_telegram.reset_mock() # after cooldown - new value not sent again (already in GroupValueResponse) await time_travel(10) xknx.cemi_handler.send_telegram.assert_not_called() await xknx.stop() xknx-3.6.0/test/devices_tests/fan_test.py000066400000000000000000000345561475530762600205440ustar00rootroot00000000000000"""Unit test for Fan objects.""" from unittest.mock import Mock, patch from xknx import XKNX from xknx.devices import Fan from xknx.dpt import DPTArray, DPTBinary from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueRead, GroupValueWrite class TestFan: """Class for testing Fan objects.""" # # SYNC # async def test_sync(self) -> None: """Test sync function / sending group reads to KNX bus.""" xknx = XKNX() fan = Fan(xknx, name="TestFan", group_address_speed_state="1/2/3") await fan.sync() assert xknx.telegrams.qsize() == 1 telegram1 = xknx.telegrams.get_nowait() assert telegram1 == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueRead() ) async def test_sync_step(self) -> None: """Test sync function / sending group reads to KNX bus.""" xknx = XKNX() fan = Fan( xknx, name="TestFan", group_address_speed_state="1/2/3", ) await fan.sync() assert xknx.telegrams.qsize() == 1 telegram1 = xknx.telegrams.get_nowait() assert telegram1 == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueRead() ) # # SYNC WITH STATE ADDRESS # async def test_sync_state_address(self) -> None: """Test sync function / sending group reads to KNX bus.""" xknx = XKNX() fan = Fan( xknx, name="TestFan", group_address_speed="1/2/3", group_address_speed_state="1/2/4", ) await fan.sync() assert xknx.telegrams.qsize() == 1 telegram1 = xknx.telegrams.get_nowait() assert telegram1 == Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueRead() ) # # TEST SWITCH ON/OFF # async def test_switch_on_off(self) -> None: """Test switching on/off of a Fan.""" xknx = XKNX() fan = Fan(xknx, name="TestFan", group_address_speed="1/2/3") # Turn the fan on via speed GA. First try without providing a speed, # which will set it to the default 50% percentage. await fan.turn_on() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() # 128 is 50% as byte (0...255) assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(128)), ) # Try again, but this time with a speed provided await fan.turn_on(55) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() # 140 is 55% as byte (0...255) assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(140)), ) # Turn the fan off via the speed GA await fan.turn_off() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(0)), ) fan_with_switch = Fan( xknx, name="TestFanSwitch", group_address_speed="1/2/3", group_address_switch="4/5/6", ) # Turn the fan on via the switch GA, which should not adjust the speed await fan_with_switch.turn_on() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("4/5/6"), payload=GroupValueWrite(DPTBinary(1)), ) # Turn the fan off via the switch GA await fan_with_switch.turn_off() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("4/5/6"), payload=GroupValueWrite(DPTBinary(0)), ) # Turn the fan on again this time with a provided speed, which for a switch GA fan # should result in separate telegrams to switch on the fan and then set the speed. await fan_with_switch.turn_on(55) assert xknx.telegrams.qsize() == 2 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("4/5/6"), payload=GroupValueWrite(DPTBinary(1)), ) telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(140)), ) # # TEST SET SPEED # async def test_set_speed(self) -> None: """Test setting the speed of a Fan.""" xknx = XKNX() fan = Fan(xknx, name="TestFan", group_address_speed="1/2/3") await fan.set_speed(55) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() # 140 is 55% as byte (0...255) assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(140)), ) fan.process(telegram) assert fan.is_on is True # A speed of 0 will turn off the fan implicitly if there is no # dedicated switch GA await fan.set_speed(0) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() # 140 is 55% as byte (0...255) assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(0)), ) fan.process(telegram) assert fan.is_on is False fan_with_switch = Fan( xknx, name="TestFan", group_address_speed="1/2/3", group_address_switch="4/5/6", ) await fan_with_switch.turn_on() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("4/5/6"), payload=GroupValueWrite(DPTBinary(1)), ) fan_with_switch.process(telegram) assert fan_with_switch.is_on is True # A speed of 0 will not turn off the fan implicitly if there is a # dedicated switch GA defined. So we only expect a speed change telegram, # but no state switch one. await fan_with_switch.set_speed(0) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(0)), ) fan_with_switch.process(telegram) assert fan_with_switch.is_on is True # # TEST SET SPEED STEP # async def test_set_speed_step(self) -> None: """Test setting the speed of a Fan in step mode.""" xknx = XKNX() fan = Fan( xknx, name="TestFan", group_address_speed="1/2/3", max_step=3, ) await fan.set_speed(2) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(2)), ) # # TEST SET OSCILLATION # async def test_set_oscillation(self) -> None: """Test setting the oscillation of a Fan.""" xknx = XKNX() fan = Fan( xknx, name="TestFan", group_address_speed="1/2/3", group_address_oscillation="1/2/5", ) await fan.set_oscillation(False) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTBinary(0)), ) # # TEST PROCESS SPEED # async def test_process_speed(self) -> None: """Test process / reading telegrams from telegram queue. Test if speed is processed.""" xknx = XKNX() fan = Fan(xknx, name="TestFan", group_address_speed="1/2/3") assert fan.is_on is False assert fan.current_speed is None # 140 is 55% as byte (0...255) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(140)), ) fan.process(telegram) # Setting a speed for a fan that has no dedicated switch GA, # should turn on the fan. assert fan.is_on is True assert fan.current_speed == 55 # Now set a speed of zero which should turn off the fan. telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(0)), ) fan.process(telegram) assert fan.is_on is False assert fan.current_speed == 0 async def test_process_speed_wrong_payload(self) -> None: """Test process wrong telegrams. (wrong payload type).""" xknx = XKNX() cb_mock = Mock() fan = Fan( xknx, name="TestFan", group_address_speed="1/2/3", device_updated_cb=cb_mock ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) with patch("logging.Logger.warning") as log_mock: fan.process(telegram) log_mock.assert_called_once() cb_mock.assert_not_called() # # TEST PROCESS SWITCH # async def test_process_switch(self) -> None: """Test process / reading telegrams from telegram queue. Test if switch is handled correctly.""" xknx = XKNX() fan = Fan( xknx, name="TestFan", group_address_speed="1/2/3", group_address_switch="4/5/6", ) assert fan.is_on is False assert fan.current_speed is None # 140 is 55% as byte (0...255) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(140)), ) fan.process(telegram) # Setting a speed for a fan with dedicated switch GA, # should not turn on the fan assert fan.is_on is False assert fan.current_speed == 55 # Now turn on the fan via its switch GA telegram = Telegram( destination_address=GroupAddress("4/5/6"), payload=GroupValueWrite(DPTBinary(1)), ) fan.process(telegram) assert fan.is_on is True assert fan.current_speed == 55 # Setting a speed of 0 should not turn off the fan telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(0)), ) fan.process(telegram) assert fan.is_on is True assert fan.current_speed == 0 # Set the speed again so we can verify that switching off the fan does not # modify the set speed telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(140)), ) fan.process(telegram) assert fan.is_on is True assert fan.current_speed == 55 # Now turn off the fan via the dedicated switch GA telegram = Telegram( destination_address=GroupAddress("4/5/6"), payload=GroupValueWrite(DPTBinary(0)), ) fan.process(telegram) assert fan.is_on is False assert fan.current_speed == 55 # # TEST PROCESS OSCILLATION # async def test_process_oscillation(self) -> None: """Test process / reading telegrams from telegram queue. Test if oscillation is processed.""" xknx = XKNX() fan = Fan( xknx, name="TestFan", group_address_speed="1/2/3", group_address_oscillation="1/2/5", ) assert fan.current_oscillation is None telegram = Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTBinary(1)), ) fan.process(telegram) assert fan.current_oscillation async def test_process_fan_payload_invalid_length(self) -> None: """Test process wrong telegrams. (wrong payload length).""" xknx = XKNX() cb_mock = Mock() fan = Fan( xknx, name="TestFan", group_address_speed="1/2/3", device_updated_cb=cb_mock ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((23, 24))), ) with patch("logging.Logger.warning") as log_mock: fan.process(telegram) log_mock.assert_called_once() cb_mock.assert_not_called() # # TEST PROCESS STEP MODE # async def test_process_speed_step(self) -> None: """Test process / reading telegrams from telegram queue. Test if speed is processed.""" xknx = XKNX() fan = Fan( xknx, name="TestFan", group_address_speed="1/2/3", max_step=3, ) assert fan.current_speed is None telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(2)), ) fan.process(telegram) assert fan.current_speed == 2 def test_has_group_address(self) -> None: """Test has_group_address.""" xknx = XKNX() fan = Fan( xknx, "TestFan", group_address_speed="1/7/1", group_address_speed_state="1/7/2", group_address_oscillation="1/6/1", group_address_oscillation_state="1/6/2", group_address_switch="1/5/1", group_address_switch_state="1/5/2", ) assert fan.has_group_address(GroupAddress("1/7/1")) assert fan.has_group_address(GroupAddress("1/7/2")) assert not fan.has_group_address(GroupAddress("1/7/3")) assert fan.has_group_address(GroupAddress("1/6/1")) assert fan.has_group_address(GroupAddress("1/6/2")) assert fan.has_group_address(GroupAddress("1/5/1")) assert fan.has_group_address(GroupAddress("1/5/2")) xknx-3.6.0/test/devices_tests/light_test.py000066400000000000000000002063351475530762600211030ustar00rootroot00000000000000"""Unit test for Light objects.""" from unittest.mock import Mock, patch from xknx import XKNX from xknx.devices import Light from xknx.devices.light import ColorTemperatureType from xknx.dpt import DPTArray, DPTBinary from xknx.dpt.dpt_242 import XYYColor from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueRead, GroupValueWrite from ..conftest import EventLoopClockAdvancer class TestLight: """Class for testing Light objects.""" # # TEST SUPPORT DIMMING # def test_supports_dimm_true(self) -> None: """Test supports_dimm attribute with a light with dimmer.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_switch="1/6/4", group_address_brightness="1/6/6", ) assert light.supports_brightness def test_supports_dimm_false(self) -> None: """Test supports_dimm attribute with a Light without dimmer.""" xknx = XKNX() light = Light(xknx, "Diningroom.Light_1", group_address_switch="1/6/4") assert not light.supports_brightness # # TEST SUPPORT COLOR # def test_supports_color_true(self) -> None: """Test supports_color true.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_switch="1/6/4", group_address_color="1/6/5", ) assert light.supports_color def test_supports_color_false(self) -> None: """Test supports_color false.""" xknx = XKNX() light = Light(xknx, "Diningroom.Light_1", group_address_switch="1/6/4") assert not light.supports_color def test_supports_individual_color_true(self) -> None: """Test supports_color true.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_switch_red="1/1/1", group_address_switch_red_state="1/1/2", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_switch_green="1/1/5", group_address_switch_green_state="1/1/6", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_switch_blue="1/1/9", group_address_switch_blue_state="1/1/10", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", ) assert light.supports_color def test_supports_individual_color_only_brightness_true(self) -> None: """Test supports_color true.""" xknx = XKNX() light = Light( xknx, "Individual colors only brightness", group_address_brightness_red="1/1/3", group_address_brightness_green="1/1/7", group_address_brightness_blue="1/1/11", ) assert light.supports_color def test_supports_individual_color_false(self) -> None: """Test supports_color false.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_switch_red="1/1/1", group_address_switch_red_state="1/1/2", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_switch_green="1/1/5", group_address_switch_green_state="1/1/6", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", ) assert not light.supports_color # # TEST SUPPORT COLOR RGBW # def test_supports_rgbw_true(self) -> None: """Test supports_rgbw true.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_switch="1/6/4", group_address_rgbw="1/6/5", group_address_color="1/6/6", ) assert light.supports_rgbw def test_supports_rgbw_false(self) -> None: """Test supports_color false.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_switch="1/6/4", group_address_color="1/6/6", ) assert not light.supports_rgbw def test_supports_individual_rgbw_true(self) -> None: """Test supports_rgbw true.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_switch_red="1/1/1", group_address_switch_red_state="1/1/2", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_switch_green="1/1/5", group_address_switch_green_state="1/1/6", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_switch_blue="1/1/9", group_address_switch_blue_state="1/1/10", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", group_address_switch_white="1/1/13", group_address_switch_white_state="1/1/14", group_address_brightness_white="1/1/15", group_address_brightness_white_state="1/1/16", ) assert light.supports_rgbw def test_supports_individual_color_only_brightness_rgbw_true(self) -> None: """Test supports_color true.""" xknx = XKNX() light = Light( xknx, "Individual colors only brightness", group_address_brightness_red="1/1/3", group_address_brightness_green="1/1/7", group_address_brightness_blue="1/1/11", group_address_brightness_white="1/1/12", ) assert light.supports_rgbw def test_supports_individual_rgbw_false(self) -> None: """Test supports_color false.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_switch_red="1/1/1", group_address_switch_red_state="1/1/2", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_switch_green="1/1/5", group_address_switch_green_state="1/1/6", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_switch_blue="1/1/9", group_address_switch_blue_state="1/1/10", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", ) assert not light.supports_rgbw def test_supports_hs_color_true(self) -> None: """Test supports_hs_color true.""" xknx = XKNX() light = Light( xknx, "Hue and saturation", group_address_switch="1/6/4", group_address_hue="1/6/5", group_address_saturation="1/6/6", ) assert light.supports_hs_color def test_supports_hs_color_false(self) -> None: """Test supports_hs_color false.""" xknx = XKNX() light_hue = Light( xknx, "Light hue only", group_address_switch="1/6/4", group_address_hue="1/6/5", ) assert not light_hue.supports_hs_color light_saturation = Light( xknx, "Light saturation only", group_address_switch="1/6/4", group_address_saturation="1/6/5", ) assert not light_saturation.supports_hs_color def test_supports_xyy_color_true(self) -> None: """Test supports_xyy_color true.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_switch="1/6/4", group_address_xyy_color="1/6/5", ) assert light.supports_xyy_color def test_supports_xyy_color_false(self) -> None: """Test supports_xyy_color false.""" xknx = XKNX() light = Light(xknx, "Diningroom.Light_1", group_address_switch="1/6/4") assert not light.supports_xyy_color # # TEST SUPPORT TUNABLE WHITE # def test_supports_tw_yes(self) -> None: """Test supports_tw attribute with a light with tunable white function.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_switch="1/6/4", group_address_tunable_white="1/6/6", ) assert light.supports_tunable_white def test_supports_tw_no(self) -> None: """Test supports_tw attribute with a Light without tunable white function.""" xknx = XKNX() light = Light(xknx, "Diningroom.Light_1", group_address_switch="1/6/4") assert not light.supports_tunable_white # # TEST SUPPORT COLOR TEMPERATURE # def test_supports_color_temp_true(self) -> None: """Test supports_color_temp attribute with a light with color temperature function.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_switch="1/6/4", group_address_color_temperature="1/6/6", ) assert light.supports_color_temperature def test_supports_color_temp_false(self) -> None: """Test supports_color_temp attribute with a Light without color temperature function.""" xknx = XKNX() light = Light(xknx, "Diningroom.Light_1", group_address_switch="1/6/4") assert not light.supports_color_temperature # # SYNC # async def test_sync(self) -> None: """Test sync function / sending group reads to KNX bus.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/3/5", group_address_switch_state="1/2/3", group_address_brightness_state="1/2/5", group_address_color_state="1/2/6", group_address_xyy_color_state="1/2/4", group_address_tunable_white_state="1/2/7", group_address_color_temperature_state="1/2/8", group_address_rgbw_state="1/2/9", ) expected_telegrams = 7 await light.sync() assert xknx.telegrams.qsize() == expected_telegrams telegrams = [xknx.telegrams.get_nowait() for _ in range(expected_telegrams)] test_telegrams = [ Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueRead() ), Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueRead() ), Telegram( destination_address=GroupAddress("1/2/6"), payload=GroupValueRead() ), Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueRead() ), Telegram( destination_address=GroupAddress("1/2/9"), payload=GroupValueRead() ), Telegram( destination_address=GroupAddress("1/2/7"), payload=GroupValueRead() ), Telegram( destination_address=GroupAddress("1/2/8"), payload=GroupValueRead() ), ] for test_telegram in test_telegrams: assert test_telegram in telegrams async def test_sync_individual_color(self) -> None: """Test sync function / sending group reads to KNX bus. Testing with a Light without dimm functionality.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_switch_red="1/1/1", group_address_switch_red_state="1/1/2", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_switch_green="1/1/5", group_address_switch_green_state="1/1/6", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_switch_blue="1/1/9", group_address_switch_blue_state="1/1/10", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", group_address_switch_white="1/1/13", group_address_switch_white_state="1/1/14", group_address_brightness_white="1/1/15", group_address_brightness_white_state="1/1/16", ) await light.sync() assert xknx.telegrams.qsize() == 8 telegrams = [xknx.telegrams.get_nowait() for _ in range(8)] test_telegrams = [ Telegram( destination_address=GroupAddress("1/1/2"), payload=GroupValueRead(), ), Telegram( destination_address=GroupAddress("1/1/4"), payload=GroupValueRead(), ), Telegram( destination_address=GroupAddress("1/1/6"), payload=GroupValueRead(), ), Telegram( destination_address=GroupAddress("1/1/8"), payload=GroupValueRead(), ), Telegram( destination_address=GroupAddress("1/1/10"), payload=GroupValueRead(), ), Telegram( destination_address=GroupAddress("1/1/12"), payload=GroupValueRead(), ), Telegram( destination_address=GroupAddress("1/1/14"), payload=GroupValueRead(), ), Telegram( destination_address=GroupAddress("1/1/16"), payload=GroupValueRead(), ), ] for test_telegram in test_telegrams: assert test_telegram in telegrams # # TEST SET ON # async def test_set_on(self) -> None: """Test switching on a Light.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_brightness="1/2/5", ) await light.set_on() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) async def test_set_on_individual_color(self) -> None: """Test switching on a Light.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_switch_red="1/1/1", group_address_switch_red_state="1/1/2", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_switch_green="1/1/5", group_address_switch_green_state="1/1/6", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_switch_blue="1/1/9", group_address_switch_blue_state="1/1/10", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", group_address_switch_white="1/1/13", group_address_switch_white_state="1/1/14", group_address_brightness_white="1/1/15", group_address_brightness_white_state="1/1/16", ) assert light.state is None for color in light._iter_individual_colors(): assert color.is_on is None await light.set_on() assert xknx.telegrams.qsize() == 4 telegrams = [xknx.telegrams.get_nowait() for _ in range(4)] test_telegrams = [ Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueWrite(DPTBinary(True)), ), Telegram( destination_address=GroupAddress("1/1/5"), payload=GroupValueWrite(DPTBinary(True)), ), Telegram( destination_address=GroupAddress("1/1/9"), payload=GroupValueWrite(DPTBinary(True)), ), Telegram( destination_address=GroupAddress("1/1/13"), payload=GroupValueWrite(DPTBinary(True)), ), ] for test_telegram in test_telegrams: assert test_telegram in telegrams for telegram in telegrams: light.process(telegram) assert light.state is True for color in light._iter_individual_colors(): assert color.is_on is True async def test_set_on_individual_color_only_brightness(self) -> None: """Test switching on a Light.""" xknx = XKNX() light = Light( xknx, "Individual colors only brightness", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", group_address_brightness_white="1/1/15", group_address_brightness_white_state="1/1/16", ) assert light.state is None for color in light._iter_individual_colors(): assert color.is_on is None await light.set_on() assert xknx.telegrams.qsize() == 4 telegrams = [xknx.telegrams.get_nowait() for _ in range(4)] test_telegrams = [ Telegram( destination_address=GroupAddress("1/1/3"), payload=GroupValueWrite(DPTArray((0xFF,))), ), Telegram( destination_address=GroupAddress("1/1/7"), payload=GroupValueWrite(DPTArray((0xFF,))), ), Telegram( destination_address=GroupAddress("1/1/11"), payload=GroupValueWrite(DPTArray((0xFF,))), ), Telegram( destination_address=GroupAddress("1/1/15"), payload=GroupValueWrite(DPTArray((0xFF,))), ), ] for test_telegram in test_telegrams: assert test_telegram in telegrams for telegram in telegrams: light.process(telegram) assert light.state is True for color in light._iter_individual_colors(): assert color.is_on is True # # TEST SET OFF # async def test_set_off(self) -> None: """Test switching off a Light.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_brightness="1/2/5", ) await light.set_off() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(0)), ) light.process(telegram) assert light.state is False async def test_set_off_individual_color(self) -> None: """Test switching off a Light.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_switch_red="1/1/1", group_address_switch_red_state="1/1/2", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_switch_green="1/1/5", group_address_switch_green_state="1/1/6", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_switch_blue="1/1/9", group_address_switch_blue_state="1/1/10", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", group_address_switch_white="1/1/13", group_address_switch_white_state="1/1/14", group_address_brightness_white="1/1/15", group_address_brightness_white_state="1/1/16", ) await light.set_off() assert xknx.telegrams.qsize() == 4 telegrams = [xknx.telegrams.get_nowait() for _ in range(4)] test_telegrams = [ Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueWrite(DPTBinary(False)), ), Telegram( destination_address=GroupAddress("1/1/5"), payload=GroupValueWrite(DPTBinary(False)), ), Telegram( destination_address=GroupAddress("1/1/9"), payload=GroupValueWrite(DPTBinary(False)), ), Telegram( destination_address=GroupAddress("1/1/13"), payload=GroupValueWrite(DPTBinary(False)), ), ] for test_telegram in test_telegrams: assert test_telegram in telegrams for telegram in telegrams: light.process(telegram) assert light.state is False for color in light._iter_individual_colors(): assert color.is_on is False async def test_set_off_individual_color_only_brightness(self) -> None: """Test switching off a Light.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", group_address_brightness_white="1/1/15", group_address_brightness_white_state="1/1/16", ) await light.set_off() assert xknx.telegrams.qsize() == 4 telegrams = [xknx.telegrams.get_nowait() for _ in range(4)] test_telegrams = [ Telegram( destination_address=GroupAddress("1/1/3"), payload=GroupValueWrite(DPTArray((0,))), ), Telegram( destination_address=GroupAddress("1/1/7"), payload=GroupValueWrite(DPTArray((0,))), ), Telegram( destination_address=GroupAddress("1/1/11"), payload=GroupValueWrite(DPTArray((0,))), ), Telegram( destination_address=GroupAddress("1/1/15"), payload=GroupValueWrite(DPTArray((0,))), ), ] for test_telegram in test_telegrams: assert test_telegram in telegrams for telegram in telegrams: light.process(telegram) assert light.state is False for color in light._iter_individual_colors(): assert color.is_on is False # # TEST SET BRIGHTNESS # async def test_set_brightness(self) -> None: """Test setting the brightness of a Light.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_brightness="1/2/5", ) await light.set_brightness(23) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray(23)), ) async def test_set_brightness_not_dimmable(self) -> None: """Test setting the brightness of a non dimmable Light.""" xknx = XKNX() light = Light(xknx, name="TestLight", group_address_switch="1/2/3") xknx.devices.async_add(light) with patch("logging.Logger.warning") as mock_warn: await light.set_brightness(23) assert xknx.telegrams.qsize() == 0 mock_warn.assert_called_with( "Dimming not supported for device %s", "TestLight" ) async def test_set_individual_color_with_gloabl_switch(self) -> None: """Test switching on and dimming a Light with global addresses.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_switch="1/0/0", group_address_brightness="1/0/1", group_address_switch_red="1/1/1", group_address_switch_red_state="1/1/2", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_switch_green="1/1/5", group_address_switch_green_state="1/1/6", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_switch_blue="1/1/9", group_address_switch_blue_state="1/1/10", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", group_address_switch_white="1/1/13", group_address_switch_white_state="1/1/14", group_address_brightness_white="1/1/15", group_address_brightness_white_state="1/1/16", ) assert light.state is None for color in light._iter_individual_colors(): assert color.is_on is None # turn on await light.set_on() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/0/0"), payload=GroupValueWrite(DPTBinary(True)), ) # brightness await light.set_brightness(23) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/0/1"), payload=GroupValueWrite(DPTArray(23)), ) # # TEST SET COLOR # async def test_set_color(self) -> None: """Test setting the color of a Light.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_color="1/2/5", ) xknx.devices.async_add(light) await light.set_color((23, 24, 25)) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray((23, 24, 25))), ) xknx.devices.process(telegram) assert light.current_color == ((23, 24, 25), None) async def test_set_color_not_possible(self) -> None: """Test setting the color of a non light without color.""" xknx = XKNX() light = Light(xknx, name="TestLight", group_address_switch="1/2/3") xknx.devices.async_add(light) with patch("logging.Logger.warning") as mock_warn: await light.set_color((23, 24, 25)) assert xknx.telegrams.qsize() == 0 mock_warn.assert_called_with( "Colors not supported for device %s", "TestLight" ) async def test_set_individual_color(self) -> None: """Test setting the color of a Light.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_switch_red="1/1/1", group_address_switch_red_state="1/1/2", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_switch_green="1/1/5", group_address_switch_green_state="1/1/6", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_switch_blue="1/1/9", group_address_switch_blue_state="1/1/10", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", ) xknx.devices.async_add(light) await light.set_color([23, 24, 25]) assert xknx.telegrams.qsize() == 3 telegrams = [xknx.telegrams.get_nowait() for _ in range(3)] test_telegrams = [ Telegram( destination_address=GroupAddress("1/1/3"), payload=GroupValueWrite(DPTArray(23)), ), Telegram( destination_address=GroupAddress("1/1/7"), payload=GroupValueWrite(DPTArray(24)), ), Telegram( destination_address=GroupAddress("1/1/11"), payload=GroupValueWrite(DPTArray(25)), ), ] for test_telegram in test_telegrams: assert test_telegram in telegrams xknx.devices.process( Telegram( destination_address=GroupAddress("1/1/4"), payload=GroupValueWrite(DPTArray(23)), ) ) xknx.devices.process( Telegram( destination_address=GroupAddress("1/1/8"), payload=GroupValueWrite(DPTArray(24)), ) ) xknx.devices.process( Telegram( destination_address=GroupAddress("1/1/12"), payload=GroupValueWrite(DPTArray(25)), ) ) assert light.current_color == ((23, 24, 25), None) async def test_set_individual_color_not_possible(self) -> None: """Test setting the color of a non light without color.""" xknx = XKNX() light = Light( xknx, "TestLight", group_address_switch_red="1/1/1", ) xknx.devices.async_add(light) with patch("logging.Logger.warning") as mock_warn: await light.set_color((23, 24, 25)) assert xknx.telegrams.qsize() == 0 mock_warn.assert_called_with( "Colors not supported for device %s", "TestLight" ) # # TEST SET COLOR AS RGBW # async def test_set_color_rgbw(self) -> None: """Test setting RGBW value of a Light.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_color="1/2/4", group_address_rgbw="1/2/5", ) xknx.devices.async_add(light) await light.set_color((23, 24, 25), 26) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray((23, 24, 25, 26, 0, 15))), ) xknx.devices.process(telegram) assert light.current_color == ((23, 24, 25), 26) async def test_set_color_rgbw_not_possible(self) -> None: """Test setting RGBW value of a non light without color.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_color="1/2/4", ) xknx.devices.async_add(light) with patch("logging.Logger.warning") as mock_warn: await light.set_color((23, 24, 25), 26) assert xknx.telegrams.qsize() == 0 mock_warn.assert_called_with( "RGBW not supported for device %s", "TestLight" ) async def test_set_individual_color_rgbw(self) -> None: """Test setting RGBW value of a Light.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_switch_red="1/1/1", group_address_switch_red_state="1/1/2", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_switch_green="1/1/5", group_address_switch_green_state="1/1/6", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_switch_blue="1/1/9", group_address_switch_blue_state="1/1/10", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", group_address_switch_white="1/1/13", group_address_switch_white_state="1/1/14", group_address_brightness_white="1/1/15", group_address_brightness_white_state="1/1/16", ) xknx.devices.async_add(light) await light.set_color([23, 24, 25], white=26) assert xknx.telegrams.qsize() == 4 telegrams = [xknx.telegrams.get_nowait() for _ in range(4)] test_telegrams = [ Telegram( destination_address=GroupAddress("1/1/3"), payload=GroupValueWrite(DPTArray(23)), ), Telegram( destination_address=GroupAddress("1/1/7"), payload=GroupValueWrite(DPTArray(24)), ), Telegram( destination_address=GroupAddress("1/1/11"), payload=GroupValueWrite(DPTArray(25)), ), Telegram( destination_address=GroupAddress("1/1/15"), payload=GroupValueWrite(DPTArray(26)), ), ] for test_telegram in test_telegrams: assert test_telegram in telegrams xknx.devices.process( Telegram( destination_address=GroupAddress("1/1/4"), payload=GroupValueWrite(DPTArray(23)), ) ) xknx.devices.process( Telegram( destination_address=GroupAddress("1/1/8"), payload=GroupValueWrite(DPTArray(24)), ) ) xknx.devices.process( Telegram( destination_address=GroupAddress("1/1/12"), payload=GroupValueWrite(DPTArray(25)), ) ) xknx.devices.process( Telegram( destination_address=GroupAddress("1/1/16"), payload=GroupValueWrite(DPTArray(26)), ) ) assert light.current_color == ((23, 24, 25), 26) async def test_set_individual_color_rgbw_not_possible(self) -> None: """Test setting RGBW value of a non light without color.""" xknx = XKNX() light = Light( xknx, "TestLight", group_address_switch_red="1/1/1", group_address_switch_red_state="1/1/2", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_switch_green="1/1/5", group_address_switch_green_state="1/1/6", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_switch_blue="1/1/9", group_address_switch_blue_state="1/1/10", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", ) xknx.devices.async_add(light) with patch("logging.Logger.warning") as mock_warn: await light.set_color((23, 24, 25), 26) assert xknx.telegrams.qsize() == 0 mock_warn.assert_called_with( "RGBW not supported for device %s", "TestLight" ) # # TEST SET COLOR AS HS # async def test_set_hs_color(self) -> None: """Test setting HS value of a Light.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_hue="1/2/4", group_address_saturation="1/2/5", ) xknx.devices.async_add(light) await light.set_hs_color((359, 99)) assert xknx.telegrams.qsize() == 2 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray((0xFE,))), ) xknx.devices.process(telegram) telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray((0xFC,))), ) xknx.devices.process(telegram) assert light.current_hs_color == (359, 99) # change only one await light.set_hs_color((18, 99)) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray((0x0D,))), ) xknx.devices.process(telegram) await light.set_hs_color((18, 3)) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray((0x08,))), ) xknx.devices.process(telegram) # call set_hs_color with current color shall trigger both values await light.set_hs_color((18, 3)) assert xknx.telegrams.qsize() == 2 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray((0x0D,))), ) xknx.devices.process(telegram) telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray((0x08,))), ) xknx.devices.process(telegram) async def test_set_hs_color_not_possible(self) -> None: """Test setting HS value of a light not supporting it.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_color="1/2/4", ) xknx.devices.async_add(light) with patch("logging.Logger.warning") as mock_warn: await light.set_hs_color((22, 25)) assert xknx.telegrams.qsize() == 0 mock_warn.assert_called_with( "HS-color not supported for device %s", "TestLight" ) # # TEST SET COLOR AS XYY # async def test_set_xyy_color(self) -> None: """Test setting XYY value of a Light.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_xyy_color="1/2/4", ) xknx.devices.async_add(light) await light.set_xyy_color(XYYColor((0.52, 0.31), 25)) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray((0x85, 0x1E, 0x4F, 0x5C, 0x19, 0x03))), ) xknx.devices.process(telegram) assert light.current_xyy_color == XYYColor((0.52, 0.31), 25) async def test_set_xyy_color_not_possible(self) -> None: """Test setting XYY value of a light not supporting it.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_color="1/2/4", ) xknx.devices.async_add(light) with patch("logging.Logger.warning") as mock_warn: await light.set_xyy_color(XYYColor((0.5, 0.3), 25)) assert xknx.telegrams.qsize() == 0 mock_warn.assert_called_with( "XYY-color not supported for device %s", "TestLight" ) # # TEST SET TUNABLE WHITE # async def test_set_tw(self) -> None: """Test setting the tunable white value of a Light.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_tunable_white="1/2/5", ) xknx.devices.async_add(light) await light.set_tunable_white(23) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray(23)), ) async def test_set_tw_unsupported(self) -> None: """Test setting the tunable white value of a non tw Light.""" xknx = XKNX() light = Light(xknx, name="TestLight", group_address_switch="1/2/3") xknx.devices.async_add(light) with patch("logging.Logger.warning") as mock_warn: await light.set_tunable_white(23) assert xknx.telegrams.qsize() == 0 mock_warn.assert_called_with( "Tunable white not supported for device %s", "TestLight" ) # # TEST SET COLOR TEMPERATURE # async def test_set_color_temp(self) -> None: """Test setting the color temperature value of a Light.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_color_temperature="1/2/5", ) xknx.devices.async_add(light) await light.set_color_temperature(4000) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite( DPTArray( ( 0x0F, 0xA0, ) ) ), ) async def test_set_color_temp_float(self) -> None: """Test setting the float color temperature value of a Light.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_color_temperature="1/2/5", color_temperature_type=ColorTemperatureType.FLOAT_2_BYTE, ) xknx.devices.async_add(light) await light.set_color_temperature(4000) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite( DPTArray( ( 0x46, 0x1A, ) ) ), ) async def test_set_color_temp_unsupported(self) -> None: """Test setting the color temperature value of an unsupported Light.""" xknx = XKNX() light = Light(xknx, name="TestLight", group_address_switch="1/2/3") xknx.devices.async_add(light) with patch("logging.Logger.warning") as mock_warn: await light.set_color_temperature(4000) assert xknx.telegrams.qsize() == 0 mock_warn.assert_called_with( "Absolute Color Temperature not supported for device %s", "TestLight" ) # # TEST PROCESS # async def test_process_switch(self) -> None: """Test process / reading telegrams from telegram queue. Test if switch position is processed correctly.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_brightness="1/2/5", ) xknx.devices.async_add(light) assert light.state is None telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) light.process(telegram) assert light.state is True telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(0)), ) light.process(telegram) assert light.state is False async def test_process_color_switch(self) -> None: """Test process / reading telegrams from telegram queue. Test if switch position is processed correctly.""" xknx = XKNX() light = Light( xknx, "TestLight", group_address_switch_red="1/1/1", group_address_switch_red_state="1/1/2", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_switch_green="1/1/5", group_address_switch_green_state="1/1/6", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_switch_blue="1/1/9", group_address_switch_blue_state="1/1/10", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", ) xknx.devices.async_add(light) assert light.state is None telegram = Telegram( destination_address=GroupAddress("1/1/2"), payload=GroupValueWrite(DPTBinary(True)), ) light.process(telegram) assert light.state is True telegram = Telegram( destination_address=GroupAddress("1/1/2"), payload=GroupValueWrite(DPTBinary(False)), ) light.process(telegram) assert light.state is False async def test_process_switch_callback(self) -> None: """Test process / reading telegrams from telegram queue. Test if callback is called.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_brightness="1/2/5", ) xknx.devices.async_add(light) after_update_callback = Mock() light.register_device_updated_cb(after_update_callback) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) light.process(telegram) after_update_callback.assert_called_with(light) async def test_process_dimm(self) -> None: """Test process / reading telegrams from telegram queue. Test if brightness is processed.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_brightness="1/2/5", ) xknx.devices.async_add(light) assert light.current_brightness is None telegram = Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray(23)), ) light.process(telegram) assert light.current_brightness == 23 async def test_process_dimm_wrong_payload(self) -> None: """Test process wrong telegrams. (wrong payload type).""" xknx = XKNX() cb_mock = Mock() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_brightness="1/2/5", device_updated_cb=cb_mock, ) xknx.devices.async_add(light) telegram = Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTBinary(1)), ) with patch("logging.Logger.warning") as log_mock: light.process(telegram) log_mock.assert_called_once() cb_mock.assert_not_called() async def test_process_dimm_payload_invalid_length(self) -> None: """Test process wrong telegrams. (wrong payload length).""" xknx = XKNX() cb_mock = Mock() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_brightness="1/2/5", device_updated_cb=cb_mock, ) xknx.devices.async_add(light) telegram = Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray((23, 24))), ) with patch("logging.Logger.warning") as log_mock: light.process(telegram) log_mock.assert_called_once() cb_mock.assert_not_called() async def test_process_color(self) -> None: """Test process / reading telegrams from telegram queue. Test if color is processed.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_color="1/2/5", ) xknx.devices.async_add(light) assert light.current_color == (None, None) telegram = Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray((23, 24, 25))), ) light.process(telegram) assert light.current_color == ((23, 24, 25), None) async def test_process_individual_color(self) -> None: """Test process / reading telegrams from telegram queue. Test if color is processed.""" xknx = XKNX() light = Light( xknx, "TestLight", group_address_switch_red="1/1/1", group_address_switch_red_state="1/1/2", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_switch_green="1/1/5", group_address_switch_green_state="1/1/6", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_switch_blue="1/1/9", group_address_switch_blue_state="1/1/10", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", ) xknx.devices.async_add(light) assert light.current_color == (None, None) telegrams = [ Telegram( destination_address=GroupAddress("1/1/4"), payload=GroupValueWrite(DPTArray(42)), ), Telegram( destination_address=GroupAddress("1/1/8"), payload=GroupValueWrite(DPTArray(43)), ), Telegram( destination_address=GroupAddress("1/1/12"), payload=GroupValueWrite(DPTArray(44)), ), ] for telegram in telegrams: light.process(telegram) assert light.current_color == ((42, 43, 44), None) async def test_process_color_rgbw(self) -> None: """Test process / reading telegrams from telegram queue. Test if RGBW is processed.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_color="1/2/4", group_address_rgbw="1/2/5", ) xknx.devices.async_add(light) assert light.current_color == (None, None) telegram = Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray((23, 24, 25, 26, 0, 15))), ) light.process(telegram) assert light.current_color == ((23, 24, 25), 26) async def test_process_individual_color_rgbw(self) -> None: """Test process / reading telegrams from telegram queue. Test if RGBW is processed.""" xknx = XKNX() light = Light( xknx, "Diningroom.Light_1", group_address_switch_red="1/1/1", group_address_switch_red_state="1/1/2", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_switch_green="1/1/5", group_address_switch_green_state="1/1/6", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_switch_blue="1/1/9", group_address_switch_blue_state="1/1/10", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", group_address_switch_white="1/1/13", group_address_switch_white_state="1/1/14", group_address_brightness_white="1/1/15", group_address_brightness_white_state="1/1/16", ) xknx.devices.async_add(light) assert light.current_color == (None, None) telegram = Telegram( destination_address=GroupAddress("1/1/16"), payload=GroupValueWrite(DPTArray(42)), ) light.process(telegram) assert light.current_color == (None, 42) async def test_process_individual_color_debouncer( self, time_travel: EventLoopClockAdvancer ) -> None: """Test the debouncer for individual color lights.""" xknx = XKNX() rgb_callback = Mock() rgbw_callback = Mock() rgb_light = Light( xknx, "TestRGBLight", group_address_switch_red="1/1/1", group_address_switch_red_state="1/1/2", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_switch_green="1/1/5", group_address_switch_green_state="1/1/6", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_switch_blue="1/1/9", group_address_switch_blue_state="1/1/10", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", device_updated_cb=rgb_callback, ) xknx.devices.async_add(rgb_light) rgbw_light = Light( xknx, "TestRGBWLight", group_address_switch="1/1/0", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", group_address_brightness_white="1/1/15", group_address_brightness_white_state="1/1/16", device_updated_cb=rgbw_callback, ) xknx.devices.async_add(rgbw_light) assert rgb_light.current_color == (None, None) assert rgbw_light.current_color == (None, None) red = Telegram( destination_address=GroupAddress("1/1/4"), payload=GroupValueWrite(DPTArray(42)), ) green = Telegram( destination_address=GroupAddress("1/1/8"), payload=GroupValueWrite(DPTArray(43)), ) blue = Telegram( destination_address=GroupAddress("1/1/12"), payload=GroupValueWrite(DPTArray(44)), ) white = Telegram( destination_address=GroupAddress("1/1/16"), payload=GroupValueWrite(DPTArray(50)), ) xknx.devices.process(red) rgb_callback.assert_not_called() rgbw_callback.assert_not_called() xknx.devices.process(green) rgb_callback.assert_not_called() rgbw_callback.assert_not_called() xknx.devices.process(blue) rgb_callback.assert_called_once() rgbw_callback.assert_not_called() xknx.devices.process(white) rgbw_callback.assert_called_once() assert rgb_light.current_color == ((42, 43, 44), None) assert rgbw_light.current_color == ((42, 43, 44), 50) rgb_callback.reset_mock() rgbw_callback.reset_mock() # second time with only 2 telegrams xknx.devices.process(red) rgb_callback.assert_not_called() rgbw_callback.assert_not_called() xknx.devices.process(green) rgb_callback.assert_not_called() rgbw_callback.assert_not_called() await time_travel(Light.DEBOUNCE_TIMEOUT / 2) rgb_callback.assert_not_called() rgbw_callback.assert_not_called() await time_travel(Light.DEBOUNCE_TIMEOUT / 2) rgb_callback.assert_called_once() rgbw_callback.assert_called_once() assert rgb_light.current_color == ((42, 43, 44), None) assert rgbw_light.current_color == ((42, 43, 44), 50) async def test_process_hs_color(self) -> None: """Test process / reading telegrams from telegram queue. Test if color is processed.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_hue="1/2/4", group_address_hue_state="1/4/4", group_address_saturation="1/2/5", group_address_saturation_state="1/4/5", ) assert light.current_hs_color is None # initialize hue light.process( Telegram( destination_address=GroupAddress("1/4/4"), payload=GroupValueWrite(DPTArray((0x2E,))), ) ) assert light.current_hs_color is None # initialize saturation light.process( Telegram( destination_address=GroupAddress("1/4/5"), payload=GroupValueWrite(DPTArray((0x55,))), ) ) assert light.current_hs_color == (65, 33) async def test_process_xyy_color(self) -> None: """Test process / reading telegrams from telegram queue. Test if color is processed.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_xyy_color="1/2/5", ) assert light.current_xyy_color is None # initial with invalid brightness light.process( Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray((0x2E, 0x14, 0x40, 0x00, 0x55, 0x02))), ) ) assert light.current_xyy_color == XYYColor((0.18, 0.25), None) # add valid brightness light.process( Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray((0x2E, 0x14, 0x40, 0x00, 0x55, 0x03))), ) ) assert light.current_xyy_color == XYYColor((0.18, 0.25), 85) # invalid color light.process( Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray((0xD1, 0xEB, 0xB0, 0xA3, 0xA5, 0x01))), ) ) assert light.current_xyy_color == XYYColor((0.18, 0.25), 165) # both valid light.process( Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray((0xD1, 0xEB, 0xB0, 0xA3, 0xA5, 0x03))), ) ) assert light.current_xyy_color == XYYColor((0.82, 0.69), 165) # invalid brightness light.process( Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray((0x2E, 0x14, 0x40, 0x00, 0x00, 0x02))), ) ) assert light.current_xyy_color == XYYColor((0.18, 0.25), 165) async def test_process_tunable_white(self) -> None: """Test process / reading telegrams from telegram queue. Test if tunable white is processed.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_tunable_white="1/2/5", ) assert light.current_tunable_white is None telegram = Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray(23)), ) light.process(telegram) assert light.current_tunable_white == 23 async def test_process_tunable_white_wrong_payload(self) -> None: """Test process wrong telegrams. (wrong payload type).""" xknx = XKNX() cb_mock = Mock() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_tunable_white="1/2/5", device_updated_cb=cb_mock, ) telegram = Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTBinary(1)), ) with patch("logging.Logger.warning") as log_mock: light.process(telegram) log_mock.assert_called_once() cb_mock.assert_not_called() async def test_process_tunable_white_payload_invalid_length(self) -> None: """Test process wrong telegrams. (wrong payload length).""" xknx = XKNX() cb_mock = Mock() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_tunable_white="1/2/5", device_updated_cb=cb_mock, ) telegram = Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray((23, 24))), ) with patch("logging.Logger.warning") as log_mock: light.process(telegram) log_mock.assert_called_once() cb_mock.assert_not_called() async def test_process_color_temperature(self) -> None: """Test process / reading telegrams from telegram queue. Test if color temperature is processed.""" xknx = XKNX() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_color_temperature="1/2/5", ) assert light.current_color_temperature is None telegram = Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite( DPTArray( ( 0x0F, 0xA0, ) ) ), ) light.process(telegram) assert light.current_color_temperature == 4000 async def test_process_color_temperature_wrong_payload(self) -> None: """Test process wrong telegrams. (wrong payload type).""" xknx = XKNX() cb_mock = Mock() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_color_temperature="1/2/5", device_updated_cb=cb_mock, ) telegram = Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTBinary(1)), ) with patch("logging.Logger.warning") as log_mock: light.process(telegram) log_mock.assert_called_once() cb_mock.assert_not_called() async def test_process_color_temperature_payload_invalid_length(self) -> None: """Test process wrong telegrams. (wrong payload length).""" xknx = XKNX() cb_mock = Mock() light = Light( xknx, name="TestLight", group_address_switch="1/2/3", group_address_color_temperature="1/2/5", device_updated_cb=cb_mock, ) telegram = Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTArray(23)), ) with patch("logging.Logger.warning") as log_mock: light.process(telegram) log_mock.assert_called_once() cb_mock.assert_not_called() def test_has_group_address(self) -> None: """Test has_group_address.""" xknx = XKNX() light = Light( xknx, "Office.Light_1", group_address_switch="1/7/1", group_address_switch_state="1/7/2", group_address_brightness="1/7/3", group_address_brightness_state="1/7/4", group_address_color="1/7/5", group_address_color_state="1/7/6", group_address_tunable_white="1/7/7", group_address_tunable_white_state="1/7/8", group_address_color_temperature="1/7/9", group_address_color_temperature_state="1/7/10", group_address_rgbw="1/7/11", group_address_rgbw_state="1/7/12", group_address_hue="1/7/81", group_address_hue_state="1/7/82", group_address_saturation="1/7/83", group_address_saturation_state="1/7/84", group_address_xyy_color="1/7/13", group_address_xyy_color_state="1/7/14", group_address_switch_red="1/1/1", group_address_switch_red_state="1/1/2", group_address_brightness_red="1/1/3", group_address_brightness_red_state="1/1/4", group_address_switch_green="1/1/5", group_address_switch_green_state="1/1/6", group_address_brightness_green="1/1/7", group_address_brightness_green_state="1/1/8", group_address_switch_blue="1/1/9", group_address_switch_blue_state="1/1/10", group_address_brightness_blue="1/1/11", group_address_brightness_blue_state="1/1/12", group_address_switch_white="1/1/13", group_address_switch_white_state="1/1/14", group_address_brightness_white="1/1/15", group_address_brightness_white_state="1/1/16", ) assert light.has_group_address(GroupAddress("1/7/1")) assert light.has_group_address(GroupAddress("1/7/2")) assert light.has_group_address(GroupAddress("1/7/3")) assert light.has_group_address(GroupAddress("1/7/4")) assert light.has_group_address(GroupAddress("1/7/5")) assert light.has_group_address(GroupAddress("1/7/6")) assert light.has_group_address(GroupAddress("1/7/7")) assert light.has_group_address(GroupAddress("1/7/8")) assert light.has_group_address(GroupAddress("1/7/9")) assert light.has_group_address(GroupAddress("1/7/10")) assert light.has_group_address(GroupAddress("1/7/11")) assert light.has_group_address(GroupAddress("1/7/12")) # hue assert light.has_group_address(GroupAddress("1/7/81")) assert light.has_group_address(GroupAddress("1/7/82")) # saturation assert light.has_group_address(GroupAddress("1/7/83")) assert light.has_group_address(GroupAddress("1/7/84")) # xyy assert light.has_group_address(GroupAddress("1/7/13")) assert light.has_group_address(GroupAddress("1/7/14")) # individual assert light.has_group_address(GroupAddress("1/1/1")) assert light.has_group_address(GroupAddress("1/1/2")) assert light.has_group_address(GroupAddress("1/1/3")) assert light.has_group_address(GroupAddress("1/1/4")) assert light.has_group_address(GroupAddress("1/1/5")) assert light.has_group_address(GroupAddress("1/1/6")) assert light.has_group_address(GroupAddress("1/1/7")) assert light.has_group_address(GroupAddress("1/1/8")) assert light.has_group_address(GroupAddress("1/1/9")) assert light.has_group_address(GroupAddress("1/1/10")) assert light.has_group_address(GroupAddress("1/1/11")) assert light.has_group_address(GroupAddress("1/1/12")) assert light.has_group_address(GroupAddress("1/1/13")) assert light.has_group_address(GroupAddress("1/1/14")) assert light.has_group_address(GroupAddress("1/1/15")) assert light.has_group_address(GroupAddress("1/1/16")) assert not light.has_group_address(GroupAddress("1/7/15")) xknx-3.6.0/test/devices_tests/notification_test.py000066400000000000000000000155051475530762600224570ustar00rootroot00000000000000"""Unit test for Notification objects.""" from unittest.mock import Mock, patch from xknx import XKNX from xknx.devices import Notification from xknx.dpt import DPTArray, DPTBinary, DPTString from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite class TestNotification: """Test class for Notification object.""" # # SYNC # async def test_sync_state(self) -> None: """Test sync function / sending group reads to KNX bus.""" xknx = XKNX() notification = Notification( xknx, "Warning", group_address="1/2/3", group_address_state="1/2/4" ) await notification.sync() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueRead() ) # # TEST PROCESS # async def test_process(self) -> None: """Test process telegram with notification. Test if device was updated.""" xknx = XKNX() notification = Notification(xknx, "Warning", group_address="1/2/3") telegram_set = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTString().to_knx("Ein Prosit!")), ) notification.process(telegram_set) assert notification.message == "Ein Prosit!" telegram_unset = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTString().to_knx("")), ) notification.process(telegram_unset) assert notification.message == "" async def test_process_callback(self) -> None: """Test process / reading telegrams from telegram queue. Test if callback was called.""" xknx = XKNX() notification = Notification(xknx, "Warning", group_address="1/2/3") after_update_callback = Mock() notification.register_device_updated_cb(after_update_callback) telegram_set = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTString().to_knx("Ein Prosit!")), ) notification.process(telegram_set) after_update_callback.assert_called_with(notification) async def test_process_payload_invalid_length(self) -> None: """Test process wrong telegram (wrong payload length).""" xknx = XKNX() after_update_callback = Mock() notification = Notification( xknx, "Warning", group_address="1/2/3", device_updated_cb=after_update_callback, ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((23, 24))), ) with patch("logging.Logger.warning") as log_mock: notification.process(telegram) log_mock.assert_called_once() after_update_callback.assert_not_called() async def test_process_wrong_payload(self) -> None: """Test process wrong telegram (wrong payload type).""" xknx = XKNX() after_update_callback = Mock() notification = Notification( xknx, "Warning", group_address="1/2/3", device_updated_cb=after_update_callback, ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) with patch("logging.Logger.warning") as log_mock: notification.process(telegram) log_mock.assert_called_once() after_update_callback.assert_not_called() # # TEST RESPOND # async def test_respond_to_read(self) -> None: """Test respond_to_read function.""" xknx = XKNX() responding = Notification( xknx, "TestSensor1", group_address="1/1/1", respond_to_read=True, value_type="latin_1", ) non_responding = Notification( xknx, "TestSensor2", group_address="1/1/1", respond_to_read=False, value_type="latin_1", ) # set initial payload of Notification responding.remote_value.value = "Halli Hallo" non_responding.remote_value.value = "Halli Hallo" read_telegram = Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueRead() ) # verify no response when respond_to_read is False non_responding.process(read_telegram) assert xknx.telegrams.qsize() == 0 # verify response when respond_to_read is True responding.process(read_telegram) assert xknx.telegrams.qsize() == 1 response = xknx.telegrams.get_nowait() assert response == Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueResponse( DPTArray( ( 0x48, 0x61, 0x6C, 0x6C, 0x69, 0x20, 0x48, 0x61, 0x6C, 0x6C, 0x6F, 0x0, 0x0, 0x0, ), ), ), ) # # TEST SET MESSAGE # async def test_set(self) -> None: """Test notificationing off notification.""" xknx = XKNX() notification = Notification(xknx, "Warning", group_address="1/2/3") await notification.set("Ein Prosit!") assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTString().to_knx("Ein Prosit!")), ) # test if message longer than 14 chars gets cropped await notification.set("This is too long.") assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTString().to_knx("This is too lo")), ) # # TEST has_group_address # def test_has_group_address(self) -> None: """Test has_group_address.""" xknx = XKNX() notification = Notification( xknx, "Warning", group_address="1/2/3", group_address_state="1/2/4" ) assert notification.has_group_address(GroupAddress("1/2/3")) assert notification.has_group_address(GroupAddress("1/2/4")) assert not notification.has_group_address(GroupAddress("2/2/2")) xknx-3.6.0/test/devices_tests/numeric_value_test.py000066400000000000000000000237661475530762600226370ustar00rootroot00000000000000"""Unit test for NumericValue objects.""" from unittest.mock import Mock import pytest from xknx import XKNX from xknx.devices import NumericValue from xknx.dpt import DPTArray from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite class TestNumericValue: """Test class for NumericValue objects.""" @pytest.mark.parametrize( "value_type,raw_payload,expected_state", [ # DPT-14 values are according to ETS group monitor values ( "absolute_temperature", DPTArray((0x44, 0xD7, 0xD2, 0x8B)), 1726.579, ), ( "angle", DPTArray((0xE4,)), 322, ), ( "brightness", DPTArray((0xC3, 0x56)), 50006, ), ( "color_temperature", DPTArray((0x6C, 0x95)), 27797, ), ( "counter_pulses", DPTArray((0x9D,)), -99, ), ( "current", DPTArray((0xCA, 0xCC)), 51916, ), ( "percent", DPTArray((0xE3,)), 89, ), ( "percentU8", DPTArray((0x6B,)), 107, ), ( "percentV8", DPTArray((0x20,)), 32, ), ( "percentV16", DPTArray((0x8A, 0x2F)), -301.61, ), ( "pulse", DPTArray((0xFC,)), 252, ), ( "scene_number", DPTArray((0x1,)), 2, ), ], ) async def test_sensor_value_types( self, value_type: str, raw_payload: DPTArray, expected_state: int, ) -> None: """Test sensor value types.""" xknx = XKNX() sensor = NumericValue( xknx, "TestSensor", group_address="1/2/3", value_type=value_type, ) sensor.process( Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(value=raw_payload), ) ) assert sensor.resolve_state() == expected_state # # TEST RESPOND # async def test_respond_to_read(self) -> None: """Test respond_to_read function.""" xknx = XKNX() responding = NumericValue( xknx, "TestSensor1", group_address="1/1/1", respond_to_read=True, value_type="volume_liquid_litre", ) non_responding = NumericValue( xknx, "TestSensor2", group_address="1/1/1", respond_to_read=False, value_type="volume_liquid_litre", ) responding_multiple = NumericValue( xknx, "TestSensor3", group_address=["1/1/1", "3/3/3"], group_address_state="2/2/2", respond_to_read=True, value_type="volume_liquid_litre", ) # set initial payload of NumericValue responding.sensor_value.value = 256 non_responding.sensor_value.value = 256 responding_multiple.sensor_value.value = 256 read_telegram = Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueRead() ) # verify no response when respond is False non_responding.process(read_telegram) assert xknx.telegrams.qsize() == 0 # verify response when respond is True responding.process(read_telegram) assert xknx.telegrams.qsize() == 1 response = xknx.telegrams.get_nowait() assert response == Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueResponse(DPTArray((0x00, 0x00, 0x01, 0x00))), ) # verify no response when GroupValueRead request is not for group_address responding_multiple.process(read_telegram) assert xknx.telegrams.qsize() == 1 response = xknx.telegrams.get_nowait() assert response == Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueResponse(DPTArray((0x00, 0x00, 0x01, 0x00))), ) responding_multiple.process( Telegram( destination_address=GroupAddress("2/2/2"), payload=GroupValueRead() ) ) responding_multiple.process( Telegram( destination_address=GroupAddress("3/3/3"), payload=GroupValueRead() ) ) assert xknx.telegrams.qsize() == 0 # # TEST SET # async def test_set_percent(self) -> None: """Test set with percent numeric value.""" xknx = XKNX() num_value = NumericValue( xknx, "TestSensor", group_address="1/2/3", value_type="percent" ) await num_value.set(75) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0xBF,))), ) async def test_set_temperature(self) -> None: """Test set with temperature numeric value.""" xknx = XKNX() num_value = NumericValue( xknx, "TestSensor", group_address="1/2/3", value_type="temperature" ) await num_value.set(21.0) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x0C, 0x1A))), ) # test HomeAssistant device class assert num_value.ha_device_class() == "temperature" # # SYNC # async def test_sync(self) -> None: """Test sync function / sending group reads to KNX bus.""" xknx = XKNX() sensor = NumericValue( xknx, "TestSensor", value_type="temperature", group_address_state="1/2/3" ) await sensor.sync() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueRead() ) # # TEST PROCESS # async def test_process(self) -> None: """Test process / reading telegrams from telegram queue.""" xknx = XKNX() sensor = NumericValue( xknx, "TestSensor", value_type="temperature", group_address="1/2/3" ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x06, 0xA0))), ) sensor.process(telegram) assert sensor.sensor_value.value == 16.96 assert sensor.sensor_value.telegram.payload.value == DPTArray((0x06, 0xA0)) assert sensor.resolve_state() == 16.96 async def test_process_callback(self) -> None: """Test process / reading telegrams from telegram queue. Test if callback is called.""" xknx = XKNX() sensor = NumericValue( xknx, "TestSensor", group_address="1/2/3", value_type="temperature" ) after_update_callback = Mock() sensor.register_device_updated_cb(after_update_callback) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x01, 0x02))), ) sensor.process(telegram) after_update_callback.assert_called_with(sensor) assert sensor.last_telegram == telegram # consecutive telegrams with same payload shall only trigger one callback after_update_callback.reset_mock() sensor.process(telegram) after_update_callback.assert_not_called() async def test_process_callback_always(self) -> None: """Test process / reading telegrams from telegram queue. Test if callback is called.""" xknx = XKNX() sensor = NumericValue( xknx, "TestSensor", group_address="1/2/3", value_type="temperature", always_callback=True, ) after_update_callback = Mock() sensor.register_device_updated_cb(after_update_callback) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x01, 0x02))), ) sensor.process(telegram) after_update_callback.assert_called_with(sensor) assert sensor.last_telegram == telegram # every telegram shall trigger callback after_update_callback.reset_mock() sensor.process(telegram) after_update_callback.assert_called_with(sensor) async def test_process_callback_set(self) -> None: """Test setting value. Test if callback is called.""" xknx = XKNX() after_update_callback = Mock() num_value = NumericValue( xknx, "TestSensor", group_address="1/2/3", value_type="temperature" ) xknx.devices.async_add(num_value) num_value.register_device_updated_cb(after_update_callback) await num_value.set(21.0) xknx.devices.process(xknx.telegrams.get_nowait()) after_update_callback.assert_called_with(num_value) def test_string(self) -> None: """Test NumericValue string representation.""" xknx = XKNX() value = NumericValue( xknx, "Num", group_address="1/2/3", value_type="temperature" ) value.sensor_value.value = 4.9 assert ( str(value) == ' value=4.9 unit="°C"/>' ) xknx-3.6.0/test/devices_tests/raw_value_test.py000066400000000000000000000161731475530762600217600ustar00rootroot00000000000000"""Unit test for RawValue objects.""" from unittest.mock import Mock import pytest from xknx import XKNX from xknx.devices import RawValue from xknx.dpt import DPTArray, DPTBinary from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite class TestRawValue: """Test class for RawValue objects.""" @pytest.mark.parametrize( "payload_length,raw_payload,expected_state", [ # DPT-14 values are according to ETS group monitor values ( 0, DPTBinary(0), 0, ), ( 0, DPTBinary(True), 1, ), ( 0, DPTBinary(45), 45, ), ( 1, DPTArray((0x6B,)), 107, ), ( 1, DPTArray((0xFC,)), 252, ), ( 2, DPTArray((0x6C, 0x95)), 27797, ), ], ) async def test_payloads( self, payload_length: int, raw_payload: DPTArray | DPTBinary, expected_state: int, ) -> None: """Test raw value types.""" xknx = XKNX() raw_value = RawValue( xknx, "Test", payload_length=payload_length, group_address="1/2/3", ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(value=raw_payload), ) raw_value.process(telegram) assert raw_value.resolve_state() == expected_state assert raw_value.last_telegram == telegram # # TEST RESPOND # async def test_respond_to_read(self) -> None: """Test respond_to_read function.""" xknx = XKNX() responding = RawValue( xknx, "TestSensor1", 2, group_address="1/1/1", respond_to_read=True, ) non_responding = RawValue( xknx, "TestSensor2", 2, group_address="1/1/1", respond_to_read=False, ) responding_multiple = RawValue( xknx, "TestSensor3", 2, group_address=["1/1/1", "3/3/3"], group_address_state="2/2/2", respond_to_read=True, ) # set initial payload of RawValue responding.remote_value.value = 256 non_responding.remote_value.value = 256 responding_multiple.remote_value.value = 256 read_telegram = Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueRead() ) # verify no response when respond is False non_responding.process(read_telegram) assert xknx.telegrams.qsize() == 0 # verify response when respond is True responding.process(read_telegram) assert xknx.telegrams.qsize() == 1 response = xknx.telegrams.get_nowait() assert response == Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueResponse(DPTArray((0x01, 0x00))), ) # verify no response when GroupValueRead request is not for group_address responding_multiple.process(read_telegram) assert xknx.telegrams.qsize() == 1 response = xknx.telegrams.get_nowait() assert response == Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueResponse(DPTArray((0x01, 0x00))), ) responding_multiple.process( Telegram( destination_address=GroupAddress("2/2/2"), payload=GroupValueRead() ) ) responding_multiple.process( Telegram( destination_address=GroupAddress("3/3/3"), payload=GroupValueRead() ) ) assert xknx.telegrams.qsize() == 0 # # TEST PROCESS CALLBACK # async def test_process_callback(self) -> None: """Test process / reading telegrams from telegram queue. Test if callback is called.""" xknx = XKNX() sensor = RawValue( xknx, "TestSensor", 2, group_address="1/2/3", ) after_update_callback = Mock() sensor.register_device_updated_cb(after_update_callback) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x01, 0x02))), ) sensor.process(telegram) after_update_callback.assert_called_with(sensor) assert sensor.last_telegram == telegram # consecutive telegrams with same payload shall only trigger one callback after_update_callback.reset_mock() sensor.process(telegram) after_update_callback.assert_not_called() async def test_process_callback_always(self) -> None: """Test process / reading telegrams from telegram queue. Test if callback is called.""" xknx = XKNX() sensor = RawValue( xknx, "TestSensor", 2, group_address="1/2/3", always_callback=True, ) after_update_callback = Mock() sensor.register_device_updated_cb(after_update_callback) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x01, 0x02))), ) sensor.process(telegram) after_update_callback.assert_called_with(sensor) assert sensor.last_telegram == telegram # every telegram shall trigger callback after_update_callback.reset_mock() sensor.process(telegram) after_update_callback.assert_called_with(sensor) # # TEST SET # async def test_set_0(self) -> None: """Test set with raw value.""" xknx = XKNX() raw_value = RawValue(xknx, "TestSensor", 0, group_address="1/2/3") await raw_value.set(True) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(True)), ) async def test_set_1(self) -> None: """Test set with raw value.""" xknx = XKNX() raw_value = RawValue(xknx, "TestSensor", 1, group_address="1/2/3") await raw_value.set(75) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x4B,))), ) def test_string(self) -> None: """Test RawValue string representation.""" xknx = XKNX() value = RawValue(xknx, "Raw", 1, group_address="1/2/3") value.remote_value.value = 4 assert ( str(value) == ' value=4/>' ) xknx-3.6.0/test/devices_tests/scene_test.py000066400000000000000000000026061475530762600210640ustar00rootroot00000000000000"""Unit test for Scene objects.""" from xknx import XKNX from xknx.devices import Scene from xknx.dpt import DPTArray from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueWrite class TestScene: """Test class for Scene objects.""" # # SYNC # async def test_sync(self) -> None: """Test sync function / sending group reads to KNX bus.""" xknx = XKNX() scene = Scene(xknx, "TestScene", group_address="1/2/1", scene_number=23) await scene.sync() assert xknx.telegrams.qsize() == 0 # # TEST RUN SCENE # async def test_run(self) -> None: """Test running scene.""" xknx = XKNX() scene = Scene(xknx, "TestScene", group_address="1/2/1", scene_number=23) await scene.run() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/1"), payload=GroupValueWrite(DPTArray(0x16)), ) # # TEST has_group_address # def test_has_group_address(self) -> None: """Test has_group_address.""" xknx = XKNX() scene = Scene(xknx, "TestScene", group_address="1/2/1", scene_number=23) assert scene.has_group_address(GroupAddress("1/2/1")) assert not scene.has_group_address(GroupAddress("2/2/2")) xknx-3.6.0/test/devices_tests/sensor_expose_loop_test.py000066400000000000000000000436501475530762600237200ustar00rootroot00000000000000"""Unit test for Sensor and ExposeSensor objects.""" from unittest.mock import AsyncMock, Mock, call import pytest from xknx import XKNX from xknx.devices import BinarySensor, ExposeSensor, Sensor from xknx.dpt import DPTArray, DPTBinary from xknx.telegram import AddressFilter, Telegram, TelegramDirection from xknx.telegram.address import GroupAddress, InternalGroupAddress from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite class TestSensorExposeLoop: """Process incoming Telegrams and send the values to the bus again.""" test_cases = [ ("absolute_temperature", DPTArray((0x44, 0xD7, 0xD2, 0x87)), 1726.579), ("acceleration", DPTArray((0x45, 0x94, 0xD8, 0x5C)), 4763.045), ("acceleration_angular", DPTArray((0x45, 0xEA, 0x62, 0x33)), 7500.275), ("activation_energy", DPTArray((0x46, 0x0, 0x3E, 0xEE)), 8207.732), ("active_energy", DPTArray((0x26, 0x37, 0x49, 0x7F)), 641157503), ("active_energy_kwh", DPTArray((0x37, 0x5, 0x5, 0xEA)), 923076074), ("activity", DPTArray((0x45, 0x76, 0x0, 0xA4)), 3936.04), ("amplitude", DPTArray((0x45, 0x9A, 0xED, 0x8)), 4957.629), ("angle", DPTArray((0xE4,)), 322), ("angle_deg", DPTArray((0x44, 0x5C, 0x20, 0x2B)), 880.5026), ("angle_rad", DPTArray((0x44, 0x36, 0x75, 0x1)), 729.8282), ("angular_frequency", DPTArray((0x43, 0xBC, 0x20, 0x8D)), 376.2543), ("angular_momentum", DPTArray((0xC2, 0x75, 0xB7, 0xB5)), -61.4294), ("angular_velocity", DPTArray((0xC4, 0xD9, 0x10, 0xB4)), -1736.522), ("apparant_energy", DPTArray((0xD3, 0xBD, 0x1E, 0xA5)), -742580571), ("apparant_energy_kvah", DPTArray((0x49, 0x40, 0xC9, 0x9)), 1228982537), ("area", DPTArray((0x45, 0x63, 0x1E, 0xCD)), 3633.925), ("brightness", DPTArray((0xC3, 0x56)), 50006), ("capacitance", DPTArray((0x45, 0xC9, 0x1D, 0x9E)), 6435.702), ("charge_density_surface", DPTArray((0x45, 0xDB, 0x66, 0x9A)), 7020.825), ("charge_density_volume", DPTArray((0xC4, 0x8C, 0x33, 0xD7)), -1121.62), ("color_temperature", DPTArray((0x6C, 0x95)), 27797), ("common_temperature", DPTArray((0x45, 0xD9, 0xC6, 0x3F)), 6968.781), ("compressibility", DPTArray((0x45, 0x89, 0x94, 0xAA)), 4402.583), ("conductance", DPTArray((0x45, 0xA6, 0x28, 0xFA)), 5317.122), ("counter_pulses", DPTArray((0x9D,)), -99), ("current", DPTArray((0xCA, 0xCC)), 51916), ("delta_time_hrs", DPTArray((0x47, 0x80)), 18304), ("delta_time_min", DPTArray((0xB9, 0x7B)), -18053), ("delta_time_ms", DPTArray((0x58, 0x77)), 22647), ("delta_time_sec", DPTArray((0xA3, 0x6A)), -23702), ("density", DPTArray((0x44, 0xA5, 0xCB, 0x2B)), 1326.349), ("electrical_conductivity", DPTArray((0xC4, 0xC6, 0xF5, 0x71)), -1591.67), ("electric_charge", DPTArray((0x46, 0x14, 0xF6, 0xA0)), 9533.656), ("electric_current", DPTArray((0x45, 0xAD, 0x45, 0x8F)), 5544.695), ("electric_current_density", DPTArray((0x45, 0x7C, 0x57, 0xF8)), 4037.498), ("electric_dipole_moment", DPTArray((0x39, 0x01, 0x74, 0x2F)), 0.0001234568), ("electric_displacement", DPTArray((0xC5, 0x34, 0x8B, 0x02)), -2888.688), ("electric_field_strength", DPTArray((0xC6, 0x17, 0x1C, 0x39)), -9671.056), ("electric_flux", DPTArray((0x45, 0x8F, 0x6C, 0xFE)), 4589.624), ("electric_flux_density", DPTArray((0xC6, 0x0, 0x50, 0xA8)), -8212.164), ("electric_polarization", DPTArray((0x45, 0xF8, 0x89, 0xC7)), 7953.222), ("electric_potential", DPTArray((0xC6, 0x18, 0xA4, 0xAF)), -9769.171), ("electric_potential_difference", DPTArray((0xC6, 0xF, 0x1D, 0x6)), -9159.256), ("electromagnetic_moment", DPTArray((0x45, 0x82, 0x48, 0xAE)), 4169.085), ("electromotive_force", DPTArray((0x45, 0xBC, 0xEF, 0xEC)), 6045.99), ("energy", DPTArray((0x45, 0x4B, 0xB3, 0xF8)), 3259.248), ("enthalpy", DPTArray((0x76, 0xDD)), 287866.88), ("flow_rate_m3h", DPTArray((0x99, 0xEA, 0xC0, 0x55)), -1712668587), ("force", DPTArray((0x45, 0x9E, 0x2C, 0xE1)), 5061.61), ("frequency", DPTArray((0x45, 0xC2, 0x3C, 0x44)), 6215.533), ("heatcapacity", DPTArray((0xC5, 0xB3, 0x56, 0x7F)), -5738.812), ("heatflowrate", DPTArray((0x44, 0xEC, 0x80, 0x7B)), 1892.015), ("heat_quantity", DPTArray((0xC5, 0xA6, 0xB6, 0xD5)), -5334.854), ("humidity", DPTArray((0x7E, 0xE1)), 577044.48), ("impedance", DPTArray((0x45, 0xDD, 0x79, 0x6D)), 7087.178), ("illuminance", DPTArray((0x7C, 0x5E)), 366346.24), ("kelvin_per_percent", DPTArray((0xFA, 0xBD)), -441384.96), ("length", DPTArray((0xC5, 0x9D, 0xAE, 0xC5)), -5045.846), ("length_mm", DPTArray((0x56, 0xB9)), 22201), ("light_quantity", DPTArray((0x45, 0x4A, 0xF5, 0x68)), 3247.338), ("long_delta_timesec", DPTArray((0x45, 0xB2, 0x17, 0x54)), 1169299284), ("luminance", DPTArray((0x45, 0x18, 0xD9, 0x75)), 2445.591), ("luminous_flux", DPTArray((0x45, 0xBD, 0x16, 0x8)), 6050.754), ("luminous_intensity", DPTArray((0x46, 0xB, 0xBE, 0x7E)), 8943.623), ("magnetic_field_strength", DPTArray((0x44, 0x15, 0xF1, 0xAD)), 599.7762), ("magnetic_flux", DPTArray((0xC5, 0xCB, 0x3C, 0x98)), -6503.574), ("magnetic_flux_density", DPTArray((0x45, 0xB6, 0xBD, 0x42)), 5847.657), ("magnetic_moment", DPTArray((0xC3, 0x8E, 0x7F, 0x73)), -284.9957), ("magnetic_polarization", DPTArray((0x45, 0x8C, 0xFA, 0xCB)), 4511.349), ("magnetization", DPTArray((0x45, 0xF7, 0x9D, 0xA2)), 7923.704), ("magnetomotive_force", DPTArray((0xC6, 0x4, 0xC2, 0xDA)), -8496.713), ("mass", DPTArray((0x45, 0x8F, 0x70, 0xA4)), 4590.08), ("mass_flux", DPTArray((0xC6, 0x7, 0x34, 0xFF)), -8653.249), ("mol", DPTArray((0xC4, 0xA0, 0xF4, 0x6A)), -1287.638), ("momentum", DPTArray((0xC5, 0x27, 0xAA, 0x5A)), -2682.647), ("percent", DPTArray((0xE3,)), 89), ("percentU8", DPTArray((0x6B,)), 107), ("percentV8", DPTArray((0x20,)), 32), ("percentV16", DPTArray((0x8A, 0x2F)), -301.61), ("phaseanglerad", DPTArray((0x45, 0x54, 0xAC, 0x2D)), 3402.761), ("phaseangledeg", DPTArray((0xC5, 0x25, 0x13, 0x37)), -2641.201), ("power", DPTArray((0x45, 0xCB, 0xE2, 0x5C)), 6524.295), ("power_2byte", DPTArray((0x6D, 0x91)), 116736.00), ("power_density", DPTArray((0x65, 0x3E)), 54968.32), ("powerfactor", DPTArray((0xC5, 0x35, 0x28, 0x21)), -2898.508), ("ppm", DPTArray((0xF3, 0xC8)), -176947.20), ("pressure", DPTArray((0xC5, 0xE6, 0xE6, 0x62)), -7388.798), ("pressure_2byte", DPTArray((0x7C, 0xF4)), 415498.24), ("pulse", DPTArray((0xFC,)), 252), ("pulse_2byte_signed", DPTArray((0x80, 0x44)), -32700), ("rain_amount", DPTArray((0xF0, 0x1)), -335380.48), ("reactance", DPTArray((0x45, 0xB0, 0x50, 0x91)), 5642.071), ("reactive_energy", DPTArray((0x1A, 0x49, 0x6D, 0xA7)), 441019815), ("reactive_energy_kvarh", DPTArray((0xCC, 0x62, 0x5, 0x31)), -865991375), ("resistance", DPTArray((0xC5, 0xFC, 0x5F, 0xC3)), -8075.97), ("resistivity", DPTArray((0xC5, 0x57, 0x76, 0xC5)), -3447.423), ("rotation_angle", DPTArray((0x2D, 0xDC)), 11740), ("scene_number", DPTArray((0x1,)), 2), ("self_inductance", DPTArray((0xC4, 0xA1, 0xB0, 0x8)), -1293.501), ("solid_angle", DPTArray((0xC5, 0xC6, 0xE5, 0x48)), -6364.66), ("sound_intensity", DPTArray((0xC4, 0xF2, 0x56, 0xE9)), -1938.716), ("speed", DPTArray((0xC5, 0xCD, 0x1C, 0x6A)), -6563.552), ("stress", DPTArray((0x45, 0xDC, 0xA8, 0xF2)), 7061.118), ("surface_tension", DPTArray((0x46, 0xB, 0xAC, 0x11)), 8939.017), ( "string", DPTArray( ( 0x4B, 0x4E, 0x58, 0x20, 0x69, 0x73, 0x20, 0x4F, 0x4B, 0x0, 0x0, 0x0, 0x0, 0x0, ) ), "KNX is OK", ), ("temperature", DPTArray((0x77, 0x88)), 315883.52), ("temperature_a", DPTArray((0xF1, 0xDB)), -257720.32), ("temperature_difference", DPTArray((0xC6, 0xC, 0x50, 0xBC)), -8980.184), ("temperature_difference_2byte", DPTArray((0xA9, 0xF4)), -495.36), ("temperature_f", DPTArray((0x67, 0xA9)), 80322.56), ("thermal_capacity", DPTArray((0x45, 0x83, 0xEA, 0xB2)), 4221.337), ("thermal_conductivity", DPTArray((0xC5, 0x9C, 0x4D, 0x23)), -5001.642), ("thermoelectric_power", DPTArray((0x41, 0xCF, 0x9E, 0x4F)), 25.9523), ("time_1", DPTArray((0x5E, 0x1E)), 32071.68), ("time_2", DPTArray((0xFB, 0x29)), -405995.52), ("time_period_100msec", DPTArray((0x6A, 0x35)), 2718900), ("time_period_10msec", DPTArray((0x32, 0x3)), 128030), ("time_period_hrs", DPTArray((0x29, 0xDE)), 10718), ("time_period_min", DPTArray((0x0, 0x54)), 84), ("time_period_msec", DPTArray((0x93, 0xC7)), 37831), ("time_period_sec", DPTArray((0xE0, 0xF5)), 57589), ("time_seconds", DPTArray((0x45, 0xEC, 0x91, 0x7D)), 7570.186), ("torque", DPTArray((0xC5, 0x9, 0x23, 0x60)), -2194.211), ("voltage", DPTArray((0x6D, 0xBF)), 120504.32), ("volume", DPTArray((0x46, 0x16, 0x98, 0x43)), 9638.065), ("volume_flow", DPTArray((0x7C, 0xF5)), 415825.92), ("volume_flux", DPTArray((0xC5, 0x4, 0x2D, 0x71)), -2114.84), ("weight", DPTArray((0x45, 0x20, 0x10, 0xE9)), 2561.057), ("work", DPTArray((0x45, 0x64, 0x5D, 0xBE)), 3653.859), ("wind_speed_ms", DPTArray((0x7D, 0x98)), 469237.76), ("wind_speed_kmh", DPTArray((0x7F, 0x55)), 615055.36), # # Generic DPT Without Min/Max and Unit. ("1byte_unsigned", DPTArray(0x08), 8), ("2byte_unsigned", DPTArray((0x30, 0x39)), 12345), ("2byte_signed", DPTArray((0x00, 0x01)), 1), ("2byte_float", DPTArray((0x2E, 0xA9)), 545.6), ("4byte_unsigned", DPTArray((0x00, 0x00, 0x00, 0x00)), 0), ("4byte_signed", DPTArray((0xFD, 0x1A, 0xA1, 0x09)), -48586487), ("4byte_float", DPTArray((0xC2, 0x09, 0xEE, 0xCC)), -34.4832), ] @pytest.mark.parametrize(("value_type", "test_payload", "test_value"), test_cases) async def test_array_sensor_loop( self, value_type: str, test_payload: DPTArray, test_value: float ) -> None: """Test sensor and expose_sensor with different values.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() await xknx.telegram_queue.start() expose = ExposeSensor( xknx, "TestExpose", group_address="1/1/1", value_type=value_type, ) xknx.devices.async_add(expose) assert expose.resolve_state() is None # set a value from expose - HA sends strings for new values stringified_value = str(test_value) await expose.set(stringified_value) outgoing_telegram = Telegram( destination_address=GroupAddress("1/1/1"), direction=TelegramDirection.OUTGOING, payload=GroupValueWrite(test_payload), ) await xknx.telegrams.join() xknx.cemi_handler.send_telegram.assert_called_with(outgoing_telegram) assert expose.resolve_state() == test_value # init sensor after expose is set - with same group address sensor = Sensor( xknx, "TestSensor", group_address_state="1/1/1", value_type=value_type, ) xknx.devices.async_add(sensor) assert sensor.resolve_state() is None # read sensor state (from expose as it has the same GA) # wait_for_result so we don't have to await self.xknx.telegrams.join() await sensor.sync(wait_for_result=True) read_telegram = Telegram( destination_address=GroupAddress("1/1/1"), direction=TelegramDirection.OUTGOING, payload=GroupValueRead(), ) response_telegram = Telegram( destination_address=GroupAddress("1/1/1"), direction=TelegramDirection.OUTGOING, payload=GroupValueResponse(test_payload), ) xknx.cemi_handler.send_telegram.assert_has_calls( [ call(read_telegram), call(response_telegram), ] ) # test if Sensor has successfully read from ExposeSensor assert sensor.resolve_state() == test_value assert expose.resolve_state() == sensor.resolve_state() await xknx.telegram_queue.stop() class TestBinarySensorExposeLoop: """Process incoming Telegrams and send the values to the bus again.""" @pytest.mark.parametrize( ("value_type", "test_payload", "test_value"), [ ("binary", DPTBinary(0), False), ("binary", DPTBinary(1), True), ], ) async def test_binary_sensor_loop( self, value_type: str, test_payload: DPTBinary, test_value: bool ) -> None: """Test binary_sensor and expose_sensor with binary values.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() await xknx.telegram_queue.start() expose = ExposeSensor( xknx, "TestExpose", group_address="1/1/1", value_type=value_type, ) xknx.devices.async_add(expose) assert expose.resolve_state() is None await expose.set(test_value) await xknx.telegrams.join() outgoing_telegram = Telegram( destination_address=GroupAddress("1/1/1"), direction=TelegramDirection.OUTGOING, payload=GroupValueWrite(test_payload), ) xknx.cemi_handler.send_telegram.assert_called_with(outgoing_telegram) assert expose.resolve_state() == test_value bin_sensor = BinarySensor( xknx, "TestSensor", group_address_state="1/1/1", ) xknx.devices.async_add(bin_sensor) assert bin_sensor.state is None # read sensor state (from expose as it has the same GA) # wait_for_result so we don't have to await self.xknx.telegrams.join() await bin_sensor.sync(wait_for_result=True) read_telegram = Telegram( destination_address=GroupAddress("1/1/1"), direction=TelegramDirection.OUTGOING, payload=GroupValueRead(), ) response_telegram = Telegram( destination_address=GroupAddress("1/1/1"), direction=TelegramDirection.OUTGOING, payload=GroupValueResponse(test_payload), ) xknx.cemi_handler.send_telegram.assert_has_calls( [ call(read_telegram), call(response_telegram), ] ) # test if Sensor has successfully read from ExposeSensor assert bin_sensor.state == test_value assert expose.resolve_state() == bin_sensor.state await xknx.telegram_queue.stop() class TestBinarySensorInternalGroupAddressExposeLoop: """Process incoming Telegrams and send values to other devices.""" @pytest.mark.parametrize( ("value_type", "test_payload", "test_value"), [ ("binary", DPTBinary(0), False), ("binary", DPTBinary(1), True), ], ) async def test_binary_sensor_loop( self, value_type: str, test_payload: DPTBinary, test_value: bool ) -> None: """Test binary_sensor and expose_sensor with binary values.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() telegram_callback = Mock() xknx.telegram_queue.register_telegram_received_cb( telegram_callback, address_filters=[AddressFilter("i-test")], match_for_outgoing=True, ) await xknx.telegram_queue.start() expose = ExposeSensor( xknx, "TestExpose", group_address="i-test", value_type=value_type, ) xknx.devices.async_add(expose) assert expose.resolve_state() is None await expose.set(test_value) await xknx.telegrams.join() outgoing_telegram = Telegram( destination_address=InternalGroupAddress("i-test"), direction=TelegramDirection.OUTGOING, payload=GroupValueWrite(test_payload), ) # InternalGroupAddress isn't passed to knxip_interface xknx.cemi_handler.send_telegram.assert_not_called() telegram_callback.assert_called_with(outgoing_telegram) assert expose.resolve_state() == test_value bin_sensor = BinarySensor( xknx, "TestSensor", group_address_state="i-test", ) xknx.devices.async_add(bin_sensor) assert bin_sensor.state is None # read sensor state (from expose as it has the same GA) # wait_for_result so we don't have to await self.xknx.telegrams.join() await bin_sensor.sync(wait_for_result=True) read_telegram = Telegram( destination_address=InternalGroupAddress("i-test"), direction=TelegramDirection.OUTGOING, payload=GroupValueRead(), ) response_telegram = Telegram( destination_address=InternalGroupAddress("i-test"), direction=TelegramDirection.OUTGOING, payload=GroupValueResponse(test_payload), ) xknx.cemi_handler.send_telegram.assert_not_called() telegram_callback.assert_has_calls( [ call(read_telegram), call(response_telegram), ] ) # test if Sensor has successfully read from ExposeSensor assert bin_sensor.state == test_value assert expose.resolve_state() == bin_sensor.state await xknx.telegram_queue.stop() xknx-3.6.0/test/devices_tests/sensor_test.py000066400000000000000000000540101475530762600212740ustar00rootroot00000000000000"""Unit test for Sensor objects.""" from unittest.mock import Mock import pytest from xknx import XKNX from xknx.devices import Sensor from xknx.dpt import DPTArray from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite class TestSensor: """Test class for Sensor objects.""" @pytest.mark.parametrize( ("value_type", "raw_payload", "expected_state"), [ # DPT-14 values are according to ETS group monitor values ( "absolute_temperature", DPTArray((0x44, 0xD7, 0xD2, 0x8B)), 1726.579, ), ( "acceleration", DPTArray((0x45, 0x94, 0xD8, 0x5D)), 4763.045, ), ( "volume_liquid_litre", DPTArray((0x00, 0x00, 0x01, 0x00)), 256, ), ( "volume_m3", DPTArray((0x00, 0x00, 0x01, 0x00)), 256, ), ( "active_energy", DPTArray((0x26, 0x37, 0x49, 0x7F)), 641157503, ), ( "active_energy_kwh", DPTArray((0x37, 0x5, 0x5, 0xEA)), 923076074, ), ( "activity", DPTArray((0x45, 0x76, 0x0, 0xA3)), 3936.04, ), ( "amplitude", DPTArray((0x45, 0x9A, 0xED, 0x8)), 4957.629, ), ( "angle", DPTArray((0xE4,)), 322, ), ( "angle_deg", DPTArray((0x44, 0x5C, 0x20, 0x2B)), 880.5026, ), ( "angle_rad", DPTArray((0x44, 0x36, 0x75, 0x1)), 729.8282, ), ( "angular_frequency", DPTArray((0x43, 0xBC, 0x20, 0x8D)), 376.2543, ), ( "angular_momentum", DPTArray((0xC2, 0x75, 0xB7, 0xB5)), -61.4294, ), ( "angular_velocity", DPTArray((0xC4, 0xD9, 0x10, 0xB3)), -1736.522, ), ( "apparant_energy", DPTArray((0xD3, 0xBD, 0x1E, 0xA5)), -742580571, ), ( "apparant_energy_kvah", DPTArray((0x49, 0x40, 0xC9, 0x9)), 1228982537, ), ( "area", DPTArray((0x45, 0x63, 0x1E, 0xCD)), 3633.925, ), ( "brightness", DPTArray((0xC3, 0x56)), 50006, ), ( "capacitance", DPTArray((0x45, 0xC9, 0x1D, 0x9D)), 6435.702, ), ( "charge_density_surface", DPTArray((0x45, 0xDB, 0x66, 0x99)), 7020.825, ), ( "charge_density_volume", DPTArray((0xC4, 0x8C, 0x33, 0xD7)), -1121.62, ), ( "color_temperature", DPTArray((0x6C, 0x95)), 27797, ), ( "common_temperature", DPTArray((0x45, 0xD9, 0xC6, 0x3F)), 6968.781, ), ( "compressibility", DPTArray((0x45, 0x89, 0x94, 0xAB)), 4402.583, ), ( "conductance", DPTArray((0x45, 0xA6, 0x28, 0xF9)), 5317.122, ), ( "counter_pulses", DPTArray((0x9D,)), -99, ), ( "current", DPTArray((0xCA, 0xCC)), 51916, ), ( "delta_time_hrs", DPTArray((0x47, 0x80)), 18304, ), ( "delta_time_min", DPTArray((0xB9, 0x7B)), -18053, ), ( "delta_time_ms", DPTArray((0x58, 0x77)), 22647, ), ( "delta_time_sec", DPTArray((0xA3, 0x6A)), -23702, ), ( "density", DPTArray((0x44, 0xA5, 0xCB, 0x27)), 1326.349, ), ( "electrical_conductivity", DPTArray((0xC4, 0xC6, 0xF5, 0x6E)), -1591.67, ), ( "electric_charge", DPTArray((0x46, 0x14, 0xF6, 0xA0)), 9533.656, ), ( "electric_current", DPTArray((0x45, 0xAD, 0x45, 0x90)), 5544.695, ), ( "electric_current_density", DPTArray((0x45, 0x7C, 0x57, 0xF6)), 4037.498, ), ( "electric_dipole_moment", DPTArray((0x45, 0x58, 0xF1, 0x73)), 3471.091, ), ( "electric_displacement", DPTArray((0xC5, 0x34, 0x8B, 0x0)), -2888.688, ), ( "electric_field_strength", DPTArray((0xC6, 0x17, 0x1C, 0x39)), -9671.056, ), ( "electric_flux", DPTArray((0x45, 0x8F, 0x6C, 0xFD)), 4589.624, ), ( "electric_flux_density", DPTArray((0xC6, 0x0, 0x50, 0xA8)), -8212.164, ), ( "electric_polarization", DPTArray((0x45, 0xF8, 0x89, 0xC6)), 7953.222, ), ( "electric_potential", DPTArray((0xC6, 0x18, 0xA4, 0xAF)), -9769.171, ), ( "electric_potential_difference", DPTArray((0xC6, 0xF, 0x1D, 0x6)), -9159.256, ), ( "electromagnetic_moment", DPTArray((0x45, 0x82, 0x48, 0xAE)), 4169.085, ), ( "electromotive_force", DPTArray((0x45, 0xBC, 0xEF, 0xEB)), 6045.99, ), ( "energy", DPTArray((0x45, 0x4B, 0xB3, 0xF8)), 3259.248, ), ( "enthalpy", DPTArray((0x76, 0xDD)), 287866.88, ), ( "flow_rate_m3h", DPTArray((0x99, 0xEA, 0xC0, 0x55)), -1712668587, ), ( "force", DPTArray((0x45, 0x9E, 0x2C, 0xE1)), 5061.61, ), ( "frequency", DPTArray((0x45, 0xC2, 0x3C, 0x44)), 6215.533, ), ( "heatcapacity", DPTArray((0xC5, 0xB3, 0x56, 0x7E)), -5738.812, ), ( "heatflowrate", DPTArray((0x44, 0xEC, 0x80, 0x7A)), 1892.015, ), ( "heat_quantity", DPTArray((0xC5, 0xA6, 0xB6, 0xD5)), -5334.854, ), ( "humidity", DPTArray((0x7E, 0xE1)), 577044.48, ), ( "impedance", DPTArray((0x45, 0xDD, 0x79, 0x6D)), 7087.178, ), ( "illuminance", DPTArray((0x7C, 0x5E)), 366346.24, ), ( "kelvin_per_percent", DPTArray((0xFA, 0xBD)), -441384.96, ), ( "length", DPTArray((0xC5, 0x9D, 0xAE, 0xC5)), -5045.846, ), ( "length_mm", DPTArray((0x56, 0xB9)), 22201, ), ( "light_quantity", DPTArray((0x45, 0x4A, 0xF5, 0x68)), 3247.338, ), ( "long_delta_timesec", DPTArray((0x45, 0xB2, 0x17, 0x54)), 1169299284, ), ( "luminance", DPTArray((0x45, 0x18, 0xD9, 0x76)), 2445.591, ), ( "luminous_flux", DPTArray((0x45, 0xBD, 0x16, 0x9)), 6050.754, ), ( "luminous_intensity", DPTArray((0x46, 0xB, 0xBE, 0x7E)), 8943.623, ), ( "magnetic_field_strength", DPTArray((0x44, 0x15, 0xF1, 0xAD)), 599.7762, ), ( "magnetic_flux", DPTArray((0xC5, 0xCB, 0x3C, 0x98)), -6503.574, ), ( "magnetic_flux_density", DPTArray((0x45, 0xB6, 0xBD, 0x42)), 5847.657, ), ( "magnetic_moment", DPTArray((0xC3, 0x8E, 0x7F, 0x73)), -284.9957, ), ( "magnetic_polarization", DPTArray((0x45, 0x8C, 0xFA, 0xCB)), 4511.349, ), ( "magnetization", DPTArray((0x45, 0xF7, 0x9D, 0xA2)), 7923.704, ), ( "magnetomotive_force", DPTArray((0xC6, 0x4, 0xC2, 0xDA)), -8496.713, ), ( "mass", DPTArray((0x45, 0x8F, 0x70, 0xA4)), 4590.08, ), ( "mass_flux", DPTArray((0xC6, 0x7, 0x34, 0xFF)), -8653.249, ), ( "mol", DPTArray((0xC4, 0xA0, 0xF4, 0x68)), -1287.638, ), ( "momentum", DPTArray((0xC5, 0x27, 0xAA, 0x5B)), -2682.647, ), ( "percent", DPTArray((0xE3,)), 89, ), ( "percentU8", DPTArray((0x6B,)), 107, ), ( "percentV8", DPTArray((0x20,)), 32, ), ( "percentV16", DPTArray((0x8A, 0x2F)), -301.61, ), ( "phaseanglerad", DPTArray((0x45, 0x54, 0xAC, 0x2E)), 3402.761, ), ( "phaseangledeg", DPTArray((0xC5, 0x25, 0x13, 0x38)), -2641.201, ), ( "power", DPTArray((0x45, 0xCB, 0xE2, 0x5C)), 6524.295, ), ( "power_2byte", DPTArray((0x6D, 0x91)), 116736.0, ), ( "power_density", DPTArray((0x65, 0x3E)), 54968.32, ), ( "powerfactor", DPTArray((0xC5, 0x35, 0x28, 0x21)), -2898.508, ), ( "ppm", DPTArray((0x7F, 0x74)), 625213.44, ), ( "pressure", DPTArray((0xC5, 0xE6, 0xE6, 0x63)), -7388.798, ), ( "pressure_2byte", DPTArray((0x7C, 0xF4)), 415498.24, ), ( "pulse", DPTArray((0xFC,)), 252, ), ( "rain_amount", DPTArray((0xE0, 0xD0)), -75366.4, ), ( "reactance", DPTArray((0x45, 0xB0, 0x50, 0x91)), 5642.071, ), ( "reactive_energy", DPTArray((0x1A, 0x49, 0x6D, 0xA7)), 441019815, ), ( "reactive_energy_kvarh", DPTArray((0xCC, 0x62, 0x5, 0x31)), -865991375, ), ( "resistance", DPTArray((0xC5, 0xFC, 0x5F, 0xC2)), -8075.97, ), ( "resistivity", DPTArray((0xC5, 0x57, 0x76, 0xC3)), -3447.423, ), ( "rotation_angle", DPTArray((0x2D, 0xDC)), 11740, ), ( "scene_number", DPTArray((0x1,)), 2, ), ( "self_inductance", DPTArray((0xC4, 0xA1, 0xB0, 0x6)), -1293.501, ), ( "solid_angle", DPTArray((0xC5, 0xC6, 0xE5, 0x47)), -6364.66, ), ( "sound_intensity", DPTArray((0xC4, 0xF2, 0x56, 0xE6)), -1938.716, ), ( "speed", DPTArray((0xC5, 0xCD, 0x1C, 0x6A)), -6563.552, ), ( "stress", DPTArray((0x45, 0xDC, 0xA8, 0xF2)), 7061.118, ), ( "surface_tension", DPTArray((0x46, 0xB, 0xAC, 0x11)), 8939.017, ), ( "temperature", DPTArray((0x77, 0x88)), 315883.52, ), ( "temperature_a", DPTArray((0xF1, 0xDB)), -257720.32, ), ( "temperature_difference", DPTArray((0xC6, 0xC, 0x50, 0xBC)), -8980.184, ), ( "temperature_difference_2byte", DPTArray((0xA9, 0xF4)), -495.36, ), ( "temperature_f", DPTArray((0x67, 0xA9)), 80322.56, ), ( "thermal_capacity", DPTArray((0x45, 0x83, 0xEA, 0xB3)), 4221.337, ), ( "thermal_conductivity", DPTArray((0xC5, 0x9C, 0x4D, 0x22)), -5001.642, ), ( "thermoelectric_power", DPTArray((0x41, 0xCF, 0x9E, 0x4F)), 25.9523, ), ( "time_1", DPTArray((0x5E, 0x1E)), 32071.68, ), ( "time_2", DPTArray((0xFB, 0x29)), -405995.52, ), ( "time_period_100msec", DPTArray((0x6A, 0x35)), 2718900, ), ( "time_period_10msec", DPTArray((0x32, 0x3)), 128030, ), ( "time_period_hrs", DPTArray((0x29, 0xDE)), 10718, ), ( "time_period_min", DPTArray((0x0, 0x54)), 84, ), ( "time_period_msec", DPTArray((0x93, 0xC7)), 37831, ), ( "time_period_sec", DPTArray((0xE0, 0xF5)), 57589, ), ( "time_seconds", DPTArray((0x45, 0xEC, 0x91, 0x7C)), 7570.186, ), ( "torque", DPTArray((0xC5, 0x9, 0x23, 0x5F)), -2194.211, ), ( "voltage", DPTArray((0x6D, 0xBF)), 120504.32, ), ( "volume", DPTArray((0x46, 0x16, 0x98, 0x43)), 9638.065, ), ( "volume_flow", DPTArray((0x7C, 0xF5)), 415825.92, ), ( "volume_flux", DPTArray((0xC5, 0x4, 0x2D, 0x72)), -2114.84, ), ( "weight", DPTArray((0x45, 0x20, 0x10, 0xE8)), 2561.057, ), ( "work", DPTArray((0x45, 0x64, 0x5D, 0xBE)), 3653.859, ), ( "wind_speed_ms", DPTArray((0x7D, 0x98)), 469237.76, ), ( "wind_speed_kmh", DPTArray((0x68, 0x0)), 0.0, ), ], ) async def test_sensor_value_types( self, value_type: str, raw_payload: DPTArray, expected_state: float, ) -> None: """Test sensor value types.""" xknx = XKNX() sensor = Sensor( xknx, "TestSensor", group_address_state="1/2/3", value_type=value_type, ) sensor.process( Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(value=raw_payload), ) ) assert sensor.resolve_state() == expected_state async def test_always_callback_sensor(self) -> None: """Test always callback sensor.""" xknx = XKNX() sensor = Sensor( xknx, "TestSensor", group_address_state="1/2/3", always_callback=False, value_type="volume_liquid_litre", ) after_update_callback = Mock() sensor.register_device_updated_cb(after_update_callback) payload = DPTArray((0x00, 0x00, 0x01, 0x00)) # set initial payload of sensor sensor.sensor_value.value = 256 telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(payload) ) response_telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueResponse(payload), ) # verify not called when always_callback is False sensor.process(telegram) after_update_callback.assert_not_called() after_update_callback.reset_mock() sensor.always_callback = True # verify called when always_callback is True sensor.process(telegram) after_update_callback.assert_called_once() after_update_callback.reset_mock() # verify not called when processing read responses sensor.process(response_telegram) after_update_callback.assert_not_called() # # SYNC # async def test_sync(self) -> None: """Test sync function / sending group reads to KNX bus.""" xknx = XKNX() sensor = Sensor( xknx, "TestSensor", value_type="temperature", group_address_state="1/2/3" ) await sensor.sync() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueRead() ) # # HAS GROUP ADDRESS # def test_has_group_address(self) -> None: """Test sensor has group address.""" xknx = XKNX() sensor = Sensor( xknx, "TestSensor", value_type="temperature", group_address_state="1/2/3" ) assert sensor.has_group_address(GroupAddress("1/2/3")) assert not sensor.has_group_address(GroupAddress("1/2/4")) # # TEST PROCESS # async def test_process(self) -> None: """Test process / reading telegrams from telegram queue.""" xknx = XKNX() sensor = Sensor( xknx, "TestSensor", value_type="temperature", group_address_state="1/2/3" ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x06, 0xA0))), ) sensor.process(telegram) assert sensor.sensor_value.value == 16.96 assert sensor.sensor_value.telegram.payload.value == DPTArray((0x06, 0xA0)) assert sensor.resolve_state() == 16.96 # test HomeAssistant device class assert sensor.ha_device_class() == "temperature" async def test_process_callback(self) -> None: """Test process / reading telegrams from telegram queue. Test if callback is called.""" xknx = XKNX() sensor = Sensor( xknx, "TestSensor", group_address_state="1/2/3", value_type="temperature" ) after_update_callback = Mock() sensor.register_device_updated_cb(after_update_callback) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x01, 0x02))), ) sensor.process(telegram) after_update_callback.assert_called_with(sensor) assert sensor.last_telegram == telegram xknx-3.6.0/test/devices_tests/switch_test.py000066400000000000000000000335751475530762600213010ustar00rootroot00000000000000"""Unit test for Switch objects.""" from unittest.mock import Mock from xknx import XKNX from xknx.devices import Switch from xknx.dpt import DPTBinary from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite from ..conftest import EventLoopClockAdvancer class TestSwitch: """Test class for Switch object.""" # # SYNC # async def test_sync(self) -> None: """Test sync function / sending group reads to KNX bus.""" xknx = XKNX() switch = Switch( xknx, "TestOutlet", group_address_state="1/2/3", group_address="1/2/4" ) await switch.sync() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueRead() ) async def test_sync_state_address(self) -> None: """Test sync function / sending group reads to KNX bus. Test with Switch with explicit state address.""" xknx = XKNX() switch = Switch( xknx, "TestOutlet", group_address="1/2/3", group_address_state="1/2/4" ) await switch.sync() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueRead() ) # # TEST PROCESS # async def test_process(self) -> None: """Test process / reading telegrams from telegram queue. Test if device was updated.""" xknx = XKNX() callback_mock = Mock() switch1 = Switch( xknx, "TestOutlet", group_address="1/2/3", device_updated_cb=callback_mock ) switch2 = Switch( xknx, "TestOutlet", group_address="1/2/3", device_updated_cb=callback_mock ) assert switch1.state is None assert switch2.state is None callback_mock.assert_not_called() telegram_on = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) telegram_off = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(0)), ) switch1.process(telegram_on) assert switch1.state is True callback_mock.assert_called_once() callback_mock.reset_mock() switch1.process(telegram_off) assert switch1.state is False callback_mock.assert_called_once() callback_mock.reset_mock() # test setting switch2 to False with first telegram switch2.process(telegram_off) assert switch2.state is False callback_mock.assert_called_once() callback_mock.reset_mock() switch2.process(telegram_on) assert switch2.state is True callback_mock.assert_called_once() callback_mock.reset_mock() async def test_process_state(self) -> None: """Test process / reading telegrams from telegram queue. Test if device was updated.""" xknx = XKNX() callback_mock = Mock() switch1 = Switch( xknx, "TestOutlet", group_address="1/2/3", group_address_state="1/2/4", device_updated_cb=callback_mock, ) switch2 = Switch( xknx, "TestOutlet", group_address="1/2/3", group_address_state="1/2/4", device_updated_cb=callback_mock, ) assert switch1.state is None assert switch2.state is None callback_mock.assert_not_called() telegram_on = Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueResponse(DPTBinary(1)), ) telegram_off = Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueResponse(DPTBinary(0)), ) switch1.process(telegram_on) assert switch1.state is True callback_mock.assert_called_once() callback_mock.reset_mock() switch1.process(telegram_off) assert switch1.state is False callback_mock.assert_called_once() callback_mock.reset_mock() # test setting switch2 to False with first telegram switch2.process(telegram_off) assert switch2.state is False callback_mock.assert_called_once() callback_mock.reset_mock() switch2.process(telegram_on) assert switch2.state is True callback_mock.assert_called_once() callback_mock.reset_mock() async def test_process_invert(self) -> None: """Test process / reading telegrams from telegram queue with inverted switch.""" xknx = XKNX() switch = Switch(xknx, "TestOutlet", group_address="1/2/3", invert=True) assert switch.state is None telegram_inv_on = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(0)), ) telegram_inv_off = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) switch.process(telegram_inv_on) assert switch.state is True switch.process(telegram_inv_off) assert switch.state is False async def test_process_reset_after( self, time_travel: EventLoopClockAdvancer ) -> None: """Test process reset_after.""" xknx = XKNX() reset_after_sec = 1 switch = Switch( xknx, "TestInput", group_address="1/2/3", reset_after=reset_after_sec ) telegram_on = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) switch.process(telegram_on) assert switch.state assert xknx.telegrams.qsize() == 0 await time_travel(reset_after_sec) assert xknx.telegrams.qsize() == 1 switch.process(xknx.telegrams.get_nowait()) assert not switch.state async def test_process_reset_after_cancel_existing( self, time_travel: EventLoopClockAdvancer ) -> None: """Test process reset_after cancels existing reset tasks.""" xknx = XKNX() reset_after_sec = 0.01 switch = Switch( xknx, "TestInput", group_address="1/2/3", reset_after=reset_after_sec ) telegram_on = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueResponse(DPTBinary(1)), ) switch.process(telegram_on) assert switch.state assert xknx.telegrams.qsize() == 0 await time_travel(reset_after_sec / 2) # half way through the reset timer switch.process(telegram_on) assert switch.state await time_travel(reset_after_sec / 2) assert xknx.telegrams.qsize() == 0 async def test_remove_device(self, xknx_no_interface: XKNX) -> None: """Test device removal cancels task.""" xknx = xknx_no_interface switch = Switch(xknx, "TestInput", group_address="1/2/3", reset_after=1) xknx.devices.async_add(switch) async with xknx: telegram_on = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueResponse(DPTBinary(1)), ) switch.process(telegram_on) assert switch.state assert switch._reset_task is not None xknx.devices.async_remove(switch) assert switch._reset_task is None async def test_process_callback(self) -> None: """Test process / reading telegrams from telegram queue. Test if callback was called.""" xknx = XKNX() switch = Switch(xknx, "TestOutlet", group_address="1/2/3") after_update_callback = Mock() switch.register_device_updated_cb(after_update_callback) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) switch.process(telegram) after_update_callback.assert_called_with(switch) # # TEST RESPOND # async def test_respond_to_read(self) -> None: """Test respond_to_read function.""" xknx = XKNX() responding = Switch( xknx, "TestSensor1", group_address="1/1/1", respond_to_read=True, ) non_responding = Switch( xknx, "TestSensor2", group_address="1/1/1", respond_to_read=False, ) responding_multiple = Switch( xknx, "TestSensor3", group_address=["1/1/1", "3/3/3"], group_address_state="2/2/2", respond_to_read=True, ) # set initial payload of Switch responding.switch.value = True non_responding.switch.value = True responding_multiple.switch.value = True read_telegram = Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueRead() ) # verify no response when respond is False non_responding.process(read_telegram) assert xknx.telegrams.qsize() == 0 # verify response when respond is True responding.process(read_telegram) assert xknx.telegrams.qsize() == 1 response = xknx.telegrams.get_nowait() assert response == Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueResponse(DPTBinary(True)), ) # verify no response when GroupValueRead request is not for group_address responding_multiple.process(read_telegram) assert xknx.telegrams.qsize() == 1 response = xknx.telegrams.get_nowait() assert response == Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueResponse(DPTBinary(True)), ) responding_multiple.process( Telegram( destination_address=GroupAddress("2/2/2"), payload=GroupValueRead() ) ) responding_multiple.process( Telegram( destination_address=GroupAddress("3/3/3"), payload=GroupValueRead() ) ) assert xknx.telegrams.qsize() == 0 # # TEST SET ON # async def test_set_on(self) -> None: """Test switching on switch.""" xknx = XKNX() switch = Switch(xknx, "TestOutlet", group_address="1/2/3") await switch.set_on() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) # # TEST SET OFF # async def test_set_off(self) -> None: """Test switching off switch.""" xknx = XKNX() switch = Switch(xknx, "TestOutlet", group_address="1/2/3") await switch.set_off() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(0)), ) # # TEST SET INVERT # async def test_set_invert(self) -> None: """Test switching on/off inverted switch.""" xknx = XKNX() switch = Switch(xknx, "TestOutlet", group_address="1/2/3", invert=True) await switch.set_on() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(0)), ) await switch.set_off() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) # # TEST has_group_address # def test_has_group_address(self) -> None: """Test has_group_address.""" xknx = XKNX() switch = Switch(xknx, "TestOutlet", group_address="1/2/3") assert switch.has_group_address(GroupAddress("1/2/3")) assert not switch.has_group_address(GroupAddress("2/2/2")) # # TEST passive group addresses # def test_has_group_address_passive(self) -> None: """Test has_group_address with passive group address.""" xknx = XKNX() switch = Switch(xknx, "TestOutlet", group_address=["1/2/3", "4/4/4"]) assert switch.has_group_address(GroupAddress("1/2/3")) assert switch.has_group_address(GroupAddress("4/4/4")) assert not switch.has_group_address(GroupAddress("2/2/2")) async def test_process_passive(self) -> None: """Test process / reading telegrams from telegram queue. Test if device was updated.""" xknx = XKNX() callback_mock = Mock() switch1 = Switch( xknx, "TestOutlet", group_address=["1/2/3", "4/4/4"], group_address_state=["1/2/30", "5/5/5"], device_updated_cb=callback_mock, ) assert switch1.state is None callback_mock.assert_not_called() telegram_on_passive = Telegram( destination_address=GroupAddress("4/4/4"), payload=GroupValueWrite(DPTBinary(1)), ) telegram_off_passive = Telegram( destination_address=GroupAddress("5/5/5"), payload=GroupValueWrite(DPTBinary(0)), ) switch1.process(telegram_on_passive) assert switch1.state is True callback_mock.assert_called_once() callback_mock.reset_mock() switch1.process(telegram_off_passive) assert switch1.state is False callback_mock.assert_called_once() callback_mock.reset_mock() xknx-3.6.0/test/devices_tests/travelcalculator_test.py000066400000000000000000000321571475530762600233420ustar00rootroot00000000000000"""Unit test for TravelCalculator objects.""" from unittest.mock import patch from xknx.devices import TravelCalculator, TravelStatus class TestTravelCalculator: """Test class for TravelCalculator objects.""" # TravelCalculator(25, 50) means: # # 2 steps / sec UP # 4 steps / sec DOWN # # INIT # def test_init(self) -> None: """Test initial status.""" travelcalculator = TravelCalculator(25, 50) assert not travelcalculator.is_closed() assert not travelcalculator.is_closing() assert not travelcalculator.is_opening() assert not travelcalculator.is_traveling() assert travelcalculator.position_reached() assert travelcalculator.current_position() is None def test_set_position(self) -> None: """Test set position.""" travelcalculator = TravelCalculator(25, 50) travelcalculator.set_position(70) assert travelcalculator.position_reached() assert travelcalculator.current_position() == 70 def test_set_position_after_travel(self) -> None: """Set explicit position after start_travel should stop traveling.""" travelcalculator = TravelCalculator(25, 50) travelcalculator.start_travel(30) travelcalculator.set_position(80) assert travelcalculator.position_reached() assert travelcalculator.current_position() == 80 def test_travel_down(self) -> None: """Test travel down.""" travelcalculator = TravelCalculator(25, 50) with patch("time.time") as mock_time: mock_time.return_value = 1580000000.0 travelcalculator.set_position(40) travelcalculator.start_travel(60) # time not changed, still at beginning assert travelcalculator.current_position() == 40 assert not travelcalculator.position_reached() assert travelcalculator.travel_direction == TravelStatus.DIRECTION_DOWN mock_time.return_value = 1580000001.0 assert travelcalculator.current_position() == 44 assert not travelcalculator.position_reached() mock_time.return_value = 1580000002.0 assert travelcalculator.current_position() == 48 assert not travelcalculator.position_reached() mock_time.return_value = 1580000004.0 assert travelcalculator.current_position() == 56 assert not travelcalculator.position_reached() mock_time.return_value = 1580000005.0 # position reached assert travelcalculator.current_position() == 60 assert travelcalculator.position_reached() mock_time.return_value = 1580000010.0 assert travelcalculator.current_position() == 60 assert travelcalculator.position_reached() def test_travel_up(self) -> None: """Test travel up.""" travelcalculator = TravelCalculator(25, 50) with patch("time.time") as mock_time: mock_time.return_value = 1580000000.0 travelcalculator.set_position(70) travelcalculator.start_travel(50) # time not changed, still at beginning assert travelcalculator.current_position() == 70 assert not travelcalculator.position_reached() assert travelcalculator.travel_direction == TravelStatus.DIRECTION_UP mock_time.return_value = 1580000002.0 assert travelcalculator.current_position() == 66 assert not travelcalculator.position_reached() mock_time.return_value = 1580000004.0 assert travelcalculator.current_position() == 62 assert not travelcalculator.position_reached() mock_time.return_value = 1580000008.0 assert travelcalculator.current_position() == 54 assert not travelcalculator.position_reached() mock_time.return_value = 1580000010.0 # position reached assert travelcalculator.current_position() == 50 assert travelcalculator.position_reached() mock_time.return_value = 1580000020.0 assert travelcalculator.current_position() == 50 assert travelcalculator.position_reached() def test_stop(self) -> None: """Test stopping.""" travelcalculator = TravelCalculator(25, 50) with patch("time.time") as mock_time: mock_time.return_value = 1580000000.0 travelcalculator.set_position(80) travelcalculator.start_travel(60) assert travelcalculator.travel_direction == TravelStatus.DIRECTION_UP # stop aftert two seconds mock_time.return_value = 1580000002.0 travelcalculator.stop() mock_time.return_value = 1580000004.0 assert travelcalculator.current_position() == 76 assert travelcalculator.position_reached() # restart after 1 additional second (3 seconds) mock_time.return_value = 1580000005.0 travelcalculator.start_travel(68) # running up for 6 seconds mock_time.return_value = 1580000006.0 assert travelcalculator.current_position() == 74 assert not travelcalculator.position_reached() mock_time.return_value = 1580000009.0 assert travelcalculator.current_position() == 68 assert travelcalculator.position_reached() def test_travel_down_with_updates(self) -> None: """Test travel down with position updates from bus.""" travelcalculator = TravelCalculator(25, 50) with patch("time.time") as mock_time: mock_time.return_value = 1580000000.0 travelcalculator.set_position(40) travelcalculator.start_travel(100) # 15 seconds to reach 100 # time not changed, still at beginning assert travelcalculator.current_position() == 40 assert not travelcalculator.position_reached() assert travelcalculator.travel_direction == TravelStatus.DIRECTION_DOWN mock_time.return_value = 1580000002.0 assert travelcalculator.current_position() == 48 assert not travelcalculator.position_reached() # update from bus matching calculation travelcalculator.update_position(48) assert travelcalculator.current_position() == 48 assert not travelcalculator.position_reached() mock_time.return_value = 1580000010.0 assert travelcalculator.current_position() == 80 assert not travelcalculator.position_reached() # update from bus not matching calculation takes precedence (1 second slower) travelcalculator.update_position(76) assert travelcalculator.current_position() == 76 assert not travelcalculator.position_reached() # travel time extended by 1 second due to update from bus mock_time.return_value = 1580000015.0 assert travelcalculator.current_position() == 96 assert not travelcalculator.position_reached() mock_time.return_value = 1580000015.0 + 1 assert travelcalculator.current_position() == 100 assert travelcalculator.position_reached() def test_travel_up_with_updates(self) -> None: """Test travel up with position updates from bus.""" travelcalculator = TravelCalculator(25, 50) with patch("time.time") as mock_time: mock_time.return_value = 1580000000.0 travelcalculator.set_position(70) travelcalculator.start_travel(50) # 10 seconds to reach 50 mock_time.return_value = 1580000005.0 assert travelcalculator.current_position() == 60 assert not travelcalculator.position_reached() # update from bus not matching calculation takes precedence (1 second faster) travelcalculator.update_position(58) assert travelcalculator.current_position() == 58 assert not travelcalculator.position_reached() # position reached 1 second earlier than predicted mock_time.return_value = 1580000010.0 - 1 assert travelcalculator.current_position() == 50 assert travelcalculator.position_reached() def test_change_direction(self) -> None: """Test changing direction while travelling.""" travelcalculator = TravelCalculator(50, 25) with patch("time.time") as mock_time: mock_time.return_value = 1580000000.0 travelcalculator.set_position(60) travelcalculator.start_travel(80) assert travelcalculator.travel_direction == TravelStatus.DIRECTION_DOWN # change direction after two seconds mock_time.return_value = 1580000002.0 assert travelcalculator.current_position() == 64 travelcalculator.start_travel(48) assert travelcalculator.travel_direction == TravelStatus.DIRECTION_UP assert travelcalculator.current_position() == 64 assert not travelcalculator.position_reached() mock_time.return_value = 1580000004.0 assert travelcalculator.current_position() == 56 assert not travelcalculator.position_reached() mock_time.return_value = 1580000006.0 assert travelcalculator.current_position() == 48 assert travelcalculator.position_reached() def test_travel_full_up(self) -> None: """Test travelling to the full up position.""" travelcalculator = TravelCalculator(25, 50) with patch("time.time") as mock_time: mock_time.return_value = 1580000000.0 travelcalculator.set_position(30) travelcalculator.start_travel_up() mock_time.return_value = 1580000014.0 assert not travelcalculator.position_reached() assert not travelcalculator.is_closed() assert not travelcalculator.is_open() mock_time.return_value = 1580000015.0 assert travelcalculator.position_reached() assert travelcalculator.is_open() assert not travelcalculator.is_closed() def test_travel_full_down(self) -> None: """Test travelling to the full down position.""" travelcalculator = TravelCalculator(25, 50) with patch("time.time") as mock_time: mock_time.return_value = 1580000000.0 travelcalculator.set_position(20) travelcalculator.start_travel_down() mock_time.return_value = 1580000019.0 assert not travelcalculator.position_reached() assert not travelcalculator.is_closed() assert not travelcalculator.is_open() mock_time.return_value = 1580000020.0 assert travelcalculator.position_reached() assert travelcalculator.is_closed() assert not travelcalculator.is_open() def test_is_traveling(self) -> None: """Test if cover is traveling and position reached.""" travelcalculator = TravelCalculator(25, 50) with patch("time.time") as mock_time: mock_time.return_value = 1580000000.0 assert not travelcalculator.is_traveling() assert travelcalculator.position_reached() travelcalculator.set_position(80) assert not travelcalculator.is_traveling() assert travelcalculator.position_reached() mock_time.return_value = 1580000000.0 travelcalculator.start_travel_down() mock_time.return_value = 1580000004.0 assert travelcalculator.is_traveling() assert not travelcalculator.position_reached() mock_time.return_value = 1580000005.0 assert not travelcalculator.is_traveling() assert travelcalculator.position_reached() def test_is_opening_closing(self) -> None: """Test reports is_opening and is_closing.""" travelcalculator = TravelCalculator(25, 50) with patch("time.time") as mock_time: mock_time.return_value = 1580000000.0 assert not travelcalculator.is_opening() assert not travelcalculator.is_closing() travelcalculator.set_position(80) assert not travelcalculator.is_opening() assert not travelcalculator.is_closing() mock_time.return_value = 1580000000.0 travelcalculator.start_travel_down() assert not travelcalculator.is_opening() assert travelcalculator.is_closing() mock_time.return_value = 1580000004.0 assert not travelcalculator.is_opening() assert travelcalculator.is_closing() mock_time.return_value = 1580000005.0 assert not travelcalculator.is_opening() assert not travelcalculator.is_closing() # up direction travelcalculator.start_travel(50) assert travelcalculator.is_opening() assert not travelcalculator.is_closing() mock_time.return_value = 1580000030.0 assert not travelcalculator.is_opening() assert not travelcalculator.is_closing() xknx-3.6.0/test/devices_tests/weather_test.py000066400000000000000000000301711475530762600214240ustar00rootroot00000000000000"""Unit test for Weather objects.""" import datetime import pytest from xknx import XKNX from xknx.devices import Weather from xknx.devices.weather import WeatherCondition from xknx.dpt import DPTArray, DPTBinary from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueWrite class TestWeather: """Test class for Weather objects.""" async def test_temperature(self) -> None: """Test resolve state with temperature.""" xknx = XKNX() weather = Weather(name="weather", xknx=xknx, group_address_temperature="1/3/4") weather.process( Telegram( destination_address=GroupAddress("1/3/4"), payload=GroupValueWrite(value=DPTArray((0x19, 0xA))), ) ) assert weather.has_group_address(GroupAddress("1/3/4")) assert weather.temperature == 21.28 assert weather._temperature.unit_of_measurement == "°C" assert weather._temperature.ha_device_class == "temperature" async def test_brightness(self) -> None: """Test resolve state for brightness east, west and south.""" xknx = XKNX() weather: Weather = Weather( name="weather", xknx=xknx, group_address_brightness_east="1/3/5", group_address_brightness_south="1/3/6", group_address_brightness_west="1/3/7", group_address_brightness_north="1/3/8", group_address_temperature="1/4/4", ) weather.process( Telegram( destination_address=GroupAddress("1/3/5"), payload=GroupValueWrite(value=DPTArray((0x7C, 0x5E))), ) ) weather.process( Telegram( destination_address=GroupAddress("1/3/7"), payload=GroupValueWrite(value=DPTArray((0x7C, 0x5C))), ) ) weather.process( Telegram( destination_address=GroupAddress("1/3/6"), payload=GroupValueWrite(value=DPTArray((0x7C, 0x5A))), ) ) weather.process( Telegram( destination_address=GroupAddress("1/3/8"), payload=GroupValueWrite(value=DPTArray((0x7C, 0x5A))), ) ) assert weather.brightness_east == 366346.24 assert weather._brightness_east.unit_of_measurement == "lx" assert weather._brightness_east.ha_device_class == "illuminance" assert weather.brightness_west == 365690.88 assert weather._brightness_west.unit_of_measurement == "lx" assert weather._brightness_west.ha_device_class == "illuminance" assert weather.brightness_south == 365035.52 assert weather._brightness_south.unit_of_measurement == "lx" assert weather._brightness_south.ha_device_class == "illuminance" assert weather.brightness_north == 365035.52 assert weather._brightness_north.unit_of_measurement == "lx" assert weather._brightness_north.ha_device_class == "illuminance" @pytest.mark.parametrize( ("value", "payload"), [ (98631.68, DPTArray((0x6C, 0xB4))), # 2byte float (98631.68, DPTArray((0x47, 0xC0, 0xA3, 0xD7))), # 4byte float ], ) async def test_pressure(self, value: float, payload: DPTArray) -> None: """Test air pressure telegram.""" xknx = XKNX() weather = Weather(name="weather", xknx=xknx, group_address_air_pressure="1/3/4") weather.process( Telegram( destination_address=GroupAddress("1/3/4"), payload=GroupValueWrite(value=payload), ) ) assert weather.air_pressure == value assert weather._air_pressure.unit_of_measurement == "Pa" assert weather._air_pressure.ha_device_class == "pressure" async def test_humidity(self) -> None: """Test humidity telegram.""" xknx = XKNX() weather = Weather(name="weather", xknx=xknx, group_address_humidity="1/2/4") weather.process( Telegram( destination_address=GroupAddress("1/2/4"), payload=GroupValueWrite(value=DPTArray((0x15, 0x73))), ) ) assert weather.humidity == 55.8 assert weather._humidity.unit_of_measurement == "%" assert weather._humidity.ha_device_class == "humidity" async def test_wind_speed(self) -> None: """Test wind speed received.""" xknx = XKNX() weather: Weather = Weather( name="weather", xknx=xknx, group_address_wind_speed="1/3/8" ) weather.process( Telegram( destination_address=GroupAddress("1/3/8"), payload=GroupValueWrite(value=DPTArray((0x7D, 0x98))), ) ) assert weather.wind_speed == 469237.76 assert weather._wind_speed.unit_of_measurement == "m/s" assert weather._wind_speed.ha_device_class == "wind_speed" async def test_wind_bearing(self) -> None: """Test wind bearing received.""" xknx = XKNX() weather: Weather = Weather( name="weather", xknx=xknx, group_address_wind_bearing="1/3/8" ) weather.process( Telegram( destination_address=GroupAddress("1/3/8"), payload=GroupValueWrite(value=DPTArray((0xBF,))), ) ) assert weather.wind_bearing == 270 assert weather._wind_bearing.unit_of_measurement == "°" assert weather._wind_bearing.ha_device_class is None def test_state_lightning(self) -> None: """Test current_state returns lightning if wind alarm and rain alarm are true.""" xknx = XKNX() weather: Weather = Weather( name="weather", xknx=xknx, group_address_rain_alarm="1/3/8", group_address_wind_alarm="1/3/9", ) weather._rain_alarm.value = True weather._wind_alarm.value = True assert weather.ha_current_state() == WeatherCondition.LIGHTNING_RAINY def test_state_snowy_rainy(self) -> None: """Test snow rain if frost alarm and rain alarm are true.""" xknx = XKNX() weather: Weather = Weather( name="weather", xknx=xknx, group_address_rain_alarm="1/3/8", group_address_frost_alarm="1/3/10", ) weather._rain_alarm.value = True weather._frost_alarm.value = True assert weather.ha_current_state() == WeatherCondition.SNOWY_RAINY def test_wind_alarm(self) -> None: """Test basic state mapping.""" xknx = XKNX() weather: Weather = Weather( name="weather", xknx=xknx, group_address_rain_alarm="1/3/8", group_address_wind_alarm="1/3/9", group_address_frost_alarm="1/3/10", ) weather._wind_alarm.value = True assert weather.ha_current_state() == WeatherCondition.WINDY def test_rain_alarm(self) -> None: """Test basic state mapping.""" xknx = XKNX() weather: Weather = Weather( name="weather", xknx=xknx, group_address_rain_alarm="1/3/8", group_address_wind_alarm="1/3/9", group_address_frost_alarm="1/3/10", ) weather._rain_alarm.value = True assert weather.ha_current_state() == WeatherCondition.RAINY async def test_cloudy_summer(self) -> None: """Test cloudy summer if illuminance matches defined interval.""" xknx = XKNX() weather: Weather = Weather( name="weather", xknx=xknx, group_address_brightness_east="1/3/5", group_address_brightness_south="1/3/6", ) weather.process( Telegram( destination_address=GroupAddress("1/3/6"), payload=GroupValueWrite(value=DPTArray((0x46, 0x45))), ) ) summer_date = datetime.datetime(2020, 10, 5, 18, 00) assert ( weather.ha_current_state(current_date=summer_date) == WeatherCondition.CLOUDY ) async def test_sunny_summer(self) -> None: """Test returns sunny condition if illuminance is in defined interval.""" xknx = XKNX() weather: Weather = Weather( name="weather", xknx=xknx, group_address_brightness_east="1/3/5", group_address_brightness_south="1/3/6", group_address_brightness_west="1/3/7", ) weather.process( Telegram( destination_address=GroupAddress("1/3/6"), payload=GroupValueWrite(value=DPTArray((0x7C, 0x5C))), ) ) summer_date = datetime.datetime(2020, 10, 5, 18, 00) assert ( weather.ha_current_state(current_date=summer_date) == WeatherCondition.SUNNY ) async def test_sunny_winter(self) -> None: """Test sunny winter if illuminance matches defined interval.""" xknx = XKNX() weather: Weather = Weather( name="weather", xknx=xknx, group_address_brightness_south="1/3/6", group_address_brightness_west="1/3/7", ) weather.process( Telegram( destination_address=GroupAddress("1/3/6"), payload=GroupValueWrite(value=DPTArray((0x7C, 0x5C))), ) ) winter_date = datetime.datetime(2020, 12, 5, 18, 00) assert ( weather.ha_current_state(current_date=winter_date) == WeatherCondition.SUNNY ) async def test_cloudy_winter(self) -> None: """Test cloudy winter if illuminance matches defined interval.""" xknx = XKNX() weather: Weather = Weather( name="weather", xknx=xknx, group_address_brightness_east="1/3/5", group_address_brightness_south="1/3/6", group_address_brightness_west="1/3/7", ) weather.process( Telegram( destination_address=GroupAddress("1/3/6"), payload=GroupValueWrite(value=DPTArray((0x46, 0x45))), ) ) winter_date = datetime.datetime(2020, 12, 31, 18, 00) assert ( weather.ha_current_state(current_date=winter_date) == WeatherCondition.CLOUDY ) async def test_day_night(self) -> None: """Test day night mapping.""" xknx = XKNX() weather: Weather = Weather( name="weather", xknx=xknx, group_address_day_night="1/3/20" ) weather.process( Telegram( destination_address=GroupAddress("1/3/20"), payload=GroupValueWrite(value=DPTBinary(0)), ) ) assert weather.ha_current_state() == WeatherCondition.CLEAR_NIGHT def test_weather_default(self) -> None: """Test default state mapping.""" xknx = XKNX() weather: Weather = Weather(name="weather", xknx=xknx) assert weather.ha_current_state() == WeatherCondition.EXCEPTIONAL # # GENERATOR _iter_remote_values # def test_iter_remote_values(self) -> None: """Test sensor has group address.""" xknx = XKNX() weather = Weather( name="weather", xknx=xknx, group_address_temperature="1/3/4", group_address_rain_alarm="1/4/5", group_address_brightness_south="7/7/0", ) assert weather.has_group_address(GroupAddress("1/3/4")) assert weather.has_group_address(GroupAddress("7/7/0")) assert weather.has_group_address(GroupAddress("1/4/5")) assert not weather.has_group_address(GroupAddress("1/2/4")) # # HAS GROUP ADDRESS # def test_has_group_address(self) -> None: """Test sensor has group address.""" xknx = XKNX() weather = Weather(name="weather", xknx=xknx, group_address_temperature="1/3/4") assert weather._temperature.has_group_address(GroupAddress("1/3/4")) assert not weather._temperature.has_group_address(GroupAddress("1/2/4")) xknx-3.6.0/test/dpt_tests/000077500000000000000000000000001475530762600155175ustar00rootroot00000000000000xknx-3.6.0/test/dpt_tests/__init__.py000066400000000000000000000000451475530762600176270ustar00rootroot00000000000000"""Unit tests for the DPT module.""" xknx-3.6.0/test/dpt_tests/dpt_10_time_test.py000066400000000000000000000073301475530762600212400ustar00rootroot00000000000000"""Unit test for KNX time objects.""" import datetime from typing import Any import pytest from xknx.dpt import DPTArray, DPTBinary, DPTTime from xknx.dpt.dpt_10 import KNXDay, KNXTime from xknx.exceptions import ConversionError, CouldNotParseTelegram class TestKNXTime: """Test class for KNX time objects.""" @pytest.mark.parametrize( ("data", "value"), [ ( {"hour": 5, "minutes": 10, "seconds": 9}, KNXTime(5, 10, 9), ), ( {"hour": 21, "minutes": 52, "seconds": 9, "day": "tuesday"}, KNXTime(21, 52, 9, KNXDay.TUESDAY), ), ( {"hour": 0, "minutes": 0, "seconds": 0}, KNXTime(0, 0, 0, KNXDay.NO_DAY), ), ], ) def test_dict(self, data: dict[str, Any], value: KNXTime) -> None: """Test from_dict and as_dict methods.""" assert KNXTime.from_dict(data) == value # day defaults to `no_day` default_dict = {"day": "no_day"} assert value.as_dict() == default_dict | data @pytest.mark.parametrize( "data", [ # invalid data {"hour": 1}, {"hour": "a"}, {"minutes": 2, "seconds": 3}, {"hour": 2, "seconds": 3}, {"hour": 1, "minutes": 2}, {"hour": 1, "minutes": 2, "seconds": "a"}, {"hour": 1, "minutes": 2, "seconds": 3, "day": "a"}, ], ) def test_dict_invalid(self, data: dict[str, Any]) -> None: """Test from_dict and as_dict methods.""" with pytest.raises(ValueError): KNXTime.from_dict(data) @pytest.mark.parametrize( ("time", "value"), [ (datetime.time(5, 10, 9), KNXTime(5, 10, 9)), (datetime.time(21, 52, 9), KNXTime(21, 52, 9)), (datetime.time(0, 0, 0), KNXTime(0, 0, 0)), (datetime.time(23, 59, 59), KNXTime(23, 59, 59)), ], ) def test_as_time(self, time: datetime.time, value: KNXTime) -> None: """Test from_time and as_time methods.""" assert KNXTime.from_time(time) == value assert value.as_time() == time class TestDPTTime: """Test class for KNX time objects.""" @pytest.mark.parametrize( ("value", "raw"), [ (KNXTime(13, 23, 42, KNXDay.TUESDAY), (0x4D, 0x17, 0x2A)), (KNXTime(0, 0, 0, KNXDay.NO_DAY), (0x0, 0x0, 0x0)), (KNXTime(23, 59, 59, KNXDay.SUNDAY), (0xF7, 0x3B, 0x3B)), ], ) def test_parse(self, value: KNXTime, raw: tuple[int, ...]) -> None: """Test parsing and streaming.""" knx_value = DPTTime.to_knx(value) assert knx_value == DPTArray(raw) assert DPTTime.from_knx(knx_value) == value def test_from_knx_wrong_value(self) -> None: """Test parsing from DPTTime object from wrong binary values.""" with pytest.raises(ConversionError): # this parameter exceeds limit DPTTime.from_knx(DPTArray((0xF7, 0x3B, 0x3C))) with pytest.raises(CouldNotParseTelegram): DPTTime.from_knx(DPTArray((0xFF, 0x4E))) with pytest.raises(CouldNotParseTelegram): DPTTime.from_knx(DPTBinary(True)) def test_to_knx_wrong_parameter(self) -> None: """Test parsing from DPTTime object from wrong value.""" with pytest.raises(ConversionError): DPTTime.to_knx(KNXTime(24, 0, 0)) # out of range with pytest.raises(ConversionError): DPTTime.to_knx(KNXTime(0, 60, 0)) # out of range with pytest.raises(ConversionError): DPTTime.to_knx("fnord") with pytest.raises(ConversionError): DPTTime.to_knx((1, 2, 3)) xknx-3.6.0/test/dpt_tests/dpt_11_date_test.py000066400000000000000000000071251475530762600212220ustar00rootroot00000000000000"""Unit test for KNX date objects.""" import datetime from typing import Any import pytest from xknx.dpt import DPTArray, DPTDate from xknx.dpt.dpt_11 import KNXDate from xknx.exceptions import ConversionError, CouldNotParseTelegram class TestKNXDate: """Test class for KNX date objects.""" @pytest.mark.parametrize( ("data", "value"), [ ({"year": 1990, "month": 1, "day": 1}, KNXDate(1990, 1, 1)), ({"year": 2024, "month": 7, "day": 26}, KNXDate(2024, 7, 26)), ({"year": 2089, "month": 12, "day": 31}, KNXDate(2089, 12, 31)), ], ) def test_dict(self, data: dict[str, Any], value: KNXDate) -> None: """Test from_dict and as_dict methods.""" assert KNXDate.from_dict(data) == value assert value.as_dict() == data @pytest.mark.parametrize( "data", [ # invalid data {"year": 1}, {"year": "a"}, {"month": 2, "day": 3}, {"year": 2, "day": 3}, {"year": 1, "month": 2}, {"year": 1, "month": 2, "day": "a"}, {"year": 1, "month": None, "day": 3}, ], ) def test_dict_invalid(self, data: dict[str, Any]) -> None: """Test from_dict and as_dict methods.""" with pytest.raises(ValueError): KNXDate.from_dict(data) @pytest.mark.parametrize( ("date", "value"), [ (datetime.date(1990, 1, 1), KNXDate(1990, 1, 1)), (datetime.date(2024, 7, 26), KNXDate(2024, 7, 26)), (datetime.date(2089, 12, 31), KNXDate(2089, 12, 31)), ], ) def test_as_date(self, date: datetime.date, value: KNXDate) -> None: """Test from_time and as_time methods.""" assert KNXDate.from_date(date) == value assert value.as_date() == date class TestDPTDate: """Test class for KNX date objects.""" @pytest.mark.parametrize( ("value", "raw"), [ (KNXDate(2002, 1, 4), (0x04, 0x01, 0x02)), (KNXDate(1990, 1, 31), (0x1F, 0x01, 0x5A)), (KNXDate(2089, 12, 4), (0x04, 0x0C, 0x59)), ], ) def test_from_knx(self, value: KNXDate, raw: tuple[int, ...]) -> None: """Test parsing and streaming.""" knx_value = DPTDate.to_knx(value) assert knx_value == DPTArray(raw) assert DPTDate.from_knx(knx_value) == value def test_from_knx_wrong_parameter(self) -> None: """Test parsing from DPTDate object from wrong binary values.""" with pytest.raises(CouldNotParseTelegram): DPTDate.from_knx(DPTArray((0xF8, 0x23))) def test_to_knx_wrong_value(self) -> None: """Test parsing from DPTDate object from wrong string value.""" with pytest.raises(ConversionError): DPTDate.to_knx(KNXDate(2090, 1, 1)) # year out of range with pytest.raises(ConversionError): DPTDate.to_knx(KNXDate(1990, 0, 1)) # month out of range with pytest.raises(ConversionError): DPTDate.to_knx(KNXDate(1990, 1, 32)) # day out of range with pytest.raises(ConversionError): DPTDate.to_knx("hello") def test_from_knx_wrong_range_month(self) -> None: """Test Exception when parsing DPTDAte from KNX with wrong month.""" with pytest.raises(ConversionError): DPTDate.from_knx(DPTArray((0x04, 0x00, 0x59))) def test_from_knx_wrong_range_year(self) -> None: """Test Exception when parsing DPTDate from KNX with wrong year.""" with pytest.raises(ConversionError): DPTDate.from_knx(DPTArray((0x04, 0x01, 0x64))) xknx-3.6.0/test/dpt_tests/dpt_12_test.py000066400000000000000000000111611475530762600202210ustar00rootroot00000000000000"""Unit test for KNX 4 byte objects.""" import struct from unittest.mock import patch import pytest from xknx.dpt import DPT4ByteSigned, DPT4ByteUnsigned, DPTArray from xknx.exceptions import ConversionError, CouldNotParseTelegram class TestDPT4Byte: """Test class for KNX 4 byte objects.""" # #################################################################### # DPT4ByteUnsigned # def test_unsigned_settings(self) -> None: """Test members of DPT4ByteUnsigned.""" assert DPT4ByteUnsigned.value_min == 0 assert DPT4ByteUnsigned.value_max == 4294967295 def test_unsigned_assert_min_exceeded(self) -> None: """Test initialization of DPT4ByteUnsigned with wrong value (Underflow).""" with pytest.raises(ConversionError): DPT4ByteUnsigned.to_knx(-1) def test_unsigned_to_knx_exceed_limits(self) -> None: """Test initialization of DPT4ByteUnsigned with wrong value (Overflow).""" with pytest.raises(ConversionError): DPT4ByteUnsigned.to_knx(4294967296) def test_unsigned_value_max_value(self) -> None: """Test DPT4ByteUnsigned parsing and streaming.""" assert DPT4ByteUnsigned.to_knx(4294967295) == DPTArray((0xFF, 0xFF, 0xFF, 0xFF)) assert ( DPT4ByteUnsigned.from_knx(DPTArray((0xFF, 0xFF, 0xFF, 0xFF))) == 4294967295 ) def test_unsigned_value_min_value(self) -> None: """Test parsing and streaming with null values.""" assert DPT4ByteUnsigned.to_knx(0) == DPTArray((0x00, 0x00, 0x00, 0x00)) assert DPT4ByteUnsigned.from_knx(DPTArray((0x00, 0x00, 0x00, 0x00))) == 0 def test_unsigned_value_01234567(self) -> None: """Test DPT4ByteUnsigned parsing and streaming.""" assert DPT4ByteUnsigned.to_knx(19088743) == DPTArray((0x01, 0x23, 0x45, 0x67)) assert DPT4ByteUnsigned.from_knx(DPTArray((0x01, 0x23, 0x45, 0x67))) == 19088743 def test_unsigned_wrong_value_from_knx(self) -> None: """Test DPT4ByteUnsigned parsing with wrong value.""" with pytest.raises(CouldNotParseTelegram): DPT4ByteUnsigned.from_knx(DPTArray((0xFF, 0x4E, 0x12))) # #################################################################### # DPT4ByteSigned # def test_signed_settings(self) -> None: """Test members of DPT4ByteSigned.""" assert DPT4ByteSigned.value_min == -2147483648 assert DPT4ByteSigned.value_max == 2147483647 def test_signed_assert_min_exceeded(self) -> None: """Test initialization of DPT4ByteSigned with wrong value (Underflow).""" with pytest.raises(ConversionError): DPT4ByteSigned.to_knx(-2147483649) def test_signed_to_knx_exceed_limits(self) -> None: """Test initialization of DPT4ByteSigned with wrong value (Overflow).""" with pytest.raises(ConversionError): DPT4ByteSigned.to_knx(2147483648) def test_signed_value_max_value(self) -> None: """Test DPT4ByteSigned parsing and streaming.""" assert DPT4ByteSigned.to_knx(2147483647) == DPTArray((0x7F, 0xFF, 0xFF, 0xFF)) assert DPT4ByteSigned.from_knx(DPTArray((0x7F, 0xFF, 0xFF, 0xFF))) == 2147483647 def test_signed_value_min_value(self) -> None: """Test DPT4ByteSigned parsing and streaming with null values.""" assert DPT4ByteSigned.to_knx(-2147483648) == DPTArray((0x80, 0x00, 0x00, 0x00)) assert ( DPT4ByteSigned.from_knx(DPTArray((0x80, 0x00, 0x00, 0x00))) == -2147483648 ) def test_signed_value_01234567(self) -> None: """Test DPT4ByteSigned parsing and streaming.""" assert DPT4ByteSigned.to_knx(19088743) == DPTArray((0x01, 0x23, 0x45, 0x67)) assert DPT4ByteSigned.from_knx(DPTArray((0x01, 0x23, 0x45, 0x67))) == 19088743 def test_signed_wrong_value_from_knx(self) -> None: """Test DPT4ByteSigned parsing with wrong value.""" with pytest.raises(CouldNotParseTelegram): DPT4ByteSigned.from_knx(DPTArray((0xFF, 0x4E, 0x12))) def test_from_knx_unpack_error(self) -> None: """Test DPT4ByteSigned parsing with unpack error.""" with patch("struct.unpack") as unpack_mock: unpack_mock.side_effect = struct.error() with pytest.raises(ConversionError): DPT4ByteSigned.from_knx(DPTArray((0x01, 0x23, 0x45, 0x67))) def test_to_knx_pack_error(self) -> None: """Test serializing DPT4ByteSigned with pack error.""" with patch("struct.pack") as pack_mock: pack_mock.side_effect = struct.error() with pytest.raises(ConversionError): DPT4ByteSigned.to_knx(19088743) xknx-3.6.0/test/dpt_tests/dpt_14_float_test.py000066400000000000000000000105041475530762600214100ustar00rootroot00000000000000"""Unit test for KNX 4 byte float objects.""" import math import struct from unittest.mock import patch import pytest from xknx.dpt import ( DPT4ByteFloat, DPTArray, DPTElectricCurrent, DPTElectricPotential, DPTFrequency, DPTPhaseAngleDeg, DPTPower, ) from xknx.exceptions import ConversionError, CouldNotParseTelegram class TestDPT4ByteFloat: """Test class for KNX 4 byte/octet float object.""" def test_4byte_float_values_from_power_meter(self) -> None: """Test parsing DPT4ByteFloat value from power meter.""" assert DPT4ByteFloat.from_knx(DPTArray((0x43, 0xC6, 0x80, 00))) == 397 assert DPT4ByteFloat.to_knx(397) == DPTArray((0x43, 0xC6, 0x80, 00)) assert DPT4ByteFloat.from_knx(DPTArray((0x42, 0x38, 0x00, 00))) == 46 assert DPT4ByteFloat.to_knx(46) == DPTArray((0x42, 0x38, 0x00, 00)) def test_14_033(self) -> None: """Test parsing DPTFrequency unit.""" assert DPTFrequency.unit == "Hz" def test_14_055(self) -> None: """Test DPTPhaseAngleDeg object.""" assert DPT4ByteFloat.from_knx(DPTArray((0x42, 0xEF, 0x00, 0x00))) == 119.5 assert DPT4ByteFloat.to_knx(119.5) == DPTArray((0x42, 0xEF, 0x00, 0x00)) assert DPTPhaseAngleDeg.unit == "°" def test_14_057(self) -> None: """Test DPT4ByteFloat object.""" assert DPT4ByteFloat.from_knx(DPTArray((0x3F, 0x71, 0xEB, 0x86))) == 0.9450001 assert DPT4ByteFloat.to_knx(0.945000052452) == DPTArray( (0x3F, 0x71, 0xEB, 0x86) ) assert DPT4ByteFloat.unit is None def test_4byte_float_values_from_voltage_meter(self) -> None: """Test parsing DPT4ByteFloat from voltage meter.""" assert DPT4ByteFloat.from_knx(DPTArray((0x43, 0x65, 0xE3, 0xD7))) == 229.89 assert DPT4ByteFloat.to_knx(229.89) == DPTArray((0x43, 0x65, 0xE3, 0xD7)) def test_4byte_float_zero_value(self) -> None: """Test parsing and streaming of DPT4ByteFloat zero value.""" assert DPT4ByteFloat.from_knx(DPTArray((0x00, 0x00, 0x00, 0x00))) == 0.00 assert DPT4ByteFloat.to_knx(0.00) == DPTArray((0x00, 0x00, 0x00, 0x00)) def test_4byte_float_special_value(self) -> None: """Test parsing and streaming of DPT4ByteFloat special value.""" assert math.isnan(DPT4ByteFloat.from_knx(DPTArray((0x7F, 0xC0, 0x00, 0x00)))) assert DPT4ByteFloat.to_knx(float("nan")) == DPTArray((0x7F, 0xC0, 0x00, 0x00)) assert math.isinf(DPT4ByteFloat.from_knx(DPTArray((0x7F, 0x80, 0x00, 0x00)))) assert DPT4ByteFloat.to_knx(float("inf")) == DPTArray((0x7F, 0x80, 0x00, 0x00)) assert DPT4ByteFloat.from_knx(DPTArray((0xFF, 0x80, 0x00, 0x00))) == float( "-inf" ) assert DPT4ByteFloat.to_knx(float("-inf")) == DPTArray((0xFF, 0x80, 0x00, 0x00)) assert DPT4ByteFloat.from_knx(DPTArray((0x80, 0x00, 0x00, 0x00))) == float("-0") assert DPT4ByteFloat.to_knx(float("-0")) == DPTArray((0x80, 0x00, 0x00, 0x00)) def test_4byte_float_to_knx_wrong_parameter(self) -> None: """Test parsing of DPT4ByteFloat with wrong value (string).""" with pytest.raises(ConversionError): DPT4ByteFloat.to_knx("fnord") def test_4byte_float_from_knx_wrong_parameter(self) -> None: """Test parsing of DPT4ByteFloat with wrong value (wrong number of bytes).""" with pytest.raises(CouldNotParseTelegram): DPT4ByteFloat.from_knx(DPTArray((0xF8, 0x01, 0x23))) def test_4byte_flaot_from_knx_unpack_error(self) -> None: """Test DPT4ByteFloat parsing with unpack error.""" with patch("struct.unpack") as unpack_mock: unpack_mock.side_effect = struct.error() with pytest.raises(ConversionError): DPT4ByteFloat.from_knx(DPTArray((0x01, 0x23, 0x02, 0x02))) # # DPTElectricCurrent # def test_electric_current_settings(self) -> None: """Test attributes of DPTElectricCurrent.""" assert DPTElectricCurrent.unit == "A" # # DPTElectricPotential # def test_electric_potential_settings(self) -> None: """Test attributes of DPTElectricPotential.""" assert DPTElectricPotential.unit == "V" # # DPTPower # def test_power_settings(self) -> None: """Test attributes of DPTPower.""" assert DPTPower.unit == "W" xknx-3.6.0/test/dpt_tests/dpt_16_string_test.py000066400000000000000000000063761475530762600216270ustar00rootroot00000000000000"""Unit test for KNX string object.""" import pytest from xknx.dpt import DPTArray, DPTLatin1, DPTString from xknx.exceptions import ConversionError, CouldNotParseTelegram class TestDPTString: """Test class for KNX ASCII string object.""" @pytest.mark.parametrize( "string,raw", [ ( "KNX is OK", (75, 78, 88, 32, 105, 115, 32, 79, 75, 0, 0, 0, 0, 0), ), ( "", (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), ), ( "AbCdEfGhIjKlMn", (65, 98, 67, 100, 69, 102, 71, 104, 73, 106, 75, 108, 77, 110), ), ( ".,:;-_!?$@&#%/", (46, 44, 58, 59, 45, 95, 33, 63, 36, 64, 38, 35, 37, 47), ), ], ) @pytest.mark.parametrize("test_dpt", [DPTString, DPTLatin1]) def test_values( self, string: str, raw: tuple[int, ...], test_dpt: type[DPTString] ) -> None: """Test parsing and streaming strings.""" assert test_dpt.to_knx(string) == DPTArray(raw) assert test_dpt.from_knx(DPTArray(raw)) == string @pytest.mark.parametrize( "string,knx_string,raw", [ ( "Matouš", "Matou?", (77, 97, 116, 111, 117, 63, 0, 0, 0, 0, 0, 0, 0, 0), ), ( "Gänsefüßchen", "G?nsef??chen", (71, 63, 110, 115, 101, 102, 63, 63, 99, 104, 101, 110, 0, 0), ), ], ) def test_to_knx_ascii_invalid_chars( self, string: str, knx_string: str, raw: tuple[int, ...] ) -> None: """Test streaming ASCII string with invalid chars.""" assert DPTString.to_knx(string) == DPTArray(raw) assert DPTString.from_knx(DPTArray(raw)) == knx_string @pytest.mark.parametrize( "string,raw", [ ( "Gänsefüßchen", (71, 228, 110, 115, 101, 102, 252, 223, 99, 104, 101, 110, 0, 0), ), ( "àáâãåæçèéêëìíî", (224, 225, 226, 227, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238), ), ], ) def test_to_knx_latin_1(self, string: str, raw: tuple[int, ...]) -> None: """Test streaming Latin-1 strings.""" assert DPTLatin1.to_knx(string) == DPTArray(raw) assert DPTLatin1.from_knx(DPTArray(raw)) == string def test_to_knx_too_long(self) -> None: """Test serializing DPTString to KNX with wrong value (to long).""" with pytest.raises(ConversionError): DPTString.to_knx("AAAAABBBBBCCCCx") @pytest.mark.parametrize( "raw", [ ((0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),), ((0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),), ], ) def test_from_knx_wrong_parameter_length(self, raw: tuple[int, ...]) -> None: """Test parsing of KNX string with wrong elements length.""" with pytest.raises(CouldNotParseTelegram): DPTString.from_knx(DPTArray(raw)) def test_no_unit_of_measurement(self) -> None: """Test for no unit set for DPT 16.""" assert DPTString.unit is None xknx-3.6.0/test/dpt_tests/dpt_17_scene_number_test.py000066400000000000000000000034211475530762600227530ustar00rootroot00000000000000"""Unit test for KNX scene number.""" import pytest from xknx.dpt import DPTArray, DPTSceneNumber from xknx.exceptions import ConversionError, CouldNotParseTelegram class TestDPTSceneNumber: """Test class for KNX scaling value.""" @pytest.mark.parametrize( ("raw", "value"), [ ((0x31,), 50), ((0x3F,), 64), ((0x00,), 1), ], ) def test_transcoder(self, raw: tuple[int], value: int) -> None: """Test parsing and streaming of DPTSceneNumber.""" assert DPTSceneNumber.to_knx(value) == DPTArray(raw) assert DPTSceneNumber.from_knx(DPTArray(raw)) == value def test_to_knx_min_exceeded(self) -> None: """Test parsing of DPTSceneNumber with wrong value (underflow).""" with pytest.raises(ConversionError): DPTSceneNumber.to_knx(DPTSceneNumber.value_min - 1) def test_to_knx_max_exceeded(self) -> None: """Test parsing of DPTSceneNumber with wrong value (overflow).""" with pytest.raises(ConversionError): DPTSceneNumber.to_knx(DPTSceneNumber.value_max + 1) def test_to_knx_wrong_parameter(self) -> None: """Test parsing of DPTSceneNumber with wrong value (string).""" with pytest.raises(ConversionError): DPTSceneNumber.to_knx("fnord") def test_from_knx_wrong_parameter(self) -> None: """Test parsing of DPTSceneNumber with wrong value (3 byte array).""" with pytest.raises(CouldNotParseTelegram): DPTSceneNumber.from_knx(DPTArray((0x01, 0x02, 0x03))) def test_from_knx_wrong_value(self) -> None: """Test parsing of DPTSceneNumber with value which exceeds limits.""" with pytest.raises(ConversionError): DPTSceneNumber.from_knx(DPTArray((0x64,))) xknx-3.6.0/test/dpt_tests/dpt_18_scene_control_test.py000066400000000000000000000065301475530762600231500ustar00rootroot00000000000000"""Unit test for KNX DPT 18 objects.""" from typing import Any import pytest from xknx.dpt import DPTArray, DPTBinary, DPTSceneControl, SceneControl from xknx.exceptions import ConversionError, CouldNotParseTelegram class TestSceneControl: """Test SceneControl class.""" @pytest.mark.parametrize( ("data", "value"), [ ( {"scene_number": 5, "learn": False}, SceneControl(5, False), ), ( {"scene_number": 2}, SceneControl(scene_number=2), ), ( {"scene_number": 17, "learn": True}, SceneControl(scene_number=17, learn=True), ), ], ) def test_dict(self, data: dict[str, Any], value: SceneControl) -> None: """Test from_dict and as_dict methods.""" test_value = SceneControl.from_dict(data) assert test_value == value # learn defaults to `False` default_dict = {"learn": False} assert value.as_dict() == default_dict | data @pytest.mark.parametrize( "data", [ # invalid data {"learn": False}, {"scene_number": "a"}, {"scene_number": None, "learn": True}, {"scene_number": 1, "learn": "a"}, {"scene_number": 1, "learn": None}, ], ) def test_dict_invalid(self, data: dict[str, Any]) -> None: """Test from_dict and as_dict methods.""" with pytest.raises(ValueError): SceneControl.from_dict(data) class TestDPTSceneControl: """Test class for KNX DPTSceneControl objects.""" @pytest.mark.parametrize( ("value", "raw"), [ (SceneControl(1, False), (0b00000000,)), (SceneControl(1, True), (0b10000000,)), (SceneControl(64, False), (0b00111111,)), (SceneControl(64, True), (0b10111111,)), ], ) def test_parse(self, value: SceneControl, raw: tuple[int]) -> None: """Test DPTTariffActiveEnergy parsing and streaming.""" knx_value = DPTSceneControl.to_knx(value) assert knx_value == DPTArray(raw) assert DPTSceneControl.from_knx(knx_value) == value @pytest.mark.parametrize( ("value", "raw"), [ ({"scene_number": 17, "learn": False}, (0x10,)), ({"scene_number": 21, "learn": True}, (0x94,)), ], ) def test_to_knx_from_dict(self, value: dict[str, Any], raw: tuple[int]) -> None: """Test DPTTariffActiveEnergy parsing from a dict.""" assert DPTSceneControl.to_knx(value) == DPTArray(raw) @pytest.mark.parametrize( ("value"), [ SceneControl(scene_number=0, learn=False), SceneControl(scene_number=65, learn=True), ], ) def test_to_knx_limits(self, value: SceneControl) -> None: """Test initialization of DPTTariffActiveEnergy with wrong value.""" with pytest.raises(ConversionError): DPTSceneControl.to_knx(value) def test_from_knx_wrong_value(self) -> None: """Test DPTTariffActiveEnergy parsing with wrong value.""" with pytest.raises(CouldNotParseTelegram): DPTSceneControl.from_knx(DPTArray((0xFF, 0x4E))) with pytest.raises(CouldNotParseTelegram): DPTSceneControl.from_knx(DPTBinary(True)) xknx-3.6.0/test/dpt_tests/dpt_19_datetime_test.py000066400000000000000000000145351475530762600221140ustar00rootroot00000000000000"""Unit test for KNX datetime objects.""" import datetime from typing import Any import pytest from xknx.dpt import DPTArray, DPTBinary, DPTDateTime from xknx.dpt.dpt_19 import KNXDateTime, KNXDayOfWeek from xknx.exceptions import ConversionError, CouldNotParseTelegram class TestKNXDateTime: """Test class for KNX datetime objects.""" @pytest.mark.parametrize( ("data", "value"), [ ( { "year": 1900, "month": 1, "day": 1, "hour": 0, "minutes": 0, "seconds": 0, }, KNXDateTime(1900, 1, 1, 0, 0, 0), ), ( { "year": 2155, "month": 12, "day": 31, "hour": 23, "minutes": 59, "seconds": 59, "day_of_week": "monday", }, KNXDateTime( 2155, 12, 31, 23, 59, 59, day_of_week=KNXDayOfWeek.MONDAY, ), ), ( { "year": 2017, "month": 11, "day": 28, "hour": 23, "minutes": 7, "seconds": 24, "day_of_week": "any_day", "fault": True, "working_day": True, "dst": True, "external_sync": True, "source_reliable": True, }, KNXDateTime( 2017, 11, 28, 23, 7, 24, day_of_week=KNXDayOfWeek.ANY_DAY, fault=True, working_day=True, dst=True, external_sync=True, source_reliable=True, ), ), ], ) def test_dict(self, data: dict[str, Any], value: KNXDateTime) -> None: """Test from_dict and as_dict methods.""" assert KNXDateTime.from_dict(data) == value default_dict = { "day_of_week": None, "fault": False, "working_day": None, "dst": False, "external_sync": False, "source_reliable": False, } assert value.as_dict() == default_dict | data @pytest.mark.parametrize( "data", [ # invalid data {"hours": 1}, # additional "s" in "hours" { "year": 1900, "month": 1, "day": 1, "hour": 0, "minutes": 0, "seconds": 0, "invalid": True, }, ], ) def test_dict_invalid(self, data: Any) -> None: """Test from_dict and as_dict methods.""" with pytest.raises(ValueError): KNXDateTime.from_dict(data) @pytest.mark.parametrize( ("dt", "value"), [ ( datetime.datetime(2024, 7, 28, 22, 59, 17), KNXDateTime(2024, 7, 28, 22, 59, 17), ), ( datetime.datetime(1999, 3, 31), KNXDateTime(1999, 3, 31, 0, 0, 0), # datetime time defaults to 0:00:00 ), ], ) def test_as_datetime(self, dt: datetime.datetime, value: KNXDateTime) -> None: """Test from_time and as_time methods.""" assert KNXDateTime.from_datetime(dt) == value assert value.as_datetime() == dt class TestDPTDateTime: """Test class for KNX datetime objects.""" @pytest.mark.parametrize( ("value", "raw"), [ ( KNXDateTime(2017, 11, 28, 23, 7, 24), (0x75, 0x0B, 0x1C, 0x17, 0x07, 0x18, 0x24, 0x00), ), ( KNXDateTime(1900, 1, 1, 0, 0, 0), (0x00, 0x1, 0x1, 0x00, 0x00, 0x00, 0x24, 0x00), ), ( KNXDateTime(2155, 12, 31, 23, 59, 59, day_of_week=KNXDayOfWeek.SUNDAY), (0xFF, 0x0C, 0x1F, 0xF7, 0x3B, 0x3B, 0x20, 0x00), ), ], ) def test_parse(self, value: KNXDateTime, raw: tuple[int, ...]) -> None: """Test parsing and streaming.""" knx_value = DPTDateTime.to_knx(value) assert knx_value == DPTArray(raw) assert DPTDateTime.from_knx(knx_value) == value def test_from_knx_wrong_value(self) -> None: """Test parsing DPTDateTime from KNX with wrong binary values.""" with pytest.raises(CouldNotParseTelegram): DPTDateTime.from_knx(DPTArray((0xF8, 0x23))) with pytest.raises(ConversionError): # (second byte exceeds value...) DPTDateTime.from_knx( DPTArray((0xFF, 0x0D, 0x1F, 0xF7, 0x3B, 0x3B, 0x00, 0x00)) ) with pytest.raises(CouldNotParseTelegram): DPTDateTime.from_knx(DPTBinary(True)) def test_to_knx_wrong_value(self) -> None: """Test parsing from DPTDateTime object from wrong value.""" with pytest.raises(ConversionError): # year out of range DPTDateTime.to_knx(KNXDateTime(1889, 1, 1, 0, 0, 0)) with pytest.raises(ConversionError): # day out of range (0) DPTDateTime.to_knx(KNXDateTime(2000, 1, 0, 0, 0, 0)) with pytest.raises(ConversionError): # hour out of range DPTDateTime.to_knx(KNXDateTime(2000, 1, 1, 25, 0, 0)) with pytest.raises(ConversionError): # minutes out of range DPTDateTime.to_knx(KNXDateTime(2000, 1, 1, 1, 60, 0)) with pytest.raises(ConversionError): # seconds out of range DPTDateTime.to_knx(KNXDateTime(2000, 1, 1, 1, 0, 60)) with pytest.raises(ConversionError): # seconds out of range at hour 24 DPTDateTime.to_knx(KNXDateTime(2000, 1, 1, 24, 0, 1)) with pytest.raises(ConversionError): DPTDateTime.to_knx("hello") with pytest.raises(ConversionError): DPTDateTime.to_knx((1, 2, 3)) xknx-3.6.0/test/dpt_tests/dpt_1_test.py000066400000000000000000000113271475530762600201430ustar00rootroot00000000000000"""Unit test for KNX DPT 1.""" from typing import Any import pytest from xknx.dpt import DPTArray, DPTBinary, DPTEnumData from xknx.dpt.dpt_1 import ( Ack, Alarm, BinaryValue, Bool, ConsumerProducer, DayNight, DimSendStyle, DPT1BitEnum, DPTAck, DPTAlarm, DPTBinaryValue, DPTBool, DPTConsumerProducer, DPTDayNight, DPTDimSendStyle, DPTEnable, DPTEnergyDirection, DPTHeatCool, DPTInputSource, DPTInvert, DPTLogicalFunction, DPTOccupancy, DPTOpenClose, DPTRamp, DPTReset, DPTSceneAB, DPTShutterBlindsMode, DPTStart, DPTState, DPTStep, DPTSwitch, DPTTrigger, DPTUpDown, DPTWindowDoor, Enable, EnergyDirection, HeatCool, InputSource, Invert, LogicalFunction, Occupancy, OpenClose, Ramp, Reset, SceneAB, ShutterBlindsMode, Start, State, Step, Switch, Trigger, UpDown, WindowDoor, ) from xknx.exceptions import ConversionError, CouldNotParseTelegram @pytest.mark.parametrize( ("dpt", "value_false", "value_true"), [ (DPTSwitch, Switch.OFF, Switch.ON), (DPTBool, Bool.FALSE, Bool.TRUE), (DPTEnable, Enable.DISABLE, Enable.ENABLE), (DPTRamp, Ramp.NO_RAMP, Ramp.RAMP), (DPTAlarm, Alarm.NO_ALARM, Alarm.ALARM), (DPTBinaryValue, BinaryValue.LOW, BinaryValue.HIGH), (DPTStep, Step.DECREASE, Step.INCREASE), (DPTUpDown, UpDown.UP, UpDown.DOWN), (DPTOpenClose, OpenClose.OPEN, OpenClose.CLOSE), (DPTStart, Start.STOP, Start.START), (DPTState, State.INACTIVE, State.ACTIVE), (DPTInvert, Invert.NOT_INVERTED, Invert.INVERTED), (DPTDimSendStyle, DimSendStyle.START_STOP, DimSendStyle.CYCLICALLY), (DPTInputSource, InputSource.FIXED, InputSource.CALCULATED), (DPTReset, Reset.NO_ACTION, Reset.RESET), (DPTAck, Ack.NO_ACTION, Ack.ACKNOWLEDGE), (DPTTrigger, Trigger.TRIGGER_0, Trigger.TRIGGER), (DPTOccupancy, Occupancy.NOT_OCCUPIED, Occupancy.OCCUPIED), (DPTWindowDoor, WindowDoor.CLOSED, WindowDoor.OPEN), (DPTLogicalFunction, LogicalFunction.OR, LogicalFunction.AND), (DPTSceneAB, SceneAB.SCENE_A, SceneAB.SCENE_B), ( DPTShutterBlindsMode, ShutterBlindsMode.UP_DOWN_MODE, ShutterBlindsMode.STEP_STOP_MODE, ), (DPTDayNight, DayNight.DAY, DayNight.NIGHT), (DPTHeatCool, HeatCool.COOL, HeatCool.HEAT), (DPTConsumerProducer, ConsumerProducer.CONSUMER, ConsumerProducer.PRODUCER), (DPTEnergyDirection, EnergyDirection.POSITIVE, EnergyDirection.NEGATIVE), ], ) class TestDPT1: """Test class for KNX DPT 1 values.""" def test_to_knx( self, dpt: type[DPT1BitEnum[Any]], value_true: DPTEnumData, value_false: DPTEnumData, ) -> None: """Test parsing to KNX.""" assert dpt.to_knx(value_true) == DPTBinary(1) assert dpt.to_knx(value_false) == DPTBinary(0) def test_to_knx_by_string( self, dpt: type[DPT1BitEnum[Any]], value_true: DPTEnumData, value_false: DPTEnumData, ) -> None: """Test parsing string values to KNX.""" assert dpt.to_knx(value_true.name.lower()) == DPTBinary(1) assert dpt.to_knx(value_false.name.lower()) == DPTBinary(0) def test_to_knx_by_value( self, dpt: type[DPT1BitEnum[Any]], value_true: DPTEnumData, value_false: DPTEnumData, ) -> None: """Test parsing string values to KNX.""" assert dpt.to_knx(True) == DPTBinary(1) assert dpt.to_knx(1) == DPTBinary(1) assert dpt.to_knx(False) == DPTBinary(0) assert dpt.to_knx(0) == DPTBinary(0) def test_to_knx_wrong_value( self, dpt: type[DPT1BitEnum[Any]], value_true: DPTEnumData, value_false: DPTEnumData, ) -> None: """Test serializing to KNX with wrong value.""" with pytest.raises(ConversionError): dpt.to_knx(2) def test_from_knx( self, dpt: type[DPT1BitEnum[Any]], value_true: DPTEnumData, value_false: DPTEnumData, ) -> None: """Test parsing from KNX.""" assert dpt.from_knx(DPTBinary(0)) == value_false assert dpt.from_knx(DPTBinary(1)) == value_true def test_from_knx_wrong_value( self, dpt: type[DPT1BitEnum[Any]], value_true: DPTEnumData, value_false: DPTEnumData, ) -> None: """Test parsing with wrong value).""" with pytest.raises(CouldNotParseTelegram): dpt.from_knx(DPTArray((1,))) with pytest.raises(CouldNotParseTelegram): dpt.from_knx(DPTBinary(2)) xknx-3.6.0/test/dpt_tests/dpt_20_hvac_mode_test.py000066400000000000000000000240251475530762600222300ustar00rootroot00000000000000"""Unit test for KNX DPT HVAC Operation modes.""" from typing import Any import pytest from xknx.dpt import DPTArray, DPTHVACContrMode, DPTHVACMode, DPTHVACStatus from xknx.dpt.dpt_20 import HeatCool, HVACControllerMode, HVACOperationMode, HVACStatus from xknx.exceptions import ConversionError, CouldNotParseTelegram class TestDPTHVACMode: """Test class for KNX DPT HVAC Operation modes.""" def test_mode_to_knx(self) -> None: """Test parsing DPTHVACMode to KNX.""" assert DPTHVACMode.to_knx(HVACOperationMode.AUTO) == DPTArray((0x00,)) assert DPTHVACMode.to_knx(HVACOperationMode.COMFORT) == DPTArray((0x01,)) assert DPTHVACMode.to_knx(HVACOperationMode.STANDBY) == DPTArray((0x02,)) assert DPTHVACMode.to_knx(HVACOperationMode.ECONOMY) == DPTArray((0x03,)) assert DPTHVACMode.to_knx(HVACOperationMode.BUILDING_PROTECTION) == DPTArray( (0x04,) ) def test_mode_to_knx_by_string(self) -> None: """Test parsing DPTHVACMode string values to KNX.""" assert DPTHVACMode.to_knx("auto") == DPTArray((0x00,)) assert DPTHVACMode.to_knx("Comfort") == DPTArray((0x01,)) assert DPTHVACMode.to_knx("standby") == DPTArray((0x02,)) assert DPTHVACMode.to_knx("ECONOMY") == DPTArray((0x03,)) assert DPTHVACMode.to_knx("Building_Protection") == DPTArray((0x04,)) def test_mode_to_knx_wrong_value(self) -> None: """Test serializing DPTHVACMode to KNX with wrong value.""" with pytest.raises(ConversionError): DPTHVACMode.to_knx(5) def test_mode_from_knx(self) -> None: """Test parsing DPTHVACMode from KNX.""" assert DPTHVACMode.from_knx(DPTArray((0x00,))) == HVACOperationMode.AUTO assert DPTHVACMode.from_knx(DPTArray((0x01,))) == HVACOperationMode.COMFORT assert DPTHVACMode.from_knx(DPTArray((0x02,))) == HVACOperationMode.STANDBY assert DPTHVACMode.from_knx(DPTArray((0x03,))) == HVACOperationMode.ECONOMY assert ( DPTHVACMode.from_knx(DPTArray((0x04,))) == HVACOperationMode.BUILDING_PROTECTION ) def test_mode_from_knx_wrong_value(self) -> None: """Test parsing of DPTHVACMode with wrong value).""" with pytest.raises(CouldNotParseTelegram): DPTHVACMode.from_knx(DPTArray((1, 2))) with pytest.raises(ConversionError): DPTHVACMode.from_knx(DPTArray((0x05,))) class TestDPTHVACControllerMode: """Test class for KNX DPT HVAC Controller modes.""" def test_mode_to_knx(self) -> None: """Test parsing DPTHVACContrMode to KNX.""" assert DPTHVACContrMode.to_knx(HVACControllerMode.AUTO) == DPTArray((0x00,)) assert DPTHVACContrMode.to_knx(HVACControllerMode.HEAT) == DPTArray((0x01,)) assert DPTHVACContrMode.to_knx(HVACControllerMode.COOL) == DPTArray((0x03,)) assert DPTHVACContrMode.to_knx(HVACControllerMode.OFF) == DPTArray((0x06,)) assert DPTHVACContrMode.to_knx(HVACControllerMode.DEHUMIDIFICATION) == DPTArray( (0x0E,) ) def test_mode_to_knx_by_string(self) -> None: """Test parsing DPTHVACMode string values to KNX.""" assert DPTHVACContrMode.to_knx("morning_warmup") == DPTArray((0x02,)) assert DPTHVACContrMode.to_knx("NIGHT_PURGE") == DPTArray((0x04,)) assert DPTHVACContrMode.to_knx("precool") == DPTArray((0x05,)) assert DPTHVACContrMode.to_knx("Test") == DPTArray((0x07,)) assert DPTHVACContrMode.to_knx("NODEM") == DPTArray((0x14,)) def test_mode_to_knx_wrong_value(self) -> None: """Test serializing DPTHVACMode to KNX with wrong value.""" with pytest.raises(ConversionError): DPTHVACContrMode.to_knx(18) with pytest.raises(ConversionError): DPTHVACContrMode.to_knx(19) with pytest.raises(ConversionError): DPTHVACContrMode.to_knx(21) @pytest.mark.parametrize( ("raw", "value"), [ (0x00, HVACControllerMode.AUTO), (0x08, HVACControllerMode.EMERGENCY_HEAT), (0x09, HVACControllerMode.FAN_ONLY), (0x0A, HVACControllerMode.FREE_COOL), (0x0B, HVACControllerMode.ICE), ], ) def test_mode_from_knx(self, raw: int, value: HVACControllerMode) -> None: """Test parsing DPTHVACMode from KNX.""" assert DPTHVACContrMode.from_knx(DPTArray((raw,))) is value def test_mode_from_knx_wrong_value(self) -> None: """Test parsing of DPTHVACMode with wrong value).""" with pytest.raises(CouldNotParseTelegram): DPTHVACContrMode.from_knx(DPTArray((1, 2))) with pytest.raises(ConversionError): DPTHVACContrMode.from_knx(DPTArray((18,))) class TestHVACStatus: """Test HVACStatus class.""" @pytest.mark.parametrize( ("data", "dict_value"), [ ( HVACStatus( mode=HVACOperationMode.COMFORT, dew_point=False, heat_cool=HeatCool.HEAT, inactive=False, frost_alarm=False, ), { "mode": "comfort", "dew_point": False, "heat_cool": "heat", "inactive": False, "frost_alarm": False, }, ), ( HVACStatus( mode=HVACOperationMode.STANDBY, dew_point=False, heat_cool=HeatCool.COOL, inactive=True, frost_alarm=False, ), { "mode": "standby", "dew_point": False, "heat_cool": "cool", "inactive": True, "frost_alarm": False, }, ), ], ) def test_dict(self, data: HVACStatus, dict_value: dict[str, Any]) -> None: """Test from_dict and as_dict methods.""" assert HVACStatus.from_dict(dict_value) == data assert data.as_dict() == dict_value @pytest.mark.parametrize( "data", [ { "mode": 1, # invalid "dew_point": False, "heat_cool": "heat", "inactive": False, "frost_alarm": False, }, { "mode": "comfort", "dew_point": False, "heat_cool": "invalid", # invalid "inactive": False, "frost_alarm": False, }, { "mode": "comfort", "dew_point": False, "heat_cool": "nodem", # invalid for HVACStatus "inactive": False, "frost_alarm": False, }, { "mode": "comfort", "dew_point": 20, # invalid "heat_cool": "heat", "inactive": False, "frost_alarm": False, }, ], ) def test_dict_invalid(self, data: dict[str, Any]) -> None: """Test from_dict with invalid data.""" with pytest.raises(ValueError): HVACStatus.from_dict(data) class TestDPTHVACStatus: """Test class for KNX DPTHVACStatus objects.""" @pytest.mark.parametrize( ("value", "raw"), [ ( HVACStatus( mode=HVACOperationMode.COMFORT, dew_point=False, heat_cool=HeatCool.HEAT, inactive=False, frost_alarm=False, ), (0x21,), ), ( HVACStatus( mode=HVACOperationMode.COMFORT, dew_point=False, heat_cool=HeatCool.COOL, inactive=False, frost_alarm=False, ), (0x01,), ), ( HVACStatus( mode=HVACOperationMode.ECONOMY, dew_point=True, heat_cool=HeatCool.COOL, inactive=False, frost_alarm=True, ), (0x94,), ), ], ) def test_dpt_encoding_decoding(self, value: HVACStatus, raw: tuple[int]) -> None: """Test DPTHVACStatus parsing and streaming.""" knx_value = DPTHVACStatus.to_knx(value) assert knx_value == DPTArray(raw) assert DPTHVACStatus.from_knx(knx_value) == value @pytest.mark.parametrize( ("value", "raw"), [ ( { "mode": "comfort", "dew_point": False, "heat_cool": "heat", "inactive": False, "frost_alarm": False, }, (0x21,), ), ( { "mode": "standby", "dew_point": False, "heat_cool": "cool", "inactive": True, "frost_alarm": False, }, (0x42,), ), ], ) def test_dpt_to_knx_from_dict(self, value: dict[str, Any], raw: tuple[int]) -> None: """Test DPTHVACStatus parsing from a dict.""" knx_value = DPTHVACStatus.to_knx(value) assert knx_value == DPTArray(raw) @pytest.mark.parametrize( "value", [ {"mode": "comfort", "dew_point": False, "heat_cool": "heat"}, 1, "cool", ], ) def test_dpt_wrong_value_to_knx(self, value: Any) -> None: """Test DPTHVACStatus parsing with wrong value.""" with pytest.raises(ConversionError): DPTHVACStatus.to_knx(value) def test_dpt_wrong_value_from_knx(self) -> None: """Test DPTHVACStatus parsing with wrong value.""" with pytest.raises(CouldNotParseTelegram): DPTHVACStatus.from_knx(DPTArray((0xFF, 0x4E))) xknx-3.6.0/test/dpt_tests/dpt_235_tariff_active_energy_test.py000066400000000000000000000127021475530762600245510ustar00rootroot00000000000000"""Unit test for KNX DPT 235 objects.""" from typing import Any import pytest from xknx.dpt import DPTArray, DPTTariffActiveEnergy, TariffActiveEnergy from xknx.exceptions import ConversionError, CouldNotParseTelegram class TestTariffActiveEnergy: """Test TariffActiveEnergy class.""" @pytest.mark.parametrize( ("data", "value"), [ ( {"energy": -2_147_483_648, "tariff": 0}, TariffActiveEnergy(-2_147_483_648, 0), ), ( {"energy": 2_147_483_647, "tariff": 255}, TariffActiveEnergy(2_147_483_647, 255), ), ({"tariff": 128}, TariffActiveEnergy(None, 128)), ({"energy": 50}, TariffActiveEnergy(50, None)), ({}, TariffActiveEnergy()), ( {"energy": 255, "tariff": None}, TariffActiveEnergy(255, None), ), ( {"energy": None, "tariff": 128}, TariffActiveEnergy(None, 128), ), ({"energy": 255, "tariff": None}, TariffActiveEnergy(255, None)), ({"tariff": 128}, TariffActiveEnergy(None, 128)), ({"energy": 128}, TariffActiveEnergy(128, None)), ({"energy": 128, "tariff": 128}, TariffActiveEnergy(128, 128)), ], ) def test_dict(self, data: dict[str, Any], value: TariffActiveEnergy) -> None: """Test from_dict and as_dict methods.""" test_value = TariffActiveEnergy.from_dict(data) assert test_value == value # fields default to `None` default_dict = {"energy": None, "tariff": None} assert value.as_dict() == default_dict | data @pytest.mark.parametrize( "data", [ # invalid data {"energy": 128, "tariff": "a"}, {"energy": "a", "tariff": 128}, ], ) def test_dict_invalid(self, data: dict[str, Any]) -> None: """Test from_dict and as_dict methods.""" with pytest.raises(ValueError): TariffActiveEnergy.from_dict(data) class TestDPTTariffActiveEnergy: """Test class for KNX DPTTariffActiveEnergy objects.""" @pytest.mark.parametrize( ("value", "raw"), [ ( TariffActiveEnergy(0, 0), (0x00, 0x00, 0x00, 0x00, 0x00, 0x03), ), # zero values ( TariffActiveEnergy(-2_147_483_648, 0), (0x80, 0x00, 0x00, 0x00, 0x00, 0x03), ), # min values ( TariffActiveEnergy(2_147_483_647, 254), (0x7F, 0xFF, 0xFF, 0xFF, 0xFE, 0x03), ), # max values ( TariffActiveEnergy(None, 0), (0x00, 0x00, 0x00, 0x00, 0x00, 0x01), ), # energy None ( TariffActiveEnergy(0, None), (0x00, 0x00, 0x00, 0x00, 0x00, 0x02), ), # tariff None (TariffActiveEnergy(None, None), (0x00, 0x00, 0x00, 0x00, 0x00, 0x00)), (TariffActiveEnergy(128, 128), (0x00, 0x00, 0x00, 0x80, 0x80, 0x03)), (TariffActiveEnergy(204, 204), (0x00, 0x00, 0x00, 0xCC, 0xCC, 0x03)), ], ) def test_dpt_tariff_active_energy_value( self, value: TariffActiveEnergy, raw: tuple[int, ...] ) -> None: """Test DPTTariffActiveEnergy parsing and streaming.""" knx_value = DPTTariffActiveEnergy.to_knx(value) assert knx_value == DPTArray(raw) assert DPTTariffActiveEnergy.from_knx(knx_value) == value @pytest.mark.parametrize( ("value", "raw"), [ ({"tariff": 128, "energy": 128}, (0x00, 0x00, 0x00, 0x80, 0x80, 0x03)), ({"tariff": 204, "energy": 204}, (0x00, 0x00, 0x00, 0xCC, 0xCC, 0x03)), ], ) def test_dpt_tariff_active_energy_to_knx_from_dict( self, value: dict[str, Any], raw: tuple[int, ...] ) -> None: """Test DPTTariffActiveEnergy parsing from a dict.""" knx_value = DPTTariffActiveEnergy.to_knx(value) assert knx_value == DPTArray(raw) @pytest.mark.parametrize( ("value"), [ TariffActiveEnergy(-2_147_483_649, 0), TariffActiveEnergy(2_147_483_648, 0), TariffActiveEnergy(0, -1), TariffActiveEnergy(0, 255), ], ) def test_dpt_tariff_active_energy_to_knx_limits( self, value: TariffActiveEnergy ) -> None: """Test initialization of DPTTariffActiveEnergy with wrong value.""" with pytest.raises(ConversionError): DPTTariffActiveEnergy.to_knx(value) @pytest.mark.parametrize( "value", [ None, (0xFF, 0x4E), 1, ((0x00, 0xFF), 0x4E), ((0xFF, 0x4E), (0x12, 0x00)), ((0, 0), "a"), ((0, 0), 0.4), TariffActiveEnergy(tariff=0, energy="a"), TariffActiveEnergy(tariff="a", energy=0), ], ) def test_dpt_tariff_active_energy_wrong_value_to_knx(self, value: Any) -> None: """Test DPTTariffActiveEnergy parsing with wrong value.""" with pytest.raises(ConversionError): DPTTariffActiveEnergy.to_knx(value) def test_dpt_tariff_active_energy_wrong_value_from_knx(self) -> None: """Test DPTTariffActiveEnergy parsing with wrong value.""" with pytest.raises(CouldNotParseTelegram): DPTTariffActiveEnergy.from_knx(DPTArray((0xFF, 0x4E))) xknx-3.6.0/test/dpt_tests/dpt_29_test.py000066400000000000000000000023201475530762600202260ustar00rootroot00000000000000"""Unit test for KNX 8 byte signed objects.""" import pytest from xknx.dpt import DPT8ByteSigned, DPTArray from xknx.exceptions import ConversionError class TestDPT8ByteSigned: """Test class for KNX 8 byte signed objects.""" @pytest.mark.parametrize( ("raw", "expected"), ( (b"\x00\x00\x00\x00\x00\x00\x00\x00", 0), (b"\x00\x00\x00\x00\x00\x00\x00\x01", 1), (b"\x00\x00\x00\x00\x00\x00\x00\xe6", 230), (b"\xff\xff\xff\xff\xff\xff\xff\x1a", -230), # limits (b"\x7f\xff\xff\xff\xff\xff\xff\xff", 9_223_372_036_854_775_807), (b"\x80\x00\x00\x00\x00\x00\x00\x00", -9_223_372_036_854_775_808), ), ) def test_values(self, raw: bytes, expected: int) -> None: """Test valid values.""" assert DPT8ByteSigned.to_knx(expected) == DPTArray(raw) assert DPT8ByteSigned.from_knx(DPTArray(raw)) == expected @pytest.mark.parametrize( "value", (9_223_372_036_854_775_808, -9_223_372_036_854_775_809) ) def test_exceeding_limits(self, value: int) -> None: """Test invalid values.""" with pytest.raises(ConversionError): DPT8ByteSigned.to_knx(value) xknx-3.6.0/test/dpt_tests/dpt_3_test.py000066400000000000000000000145221475530762600201450ustar00rootroot00000000000000"""Unit test for DPT 3 objects.""" from typing import Any import pytest from xknx.dpt import DPTArray, DPTBinary, DPTControlBlinds, DPTControlDimming from xknx.dpt.dpt_1 import Step, UpDown from xknx.dpt.dpt_3 import ControlBlinds, ControlDimming from xknx.exceptions import ConversionError, CouldNotParseTelegram class TestControlData: """Test class for Control data objects.""" @pytest.mark.parametrize( ("data", "dict_value"), [ ( ControlBlinds(control=UpDown.UP, step_code=0), {"control": "up", "step_code": 0}, ), ( ControlBlinds(control=UpDown.DOWN, step_code=7), {"control": "down", "step_code": 7}, ), ( ControlDimming(control=Step.DECREASE, step_code=5), {"control": "decrease", "step_code": 5}, ), ( ControlDimming(control=Step.INCREASE, step_code=0), {"control": "increase", "step_code": 0}, ), ], ) def test_dict(self, data: ControlBlinds, dict_value: dict[str, Any]) -> None: """Test from_dict and as_dict methods.""" test_dataclass = data.__class__ assert test_dataclass.from_dict(dict_value) == data assert data.as_dict() == dict_value @pytest.mark.parametrize( "data", [ {"control": 1, "step_code": "invalid"}, {"control": "up", "step_code": "invalid"}, {"control": "increase", "step_code": "invalid"}, {"control": "down", "step_code": None}, {"control": None, "step_code": 1}, {"control": "invalid", "step_code": 0}, {"control": 2, "step_code": 4}, ], ) def test_dict_invalid(self, data: dict[str, Any]) -> None: """Test from_dict with invalid data.""" with pytest.raises(ValueError): ControlBlinds.from_dict(data) with pytest.raises(ValueError): ControlDimming.from_dict(data) @pytest.mark.parametrize( ("dpt", "dpt_data", "control_enum"), [ (DPTControlDimming, ControlDimming, Step), (DPTControlBlinds, ControlBlinds, UpDown), ], ) class TestDPTControlStepCode: """Test class for DPT 3 objects.""" def test_to_knx( self, dpt: type[DPTControlDimming | DPTControlBlinds], dpt_data: type[ControlDimming | ControlBlinds], control_enum: type[Step | UpDown], ) -> None: """Test serializing values.""" for rawref in range(16): control = 1 if rawref >> 3 else 0 raw = dpt.to_knx( dpt_data( control=control_enum(control), step_code=rawref & 0x07, ) ) assert raw == DPTBinary(rawref) def test_to_knx_dict( self, dpt: type[DPTControlDimming | DPTControlBlinds], dpt_data: type[ControlDimming | ControlBlinds], control_enum: type[Step | UpDown], ) -> None: """Test serializing values from dict.""" for rawref in range(16): control = 1 if rawref >> 3 else 0 raw = dpt.to_knx( { "control": control_enum(control).name.lower(), "step_code": rawref & 0x07, } ) assert raw == DPTBinary(rawref) def test_to_knx_wrong_type( self, dpt: type[DPTControlDimming | DPTControlBlinds], dpt_data: type[ControlDimming | ControlBlinds], control_enum: type[Step | UpDown], ) -> None: """Test serializing wrong type.""" with pytest.raises(ConversionError): dpt.to_knx("") with pytest.raises(ConversionError): dpt.to_knx(0) def test_to_knx_missing_keys( self, dpt: type[DPTControlDimming | DPTControlBlinds], dpt_data: type[ControlDimming | ControlBlinds], control_enum: type[Step | UpDown], ) -> None: """Test serializing map with missing keys.""" with pytest.raises(ConversionError): dpt.to_knx({"control": 0}) with pytest.raises(ConversionError): dpt.to_knx({"step_code": 0}) def test_to_knx_wrong_value_types( self, dpt: type[DPTControlDimming | DPTControlBlinds], dpt_data: type[ControlDimming | ControlBlinds], control_enum: type[Step | UpDown], ) -> None: """Test serializing map with keys of invalid type.""" with pytest.raises(ConversionError): dpt.to_knx({"control": "", "step_code": 0}) with pytest.raises(ConversionError): dpt.to_knx({"control": 0, "step_code": ""}) def test_to_knx_wrong_values( self, dpt: type[DPTControlDimming | DPTControlBlinds], dpt_data: type[ControlDimming | ControlBlinds], control_enum: type[Step | UpDown], ) -> None: """Test serializing with invalid values.""" with pytest.raises(ConversionError): dpt.to_knx({"control": -1, "step_code": 0}) with pytest.raises(ConversionError): dpt.to_knx({"control": 2, "step_code": 0}) with pytest.raises(ConversionError): dpt.to_knx({"control": 0, "step_code": -1}) with pytest.raises(ConversionError): dpt.to_knx(dpt_data(control=control_enum(0), step_code=8)) def test_from_knx( self, dpt: type[DPTControlDimming | DPTControlBlinds], dpt_data: type[ControlDimming | ControlBlinds], control_enum: type[Step | UpDown], ) -> None: """Test parsing from KNX.""" for raw in range(16): control = 1 if raw >> 3 else 0 valueref = dpt_data( control=control_enum(control), step_code=raw & 0x07, ) value = dpt.from_knx(DPTBinary((raw,))) assert value == valueref def test_from_knx_wrong_value( self, dpt: type[DPTControlDimming | DPTControlBlinds], dpt_data: type[ControlDimming | ControlBlinds], control_enum: type[Step | UpDown], ) -> None: """Test parsing invalid values from KNX.""" with pytest.raises(CouldNotParseTelegram): dpt.from_knx(DPTBinary((0x1F,))) with pytest.raises(CouldNotParseTelegram): dpt.from_knx(DPTArray((1,))) xknx-3.6.0/test/dpt_tests/dpt_5_test.py000066400000000000000000000117731475530762600201540ustar00rootroot00000000000000"""Unit test for KNX DPT 5.010 value.""" import pytest from xknx.dpt import DPTAngle, DPTArray, DPTScaling, DPTTariff, DPTValue1Ucount from xknx.exceptions import ConversionError, CouldNotParseTelegram class TestDPTValue1Ucount: """Test class for KNX 8-bit unsigned value.""" def test_value_50(self) -> None: """Test parsing and streaming of DPTValue1Ucount 50.""" assert DPTValue1Ucount.to_knx(50) == DPTArray(0x32) assert DPTValue1Ucount.from_knx(DPTArray((0x32,))) == 50 def test_value_max(self) -> None: """Test parsing and streaming of DPTValue1Ucount 255.""" assert DPTValue1Ucount.to_knx(255) == DPTArray(0xFF) assert DPTValue1Ucount.from_knx(DPTArray((0xFF,))) == 255 def test_value_min(self) -> None: """Test parsing and streaming of DPTValue1Ucount 0.""" assert DPTValue1Ucount.to_knx(0) == DPTArray(0x00) assert DPTValue1Ucount.from_knx(DPTArray((0x00,))) == 0 def test_to_knx_min_exceeded(self) -> None: """Test parsing of DPTValue1Ucount with wrong value (underflow).""" with pytest.raises(ConversionError): DPTValue1Ucount.to_knx(DPTValue1Ucount.value_min - 1) def test_to_knx_max_exceeded(self) -> None: """Test parsing of DPTValue1Ucount with wrong value (overflow).""" with pytest.raises(ConversionError): DPTValue1Ucount.to_knx(DPTValue1Ucount.value_max + 1) def test_to_knx_wrong_parameter(self) -> None: """Test parsing of DPTValue1Ucount with wrong value (string).""" with pytest.raises(ConversionError): DPTValue1Ucount.to_knx("fnord") def test_from_knx_wrong_parameter(self) -> None: """Test parsing of DPTValue1Ucount with wrong value (3 byte array).""" with pytest.raises(CouldNotParseTelegram): DPTValue1Ucount.from_knx(DPTArray((0x01, 0x02, 0x03))) def test_from_knx_wrong_value(self) -> None: """Test parsing of DPTValue1Ucount with value which exceeds limits.""" with pytest.raises(ConversionError): DPTValue1Ucount.from_knx(DPTArray((0x256,))) def test_from_knx_wrong_parameter2(self) -> None: """Test parsing of DPTValue1Ucount with wrong value (array containing string).""" with pytest.raises(CouldNotParseTelegram): DPTValue1Ucount.from_knx("0x23") class TestDPTScaling: """Test class for KNX scaling value.""" @pytest.mark.parametrize( ("raw", "value"), [ ((0x4C,), 30), ((0xFC,), 99), ((0xFF,), 100), ((0x00,), 0), ], ) def test_transcoder(self, raw: tuple[int], value: int) -> None: """Test parsing and streaming of DPTScaling.""" assert DPTScaling.to_knx(value) == DPTArray(raw) assert DPTScaling.from_knx(DPTArray(raw)) == value def test_to_knx_min_exceeded(self) -> None: """Test parsing of DPTScaling with wrong value (underflow).""" with pytest.raises(ConversionError): DPTScaling.to_knx(-1) def test_to_knx_max_exceeded(self) -> None: """Test parsing of DPTScaling with wrong value (overflow).""" with pytest.raises(ConversionError): DPTScaling.to_knx(101) def test_to_knx_wrong_parameter(self) -> None: """Test parsing of DPTScaling with wrong value (string).""" with pytest.raises(ConversionError): DPTScaling.to_knx("fnord") def test_from_knx_wrong_parameter(self) -> None: """Test parsing of DPTScaling with wrong value (3 byte array).""" with pytest.raises(CouldNotParseTelegram): DPTScaling.from_knx(DPTArray((0x01, 0x02, 0x03))) def test_from_knx_wrong_value(self) -> None: """Test parsing of DPTScaling with value which exceeds limits.""" with pytest.raises(ConversionError): DPTScaling.from_knx(DPTArray((0x256,))) class TestDPTAngle: """Test class for KNX scaling value.""" @pytest.mark.parametrize( ("raw", "value"), [ ((0x15,), 30), ((0xBF,), 270), ((0xFF,), 360), ((0x00,), 0), ], ) def test_transcoder(self, raw: tuple[int], value: int) -> None: """Test parsing and streaming of DPTAngle.""" assert DPTAngle.to_knx(value) == DPTArray(raw) assert DPTAngle.from_knx(DPTArray(raw)) == value def test_to_knx_min_exceeded(self) -> None: """Test parsing of DPTAngle with wrong value (underflow).""" with pytest.raises(ConversionError): DPTAngle.to_knx(-1) def test_to_knx_max_exceeded(self) -> None: """Test parsing of DPTAngle with wrong value (overflow).""" with pytest.raises(ConversionError): DPTAngle.to_knx(361) class TestDPTTariff: """Test class for KNX 8-bit tariff information.""" def test_from_knx_max_exceeded(self) -> None: """Test parsing of DPTTariff with wrong value (overflow).""" with pytest.raises(ConversionError): DPTTariff.from_knx(DPTArray((0xFF,))) xknx-3.6.0/test/dpt_tests/dpt_6_test.py000066400000000000000000000027471475530762600201560ustar00rootroot00000000000000"""Unit test for KNX DPT 1 byte relative value objects.""" import pytest from xknx.dpt import DPTArray, DPTPercentV8, DPTSignedRelativeValue, DPTValue1Count from xknx.exceptions import ConversionError class TestDPTRelativeValue: """Test class for KNX DPT Relative Value.""" @pytest.mark.parametrize( ("raw", "value"), [ ((0x00,), 0), ((0x01,), 1), ((0x02,), 2), ((0x64,), 100), ((0x7F,), 127), ((0x80,), -128), ((0x9C,), -100), ((0xFE,), -2), ((0xFF,), -1), ], ) def test_transcoder(self, raw: tuple[int], value: int) -> None: """Test value from and to KNX.""" assert DPTSignedRelativeValue.from_knx(DPTArray(raw)) == value assert DPTSignedRelativeValue.to_knx(value) == DPTArray(raw) def test_assert_min_exceeded(self) -> None: """Test initialization with wrong value (Underflow).""" with pytest.raises(ConversionError): DPTSignedRelativeValue.to_knx(-129) def test_assert_max_exceeded(self) -> None: """Test initialization with wrong value (Overflow).""" with pytest.raises(ConversionError): DPTSignedRelativeValue.to_knx(128) def test_unit(self) -> None: """Test unit of 1 byte relative value objects.""" assert DPTSignedRelativeValue.unit is None assert DPTPercentV8.unit == "%" assert DPTValue1Count.unit == "counter pulses" xknx-3.6.0/test/dpt_tests/dpt_7_test.py000066400000000000000000000047131475530762600201520ustar00rootroot00000000000000"""Unit test for KNX 2 byte objects.""" import pytest from xknx.dpt import DPTArray, DPTUElCurrentmA from xknx.exceptions import ConversionError, CouldNotParseTelegram class TestDPT2byte: """Test class for KNX 2 byte objects.""" # # DPTUElCurrentmA # def test_current_settings(self) -> None: """Test members of DPTUElCurrentmA.""" assert DPTUElCurrentmA.value_min == 0 assert DPTUElCurrentmA.value_max == 65535 assert DPTUElCurrentmA.unit == "mA" assert DPTUElCurrentmA.resolution == 1 def test_current_assert_min_exceeded(self) -> None: """Test initialization of DPTUElCurrentmA with wrong value (Underflow).""" with pytest.raises(ConversionError): DPTUElCurrentmA.to_knx(-1) def test_current_to_knx_exceed_limits(self) -> None: """Test initialization of DPTUElCurrentmA with wrong value (Overflow).""" with pytest.raises(ConversionError): DPTUElCurrentmA.to_knx(65536) def test_current_value_max_value(self) -> None: """Test DPTUElCurrentmA parsing and streaming.""" assert DPTUElCurrentmA.to_knx(65535) == DPTArray((0xFF, 0xFF)) assert DPTUElCurrentmA.from_knx(DPTArray((0xFF, 0xFF))) == 65535 def test_current_value_min_value(self) -> None: """Test DPTUElCurrentmA parsing and streaming with null values.""" assert DPTUElCurrentmA.to_knx(0) == DPTArray((0x00, 0x00)) assert DPTUElCurrentmA.from_knx(DPTArray((0x00, 0x00))) == 0 def test_current_value_38(self) -> None: """Test DPTUElCurrentmA parsing and streaming 38mA.""" assert DPTUElCurrentmA.to_knx(38) == DPTArray((0x00, 0x26)) assert DPTUElCurrentmA.from_knx(DPTArray((0x00, 0x26))) == 38 def test_current_value_78(self) -> None: """Test DPTUElCurrentmA parsing and streaming 78mA.""" assert DPTUElCurrentmA.to_knx(78) == DPTArray((0x00, 0x4E)) assert DPTUElCurrentmA.from_knx(DPTArray((0x00, 0x4E))) == 78 def test_current_value_1234(self) -> None: """Test DPTUElCurrentmA parsing and streaming 4660mA.""" assert DPTUElCurrentmA.to_knx(4660) == DPTArray((0x12, 0x34)) assert DPTUElCurrentmA.from_knx(DPTArray((0x12, 0x34))) == 4660 def test_current_wrong_value_from_knx(self) -> None: """Test DPTUElCurrentmA parsing with wrong value.""" with pytest.raises(CouldNotParseTelegram): DPTUElCurrentmA.from_knx(DPTArray((0xFF, 0x4E, 0x12))) xknx-3.6.0/test/dpt_tests/dpt_8_test.py000066400000000000000000000050131475530762600201450ustar00rootroot00000000000000"""Unit test for KNX 2 byte signed objects.""" import struct from unittest.mock import patch import pytest from xknx.dpt import DPT2ByteSigned, DPTArray from xknx.exceptions import ConversionError, CouldNotParseTelegram class TestDPT2ByteSigned: """Test class for KNX 2 byte signed objects.""" def test_signed_settings(self) -> None: """Test members of DPT2ByteSigned.""" assert DPT2ByteSigned.value_min == -32768 assert DPT2ByteSigned.value_max == 32767 def test_signed_assert_min_exceeded(self) -> None: """Test initialization of DPT2ByteSigned with wrong value (Underflow).""" with pytest.raises(ConversionError): DPT2ByteSigned.to_knx(-32769) def test_signed_to_knx_exceed_limits(self) -> None: """Test initialization of DPT2ByteSigned with wrong value (Overflow).""" with pytest.raises(ConversionError): DPT2ByteSigned.to_knx(32768) def test_signed_value_max_value(self) -> None: """Test DPT2ByteSigned parsing and streaming.""" assert DPT2ByteSigned.to_knx(32767) == DPTArray((0x7F, 0xFF)) assert DPT2ByteSigned.from_knx(DPTArray((0x7F, 0xFF))) == 32767 def test_signed_value_min_value(self) -> None: """Test DPT2ByteSigned parsing and streaming with null values.""" assert DPT2ByteSigned.to_knx(-20480) == DPTArray((0xB0, 0x00)) assert DPT2ByteSigned.from_knx(DPTArray((0xB0, 0x00))) == -20480 def test_signed_value_0123(self) -> None: """Test DPT2ByteSigned parsing and streaming.""" assert DPT2ByteSigned.to_knx(291) == DPTArray((0x01, 0x23)) assert DPT2ByteSigned.from_knx(DPTArray((0x01, 0x23))) == 291 def test_signed_wrong_value_from_knx(self) -> None: """Test DPT2ByteSigned parsing with wrong value.""" with pytest.raises(CouldNotParseTelegram): DPT2ByteSigned.from_knx(DPTArray((0xFF, 0x4E, 0x12))) def test_from_knx_unpack_error(self) -> None: """Test DPT2ByteSigned parsing with unpack error.""" with patch("struct.unpack") as unpack_mock: unpack_mock.side_effect = struct.error() with pytest.raises(ConversionError): DPT2ByteSigned.from_knx(DPTArray((0x01, 0x23))) def test_to_knx_pack_error(self) -> None: """Test serializing DPT2ByteSigned with pack error.""" with patch("struct.pack") as pack_mock: pack_mock.side_effect = struct.error() with pytest.raises(ConversionError): DPT2ByteSigned.to_knx(1234) xknx-3.6.0/test/dpt_tests/dpt_9_float_test.py000066400000000000000000000211431475530762600213350ustar00rootroot00000000000000"""Unit test for KNX 2 byte float objects.""" import pytest from xknx.dpt import ( DPT2ByteFloat, DPTAirFlow, DPTArray, DPTEnthalpy, DPTHumidity, DPTLux, DPTPartsPerMillion, DPTTemperature, DPTVoltage, ) from xknx.exceptions import ConversionError, CouldNotParseTelegram class TestDPTFloat: """Test class for KNX 2 byte/octet float object.""" # #################################################################### # DPT2ByteFloat # def test_value_from_documentation(self) -> None: """Test parsing and streaming of DPT2ByteFloat -30.00. Example from the internet[tm].""" assert DPT2ByteFloat.to_knx(-30.00) == DPTArray((0x8A, 0x24)) assert DPT2ByteFloat.from_knx(DPTArray((0x8A, 0x24))) == -30.00 def test_value_taken_from_live_thermostat(self) -> None: """Test parsing and streaming of DPT2ByteFloat 19.96.""" assert DPT2ByteFloat.to_knx(16.96) == DPTArray((0x06, 0xA0)) assert DPT2ByteFloat.from_knx(DPTArray((0x06, 0xA0))) == 16.96 def test_zero_value(self) -> None: """Test parsing and streaming of DPT2ByteFloat zero value.""" assert DPT2ByteFloat.to_knx(0.00) == DPTArray((0x00, 0x00)) assert DPT2ByteFloat.from_knx(DPTArray((0x00, 0x00))) == 0.00 def test_near_zero_value(self) -> None: """Test parsing and streaming of DPT2ByteFloat near zero value.""" assert DPT2ByteFloat.to_knx(0.0002) == DPTArray((0x00, 0x00)) assert DPT2ByteFloat.to_knx(0.005) == DPTArray((0x00, 0x00)) assert DPT2ByteFloat.to_knx(0.00501) == DPTArray((0x00, 0x01)) assert DPT2ByteFloat.to_knx(-0.0) == DPTArray((0x00, 0x00)) # ETS would convert values < 0 and >= -0.005 to 0x8000 # which is equivalent to -20.48 so we handle this differently assert DPT2ByteFloat.to_knx(-0.0002) == DPTArray((0x00, 0x00)) assert DPT2ByteFloat.to_knx(-0.005) == DPTArray((0x00, 0x00)) assert DPT2ByteFloat.to_knx(-0.00501) == DPTArray( (0x87, 0xFF) ) # this is ETS-conform again assert DPT2ByteFloat.from_knx(DPTArray((0x00, 0x01))) == 0.01 assert DPT2ByteFloat.from_knx(DPTArray((0x87, 0xFF))) == -0.01 def test_room_temperature(self) -> None: """Test parsing and streaming of DPT2ByteFloat 21.00. Room temperature.""" assert DPT2ByteFloat.to_knx(21.00) == DPTArray((0x0C, 0x1A)) assert DPT2ByteFloat.from_knx(DPTArray((0x0C, 0x1A))) == 21.00 def test_high_temperature(self) -> None: """Test parsing and streaming of DPT2ByteFloat 500.00, 499.84, 500.16. Testing rounding issues.""" assert DPT2ByteFloat.to_knx(500.00) == DPTArray((0x2E, 0x1A)) assert ( round(abs(DPT2ByteFloat.from_knx(DPTArray((0x2E, 0x1A))) - 499.84), 7) == 0 ) assert ( round(abs(DPT2ByteFloat.from_knx(DPTArray((0x2E, 0x1B))) - 500.16), 7) == 0 ) assert DPT2ByteFloat.to_knx(499.84) == DPTArray((0x2E, 0x1A)) assert DPT2ByteFloat.to_knx(500.16) == DPTArray((0x2E, 0x1B)) def test_minor_negative_temperature(self) -> None: """Test parsing and streaming of DPT2ByteFloat -10.00. Testing negative values.""" assert DPT2ByteFloat.to_knx(-10.00) == DPTArray((0x84, 0x18)) assert DPT2ByteFloat.from_knx(DPTArray((0x84, 0x18))) == -10.00 def test_very_cold_temperature(self) -> None: """ Test parsing and streaming of DPT2ByteFloat -1000.00,-999.68, -1000.32. Testing rounding issues of negative values. """ assert DPT2ByteFloat.to_knx(-1000.00) == DPTArray((0xB1, 0xE6)) assert DPT2ByteFloat.from_knx(DPTArray((0xB1, 0xE6))) == -999.68 assert DPT2ByteFloat.from_knx(DPTArray((0xB1, 0xE5))) == -1000.32 assert DPT2ByteFloat.to_knx(-999.68) == DPTArray((0xB1, 0xE6)) assert DPT2ByteFloat.to_knx(-1000.32) == DPTArray((0xB1, 0xE5)) def test_max(self) -> None: """Test parsing and streaming of DPT2ByteFloat with maximum value.""" assert DPT2ByteFloat.to_knx(DPT2ByteFloat.value_max) == DPTArray((0x7F, 0xFF)) assert DPT2ByteFloat.from_knx(DPTArray((0x7F, 0xFF))) == DPT2ByteFloat.value_max def test_close_to_limit(self) -> None: """Test parsing and streaming of DPT2ByteFloat with numeric limit.""" assert DPT2ByteFloat.to_knx(20.48) == DPTArray((0x0C, 0x00)) assert DPT2ByteFloat.from_knx(DPTArray((0x0C, 0x00))) == 20.48 assert DPT2ByteFloat.to_knx(-20.48) == DPTArray((0x80, 0x00)) assert DPT2ByteFloat.from_knx(DPTArray((0x80, 0x00))) == -20.48 def test_min(self) -> None: """Test parsing and streaming of DPT2ByteFloat with minimum value.""" assert DPT2ByteFloat.to_knx(DPT2ByteFloat.value_min) == DPTArray((0xF8, 0x00)) assert DPT2ByteFloat.from_knx(DPTArray((0xF8, 0x00))) == DPT2ByteFloat.value_min def test_close_to_max(self) -> None: """Test parsing and streaming of DPT2ByteFloat with maximum value -1.""" assert DPT2ByteFloat.to_knx(670433.28) == DPTArray((0x7F, 0xFE)) assert DPT2ByteFloat.from_knx(DPTArray((0x7F, 0xFE))) == 670433.28 def test_close_to_min(self) -> None: """Test parsing and streaming of DPT2ByteFloat with minimum value +1.""" assert DPT2ByteFloat.to_knx(-670760.96) == DPTArray((0xF8, 0x01)) assert DPT2ByteFloat.from_knx(DPTArray((0xF8, 0x01))) == -670760.96 def test_to_knx_min_exceeded(self) -> None: """Test parsing of DPT2ByteFloat with wrong value (underflow).""" with pytest.raises(ConversionError): DPT2ByteFloat.to_knx(DPT2ByteFloat.value_min - 1) def test_to_knx_max_exceeded(self) -> None: """Test parsing of DPT2ByteFloat with wrong value (overflow).""" with pytest.raises(ConversionError): DPT2ByteFloat.to_knx(DPT2ByteFloat.value_max + 1) def test_to_knx_wrong_parameter(self) -> None: """Test parsing of DPT2ByteFloat with wrong value (string).""" with pytest.raises(ConversionError): DPT2ByteFloat.to_knx("fnord") def test_from_knx_wrong_parameter(self) -> None: """Test parsing of DPT2ByteFloat with wrong value (wrong number of bytes).""" with pytest.raises(CouldNotParseTelegram): DPT2ByteFloat.from_knx(DPTArray((0xF8, 0x01, 0x23))) # # DPTTemperature # def test_temperature_settings(self) -> None: """Test attributes of DPTTemperature.""" assert DPTTemperature.value_min == -273 assert DPTTemperature.value_max == 670760 assert DPTTemperature.unit == "°C" assert DPTTemperature.resolution == 0.01 def test_temperature_assert_min_exceeded(self) -> None: """Testing parsing of DPTTemperature with wrong value.""" with pytest.raises(ConversionError): DPTTemperature.to_knx(-274) def test_temperature_assert_min_exceeded_from_knx(self) -> None: """Testing parsing of DPTTemperature with wrong value.""" with pytest.raises(ConversionError): DPTTemperature.from_knx(DPTArray((0xB1, 0xE6))) # -1000 # # DPTLux # def test_lux_settings(self) -> None: """Test attributes of DPTLux.""" assert DPTLux.value_min == 0 assert DPTLux.value_max == 670760 assert DPTLux.unit == "lx" assert DPTLux.resolution == 0.01 def test_lux_assert_min_exceeded(self) -> None: """Test parsing of DPTLux with wrong value.""" with pytest.raises(ConversionError): DPTLux.to_knx(-1) # # DPTHumidity # def test_humidity_settings(self) -> None: """Test attributes of DPTHumidity.""" assert DPTHumidity.value_min == 0 assert DPTHumidity.value_max == 670760 assert DPTHumidity.unit == "%" assert DPTHumidity.resolution == 0.01 def test_humidity_assert_min_exceeded(self) -> None: """Test parsing of DPTHumidity with wrong value.""" with pytest.raises(ConversionError): DPTHumidity.to_knx(-1) # # DPTEnthalpy # def test_enthalpy_settings(self) -> None: """Test attributes of DPTEnthalpy.""" assert DPTEnthalpy.unit == "H" # # DPTPartsPerMillion # def test_partspermillion_settings(self) -> None: """Test attributes of DPTPartsPerMillion.""" assert DPTPartsPerMillion.unit == "ppm" # # DPTAirFlow # def test_airflow_settings(self) -> None: """Test attributes of DPTAirFlow.""" assert DPTAirFlow.unit == "m³/h" # # DPTVoltage # def test_voltage_settings(self) -> None: """Test attributes of DPTVoltage.""" assert DPTVoltage.unit == "mV" xknx-3.6.0/test/dpt_tests/dpt_color_test.py000066400000000000000000000377661475530762600211400ustar00rootroot00000000000000"""Unit test for KNX color objects.""" from typing import Any import pytest from xknx.dpt import ( DPTArray, DPTColorRGB, DPTColorRGBW, DPTColorXYY, RGBColor, RGBWColor, XYYColor, ) from xknx.exceptions import ConversionError, CouldNotParseTelegram class TestRGBColor: """Test RGBColor class.""" @pytest.mark.parametrize( ("data", "value"), [ ({"red": 0, "green": 128, "blue": 255}, RGBColor(0, 128, 255)), ({"red": 255, "green": 255, "blue": 255}, RGBColor(255, 255, 255)), ({"red": 0, "green": 0, "blue": 0}, RGBColor(0, 0, 0)), ], ) def test_dict(self, data: dict[str, int], value: RGBColor) -> None: """Test from_dict and as_dict methods.""" test_value = RGBColor.from_dict(data) assert test_value == value assert value.as_dict() == data @pytest.mark.parametrize( "data", [ # incomplete data {}, {"red": 128}, {"green": 128}, {"blue": 128}, {"red": 128, "green": 128}, {"red": 128, "blue": 128}, {"green": 128, "blue": 128}, # invalid data {"red": "a", "green": 128, "blue": 128}, {"red": 128, "green": "a", "blue": 128}, {"red": 128, "green": 128, "blue": "a"}, ], ) def test_dict_invalid(self, data: dict[str, Any]) -> None: """Test from_dict and as_dict methods.""" with pytest.raises(ValueError): RGBColor.from_dict(data) class TestDPTColorRGB: """Test class for KNX RGB-color objects.""" @pytest.mark.parametrize( ("value", "raw"), [ (RGBColor(0, 0, 0), (0x00, 0x00, 0x00)), # min values (RGBColor(255, 255, 255), (0xFF, 0xFF, 0xFF)), # max values (RGBColor(128, 128, 128), (0x80, 0x80, 0x80)), # mid values ], ) def test_rgbcolor_value(self, value: RGBColor, raw: tuple[int]) -> None: """Test DPTColorRGB parsing and streaming.""" knx_value = DPTColorRGB.to_knx(value) assert knx_value == DPTArray(raw) assert DPTColorRGB.from_knx(knx_value) == value @pytest.mark.parametrize( ("value", "raw"), [ ({"red": 128, "green": 128, "blue": 128}, (0x80, 0x80, 0x80)), ({"red": 255, "green": 255, "blue": 255}, (0xFF, 0xFF, 0xFF)), ], ) def test_rgbcolor_to_knx_from_dict( self, value: dict[str, int], raw: tuple[int] ) -> None: """Test DPTColorRGB parsing from a dict.""" knx_value = DPTColorRGB.to_knx(value) assert knx_value == DPTArray(raw) @pytest.mark.parametrize( ("red", "green", "blue"), [ (-1, 0, 0), (0, -1, 0), (0, 0, -1), (256, 0, 0), (0, 256, 0), (0, 0, 256), ], ) def test_rgbcolor_to_knx_limits(self, red: int, green: int, blue: int) -> None: """Test initialization of DPTColorRGB with wrong value.""" value = RGBColor(red=red, green=green, blue=blue) with pytest.raises(ConversionError): DPTColorRGB.to_knx(value) @pytest.mark.parametrize( "value", [ None, (0xFF, 0x4E, 0x12), 1, ((0x00, 0xFF, 0x4E), 0x12), ((0xFF, 0x4E), (0x12, 0x00)), ((0, 0), "a"), ((0, 0), 0.4), RGBColor(red=0, green=0, blue="a"), RGBColor(red=4, green="a", blue=4), RGBColor(red="a", green=0, blue=1), ], ) def test_rgbcolor_wrong_value_to_knx(self, value: Any) -> None: """Test DPTColorRGB parsing with wrong value.""" with pytest.raises(ConversionError): DPTColorRGB.to_knx(value) def test_rgbcolor_wrong_value_from_knx(self) -> None: """Test DPTColorRGB parsing with wrong value.""" with pytest.raises(CouldNotParseTelegram): DPTColorRGB.from_knx(DPTArray((0xFF, 0x4E, 0x12, 0x23))) with pytest.raises(CouldNotParseTelegram): DPTColorRGB.from_knx(DPTArray((0xFF, 0x4E))) class TestRGBWColor: """Test RGBWColor class.""" @pytest.mark.parametrize( ("data", "value"), [ ( {"red": 128, "green": 128, "blue": 128, "white": 128}, RGBWColor(128, 128, 128, 128), ), ( {"red": 255, "green": 255, "blue": 255, "white": 255}, RGBWColor(255, 255, 255, 255), ), ({"red": 128, "green": 128, "blue": 128}, RGBWColor(128, 128, 128)), ({"white": 50}, RGBWColor(None, None, None, 50)), ({}, RGBWColor()), ( {"red": None, "green": None, "blue": None, "white": 255}, RGBWColor(None, None, None, 255), ), ( {"red": 128, "green": 128, "blue": 128, "white": None}, RGBWColor(128, 128, 128, None), ), ({"red": None, "white": 255}, RGBWColor(None, None, None, 255)), ({"green": None, "white": 255}, RGBWColor(None, None, None, 255)), ({"red": 128}, RGBWColor(128, None, None, None)), ({"green": 128}, RGBWColor(None, 128, None, None)), ({"blue": 128}, RGBWColor(None, None, 128, None)), ({"red": 128, "white": 128}, RGBWColor(128, None, None, 128)), ({"green": 128, "white": 128}, RGBWColor(None, 128, None, 128)), ({"blue": 128, "white": 128}, RGBWColor(None, None, 128, 128)), ], ) def test_dict(self, data: dict[str, int], value: RGBWColor) -> None: """Test from_dict and as_dict methods.""" test_value = RGBWColor.from_dict(data) assert test_value == value # fields default to `None` default_dict = {"red": None, "green": None, "blue": None, "white": None} assert value.as_dict() == default_dict | data @pytest.mark.parametrize( "data", [ # invalid data {"red": "a", "green": 128, "blue": 128, "white": 128}, {"red": 128, "green": "a", "blue": 128, "white": 128}, {"red": 128, "green": 128, "blue": "a", "white": 128}, {"red": 128, "green": 128, "blue": 128, "white": "a"}, ], ) def test_dict_invalid(self, data: dict[str, Any]) -> None: """Test from_dict and as_dict methods.""" with pytest.raises(ValueError): RGBWColor.from_dict(data) def test_merge(self) -> None: """Test merging two RGBWColor objects.""" color1 = RGBWColor(1, 1, 1, None) color2 = RGBWColor(None, None, None, 2) color3 = RGBWColor(3, None, None, 3) assert color1 | color2 == RGBWColor(1, 1, 1, 2) assert color2 | color1 == RGBWColor(1, 1, 1, 2) assert color1 | color3 == RGBWColor(3, 1, 1, 3) assert color3 | color1 == RGBWColor(1, 1, 1, 3) assert color2 | color3 == RGBWColor(3, None, None, 3) assert color3 | color2 == RGBWColor(3, None, None, 2) class TestDPTColorRGBW: """Test class for KNX RGBW-color objects.""" @pytest.mark.parametrize( ("value", "raw"), [ (RGBWColor(0, 0, 0, 0), (0x00, 0x00, 0x00, 0x00, 0, 0xF)), # min values (RGBWColor(255, 255, 255, 255), (0xFF, 0xFF, 0xFF, 0xFF, 0, 0xF)), # max (RGBWColor(None, 0, 0, 0), (0x00, 0x00, 0x00, 0x00, 0, 0x7)), # red None (RGBWColor(0, 0, 0, None), (0x00, 0x00, 0x00, 0x00, 0, 0xE)), # white None (RGBWColor(None, None, None, None), (0x00, 0x00, 0x00, 0x00, 0, 0)), (RGBWColor(128, 128, 128, 128), (0x80, 0x80, 0x80, 0x80, 0, 0b1111)), (RGBWColor(204, 204, 204, 204), (0xCC, 0xCC, 0xCC, 0xCC, 0, 0b1111)), ], ) def test_rgbwcolor_value(self, value: RGBWColor, raw: tuple[int]) -> None: """Test DPTColorRGBW parsing and streaming.""" knx_value = DPTColorRGBW.to_knx(value) assert knx_value == DPTArray(raw) assert DPTColorRGBW.from_knx(knx_value) == value @pytest.mark.parametrize( ("value", "raw"), [ ( {"red": 128, "green": 128, "blue": 128, "white": 128}, (0x80, 0x80, 0x80, 0x80, 0, 0xF), ), ( {"red": 204, "green": 204, "blue": 204, "white": 204}, (0xCC, 0xCC, 0xCC, 0xCC, 0, 0xF), ), ( {"red": 204, "green": 204, "blue": 204}, (0xCC, 0xCC, 0xCC, 0x00, 0, 0xE), ), ], ) def test_rgbwcolor_to_knx_from_dict( self, value: dict[str, int], raw: tuple[int] ) -> None: """Test DPTColorRGBW parsing from a dict.""" knx_value = DPTColorRGBW.to_knx(value) assert knx_value == DPTArray(raw) @pytest.mark.parametrize( ("red", "green", "blue", "white"), [ (-1, 0, 0, 0), (0, -1, 0, 0), (0, 0, -1, 0), (0, 0, 0, -1), (256, 0, 0, 0), (0, 256, 0, 0), (0, 0, 256, 0), (0, 0, 0, 256), ], ) def test_rgbwcolor_to_knx_limits( self, red: int, green: int, blue: int, white: int ) -> None: """Test initialization of DPTColorRGBW with wrong value.""" value = RGBWColor(red=red, green=green, blue=blue, white=white) with pytest.raises(ConversionError): DPTColorRGBW.to_knx(value) @pytest.mark.parametrize( "value", [ None, (0xFF, 0x4E, 0x12), 1, ((0x00, 0xFF, 0x4E), 0x12), ((0xFF, 0x4E), (0x12, 0x00)), ((0, 0), "a"), ((0, 0), 0.4), RGBWColor(red=0, green=0, blue=0, white="a"), RGBWColor(red=4, green=4, blue="a", white=4), RGBWColor(red="a", green=0, blue=0, white=1), ], ) def test_rgbwcolor_wrong_value_to_knx(self, value: Any) -> None: """Test DPTColorRGBW parsing with wrong value.""" with pytest.raises(ConversionError): DPTColorRGBW.to_knx(value) def test_rgbwcolor_wrong_value_from_knx(self) -> None: """Test DPTColorRGBW parsing with wrong value.""" with pytest.raises(CouldNotParseTelegram): DPTColorRGBW.from_knx(DPTArray((0xFF, 0x4E, 0x12))) class TestXYYColor: """Test XYYColor class.""" @pytest.mark.parametrize( ("data", "value"), [ ( {"x_axis": 0.1, "y_axis": 0.2, "brightness": 128}, XYYColor((0.1, 0.2), 128), ), ( {"x_axis": 0.5, "y_axis": 0.5, "brightness": 255}, XYYColor((0.5, 0.5), 255), ), ( {"x_axis": 0.9, "y_axis": 0.8}, XYYColor((0.9, 0.8)), ), ( {"brightness": 50}, XYYColor(None, 50), ), ( {}, XYYColor(), ), ( {"x_axis": None, "y_axis": None, "brightness": 255}, XYYColor(None, 255), ), ( {"x_axis": 0.5, "y_axis": 0.5, "brightness": None}, XYYColor((0.5, 0.5), None), ), ( {"x_axis": None, "brightness": 255}, XYYColor(None, 255), ), ( {"y_axis": None, "brightness": 255}, XYYColor(None, 255), ), ], ) def test_dict(self, data: dict[str, Any], value: XYYColor) -> None: """Test from_dict and as_dict methods.""" test_value = XYYColor.from_dict(data) assert test_value == value # fields default to `None` default_dict = {"x_axis": None, "y_axis": None, "brightness": None} assert value.as_dict() == default_dict | data @pytest.mark.parametrize( "data", [ # incomplete data {"x_axis": 0.1}, {"y_axis": 0.1}, {"x_axis": 0.1, "brightness": 128}, {"y_axis": 0.1, "brightness": 128}, # invalid data {"x_axis": "a", "y_axis": 0.1, "brightness": 128}, {"x_axis": 0.1, "y_axis": "a", "brightness": 128}, {"x_axis": 0.1, "y_axis": 0.1, "brightness": "a"}, ], ) def test_dict_invalid(self, data: dict[str, Any]) -> None: """Test from_dict and as_dict methods.""" with pytest.raises(ValueError): XYYColor.from_dict(data) def test_merge(self) -> None: """Test merging two XYYColor objects.""" color1 = XYYColor((1, 1), None) color2 = XYYColor(None, 2) color3 = XYYColor((3, 3), 3) assert color1 | color2 == XYYColor((1, 1), 2) assert color2 | color1 == XYYColor((1, 1), 2) assert color1 | color3 == XYYColor((3, 3), 3) assert color3 | color1 == XYYColor((1, 1), 3) assert color2 | color3 == XYYColor((3, 3), 3) assert color3 | color2 == XYYColor((3, 3), 2) class TestDPTColorXYY: """Test class for KNX xyY-color objects.""" @pytest.mark.parametrize( ("value", "raw"), [ (XYYColor((0, 0), 0), (0x00, 0x00, 0x00, 0x00, 0x00, 0x03)), # min values (XYYColor((1, 1), 255), (0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x03)), # max values (XYYColor(None, 0), (0x00, 0x00, 0x00, 0x00, 0x00, 0x01)), # color None (XYYColor((0, 0), None), (0x00, 0x00, 0x00, 0x00, 0x00, 0x02)), (XYYColor(None, None), (0x00, 0x00, 0x00, 0x00, 0x00, 0x00)), (XYYColor((0.2, 0.2), 128), (0x33, 0x33, 0x33, 0x33, 0x80, 0x03)), (XYYColor((0.8, 0.8), 204), (0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0x03)), ], ) def test_xyycolor_value(self, value: XYYColor, raw: tuple[int]) -> None: """Test DPTColorXYY parsing and streaming.""" knx_value = DPTColorXYY.to_knx(value) assert knx_value == DPTArray(raw) assert DPTColorXYY.from_knx(knx_value) == value @pytest.mark.parametrize( ("value", "raw"), [ ( {"x_axis": 0.2, "y_axis": 0.2, "brightness": 128}, (0x33, 0x33, 0x33, 0x33, 0x80, 0x03), ), ( {"x_axis": 0.8, "y_axis": 0.8}, (0xCC, 0xCC, 0xCC, 0xCC, 0x00, 0x02), ), ], ) def test_xyycolor_to_knx_from_dict( self, value: dict[str, Any], raw: tuple[int] ) -> None: """Test DPTColorXYY parsing from a dict.""" knx_value = DPTColorXYY.to_knx(value) assert knx_value == DPTArray(raw) @pytest.mark.parametrize( ("color", "brightness"), [ ((-0.1, 0), 0), ((0, -0.1), 0), ((0, 0), -1), ((1.1, 0), 0), ((0, 1.1), 0), ((0, 0), 256), ], ) def test_xyycolor_to_knx_limits( self, color: tuple[float, float], brightness: int ) -> None: """Test initialization of DPTColorXYY with wrong value.""" value = XYYColor(color=color, brightness=brightness) with pytest.raises(ConversionError): DPTColorXYY.to_knx(value) @pytest.mark.parametrize( "value", [ None, (0xFF, 0x4E, 0x12), 1, ((0x00, 0xFF, 0x4E), 0x12), ((0xFF, 0x4E), (0x12, 0x00)), ((0, 0), "a"), ((0, 0), 0.4), XYYColor(color=(0, 0), brightness="a"), XYYColor(color=4, brightness=4), XYYColor(color=("a", 0), brightness=1), ], ) def test_xyycolor_wrong_value_to_knx(self, value: Any) -> None: """Test DPTColorXYY parsing with wrong value.""" with pytest.raises(ConversionError): DPTColorXYY.to_knx(value) def test_xyycolor_wrong_value_from_knx(self) -> None: """Test DPTColorXYY parsing with wrong value.""" with pytest.raises(CouldNotParseTelegram): DPTColorXYY.from_knx(DPTArray((0xFF, 0x4E, 0x12))) xknx-3.6.0/test/dpt_tests/dpt_lookup_test.py000066400000000000000000000311261475530762600213130ustar00rootroot00000000000000"""Test DPT lookup.""" # flake8: noqa import pytest from xknx.dpt import * # pylint: disable=wildcard-import,unused-wildcard-import # """ # Generate a list of all DPTs for the following test. # Run this function manually to update the list. # """ # from xknx.dpt import DPTBase # dpt_dict = {} # for dpt in DPTBase.dpt_class_tree(): # if dpt.value_type is not None: # dpt_dict[dpt.value_type] = dpt # for _, dpt in sorted(dpt_dict.items()): # unit = None # if dpt.unit is not None: # unit = f'"{dpt.unit}"' # print( # f'("{dpt.value_type}", {dpt.__name__}, {dpt.dpt_main_number}, {dpt.dpt_sub_number}, {unit}),' # ) @pytest.mark.parametrize( ("value_type", "dpt_class", "main", "sub", "unit"), [ ("1byte_signed", DPTSignedRelativeValue, 6, None, None), ("1byte_unsigned", DPTValue1ByteUnsigned, 5, None, None), ("2byte_float", DPT2ByteFloat, 9, None, None), ("2byte_signed", DPT2ByteSigned, 8, None, None), ("2byte_unsigned", DPT2ByteUnsigned, 7, None, None), ("4byte_float", DPT4ByteFloat, 14, None, None), ("4byte_signed", DPT4ByteSigned, 13, None, None), ("4byte_unsigned", DPT4ByteUnsigned, 12, None, None), ("8byte_signed", DPT8ByteSigned, 29, None, None), ("absolute_humidity", DPTAbsoluteHumidity, 9, 29, "g/m³"), ("absolute_temperature", DPTAbsoluteTemperature, 14, 69, "K"), ("acceleration", DPTAcceleration, 14, 0, "m/s²"), ("acceleration_angular", DPTAccelerationAngular, 14, 1, "rad/s²"), ("ack", DPTAck, 1, 16, None), ("activation_energy", DPTActivationEnergy, 14, 2, "J/mol"), ("active_energy", DPTActiveEnergy, 13, 10, "Wh"), ("active_energy_8byte", DPTActiveEnergy8Byte, 29, 10, "Wh"), ("active_energy_kwh", DPTActiveEnergykWh, 13, 13, "kWh"), ("active_energy_mwh", DPTActiveEnergyMWh, 13, 16, "MWh"), ("activity", DPTActivity, 14, 3, "s⁻¹"), ("air_flow", DPTAirFlow, 9, 9, "m³/h"), ("alarm", DPTAlarm, 1, 5, None), ("amplitude", DPTAmplitude, 14, 5, None), ("angle", DPTAngle, 5, 3, "°"), ("angle_deg", DPTAngleDeg, 14, 7, "°"), ("angle_rad", DPTAngleRad, 14, 6, "rad"), ("angular_frequency", DPTAngularFrequency, 14, 34, "rad/s"), ("angular_momentum", DPTAngularMomentum, 14, 8, "J s"), ("angular_velocity", DPTAngularVelocity, 14, 9, "rad/s"), ("apparant_energy", DPTApparantEnergy, 13, 11, "VAh"), ("apparant_energy_8byte", DPTApparantEnergy8Byte, 29, 11, "VAh"), ("apparant_energy_kvah", DPTApparantEnergykVAh, 13, 14, "kVAh"), ("apparent_power", DPTApparentPower, 14, 80, "VA"), ("area", DPTArea, 14, 10, "m²"), ("binary_value", DPTBinaryValue, 1, 6, None), ("bool", DPTBool, 1, 2, None), ("brightness", DPTBrightness, 7, 13, "lx"), ("capacitance", DPTCapacitance, 14, 11, "F"), ("charge_density_surface", DPTChargeDensitySurface, 14, 12, "C/m²"), ("charge_density_volume", DPTChargeDensityVolume, 14, 13, "C/m³"), ("color_rgb", DPTColorRGB, 232, 600, None), ("color_rgbw", DPTColorRGBW, 251, 600, None), ("color_temperature", DPTColorTemperature, 7, 600, "K"), ("color_xyy", DPTColorXYY, 242, 600, None), ("common_temperature", DPTCommonTemperature, 14, 68, "°C"), ("compressibility", DPTCompressibility, 14, 14, "m²/N"), ("concentration_ugm3", DPTConcentrationUGM3, 9, 30, "μg/m³"), ("conductance", DPTConductance, 14, 15, "S"), ("consumer_producer", DPTConsumerProducer, 1, 1200, None), ("control_blinds", DPTControlBlinds, 3, 8, None), ("control_dimming", DPTControlDimming, 3, 7, None), ("counter_pulses", DPTValue1Count, 6, 10, "counter pulses"), ("curr", DPTCurrent, 9, 21, "mA"), ("current", DPTUElCurrentmA, 7, 12, "mA"), ("date", DPTDate, 11, 1, None), ("datetime", DPTDateTime, 19, 1, None), ("day_night", DPTDayNight, 1, 24, None), ("decimal_factor", DPTDecimalFactor, 5, 5, None), ("delta_time_100ms", DPTDeltaTime100Msec, 8, 4, "ms"), ("delta_time_10ms", DPTDeltaTime10Msec, 8, 3, "ms"), ("delta_time_hrs", DPTDeltaTimeHrs, 8, 7, "h"), ("delta_time_min", DPTDeltaTimeMin, 8, 6, "min"), ("delta_time_ms", DPTDeltaTimeMsec, 8, 2, "ms"), ("delta_time_sec", DPTDeltaTimeSec, 8, 5, "s"), ("density", DPTDensity, 14, 17, "kg/m³"), ("dim_send_style", DPTDimSendStyle, 1, 13, None), ("electric_charge", DPTElectricCharge, 14, 18, "C"), ("electric_current", DPTElectricCurrent, 14, 19, "A"), ("electric_current_density", DPTElectricCurrentDensity, 14, 20, "A/m²"), ("electric_dipole_moment", DPTElectricDipoleMoment, 14, 21, "C m"), ("electric_displacement", DPTElectricDisplacement, 14, 22, "C/m²"), ("electric_field_strength", DPTElectricFieldStrength, 14, 23, "V/m"), ("electric_flux", DPTElectricFlux, 14, 24, "c"), ("electric_flux_density", DPTElectricFluxDensity, 14, 25, "C/m²"), ("electric_polarization", DPTElectricPolarization, 14, 26, "C/m²"), ("electric_potential", DPTElectricPotential, 14, 27, "V"), ("electric_potential_difference", DPTElectricPotentialDifference, 14, 28, "V"), ("electrical_conductivity", DPTElectricalConductivity, 14, 16, "S/m"), ("electromagnetic_moment", DPTElectromagneticMoment, 14, 29, "A m²"), ("electromotive_force", DPTElectromotiveForce, 14, 30, "V"), ("enable", DPTEnable, 1, 3, None), ("energy", DPTEnergy, 14, 31, "J"), ("energy_direction", DPTEnergyDirection, 1, 1201, None), ("enthalpy", DPTEnthalpy, 9, 60000, "H"), ("flow_rate_m3h", DPTFlowRateM3H, 13, 2, "m³/h"), ("force", DPTForce, 14, 32, "N"), ("frequency", DPTFrequency, 14, 33, "Hz"), ("heat_cool", DPTHeatCool, 1, 100, None), ("heat_quantity", DPTHeatQuantity, 14, 37, "J"), ("heatcapacity", DPTHeatCapacity, 14, 35, "J/K"), ("heatflowrate", DPTHeatFlowRate, 14, 36, "W"), ("humidity", DPTHumidity, 9, 7, "%"), ("hvac_controller_mode", DPTHVACContrMode, 20, 105, None), ("hvac_mode", DPTHVACMode, 20, 102, None), ("hvac_status", DPTHVACStatus, 20, 60102, None), ("illuminance", DPTLux, 9, 4, "lx"), ("impedance", DPTImpedance, 14, 38, "Ω"), ("input_source", DPTInputSource, 1, 14, None), ("invert", DPTInvert, 1, 12, None), ("kelvin_per_percent", DPTKelvinPerPercent, 9, 23, "K/%"), ("latin_1", DPTLatin1, 16, 1, None), ("length", DPTLength, 14, 39, "m"), ("length_m", DPTLengthM, 8, 12, "m"), ("length_mm", DPTLengthMm, 7, 11, "mm"), ("light_quantity", DPTLightQuantity, 14, 40, "lm s"), ("logical_function", DPTLogicalFunction, 1, 21, None), ("long_delta_timesec", DPTLongDeltaTimeSec, 13, 100, "s"), ("long_time_period_hrs", DPTLongTimePeriodHrs, 12, 102, "h"), ("long_time_period_min", DPTLongTimePeriodMin, 12, 101, "min"), ("long_time_period_sec", DPTLongTimePeriodSec, 12, 100, "s"), ("luminance", DPTLuminance, 14, 41, "cd/m²"), ("luminous_flux", DPTLuminousFlux, 14, 42, "lm"), ("luminous_intensity", DPTLuminousIntensity, 14, 43, "cd"), ("magnetic_field_strength", DPTMagneticFieldStrength, 14, 44, "A/m"), ("magnetic_flux", DPTMagneticFlux, 14, 45, "Wb"), ("magnetic_flux_density", DPTMagneticFluxDensity, 14, 46, "T"), ("magnetic_moment", DPTMagneticMoment, 14, 47, "A m²"), ("magnetic_polarization", DPTMagneticPolarization, 14, 48, "T"), ("magnetization", DPTMagnetization, 14, 49, "A/m"), ("magnetomotive_force", DPTMagnetomotiveForce, 14, 50, "A"), ("mass", DPTMass, 14, 51, "kg"), ("mass_flux", DPTMassFlux, 14, 52, "kg/s"), ("mol", DPTMol, 14, 4, "mol"), ("momentum", DPTMomentum, 14, 53, "N/s"), ("occupancy", DPTOccupancy, 1, 18, None), ("open_close", DPTOpenClose, 1, 9, None), ("percent", DPTScaling, 5, 1, "%"), ("percentU8", DPTPercentU8, 5, 4, "%"), ("percentV16", DPTPercentV16, 8, 10, "%"), ("percentV8", DPTPercentV8, 6, 1, "%"), ("phaseangledeg", DPTPhaseAngleDeg, 14, 55, "°"), ("phaseanglerad", DPTPhaseAngleRad, 14, 54, "rad"), ("power", DPTPower, 14, 56, "W"), ("power_2byte", DPTPower2Byte, 9, 24, "kW"), ("power_density", DPTPowerDensity, 9, 22, "W/m²"), ("powerfactor", DPTPowerFactor, 14, 57, None), ("ppm", DPTPartsPerMillion, 9, 8, "ppm"), ("pressure", DPTPressure, 14, 58, "Pa"), ("pressure_2byte", DPTPressure2Byte, 9, 6, "Pa"), ("prop_data_type", DPTPropDataType, 7, 10, None), ("pulse", DPTValue1Ucount, 5, 10, "counter pulses"), ("pulse_2byte", DPT2Ucount, 7, 1, "pulses"), ("pulse_2byte_signed", DPTValue2Count, 8, 1, "pulses"), ("pulse_4_ucount", DPTValue4Ucount, 12, 1, "counter pulses"), ("pulse_4byte", DPTValue4Count, 13, 1, "counter pulses"), ("rain_amount", DPTRainAmount, 9, 26, "L/m²"), ("ramp", DPTRamp, 1, 4, None), ("reactance", DPTReactance, 14, 59, "Ω"), ("reactive_energy", DPTReactiveEnergy, 13, 12, "VARh"), ("reactive_energy_8byte", DPTReactiveEnergy8Byte, 29, 12, "VARh"), ("reactive_energy_kvarh", DPTReactiveEnergykVARh, 13, 15, "kVARh"), ("reset", DPTReset, 1, 15, None), ("resistance", DPTResistance, 14, 60, "Ω"), ("resistivity", DPTResistivity, 14, 61, "Ωm"), ("rotation_angle", DPTRotationAngle, 8, 11, "°"), ("scene_ab", DPTSceneAB, 1, 22, None), ("scene_control", DPTSceneControl, 18, 1, None), ("scene_number", DPTSceneNumber, 17, 1, None), ("self_inductance", DPTSelfInductance, 14, 62, "H"), ("shutter_blinds_mode", DPTShutterBlindsMode, 1, 23, None), ("solid_angle", DPTSolidAngle, 14, 63, "sr"), ("sound_intensity", DPTSoundIntensity, 14, 64, "W/m²"), ("speed", DPTSpeed, 14, 65, "m/s"), ("start", DPTStart, 1, 10, None), ("state", DPTState, 1, 11, None), ("step", DPTStep, 1, 7, None), ("stress", DPTStress, 14, 66, "Pa"), ("string", DPTString, 16, 0, None), ("surface_tension", DPTSurfaceTension, 14, 67, "N/m"), ("switch", DPTSwitch, 1, 1, None), ("tariff", DPTTariff, 5, 6, None), ("tariff_active_energy", DPTTariffActiveEnergy, 235, 1, None), ("temperature", DPTTemperature, 9, 1, "°C"), ("temperature_a", DPTTemperatureA, 9, 3, "K/h"), ("temperature_difference", DPTTemperatureDifference, 14, 70, "K"), ("temperature_difference_2byte", DPTTemperatureDifference2Byte, 9, 2, "K"), ("temperature_f", DPTTemperatureF, 9, 27, "°F"), ("thermal_capacity", DPTThermalCapacity, 14, 71, "J/K"), ("thermal_conductivity", DPTThermalConductivity, 14, 72, "W/mK"), ("thermoelectric_power", DPTThermoelectricPower, 14, 73, "V/K"), ("time", DPTTime, 10, 1, None), ("time_1", DPTTime1, 9, 10, "s"), ("time_2", DPTTime2, 9, 11, "ms"), ("time_period_100msec", DPTTimePeriod100Msec, 7, 4, "ms"), ("time_period_10msec", DPTTimePeriod10Msec, 7, 3, "ms"), ("time_period_hrs", DPTTimePeriodHrs, 7, 7, "h"), ("time_period_min", DPTTimePeriodMin, 7, 6, "min"), ("time_period_msec", DPTTimePeriodMsec, 7, 2, "ms"), ("time_period_sec", DPTTimePeriodSec, 7, 5, "s"), ("time_seconds", DPTTimeSeconds, 14, 74, "s"), ("torque", DPTTorque, 14, 75, "Nm"), ("trigger", DPTTrigger, 1, 17, None), ("up_down", DPTUpDown, 1, 8, None), ("voltage", DPTVoltage, 9, 20, "mV"), ("volume", DPTVolume, 14, 76, "m³"), ("volume_flow", DPTVolumeFlow, 9, 25, "L/h"), ("volume_flux", DPTVolumeFlux, 14, 77, "m³/s"), ("volume_liquid_litre", DPTVolumeLiquidLitre, 12, 1200, "L"), ("volume_m3", DPTVolumeM3, 12, 1201, "m³"), ("weight", DPTWeight, 14, 78, "N"), ("wind_speed_kmh", DPTWspKmh, 9, 28, "km/h"), ("wind_speed_ms", DPTWsp, 9, 5, "m/s"), ("window_door", DPTWindowDoor, 1, 19, None), ("work", DPTWork, 14, 79, "J"), ], ) def test_dpt_lookup( value_type: str, dpt_class: type[DPTBase], main: int, sub: int | None, unit: str | None, ) -> None: """Test DPT4ByteUnsigned.""" dpt = DPTBase.parse_transcoder(value_type) assert dpt is dpt_class assert DPTBase.transcoder_by_dpt(dpt_main=main, dpt_sub=sub) is dpt_class assert dpt.unit == unit xknx-3.6.0/test/dpt_tests/dpt_test.py000066400000000000000000000246241475530762600177270ustar00rootroot00000000000000"""Unit test for KNX binary/integer objects.""" from inspect import isabstract from typing import Any import pytest from xknx.dpt import ( DPT2ByteFloat, DPT2ByteUnsigned, DPTActiveEnergy, DPTArray, DPTBase, DPTBinary, DPTColorRGBW, DPTComplex, DPTConsumerProducer, DPTEnum, DPTHVACContrMode, DPTNumeric, DPTScaling, DPTString, DPTTemperature, ) from xknx.exceptions import CouldNotParseTelegram class TestDPTBase: """Test class for transcoder base object.""" def test_dpt_abstract_subclasses_ignored(self) -> None: """Test if abstract base classes are ignored by dpt_class_tree and __recursive_subclasses__.""" for dpt in DPTBase.dpt_class_tree(): assert dpt not in (DPTBase, DPTNumeric, DPTEnum, DPTComplex) assert not isabstract(dpt) assert dpt() # test for abstract class - to be removed if instantiation is not allowed anymore def test_dpt_concrete_subclasses_included(self) -> None: """Test if concrete subclasses are included by dpt_class_tree.""" for dpt in ( DPT2ByteFloat, DPTString, DPTTemperature, DPTScaling, DPTHVACContrMode, DPTColorRGBW, DPTConsumerProducer, ): assert dpt in DPTBase.dpt_class_tree() @pytest.mark.parametrize("dpt_class", [DPTString, DPT2ByteFloat]) def test_dpt_non_abstract_baseclass_included( self, dpt_class: type[DPTBase] ) -> None: """Test if non-abstract base classes is included by dpt_class_tree.""" assert dpt_class in dpt_class.dpt_class_tree() def test_dpt_subclasses_definition_types(self) -> None: """Test value_type and dpt_*_number values for correct type in subclasses of DPTBase.""" for dpt in DPTBase.dpt_class_tree(): assert isinstance(dpt.value_type, str), ( f"Wrong type for value_type in {dpt} : {type(dpt.value_type)} - str expected" ) assert dpt.value_type, f"Empty string for value_type in {dpt} not allowed" assert isinstance(dpt.dpt_main_number, int), ( f"Wrong type for dpt_main_number in {dpt} : {type(dpt.dpt_main_number)} - int expected" ) assert dpt.dpt_main_number, ( f"Zero value for dpt_main_number in {dpt} not allowed" ) assert isinstance(dpt.dpt_sub_number, int | type(None)), ( f"Wrong type for dpt_sub_number in {dpt} : {type(dpt.dpt_sub_number)} - int or `None` expected" ) def test_dpt_subclasses_no_duplicate_value_types(self) -> None: """Test for duplicate value_type values in subclasses of DPTBase.""" value_types = [ dpt.value_type for dpt in DPTBase.dpt_class_tree() if dpt.value_type is not None ] assert len(value_types) == len(set(value_types)), ( f"Duplicate DPT value_types found: { {item for item in value_types if value_types.count(item) > 1} }" ) def test_dpt_subclasses_no_duplicate_dpt_number(self) -> None: """Test for duplicate value_type values in subclasses of DPTBase.""" dpt_tuples = [ (dpt.dpt_main_number, dpt.dpt_sub_number) for dpt in DPTBase.dpt_class_tree() ] assert len(dpt_tuples) == len(set(dpt_tuples)), ( f"Duplicate DPT numbers found: { {item for item in dpt_tuples if dpt_tuples.count(item) > 1} }" ) @pytest.mark.parametrize( "equal_dpts", [ # strings in dictionaries would fail type checking, but should work nevertheless ["2byte_unsigned", 7, "DPT-7", {"main": 7}, {"main": "7", "sub": None}], ["temperature", "9.001", {"main": 9, "sub": 1}, {"main": "9", "sub": "1"}], ["active_energy", "13.010", {"main": 13, "sub": 10}], ["consumer_producer", "1.1200", {"main": 1, "sub": 1200}], ], ) def test_dpt_alternative_notations(self, equal_dpts: list[Any]) -> None: """Test the parser for accepting alternative notations for the same DPT class.""" parsed = [DPTBase.parse_transcoder(dpt) for dpt in equal_dpts] assert issubclass(parsed[0], DPTBase) assert all(parsed[0] == dpt for dpt in parsed) @pytest.mark.parametrize( "equal_dpts", [ # strings in dictionaries would fail type checking, but should work nevertheless [ "2byte_unsigned", 7, "DPT-7", {"main": 7}, {"main": "7", "sub": None}, DPT2ByteUnsigned, ], [ "temperature", "9.001", {"main": 9, "sub": 1}, {"main": "9", "sub": "1"}, DPTTemperature, ], ["active_energy", "13.010", {"main": 13, "sub": 10}, DPTActiveEnergy], ], ) def test_get_dpt_alternative_notations(self, equal_dpts: list[Any]) -> None: """Test the parser for accepting alternative notations for the same DPT class.""" parsed = [DPTBase.get_dpt(dpt) for dpt in equal_dpts] assert issubclass(parsed[0], DPTBase) assert all(parsed[0] == dpt for dpt in parsed) INVALID_DPT_IDENTIFIERS = [ None, 0, 999999999, 9.001, # float is not valid "invalid_string", {"sub": 1}, {"main": None, "sub": None}, {"main": "invalid"}, {"main": 9, "sub": "invalid"}, [9, 1], (9,), ] @pytest.mark.parametrize("value", INVALID_DPT_IDENTIFIERS) def test_parse_transcoder_invalid_data(self, value: Any) -> None: """Test parsing invalid data.""" assert DPTBase.parse_transcoder(value) is None @pytest.mark.parametrize("value", INVALID_DPT_IDENTIFIERS) def test_get_dpt_invalid_data(self, value: Any) -> None: """Test parsing invalid data.""" with pytest.raises(ValueError): DPTBase.get_dpt(value) def test_parse_transcoder_from_subclass(self) -> None: """Test parsing only subclasses of a DPT class.""" assert DPTBase.parse_transcoder("string") == DPTString assert DPTNumeric.parse_transcoder("string") is None assert DPT2ByteFloat.parse_transcoder("string") is None assert DPTBase.parse_transcoder("percent") == DPTScaling assert DPTNumeric.parse_transcoder("percent") == DPTScaling assert DPT2ByteFloat.parse_transcoder("percent") is None assert DPTBase.parse_transcoder("temperature") == DPTTemperature assert DPTNumeric.parse_transcoder("temperature") == DPTTemperature assert DPT2ByteFloat.parse_transcoder("temperature") == DPTTemperature def test_get_dpt_from_subclass(self) -> None: """Test parsing only subclasses of a DPT class.""" assert DPTBase.get_dpt("string") == DPTString with pytest.raises(ValueError): DPTNumeric.get_dpt("string") assert DPTBase.get_dpt("percent") == DPTScaling assert DPTNumeric.get_dpt("percent") == DPTScaling assert DPTBase.get_dpt("temperature") == DPTTemperature assert DPTNumeric.get_dpt("temperature") == DPTTemperature assert DPT2ByteFloat.get_dpt("temperature") == DPTTemperature def test_dpt_name(self) -> None: """Test DPT name.""" assert DPTBase.dpt_name() == "DPTBase (abstract)" assert DPTNumeric.dpt_name() == "DPTNumeric (abstract)" assert DPT2ByteFloat.dpt_name() == "DPT2ByteFloat (9)" assert DPTString.dpt_name() == "DPTString (16.000)" assert DPTColorRGBW.dpt_name() == "DPTColorRGBW (251.600)" assert DPTConsumerProducer.dpt_name() == "DPTConsumerProducer (1.1200)" class TestDPTBaseSubclass: """Test subclass of transcoder base object.""" @pytest.mark.parametrize("dpt_class", DPTBase.dpt_class_tree()) def test_required_values(self, dpt_class: type[DPTBase]) -> None: """Test required class variables are set for definitions.""" assert dpt_class.payload_type in (DPTArray, DPTBinary) assert isinstance(dpt_class.payload_length, int) assert dpt_class.payload_length, "Payload_length 0 is invalid" assert isinstance(dpt_class.dpt_main_number, int) assert dpt_class.dpt_main_number, "DPT main number 0 is invalid" assert isinstance(dpt_class.dpt_sub_number, int | type(None)) assert isinstance(dpt_class.value_type, str) assert dpt_class.value_type, "Empty string for value_type is invalid" def test_validate_payload_array(self) -> None: """Test validate_payload method.""" class DPTArrayTest(DPTBase): """Mock class for testing array payloads.""" payload_type = DPTArray payload_length = 2 with pytest.raises(CouldNotParseTelegram): DPTArrayTest.validate_payload(DPTArray((1,))) with pytest.raises(CouldNotParseTelegram): DPTArrayTest.validate_payload(DPTArray((1, 1, 1))) with pytest.raises(CouldNotParseTelegram): DPTArrayTest.validate_payload(DPTBinary(1)) with pytest.raises(CouldNotParseTelegram): DPTArrayTest.validate_payload("why?") assert DPTArrayTest.validate_payload(DPTArray((1, 1))) == (1, 1) def test_validate_payload_binary(self) -> None: """Test validate_payload method.""" class DPTBinaryTest(DPTBase): """Mock class for testing binary payloads.""" payload_type = DPTBinary payload_length = 1 with pytest.raises(CouldNotParseTelegram): DPTBinaryTest.validate_payload(DPTArray(1)) with pytest.raises(CouldNotParseTelegram): DPTBinaryTest.validate_payload(DPTArray((1, 1))) with pytest.raises(CouldNotParseTelegram): DPTBinaryTest.validate_payload("why?") assert DPTBinaryTest.validate_payload(DPTBinary(1)) == (1,) class TestDPTNumeric: """Test class for numeric transcoder base object.""" @pytest.mark.parametrize("dpt_class", DPTNumeric.dpt_class_tree()) def test_values(self, dpt_class: type[DPTNumeric]) -> None: """Test boundary values are set for numeric definitions (because mypy doesn't).""" assert isinstance(dpt_class.value_min, int | float) assert isinstance(dpt_class.value_max, int | float) assert isinstance(dpt_class.resolution, int | float) xknx-3.6.0/test/dpt_tests/payload_test.py000066400000000000000000000060651475530762600205700ustar00rootroot00000000000000"""Unit test for KNX payload objects.""" import pytest from xknx.dpt import DPTArray, DPTBinary from xknx.exceptions import ConversionError class TestDPT: """Test class for KNX binary/integer objects.""" def test_compare_binary(self) -> None: """Test comparison of DPTBinary objects.""" assert DPTBinary(0) == DPTBinary(0) assert DPTBinary(0) == DPTBinary(False) assert DPTBinary(1) == DPTBinary(True) assert DPTBinary(2) == DPTBinary(2) assert DPTBinary(1) != DPTBinary(4) assert DPTBinary(2) != DPTBinary(0) assert DPTBinary(0) != DPTBinary(2) def test_compare_array(self) -> None: """Test comparison of DPTArray objects.""" assert DPTArray(()) == DPTArray(()) assert DPTArray([1]) == DPTArray((1,)) assert DPTArray([1, 2, 3]) == DPTArray([1, 2, 3]) assert DPTArray([1, 2, 3]) == DPTArray((1, 2, 3)) assert DPTArray((1, 2, 3)) == DPTArray([1, 2, 3]) assert DPTArray((1, 2, 3)) != DPTArray([1, 2, 3, 4]) assert DPTArray((1, 2, 3, 4)) != DPTArray([1, 2, 3]) assert DPTArray((1, 2, 3)) != DPTArray([1, 2, 4]) def test_compare_none(self) -> None: """Test comparison DPTArray objects with None.""" assert DPTArray(()) is not None assert None is not DPTArray(()) assert DPTBinary(0) is not None assert None is not DPTBinary(0) assert DPTArray((1, 2, 3)) is not None assert None is not DPTArray((1, 2, 3)) assert DPTBinary(1) is not None assert None is not DPTBinary(1) def test_compare_array_binary(self) -> None: """Test comparison of empty DPTArray objects with DPTBinary objects.""" assert DPTArray(()) != DPTBinary(0) assert DPTBinary(0) != DPTArray(()) assert DPTBinary(0) != DPTArray(0) assert DPTBinary(1) != DPTArray(1) assert DPTArray((1, 2, 3)) != DPTBinary(2) assert DPTBinary(2) != DPTArray((1, 2, 3)) assert DPTArray((2,)) != DPTBinary(2) assert DPTBinary(2) != DPTArray((2,)) def test_dpt_binary_assign(self) -> None: """Test initialization of DPTBinary objects.""" assert DPTBinary(8).value == 8 def test_dpt_binary_assign_limit_exceeded(self) -> None: """Test initialization of DPTBinary objects with wrong value (value exceeded).""" with pytest.raises(ConversionError): DPTBinary(DPTBinary.APCI_BITMASK + 1) def test_dpt_init_with_string(self) -> None: """Teest initialization of DPTBinary object with wrong value (wrong type).""" with pytest.raises(TypeError): DPTBinary("bla") def test_dpt_array_init_with_string(self) -> None: """Test initialization of DPTArray object with wrong value (wrong type).""" with pytest.raises(TypeError): DPTArray("bla") def test_dpt_representation(self) -> None: """Test representation of DPTBinary and DPTArray.""" assert repr(DPTBinary(True)) == "DPTBinary(0x1)" assert repr(DPTArray((5, 15))) == "DPTArray((0x5, 0xf))" xknx-3.6.0/test/io_tests/000077500000000000000000000000001475530762600153375ustar00rootroot00000000000000xknx-3.6.0/test/io_tests/__init__.py000066400000000000000000000000441475530762600174460ustar00rootroot00000000000000"""Unit tests for the IO Module.""" xknx-3.6.0/test/io_tests/connection_test.py000066400000000000000000000010011475530762600210770ustar00rootroot00000000000000"""Unit test for xknx.io.connection module.""" from xknx.io.connection import ConnectionConfig, SecureConfig def test_connection_config_compare() -> None: """Test ConnectionConfig comparison.""" assert ConnectionConfig() == ConnectionConfig() assert ConnectionConfig() != ConnectionConfig(individual_address="1.1.1") def test_secure_config_compare() -> None: """Test SecureConfig comparison.""" assert SecureConfig() == SecureConfig() assert SecureConfig() != SecureConfig(user_id=5) xknx-3.6.0/test/io_tests/gateway_scanner_test.py000066400000000000000000000502271475530762600221300ustar00rootroot00000000000000"""Unit test for KNX/IP gateway scanner.""" import asyncio from typing import Any from unittest.mock import Mock, create_autospec, patch import pytest from xknx import XKNX from xknx.exceptions import XKNXException from xknx.io import GatewayScanFilter, GatewayScanner from xknx.io.gateway_scanner import GatewayDescriptor from xknx.io.transport import UDPTransport from xknx.knxip import ( HPAI, DIBDeviceInformation, DIBServiceFamily, DIBSuppSVCFamilies, KNXIPFrame, SearchRequest, SearchRequestExtended, SearchResponse, SearchResponseExtended, ) from xknx.telegram import IndividualAddress from ..conftest import EventLoopClockAdvancer class TestGatewayDescriptor: """Test GatewayDescriptor object.""" @pytest.mark.parametrize( "raw,expected", [ ( # MDT SCN-IP000.03 IP Interface (IP-Secure enabled) bytes.fromhex( "0610020c00a20801c0a800ba0e57360102001101000000837b40054500000000" "cc1be080b80153434e2d49503030302e303320495020496e7465726661636520" "6d6974200c02020203020402070209010808000000f000121003000000004005" "45000000000001041404c0a800ba40054500c0a800010000000004000c051101" "1102110311041105060603010401140700f01102000011030000110400001105" "0000" ), { "supports_routing": False, "supports_tunnelling": True, "supports_tunnelling_tcp": True, "supports_secure": True, "routing_requires_secure": False, "tunnelling_requires_secure": True, }, ), ( # MDT SCN-IP100.02 IP Router (no IP-Secure support, CoreV1) bytes.fromhex( "06100202004e08010a0102280e5736010200200000000083477f0124e000170c" "cc1be08004c44d4454204b4e5820495020526f75746572000000000000000000" "000000000a020201030104010501" ), { "supports_routing": True, "supports_tunnelling": True, "supports_tunnelling_tcp": False, "supports_secure": False, "routing_requires_secure": None, "tunnelling_requires_secure": None, }, ), ( # Gira X1 (no IP-Secure support but CoreV2) bytes.fromhex( "0610020c006408010a0100290e57360102001001000000081171012600000000" "000ab3290b134769726120583100000000000000000000000000000000000000" "000000000c0202020302040207010901140700dc10fbfff810fcffff10fdffff" "10feffff" ), { "supports_routing": False, "supports_tunnelling": True, "supports_tunnelling_tcp": True, "supports_secure": True, "routing_requires_secure": None, "tunnelling_requires_secure": None, }, ), ( # Gira IP-Router I12 (no IP-Secure support but CoreV2) bytes.fromhex( "0610020c006608010a0100280e57360102001000000000082d40834de000170c" "000ab3274a3247697261204b4e582f49502d526f757465720000000000000000" "000000000e02020203020402050207010901140700dc10f1fffe10f2ffff10f3" "ffff10f4ffff" ), { "supports_routing": True, "supports_tunnelling": True, "supports_tunnelling_tcp": True, "supports_secure": True, "routing_requires_secure": None, "tunnelling_requires_secure": None, }, ), ( # Jung IPR 300 SREG (IP-Secure disabled) bytes.fromhex( "0610020c007408010a0101550e57360102004000000000a615000037e000170c" "0022d10400374a756e67204b4e582049502d526f757465720000000000000000" "000000000c0202020302040205020901240700f8400100054002000540030005" "4004000540050005400600054007000540080005" ), { "supports_routing": True, "supports_tunnelling": True, "supports_tunnelling_tcp": True, "supports_secure": True, "routing_requires_secure": None, "tunnelling_requires_secure": None, }, ), ( # Jung IPR 300 SREG (IP-Secure enabled) bytes.fromhex( "0610020c008408010a0101550e57360102004000000000a615000037e000170c" "0022d10400374b4e582049502d526f7574657200000000000000000000000000" "000000000c02020203020402050209010808000004b0091a0806030104010501" "240700f840010005400200054003000540040005400500054006000540070005" "40080005" ), { "supports_routing": True, "supports_tunnelling": True, "supports_tunnelling_tcp": True, "supports_secure": True, "routing_requires_secure": True, "tunnelling_requires_secure": True, }, ), ], ) def test_parser(self, raw: bytes, expected: dict[str, Any]) -> None: """Test parsing GatewayDescriptor objects from real-world responses.""" response, _ = KNXIPFrame.from_knx(raw) assert isinstance(response.body, SearchResponse | SearchResponseExtended) descriptor = GatewayDescriptor( ip_addr=response.body.control_endpoint.ip_addr, port=response.body.control_endpoint.port, ) descriptor.parse_dibs(response.body.dibs) assert descriptor.supports_routing is expected["supports_routing"] assert descriptor.supports_tunnelling is expected["supports_tunnelling"] assert descriptor.supports_tunnelling_tcp is expected["supports_tunnelling_tcp"] assert descriptor.supports_secure is expected["supports_secure"] assert descriptor.routing_requires_secure is expected["routing_requires_secure"] assert ( descriptor.tunnelling_requires_secure is expected["tunnelling_requires_secure"] ) class TestGatewayScanner: """Test class for xknx/io/GatewayScanner objects.""" gateway_desc_interface = GatewayDescriptor( name="KNX-Interface", ip_addr="10.1.1.11", port=3761, local_interface="en1", local_ip="10.1.1.100", supports_tunnelling=True, supports_routing=False, ) gateway_desc_router = GatewayDescriptor( name="KNX-Router", ip_addr="10.1.1.12", port=3761, local_interface="en1", local_ip="10.1.1.100", supports_tunnelling=False, supports_routing=True, ) gateway_desc_both = GatewayDescriptor( name="Gira KNX/IP-Router", ip_addr="192.168.42.10", port=3671, local_interface="en1", local_ip="192.168.42.50", supports_tunnelling=True, supports_tunnelling_tcp=True, supports_routing=True, individual_address=IndividualAddress("1.1.0"), ) gateway_desc_both.tunnelling_requires_secure = False gateway_desc_neither = GatewayDescriptor( name="AC/S 1.1.1 Application Control", ip_addr="10.1.1.15", port=3671, local_interface="en1", local_ip="192.168.42.50", supports_tunnelling=False, supports_routing=False, ) gateway_desc_secure_tunnel = GatewayDescriptor( name="KNX-Interface", ip_addr="10.1.1.11", port=3761, local_interface="en1", local_ip="10.1.1.111", supports_routing=False, supports_tunnelling=True, supports_tunnelling_tcp=True, supports_secure=True, ) gateway_desc_secure_tunnel.tunnelling_requires_secure = True gateway_desc_secure_router = GatewayDescriptor( name="KNX-Secure-Router", ip_addr="10.1.1.12", port=3761, local_interface="en1", local_ip="10.1.1.100", supports_tunnelling=False, supports_routing=True, ) gateway_desc_secure_router.routing_requires_secure = True def test_gateway_scan_filter_match(self) -> None: """Test match function of gateway filter.""" filter_default = GatewayScanFilter() filter_tunnel = GatewayScanFilter(routing=False, secure_routing=False) filter_tcp_tunnel = GatewayScanFilter( tunnelling=False, tunnelling_tcp=True, secure_tunnelling=None, routing=False, secure_routing=False, ) filter_secure_tunnel = GatewayScanFilter( tunnelling=False, tunnelling_tcp=False, secure_tunnelling=True, routing=False, secure_routing=False, ) filter_router = GatewayScanFilter( tunnelling=False, tunnelling_tcp=False, secure_tunnelling=False, routing=True, secure_routing=False, ) filter_name = GatewayScanFilter(name="KNX-Router") filter_secure_router = GatewayScanFilter( tunnelling=False, tunnelling_tcp=False, secure_tunnelling=False, routing=False, secure_routing=True, ) assert filter_default.match(self.gateway_desc_interface) assert filter_default.match(self.gateway_desc_router) assert filter_default.match(self.gateway_desc_both) assert not filter_default.match(self.gateway_desc_neither) assert filter_default.match(self.gateway_desc_secure_tunnel) assert filter_default.match(self.gateway_desc_secure_router) assert filter_tunnel.match(self.gateway_desc_interface) assert not filter_tunnel.match(self.gateway_desc_router) assert filter_tunnel.match(self.gateway_desc_both) assert not filter_tunnel.match(self.gateway_desc_neither) assert filter_tunnel.match(self.gateway_desc_secure_tunnel) assert not filter_tunnel.match(self.gateway_desc_secure_router) assert not filter_tcp_tunnel.match(self.gateway_desc_interface) assert not filter_tcp_tunnel.match(self.gateway_desc_router) assert filter_tcp_tunnel.match(self.gateway_desc_both) assert not filter_tcp_tunnel.match(self.gateway_desc_neither) assert not filter_tcp_tunnel.match(self.gateway_desc_secure_tunnel) assert not filter_tcp_tunnel.match(self.gateway_desc_secure_router) assert not filter_secure_tunnel.match(self.gateway_desc_interface) assert not filter_secure_tunnel.match(self.gateway_desc_router) assert not filter_secure_tunnel.match(self.gateway_desc_both) assert not filter_secure_tunnel.match(self.gateway_desc_neither) assert filter_secure_tunnel.match(self.gateway_desc_secure_tunnel) assert not filter_secure_tunnel.match(self.gateway_desc_secure_router) assert not filter_router.match(self.gateway_desc_interface) assert filter_router.match(self.gateway_desc_router) assert filter_router.match(self.gateway_desc_both) assert not filter_router.match(self.gateway_desc_neither) assert not filter_router.match(self.gateway_desc_secure_tunnel) assert not filter_router.match(self.gateway_desc_secure_router) assert not filter_name.match(self.gateway_desc_interface) assert filter_name.match(self.gateway_desc_router) assert not filter_name.match(self.gateway_desc_both) assert not filter_name.match(self.gateway_desc_neither) assert not filter_name.match(self.gateway_desc_secure_tunnel) assert not filter_name.match(self.gateway_desc_secure_router) assert not filter_secure_router.match(self.gateway_desc_interface) assert not filter_secure_router.match(self.gateway_desc_router) assert not filter_secure_router.match(self.gateway_desc_both) assert not filter_secure_router.match(self.gateway_desc_neither) assert not filter_secure_router.match(self.gateway_desc_secure_tunnel) assert filter_secure_router.match(self.gateway_desc_secure_router) def test_search_response_reception(self) -> None: """Test function of gateway scanner.""" xknx = XKNX() gateway_scanner = GatewayScanner(xknx) test_search_response = fake_router_search_response() udp_transport_mock = create_autospec(UDPTransport) udp_transport_mock.local_addr = ("192.168.42.50", 0) udp_transport_mock.getsockname.return_value = ("192.168.42.50", 0) assert not gateway_scanner.found_gateways gateway_scanner._response_rec_callback( test_search_response, HPAI("192.168.42.50", 0), udp_transport_mock, interface="en1", ) assert len(gateway_scanner.found_gateways) == 1 gateway_scanner._response_rec_callback( test_search_response, HPAI("192.168.42.230", 0), udp_transport_mock, interface="eth1", ) assert len(gateway_scanner.found_gateways) == 1 assert str( gateway_scanner.found_gateways[test_search_response.body.control_endpoint] ) == str(self.gateway_desc_both) @patch("xknx.io.gateway_scanner.UDPTransport.connect") @patch("xknx.io.gateway_scanner.UDPTransport.send") @patch( "xknx.io.gateway_scanner.UDPTransport.getsockname", return_value=("10.1.1.2", 56789), ) async def test_scan_timeout( self, getsockname_mock: Mock, udp_transport_send_mock: Mock, udp_transport_connect_mock: Mock, time_travel: EventLoopClockAdvancer, ) -> None: """Test gateway scanner timeout.""" xknx = XKNX() gateway_scanner = GatewayScanner(xknx) timed_out_scan_task = asyncio.create_task(gateway_scanner.scan()) await time_travel(gateway_scanner.timeout_in_seconds) # Unsuccessful scan() returns empty list assert await timed_out_scan_task == [] @patch("xknx.io.gateway_scanner.UDPTransport.connect") @patch("xknx.io.gateway_scanner.UDPTransport.send") async def test_async_scan_timeout( self, udp_transport_send_mock: Mock, udp_transport_connect_mock: Mock, time_travel: EventLoopClockAdvancer, ) -> None: """Test gateway scanner timeout for async generator.""" async def test() -> bool: xknx = XKNX() async for _ in GatewayScanner(xknx).async_scan(): break else: return True # timeout with ( patch( "xknx.io.util.get_default_local_ip", return_value="10.1.1.2", ), patch( "xknx.io.gateway_scanner.UDPTransport.getsockname", return_value=("10.1.1.2", 56789), ), ): timed_out_scan_task = asyncio.create_task(test()) await time_travel(3) assert await timed_out_scan_task # no matching interface found with patch( "xknx.io.util.get_default_local_ip", return_value=None, ): timed_out_scan_task = asyncio.create_task(test()) await time_travel(3) with pytest.raises(XKNXException): await timed_out_scan_task @patch("xknx.io.gateway_scanner.UDPTransport.connect") @patch("xknx.io.gateway_scanner.UDPTransport.send") async def test_async_scan_exit( self, udp_transport_send_mock: Mock, udp_transport_connect_mock: Mock, time_travel: EventLoopClockAdvancer, ) -> None: """Test gateway scanner timeout for async generator.""" xknx = XKNX() test_search_response = fake_router_search_response() udp_transport_mock = Mock() udp_transport_mock.local_addr = ("10.1.1.2", 56789) gateway_scanner = GatewayScanner(xknx, local_ip="10.1.1.2") async def test() -> bool: async for gateway in gateway_scanner.async_scan(): assert isinstance(gateway, GatewayDescriptor) return True return False with ( patch( "xknx.io.gateway_scanner.UDPTransport.getsockname", return_value=("10.1.1.2", 56789), ), patch( "xknx.io.gateway_scanner.UDPTransport.register_callback" ) as register_callback_mock, ): scan_task = asyncio.create_task(test()) await time_travel(0) _fished_response_rec_callback = register_callback_mock.call_args.args[0] _fished_response_rec_callback( test_search_response, HPAI("192.168.42.50", 0), udp_transport_mock, ) assert await scan_task await time_travel(0) # for task cleanup @patch("xknx.io.gateway_scanner.UDPTransport.connect") @patch("xknx.io.gateway_scanner.UDPTransport.send") async def test_send_search_requests( self, udp_transport_send_mock: Mock, udp_transport_connect_mock: Mock, ) -> None: """Test if both search requests are sent per interface.""" xknx = XKNX() gateway_scanner = GatewayScanner(xknx, timeout_in_seconds=0) with ( patch( "xknx.io.util.get_default_local_ip", return_value="10.1.1.2", ), patch( "xknx.io.util.get_local_interface_name", return_value="en_0123", ), patch( "xknx.io.gateway_scanner.UDPTransport.getsockname", return_value=("10.1.1.2", 56789), ), ): await gateway_scanner.scan() assert udp_transport_connect_mock.call_count == 1 assert udp_transport_send_mock.call_count == 2 frame_1 = udp_transport_send_mock.call_args_list[0].args[0] frame_2 = udp_transport_send_mock.call_args_list[1].args[0] assert isinstance(frame_1.body, SearchRequestExtended) assert isinstance(frame_2.body, SearchRequest) assert frame_1.body.discovery_endpoint == HPAI(ip_addr="10.1.1.2", port=56789) def test_gateway_scan_filter_compare(self) -> None: """Test GatewayScanFilter comparison.""" assert GatewayScanFilter() == GatewayScanFilter() assert GatewayScanFilter() != GatewayScanFilter(tunnelling=False) def fake_router_search_response() -> KNXIPFrame: """Return the KNXIPFrame of a KNX/IP Router with a SearchResponse body.""" frame_body = SearchResponse() frame_body.control_endpoint = HPAI(ip_addr="192.168.42.10", port=3671) device_information = DIBDeviceInformation() device_information.name = "Gira KNX/IP-Router" device_information.serial_number = "11:22:33:44:55:66" device_information.individual_address = IndividualAddress("1.1.0") device_information.mac_address = "01:02:03:04:05:06" svc_families = DIBSuppSVCFamilies() svc_families.families.append( DIBSuppSVCFamilies.Family(name=DIBServiceFamily.CORE, version=1) ) svc_families.families.append( DIBSuppSVCFamilies.Family(name=DIBServiceFamily.DEVICE_MANAGEMENT, version=2) ) svc_families.families.append( DIBSuppSVCFamilies.Family(name=DIBServiceFamily.TUNNELING, version=1) ) svc_families.families.append( DIBSuppSVCFamilies.Family(name=DIBServiceFamily.ROUTING, version=1) ) svc_families.families.append( DIBSuppSVCFamilies.Family( name=DIBServiceFamily.REMOTE_CONFIGURATION_DIAGNOSIS, version=1 ) ) frame_body.dibs.append(device_information) frame_body.dibs.append(svc_families) return KNXIPFrame.init_from_body(frame_body) xknx-3.6.0/test/io_tests/knxip_interface_test.py000066400000000000000000000723221475530762600221270ustar00rootroot00000000000000"""Unit test for KNX/IP Interface.""" from collections.abc import AsyncGenerator from pathlib import Path import threading from typing import Any from unittest.mock import DEFAULT, Mock, patch import pytest from xknx import XKNX from xknx.exceptions.exception import CommunicationError, InvalidSecureConfiguration from xknx.io import ( ConnectionConfig, ConnectionType, GatewayDescriptor, SecureConfig, knx_interface_factory, ) from xknx.io.routing import Routing, SecureGroup, SecureRouting from xknx.io.tunnel import SecureTunnel, TCPTunnel, UDPTunnel from xknx.knxip.dib import TunnelingSlotStatus from xknx.telegram import IndividualAddress class TestKNXIPInterface: """Test class for KNX interface objects.""" knxkeys_file = Path(__file__).parent / "resources/testcase.knxkeys" def setup_method(self) -> None: """Set up test class.""" # pylint: disable=attribute-defined-outside-init self.xknx = XKNX() async def test_start_automatic_connection(self) -> None: """Test starting automatic connection.""" connection_config = ConnectionConfig() assert connection_config.connection_type == ConnectionType.AUTOMATIC interface = knx_interface_factory(self.xknx, connection_config) with patch("xknx.io.KNXIPInterface._start_automatic") as start_automatic_mock: await interface.start() start_automatic_mock.assert_called_once_with(local_ip=None, keyring=None) assert threading.active_count() == 1 async def gateway_generator_mock(_: Any) -> AsyncGenerator[GatewayDescriptor]: secure_interface = GatewayDescriptor( ip_addr="10.1.2.3", port=3671, supports_tunnelling_tcp=True, supports_secure=True, ) secure_interface.tunnelling_requires_secure = True yield secure_interface yield GatewayDescriptor( ip_addr="10.1.2.3", port=3671, supports_tunnelling_tcp=True ) yield GatewayDescriptor( ip_addr="10.1.2.3", port=3671, supports_tunnelling=True ) yield GatewayDescriptor( ip_addr="10.1.2.3", port=3671, supports_routing=True ) with ( patch( "xknx.io.knxip_interface.GatewayScanner.async_scan", new=gateway_generator_mock, ), patch( "xknx.io.KNXIPInterface._start_secure_tunnelling_tcp", side_effect=InvalidSecureConfiguration("Error"), ) as start_secure_tunnelling_tcp, patch( "xknx.io.KNXIPInterface._start_tunnelling_tcp", side_effect=CommunicationError("Error"), ) as start_tunnelling_tcp_mock, patch( "xknx.io.KNXIPInterface._start_tunnelling_udp", side_effect=CommunicationError("Error"), ) as start_tunnelling_udp_mock, patch( "xknx.io.KNXIPInterface._start_routing", side_effect=CommunicationError("Error"), ) as start_routing_mock, ): with pytest.raises(CommunicationError): await interface.start() start_secure_tunnelling_tcp.assert_called_once() start_tunnelling_tcp_mock.assert_called_once() start_tunnelling_udp_mock.assert_called_once() start_routing_mock.assert_called_once() async def test_start_automatic_with_keyring(self) -> None: """Test starting with automatic mode and keyring.""" connection_config = ConnectionConfig( secure_config=SecureConfig( knxkeys_file_path=self.knxkeys_file, knxkeys_password="password", ) ) assert connection_config.connection_type == ConnectionType.AUTOMATIC interface = knx_interface_factory(self.xknx, connection_config) # in the test keyfile the only host is 1.0.0 - others shall be skipped async def gateway_generator_mock(_: Any) -> AsyncGenerator[GatewayDescriptor]: yield GatewayDescriptor( ip_addr="10.1.5.5", port=3671, supports_tunnelling_tcp=True, individual_address=IndividualAddress("5.0.0"), ) yield GatewayDescriptor( ip_addr="10.1.0.0", port=3671, supports_tunnelling_tcp=True, individual_address=IndividualAddress("1.0.0"), ) with ( patch( "xknx.io.knxip_interface.GatewayScanner.async_scan", new=gateway_generator_mock, ), patch( "xknx.io.KNXIPInterface._start_tunnelling_tcp", ) as start_tunnelling_tcp_mock, ): await interface.start() start_tunnelling_tcp_mock.assert_called_once_with( gateway_ip="10.1.0.0", gateway_port=3671, ) async def test_start_automatic_with_keyring_and_ia(self) -> None: """Test starting with automatic mode and keyring and individual address.""" connection_config = ConnectionConfig( individual_address=IndividualAddress("1.0.12"), secure_config=SecureConfig( knxkeys_file_path=self.knxkeys_file, knxkeys_password="password", ), ) assert connection_config.connection_type == ConnectionType.AUTOMATIC interface = knx_interface_factory(self.xknx, connection_config) # in the test keyfile the only host is 1.0.0 - others shall be skipped async def gateway_generator_mock(_: Any) -> AsyncGenerator[GatewayDescriptor]: yield GatewayDescriptor( ip_addr="10.1.5.5", port=3671, supports_tunnelling_tcp=True, individual_address=IndividualAddress("5.0.0"), ) yield GatewayDescriptor( ip_addr="10.1.0.0", port=3671, supports_tunnelling_tcp=True, individual_address=IndividualAddress("1.0.0"), ) with ( patch( "xknx.io.knxip_interface.GatewayScanner.async_scan", new=gateway_generator_mock, ), patch( "xknx.io.KNXIPInterface._start_tunnelling_tcp", ) as start_tunnelling_tcp_mock, ): await interface.start() start_tunnelling_tcp_mock.assert_called_once_with( gateway_ip="10.1.0.0", gateway_port=3671, ) # IA not listed in keyring invalid_config = ConnectionConfig( individual_address=IndividualAddress("5.5.5"), secure_config=SecureConfig( knxkeys_file_path=self.knxkeys_file, knxkeys_password="password", ), ) assert invalid_config.connection_type == ConnectionType.AUTOMATIC interface = knx_interface_factory(self.xknx, invalid_config) with ( patch( "xknx.io.knxip_interface.GatewayScanner.async_scan", new=gateway_generator_mock, ), pytest.raises(InvalidSecureConfiguration), ): await interface.start() async def test_start_udp_tunnel_connection(self) -> None: """Test starting UDP tunnel connection.""" # without gateway_ip automatic is called gateway_ip = "127.0.0.2" connection_config = ConnectionConfig( connection_type=ConnectionType.TUNNELING, gateway_ip=gateway_ip, ) with patch( "xknx.io.KNXIPInterface._start_tunnelling_udp" ) as start_tunnelling_udp: interface = knx_interface_factory(self.xknx, connection_config) await interface.start() start_tunnelling_udp.assert_called_once_with( gateway_ip=gateway_ip, gateway_port=3671, local_ip=None, ) with patch("xknx.io.tunnel.UDPTunnel.connect") as connect_udp: interface = knx_interface_factory(self.xknx, connection_config) await interface.start() assert isinstance(interface._interface, UDPTunnel) assert interface._interface.local_ip == "127.0.0.1" assert interface._interface.local_port == 0 assert interface._interface.gateway_ip == gateway_ip assert interface._interface.gateway_port == 3671 assert interface._interface.auto_reconnect assert interface._interface.auto_reconnect_wait == 3 assert interface._interface.route_back is False assert ( # pylint: disable=comparison-with-callable interface._interface.cemi_received_callback == interface.cemi_received ) connect_udp.assert_called_once_with() async def test_start_tcp_tunnel_connection(self) -> None: """Test starting TCP tunnel connection.""" # without gateway_ip automatic is called gateway_ip = "127.0.0.2" connection_config = ConnectionConfig( connection_type=ConnectionType.TUNNELING_TCP, gateway_ip=gateway_ip ) with patch( "xknx.io.KNXIPInterface._start_tunnelling_tcp" ) as start_tunnelling_tcp: interface = knx_interface_factory(self.xknx, connection_config) await interface.start() start_tunnelling_tcp.assert_called_once_with( gateway_ip=gateway_ip, gateway_port=3671, ) with patch("xknx.io.tunnel.TCPTunnel.connect") as connect_tcp: interface = knx_interface_factory(self.xknx, connection_config) await interface.start() assert isinstance(interface._interface, TCPTunnel) assert interface._interface.gateway_ip == gateway_ip assert interface._interface.gateway_port == 3671 assert interface._interface.auto_reconnect assert interface._interface.auto_reconnect_wait == 3 assert ( # pylint: disable=comparison-with-callable interface._interface.cemi_received_callback == interface.cemi_received ) connect_tcp.assert_called_once_with() async def test_start_tcp_tunnel_connection_with_ia(self) -> None: """Test starting TCP tunnel connection requesting specific tunnel.""" # without gateway_ip automatic is called gateway_ip = "127.0.0.2" connection_config = ConnectionConfig( connection_type=ConnectionType.TUNNELING_TCP, gateway_ip=gateway_ip, individual_address="1.1.1", ) with patch( "xknx.io.KNXIPInterface._start_tunnelling_tcp" ) as start_tunnelling_tcp: interface = knx_interface_factory(self.xknx, connection_config) await interface.start() start_tunnelling_tcp.assert_called_once_with( gateway_ip=gateway_ip, gateway_port=3671, ) with patch("xknx.io.tunnel.TCPTunnel.connect") as connect_tcp: interface = knx_interface_factory(self.xknx, connection_config) await interface.start() assert isinstance(interface._interface, TCPTunnel) assert interface._interface.gateway_ip == gateway_ip assert interface._interface.gateway_port == 3671 assert interface._interface._requested_address == IndividualAddress("1.1.1") assert interface._interface.auto_reconnect assert interface._interface.auto_reconnect_wait == 3 assert ( # pylint: disable=comparison-with-callable interface._interface.cemi_received_callback == interface.cemi_received ) connect_tcp.assert_called_once_with() async def test_start_routing_connection(self) -> None: """Test starting routing connection.""" local_ip = "127.0.0.1" # set local_ip to avoid gateway scanner connection_config = ConnectionConfig( connection_type=ConnectionType.ROUTING, local_ip=local_ip ) with patch("xknx.io.KNXIPInterface._start_routing") as start_routing: interface = knx_interface_factory(self.xknx, connection_config) await interface.start() start_routing.assert_called_once_with(local_ip=local_ip) with patch("xknx.io.routing.Routing.connect") as connect_routing: interface = knx_interface_factory(self.xknx, connection_config) await interface.start() assert isinstance(interface._interface, Routing) assert interface._interface.local_ip == local_ip assert interface._interface.multicast_group == "224.0.23.12" assert interface._interface.multicast_port == 3671 assert ( # pylint: disable=comparison-with-callable interface._interface.cemi_received_callback == interface.cemi_received ) connect_routing.assert_called_once_with() async def test_threaded_connection(self) -> None: """Test starting threaded connection.""" # pylint: disable=attribute-defined-outside-init self.main_thread = threading.get_ident() def assert_thread(*args: Any, **kwargs: dict[str, Any]) -> None: """Test threaded connection.""" assert self.main_thread != threading.get_ident() connection_config = ConnectionConfig(threaded=True) assert connection_config.connection_type == ConnectionType.AUTOMATIC with patch( "xknx.io.KNXIPInterface._start_automatic", side_effect=assert_thread ) as start_automatic_mock: interface = knx_interface_factory(self.xknx, connection_config) await interface.start() start_automatic_mock.assert_called_once_with(local_ip=None, keyring=None) with pytest.raises(CommunicationError): # interface can only be started once await interface.start() async def test_threaded_send_cemi(self) -> None: """Test sending cemi with threaded connection.""" # pylint: disable=attribute-defined-outside-init self.main_thread = threading.get_ident() def assert_thread(*args: Any, **kwargs: dict[str, Any]) -> Any: """Test threaded connection.""" assert self.main_thread != threading.get_ident() return DEFAULT # to not disable `return_value` of send_cemi_mock local_ip = "127.0.0.1" # set local_ip to avoid gateway scanner; use routing as it is the simplest mode connection_config = ConnectionConfig( connection_type=ConnectionType.ROUTING, local_ip=local_ip, threaded=True ) cemi_mock = Mock() with ( patch( "xknx.io.routing.Routing.connect", side_effect=assert_thread ) as connect_routing_mock, patch( "xknx.io.routing.Routing.send_cemi", side_effect=assert_thread, return_value="test", ) as send_cemi_mock, patch( "xknx.io.routing.Routing.disconnect", side_effect=assert_thread ) as disconnect_routing_mock, ): interface = knx_interface_factory(self.xknx, connection_config) await interface.start() connect_routing_mock.assert_called_once_with() assert await interface.send_cemi(cemi_mock) == "test" send_cemi_mock.assert_called_once_with(cemi_mock) await interface.stop() disconnect_routing_mock.assert_called_once_with() assert interface._interface is None async def test_threaded_connection_unsuccessful_start(self) -> None: """Test cleanup when unsuccessful initial connection.""" connection_config = ConnectionConfig(threaded=True) assert connection_config.connection_type == ConnectionType.AUTOMATIC with patch( "xknx.io.KNXIPInterface._start", side_effect=CommunicationError("Error") ): interface = knx_interface_factory(self.xknx, connection_config) with pytest.raises(CommunicationError): await interface.start() # thread and loop are cleaned up after unsuccessful start assert interface._connection_thread is None assert interface._thread_loop is None async def test_start_secure_connection_knx_keys_user_id(self) -> None: """Test starting a secure connection from a knxkeys file with user_id.""" gateway_ip = "192.168.1.1" connection_config = ConnectionConfig( connection_type=ConnectionType.TUNNELING_TCP_SECURE, gateway_ip=gateway_ip, secure_config=SecureConfig( user_id=3, knxkeys_file_path=self.knxkeys_file, knxkeys_password="password", ), ) gateway_description = GatewayDescriptor( ip_addr=gateway_ip, port=3671, supports_tunnelling_tcp=True, supports_secure=True, individual_address=IndividualAddress("1.0.0"), ) gateway_description.tunnelling_requires_secure = True with ( patch( "xknx.io.knxip_interface.request_description", return_value=gateway_description, ), patch("xknx.io.tunnel.SecureTunnel.connect") as connect_secure, ): interface = knx_interface_factory(self.xknx, connection_config) await interface.start() assert isinstance(interface._interface, SecureTunnel) assert interface._interface.gateway_ip == gateway_ip assert interface._interface.gateway_port == 3671 assert interface._interface.auto_reconnect is True assert interface._interface.auto_reconnect_wait == 3 assert interface._interface._user_id == 3 assert interface._interface._user_password == "user1" assert ( interface._interface._device_authentication_password == "authenticationcode" ) assert ( # pylint: disable=comparison-with-callable interface._interface.cemi_received_callback == interface.cemi_received ) connect_secure.assert_called_once_with() async def test_start_secure_connection_knx_keys_ia(self) -> None: """Test starting a secure connection from a knxkeys file with individual address.""" gateway_ip = "192.168.1.1" connection_config = ConnectionConfig( connection_type=ConnectionType.TUNNELING_TCP_SECURE, gateway_ip=gateway_ip, individual_address="1.0.12", secure_config=SecureConfig( knxkeys_file_path=self.knxkeys_file, knxkeys_password="password" ), ) # result of request_description is currently not used when IA is defined with ( patch( "xknx.io.knxip_interface.request_description", ), patch("xknx.io.tunnel.SecureTunnel.connect") as connect_secure, ): interface = knx_interface_factory(self.xknx, connection_config) await interface.start() assert isinstance(interface._interface, SecureTunnel) assert interface._interface.gateway_ip == gateway_ip assert interface._interface.gateway_port == 3671 assert interface._interface.auto_reconnect is True assert interface._interface.auto_reconnect_wait == 3 assert interface._interface._user_id == 5 assert interface._interface._user_password == "user3" assert ( interface._interface._device_authentication_password == "authenticationcode" ) assert ( # pylint: disable=comparison-with-callable interface._interface.cemi_received_callback == interface.cemi_received ) connect_secure.assert_called_once_with() async def test_start_secure_connection_knx_keys_first_interface(self) -> None: """Test starting a secure connection from a knxkeys file.""" gateway_ip = "192.168.1.1" connection_config = ConnectionConfig( connection_type=ConnectionType.TUNNELING_TCP_SECURE, gateway_ip=gateway_ip, secure_config=SecureConfig( knxkeys_file_path=self.knxkeys_file, knxkeys_password="password" ), ) gateway_description = GatewayDescriptor( ip_addr=gateway_ip, port=3671, supports_tunnelling_tcp=True, supports_secure=True, individual_address=IndividualAddress("1.0.0"), ) gateway_description.tunnelling_requires_secure = True gateway_description.tunnelling_slots = { IndividualAddress("1.0.1"): TunnelingSlotStatus( usable=True, authorized=False, free=False ), IndividualAddress("1.0.11"): TunnelingSlotStatus( usable=True, authorized=False, free=True ), IndividualAddress("1.0.12"): TunnelingSlotStatus( usable=True, authorized=False, free=True ), IndividualAddress("1.0.13"): TunnelingSlotStatus( usable=True, authorized=False, free=True ), } with ( patch( "xknx.io.knxip_interface.request_description", return_value=gateway_description, ), patch("xknx.io.tunnel.SecureTunnel.connect") as connect_secure, ): interface = knx_interface_factory(self.xknx, connection_config) await interface.start() assert isinstance(interface._interface, SecureTunnel) assert interface._interface.gateway_ip == gateway_ip assert interface._interface.gateway_port == 3671 assert interface._interface.auto_reconnect is True assert interface._interface.auto_reconnect_wait == 3 assert interface._interface._user_id == 4 assert interface._interface._user_password == "user2" assert ( interface._interface._device_authentication_password == "authenticationcode" ) assert ( # pylint: disable=comparison-with-callable interface._interface.cemi_received_callback == interface.cemi_received ) connect_secure.assert_called_once_with() async def test_start_secure_with_manual_passwords(self) -> None: """Test starting a secure connection from manual passwords.""" gateway_ip = "192.168.1.1" connection_config = ConnectionConfig( connection_type=ConnectionType.TUNNELING_TCP_SECURE, gateway_ip=gateway_ip, secure_config=SecureConfig( user_id=3, device_authentication_password="authenticationcode", user_password="user1", ), ) with patch("xknx.io.tunnel.SecureTunnel.connect") as connect_secure: interface = knx_interface_factory(self.xknx, connection_config) await interface.start() assert isinstance(interface._interface, SecureTunnel) assert interface._interface.gateway_ip == gateway_ip assert interface._interface.gateway_port == 3671 assert interface._interface.auto_reconnect is True assert interface._interface.auto_reconnect_wait == 3 assert interface._interface._user_id == 3 assert interface._interface._user_password == "user1" assert ( interface._interface._device_authentication_password == "authenticationcode" ) assert ( # pylint: disable=comparison-with-callable interface._interface.cemi_received_callback == interface.cemi_received ) connect_secure.assert_called_once_with() @pytest.mark.parametrize( "connection_config", [ ConnectionConfig( # invalid user_id connection_type=ConnectionType.TUNNELING_TCP_SECURE, gateway_ip="192.168.1.1", secure_config=SecureConfig( user_id=12, knxkeys_file_path=knxkeys_file, knxkeys_password="password", ), ), ConnectionConfig( # invalid IA connection_type=ConnectionType.TUNNELING_TCP_SECURE, gateway_ip="192.168.1.1", individual_address="9.9.9", secure_config=SecureConfig( knxkeys_file_path=knxkeys_file, knxkeys_password="password", ), ), ConnectionConfig( # no secure_config connection_type=ConnectionType.TUNNELING_TCP_SECURE, gateway_ip="192.168.1.1", individual_address="9.9.9", ), ConnectionConfig( # no password connection_type=ConnectionType.TUNNELING_TCP_SECURE, gateway_ip="192.168.1.1", secure_config=SecureConfig( user_id=3, knxkeys_file_path=knxkeys_file, ), ), ], ) async def test_invalid_secure_error( self, connection_config: ConnectionConfig ) -> None: """Test ip secure invalid configurations.""" gateway_ip = "192.168.1.1" gateway_description = GatewayDescriptor( ip_addr=gateway_ip, port=3671, supports_tunnelling_tcp=True, supports_secure=True, individual_address=IndividualAddress("1.0.0"), ) gateway_description.tunnelling_requires_secure = True with ( patch( "xknx.io.knxip_interface.request_description", return_value=gateway_description, ), pytest.raises(InvalidSecureConfiguration), ): interface = knx_interface_factory(self.xknx, connection_config) await interface.start() async def test_invalid_user_password(self) -> None: """Test ip secure.""" gateway_ip = "192.168.1.1" connection_config = ConnectionConfig( connection_type=ConnectionType.TUNNELING_TCP_SECURE, gateway_ip=gateway_ip, secure_config=SecureConfig( user_id=1, ), ) with pytest.raises(InvalidSecureConfiguration): interface = knx_interface_factory(self.xknx, connection_config) await interface.start() async def test_start_secure_routing_knx_keys(self) -> None: """Test starting a secure routing connection from a knxkeys file.""" backbone_key = bytes.fromhex("cf89fd0f18f4889783c7ef44ee1f5e14") connection_config = ConnectionConfig( connection_type=ConnectionType.ROUTING_SECURE, secure_config=SecureConfig( knxkeys_file_path=self.knxkeys_file, knxkeys_password="password" ), ) with patch("xknx.io.routing.SecureRouting.connect") as connect_secure: interface = knx_interface_factory(self.xknx, connection_config) await interface.start() assert isinstance(interface._interface, SecureRouting) assert interface._interface.backbone_key == backbone_key assert interface._interface.latency_ms == 1000 assert isinstance(interface._interface.transport, SecureGroup) assert interface._interface.transport.remote_addr == ( "224.0.23.12", 3671, ) assert ( # pylint: disable=comparison-with-callable interface._interface.cemi_received_callback == interface.cemi_received ) connect_secure.assert_called_once_with() async def test_start_secure_routing_manual(self) -> None: """Test starting a secure routing connection from a knxkeys file.""" backbone_key_str = "cf89fd0f18f4889783c7ef44ee1f5e14" backbone_key = bytes.fromhex(backbone_key_str) connection_config = ConnectionConfig( connection_type=ConnectionType.ROUTING_SECURE, secure_config=SecureConfig(backbone_key=backbone_key_str, latency_ms=2000), ) with patch("xknx.io.routing.SecureRouting.connect") as connect_secure: interface = knx_interface_factory(self.xknx, connection_config) await interface.start() assert isinstance(interface._interface, SecureRouting) assert interface._interface.backbone_key == backbone_key assert interface._interface.latency_ms == 2000 assert isinstance(interface._interface.transport, SecureGroup) assert interface._interface.transport.remote_addr == ( "224.0.23.12", 3671, ) assert ( # pylint: disable=comparison-with-callable interface._interface.cemi_received_callback == interface.cemi_received ) connect_secure.assert_called_once_with() xknx-3.6.0/test/io_tests/request_response_tests/000077500000000000000000000000001475530762600221675ustar00rootroot00000000000000xknx-3.6.0/test/io_tests/request_response_tests/__init__.py000066400000000000000000000000621475530762600242760ustar00rootroot00000000000000"""Unit tests for the Request_Response Module.""" xknx-3.6.0/test/io_tests/request_response_tests/authenticate_test.py000066400000000000000000000040101475530762600262510ustar00rootroot00000000000000"""Unit test for KNX/IP Authenticate Request/Response.""" from unittest.mock import Mock, patch from xknx.io.request_response import Authenticate from xknx.knxip import ( HPAI, ConnectionStateRequest, KNXIPFrame, SessionAuthenticate, SessionStatus, ) from xknx.knxip.knxip_enum import SecureSessionStatusCode class TestAuthenticate: """Test class for xknx/io/Authenticate objects.""" async def test_authenticate(self) -> None: """Test authenticating to secure KNX device.""" transport_mock = Mock() user_id = 123 mac = bytes(16) authenticate = Authenticate( transport_mock, user_id=user_id, message_authentication_code=mac, ) authenticate.timeout_in_seconds = 0 assert authenticate.awaited_response_class == SessionStatus # Expected KNX/IP-Frame: exp_knxipframe = KNXIPFrame.init_from_body( SessionAuthenticate( user_id=user_id, message_authentication_code=mac, ) ) await authenticate.start() transport_mock.send.assert_called_with(exp_knxipframe) # Response KNX/IP-Frame with wrong type wrong_knxipframe = KNXIPFrame.init_from_body(ConnectionStateRequest()) with patch("logging.Logger.warning") as mock_warning: authenticate.response_rec_callback(wrong_knxipframe, HPAI(), None) mock_warning.assert_called_with("Could not understand knxipframe") assert authenticate.success is False # Correct Response KNX/IP-Frame: res_knxipframe = KNXIPFrame.init_from_body(SessionStatus()) res_knxipframe.body.status = ( SecureSessionStatusCode.STATUS_AUTHENTICATION_SUCCESS ) authenticate.response_rec_callback(res_knxipframe, HPAI(), None) assert authenticate.success is True assert ( authenticate.response.status == SecureSessionStatusCode.STATUS_AUTHENTICATION_SUCCESS ) xknx-3.6.0/test/io_tests/request_response_tests/connect_test.py000066400000000000000000000120121475530762600252250ustar00rootroot00000000000000"""Unit test for KNX/IP Connect Request/Response.""" from unittest.mock import patch from xknx.io.request_response import Connect from xknx.io.transport import UDPTransport from xknx.knxip import ( HPAI, ConnectionStateRequest, ConnectRequest, ConnectResponse, ConnectResponseData, ErrorCode, KNXIPFrame, ) from xknx.telegram import IndividualAddress class TestConnect: """Test class for xknx/io/Connect objects.""" async def test_connect(self) -> None: """Test connecting from KNX bus.""" udp_transport = UDPTransport(("192.168.1.1", 0), ("192.168.1.2", 1234)) local_hpai = HPAI(ip_addr="192.168.1.3", port=4321) connect = Connect(udp_transport, local_hpai=local_hpai) connect.timeout_in_seconds = 0 assert connect.awaited_response_class == ConnectResponse # Expected KNX/IP-Frame: exp_knxipframe = KNXIPFrame.init_from_body( ConnectRequest( control_endpoint=local_hpai, data_endpoint=local_hpai, ) ) with ( patch("xknx.io.transport.UDPTransport.send") as mock_udp_send, patch("xknx.io.transport.UDPTransport.getsockname") as mock_udp_getsockname, ): mock_udp_getsockname.return_value = ("192.168.1.3", 4321) await connect.start() mock_udp_send.assert_called_with(exp_knxipframe) # Response KNX/IP-Frame with wrong type wrong_knxipframe = KNXIPFrame.init_from_body(ConnectionStateRequest()) with patch("logging.Logger.warning") as mock_warning: connect.response_rec_callback(wrong_knxipframe, HPAI(), None) mock_warning.assert_called_with("Could not understand knxipframe") # Response KNX/IP-Frame with error: err_knxipframe = KNXIPFrame.init_from_body( ConnectResponse(status_code=ErrorCode.E_CONNECTION_ID) ) with patch("logging.Logger.debug") as mock_warning: connect.response_rec_callback(err_knxipframe, HPAI(), None) mock_warning.assert_called_with( "Error: KNX bus responded to request of type '%s' with error in '%s': %s", type(connect).__name__, type(err_knxipframe.body).__name__, ErrorCode.E_CONNECTION_ID, ) # Correct Response KNX/IP-Frame: res_knxipframe = KNXIPFrame.init_from_body( ConnectResponse( communication_channel=23, crd=ConnectResponseData(individual_address=IndividualAddress(7)), ) ) connect.response_rec_callback(res_knxipframe, HPAI(), None) assert connect.success assert connect.communication_channel == 23 assert connect.crd.individual_address.raw == 7 async def test_connect_route_back_true(self) -> None: """Test connecting from KNX bus.""" udp_transport = UDPTransport(("192.168.1.1", 0), ("192.168.1.2", 1234)) local_hpai = HPAI() # route_back: No IP address, no port, UDP connect = Connect(udp_transport, local_hpai=local_hpai) connect.timeout_in_seconds = 0 assert connect.awaited_response_class == ConnectResponse # Expected KNX/IP-Frame: exp_knxipframe = KNXIPFrame.init_from_body(ConnectRequest()) with ( patch("xknx.io.transport.UDPTransport.send") as mock_udp_send, patch("xknx.io.transport.UDPTransport.getsockname") as mock_udp_getsockname, ): mock_udp_getsockname.return_value = ("192.168.1.3", 4321) await connect.start() mock_udp_send.assert_called_with(exp_knxipframe) # Response KNX/IP-Frame with wrong type wrong_knxipframe = KNXIPFrame.init_from_body(ConnectionStateRequest()) with patch("logging.Logger.warning") as mock_warning: connect.response_rec_callback(wrong_knxipframe, HPAI(), None) mock_warning.assert_called_with("Could not understand knxipframe") # Response KNX/IP-Frame with error: err_knxipframe = KNXIPFrame.init_from_body( ConnectResponse(status_code=ErrorCode.E_CONNECTION_ID) ) with patch("logging.Logger.debug") as mock_warning: connect.response_rec_callback(err_knxipframe, HPAI(), None) mock_warning.assert_called_with( "Error: KNX bus responded to request of type '%s' with error in '%s': %s", type(connect).__name__, type(err_knxipframe.body).__name__, ErrorCode.E_CONNECTION_ID, ) # Correct Response KNX/IP-Frame: res_knxipframe = KNXIPFrame.init_from_body( ConnectResponse( communication_channel=23, crd=ConnectResponseData(individual_address=IndividualAddress(7)), ) ) connect.response_rec_callback(res_knxipframe, HPAI(), None) assert connect.success assert connect.communication_channel == 23 assert connect.crd.individual_address.raw == 7 xknx-3.6.0/test/io_tests/request_response_tests/connectionstate_test.py000066400000000000000000000121561475530762600270050ustar00rootroot00000000000000"""Unit test for KNX/IP ConnectionState Request/Response.""" from unittest.mock import patch from xknx.io.request_response import ConnectionState from xknx.io.transport import UDPTransport from xknx.knxip import ( HPAI, ConnectionStateRequest, ConnectionStateResponse, ErrorCode, KNXIPFrame, ) class TestConnectionState: """Test class for xknx/io/ConnectionState objects.""" async def test_connectionstate(self) -> None: """Test connectionstateing from KNX bus.""" communication_channel_id = 23 udp_transport = UDPTransport(("192.168.1.1", 0), ("192.168.1.2", 1234)) local_hpai = HPAI(ip_addr="192.168.1.3", port=4321) connectionstate = ConnectionState( udp_transport, communication_channel_id, local_hpai=local_hpai ) connectionstate.timeout_in_seconds = 0 assert connectionstate.awaited_response_class == ConnectionStateResponse assert connectionstate.communication_channel_id == communication_channel_id # Expected KNX/IP-Frame: exp_knxipframe = KNXIPFrame.init_from_body( ConnectionStateRequest( communication_channel_id=communication_channel_id, control_endpoint=local_hpai, ) ) with ( patch("xknx.io.transport.UDPTransport.send") as mock_udp_send, patch("xknx.io.transport.UDPTransport.getsockname") as mock_udp_getsockname, ): mock_udp_getsockname.return_value = ("192.168.1.3", 4321) await connectionstate.start() mock_udp_send.assert_called_with(exp_knxipframe) # Response KNX/IP-Frame with wrong type wrong_knxipframe = KNXIPFrame.init_from_body(ConnectionStateRequest()) with patch("logging.Logger.warning") as mock_warning: connectionstate.response_rec_callback(wrong_knxipframe, HPAI(), None) mock_warning.assert_called_with("Could not understand knxipframe") # Response KNX/IP-Frame with error: err_knxipframe = KNXIPFrame.init_from_body( ConnectionStateResponse(status_code=ErrorCode.E_CONNECTION_ID) ) with patch("logging.Logger.debug") as mock_warning: connectionstate.response_rec_callback(err_knxipframe, HPAI(), None) mock_warning.assert_called_with( "Error: KNX bus responded to request of type '%s' with error in '%s': %s", type(connectionstate).__name__, type(err_knxipframe.body).__name__, ErrorCode.E_CONNECTION_ID, ) # Correct Response KNX/IP-Frame: res_knxipframe = KNXIPFrame.init_from_body(ConnectionStateResponse()) connectionstate.response_rec_callback(res_knxipframe, HPAI(), None) assert connectionstate.success async def test_connectionstate_route_back_true(self) -> None: """Test connectionstateing from KNX bus.""" communication_channel_id = 23 udp_transport = UDPTransport(("192.168.1.1", 0), ("192.168.1.2", 1234)) local_hpai = HPAI() connectionstate = ConnectionState( udp_transport, communication_channel_id, local_hpai=local_hpai ) connectionstate.timeout_in_seconds = 0 assert connectionstate.awaited_response_class == ConnectionStateResponse assert connectionstate.communication_channel_id == communication_channel_id # Expected KNX/IP-Frame: exp_knxipframe = KNXIPFrame.init_from_body( ConnectionStateRequest( communication_channel_id=communication_channel_id, ) ) with ( patch("xknx.io.transport.UDPTransport.send") as mock_udp_send, patch("xknx.io.transport.UDPTransport.getsockname") as mock_udp_getsockname, ): mock_udp_getsockname.return_value = ("192.168.1.3", 4321) await connectionstate.start() mock_udp_send.assert_called_with(exp_knxipframe) # Response KNX/IP-Frame with wrong type wrong_knxipframe = KNXIPFrame.init_from_body(ConnectionStateRequest()) with patch("logging.Logger.warning") as mock_warning: connectionstate.response_rec_callback(wrong_knxipframe, HPAI(), None) mock_warning.assert_called_with("Could not understand knxipframe") # Response KNX/IP-Frame with error: err_knxipframe = KNXIPFrame.init_from_body( ConnectionStateResponse(status_code=ErrorCode.E_CONNECTION_ID) ) with patch("logging.Logger.debug") as mock_warning: connectionstate.response_rec_callback(err_knxipframe, HPAI(), None) mock_warning.assert_called_with( "Error: KNX bus responded to request of type '%s' with error in '%s': %s", type(connectionstate).__name__, type(err_knxipframe.body).__name__, ErrorCode.E_CONNECTION_ID, ) # Correct Response KNX/IP-Frame: res_knxipframe = KNXIPFrame.init_from_body(ConnectionStateResponse()) connectionstate.response_rec_callback(res_knxipframe, HPAI(), None) assert connectionstate.success xknx-3.6.0/test/io_tests/request_response_tests/device_configuration_test.py000066400000000000000000000067011475530762600277720ustar00rootroot00000000000000"""Unit test for KNX/IP DeviceConfiguration Request/Response.""" from unittest.mock import patch from xknx.cemi import CEMIFrame, CEMIMPropInfo, CEMIMPropReadRequest from xknx.cemi.const import CEMIMessageCode from xknx.io.request_response import DeviceConfiguration from xknx.io.transport import UDPTransport from xknx.knxip import ( HPAI, ConnectionStateRequest, DeviceConfigurationAck, DeviceConfigurationRequest, ErrorCode, KNXIPFrame, ) from xknx.profile.const import ResourceKNXNETIPPropertyId, ResourceObjectType class TestDeviceConfiguration: """Test class for xknx/io/DeviceConfiguration objects.""" async def test_device_configuration(self) -> None: """Test device_configuration from KNX bus.""" data_endpoint = ("192.168.1.2", 4567) udp_transport = UDPTransport(("192.168.1.1", 0), ("192.168.1.2", 1234)) raw_cemi = CEMIFrame( code=CEMIMessageCode.M_PROP_READ_REQ, data=CEMIMPropReadRequest( property_info=CEMIMPropInfo( object_type=ResourceObjectType.OBJECT_KNXNETIP_PARAMETER, object_instance=1, property_id=ResourceKNXNETIPPropertyId.PID_KNX_INDIVIDUAL_ADDRESS, start_index=1, number_of_elements=1, ) ), ).to_knx() device_configuration_request = DeviceConfigurationRequest( communication_channel_id=23, sequence_counter=42, raw_cemi=raw_cemi, ) device_configuration = DeviceConfiguration( udp_transport, data_endpoint, device_configuration_request ) device_configuration.timeout_in_seconds = 0 assert device_configuration.awaited_response_class == DeviceConfigurationAck exp_knxipframe = KNXIPFrame.init_from_body(device_configuration_request) with ( patch("xknx.io.transport.UDPTransport.send") as mock_udp_send, patch("xknx.io.transport.UDPTransport.getsockname") as mock_udp_getsockname, ): mock_udp_getsockname.return_value = ("192.168.1.3", 4321) await device_configuration.start() mock_udp_send.assert_called_with(exp_knxipframe, addr=data_endpoint) # Response KNX/IP-Frame with wrong type wrong_knxipframe = KNXIPFrame.init_from_body(ConnectionStateRequest()) with patch("logging.Logger.warning") as mock_warning: device_configuration.response_rec_callback(wrong_knxipframe, HPAI(), None) mock_warning.assert_called_with("Could not understand knxipframe") # Response KNX/IP-Frame with error: err_knxipframe = KNXIPFrame.init_from_body( DeviceConfigurationAck(status_code=ErrorCode.E_CONNECTION_ID) ) with patch("logging.Logger.debug") as mock_warning: device_configuration.response_rec_callback(err_knxipframe, HPAI(), None) mock_warning.assert_called_with( "Error: KNX bus responded to request of type '%s' with error in '%s': %s", type(device_configuration).__name__, type(err_knxipframe.body).__name__, ErrorCode.E_CONNECTION_ID, ) # Correct Response KNX/IP-Frame: res_knxipframe = KNXIPFrame.init_from_body(DeviceConfigurationAck()) device_configuration.response_rec_callback(res_knxipframe, HPAI(), None) assert device_configuration.success xknx-3.6.0/test/io_tests/request_response_tests/disconnect_test.py000066400000000000000000000116341475530762600257360ustar00rootroot00000000000000"""Unit test for KNX/IP Disconnect Request/Response.""" from unittest.mock import patch from xknx.io.request_response import Disconnect from xknx.io.transport import UDPTransport from xknx.knxip import ( HPAI, DisconnectRequest, DisconnectResponse, ErrorCode, KNXIPFrame, ) class TestDisconnect: """Test class for xknx/io/Disconnect objects.""" async def test_disconnect(self) -> None: """Test disconnecting from KNX bus.""" communication_channel_id = 23 udp_transport = UDPTransport(("192.168.1.1", 0), ("192.168.1.2", 1234)) local_hpai = HPAI(ip_addr="192.168.1.3", port=4321) disconnect = Disconnect( udp_transport, communication_channel_id, local_hpai=local_hpai ) disconnect.timeout_in_seconds = 0 assert disconnect.awaited_response_class == DisconnectResponse assert disconnect.communication_channel_id == communication_channel_id # Expected KNX/IP-Frame: exp_knxipframe = KNXIPFrame.init_from_body( DisconnectRequest( communication_channel_id=communication_channel_id, control_endpoint=local_hpai, ) ) with ( patch("xknx.io.transport.UDPTransport.send") as mock_udp_send, patch("xknx.io.transport.UDPTransport.getsockname") as mock_udp_getsockname, ): mock_udp_getsockname.return_value = ("192.168.1.3", 4321) await disconnect.start() mock_udp_send.assert_called_with(exp_knxipframe) # Response KNX/IP-Frame with wrong type wrong_knxipframe = KNXIPFrame.init_from_body(DisconnectRequest()) with patch("logging.Logger.warning") as mock_warning: disconnect.response_rec_callback(wrong_knxipframe, HPAI(), None) mock_warning.assert_called_with("Could not understand knxipframe") # Response KNX/IP-Frame with error: err_knxipframe = KNXIPFrame.init_from_body( DisconnectResponse(status_code=ErrorCode.E_CONNECTION_ID) ) with patch("logging.Logger.debug") as mock_warning: disconnect.response_rec_callback(err_knxipframe, HPAI(), None) mock_warning.assert_called_with( "Error: KNX bus responded to request of type '%s' with error in '%s': %s", type(disconnect).__name__, type(err_knxipframe.body).__name__, ErrorCode.E_CONNECTION_ID, ) # Correct Response KNX/IP-Frame: res_knxipframe = KNXIPFrame.init_from_body(DisconnectResponse()) disconnect.response_rec_callback(res_knxipframe, HPAI(), None) assert disconnect.success async def test_disconnect_route_back_true(self) -> None: """Test disconnecting from KNX bus.""" communication_channel_id = 23 udp_transport = UDPTransport(("192.168.1.1", 0), ("192.168.1.2", 1234)) local_hpai = HPAI() disconnect = Disconnect( udp_transport, communication_channel_id, local_hpai=local_hpai ) disconnect.timeout_in_seconds = 0 assert disconnect.awaited_response_class == DisconnectResponse assert disconnect.communication_channel_id == communication_channel_id # Expected KNX/IP-Frame: exp_knxipframe = KNXIPFrame.init_from_body( DisconnectRequest( communication_channel_id=communication_channel_id, ) ) with ( patch("xknx.io.transport.UDPTransport.send") as mock_udp_send, patch("xknx.io.transport.UDPTransport.getsockname") as mock_udp_getsockname, ): mock_udp_getsockname.return_value = ("192.168.1.3", 4321) await disconnect.start() mock_udp_send.assert_called_with(exp_knxipframe) # Response KNX/IP-Frame with wrong type wrong_knxipframe = KNXIPFrame.init_from_body(DisconnectRequest()) with patch("logging.Logger.warning") as mock_warning: disconnect.response_rec_callback(wrong_knxipframe, HPAI(), None) mock_warning.assert_called_with("Could not understand knxipframe") # Response KNX/IP-Frame with error: err_knxipframe = KNXIPFrame.init_from_body( DisconnectResponse(status_code=ErrorCode.E_CONNECTION_ID) ) with patch("logging.Logger.debug") as mock_warning: disconnect.response_rec_callback(err_knxipframe, HPAI(), None) mock_warning.assert_called_with( "Error: KNX bus responded to request of type '%s' with error in '%s': %s", type(disconnect).__name__, type(err_knxipframe.body).__name__, ErrorCode.E_CONNECTION_ID, ) # Correct Response KNX/IP-Frame: res_knxipframe = KNXIPFrame.init_from_body(DisconnectResponse()) disconnect.response_rec_callback(res_knxipframe, HPAI(), None) assert disconnect.success xknx-3.6.0/test/io_tests/request_response_tests/request_response_test.py000066400000000000000000000046541475530762600272170ustar00rootroot00000000000000"""Unit test for KNX/IP Disconnect Request/Response.""" import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest from xknx.io.request_response import RequestResponse from xknx.io.transport import UDPTransport from xknx.knxip import DisconnectResponse, KNXIPBody class TestConnectResponse: """Test class for xknx/io/Disconnect objects.""" async def test_create_knxipframe_err(self) -> None: """Test if create_knxipframe of base class raises an exception.""" udp_transport = UDPTransport(("192.168.1.1", 0), ("192.168.1.2", 1234)) request_response = RequestResponse(udp_transport, DisconnectResponse) request_response.timeout_in_seconds = 0 with pytest.raises(NotImplementedError): await request_response.start() @patch("logging.Logger.debug") @patch( "xknx.io.request_response.RequestResponse.send_request", new_callable=AsyncMock ) async def test_request_response_timeout( self, _send_request_mock: MagicMock, logger_debug_mock: AsyncMock ) -> None: """Test RequestResponse: timeout. No callback shall be left.""" udp_transport = UDPTransport(("192.168.1.1", 0), ("192.168.1.2", 1234)) requ_resp = RequestResponse(udp_transport, KNXIPBody) requ_resp.response_received_event.wait = MagicMock( side_effect=asyncio.TimeoutError() ) await requ_resp.start() # Debug message was logged logger_debug_mock.assert_called_once_with( "Error: KNX bus did not respond in time (%s secs) to request of type '%s'", 1.0, "RequestResponse", ) # Callback was removed again assert not udp_transport.callbacks @patch( "xknx.io.request_response.RequestResponse.send_request", new_callable=AsyncMock ) async def test_request_response_cancelled( self, _send_request_mock: AsyncMock ) -> None: """Test RequestResponse: task cancelled. No callback shall be left.""" udp_transport = UDPTransport(("192.168.1.1", 0), ("192.168.1.2", 1234)) requ_resp = RequestResponse(udp_transport, KNXIPBody) requ_resp.response_received_event.wait = MagicMock( side_effect=asyncio.CancelledError() ) with pytest.raises(asyncio.CancelledError): await requ_resp.start() # Callback was removed again assert not udp_transport.callbacks xknx-3.6.0/test/io_tests/request_response_tests/session_test.py000066400000000000000000000031161475530762600252640ustar00rootroot00000000000000"""Unit test for KNX/IP Session Request/Response.""" from unittest.mock import Mock, patch from xknx.io.request_response import Session from xknx.knxip import HPAI, KNXIPFrame, SessionRequest, SessionResponse, SessionStatus class TestSession: """Test class for xknx/io/Session objects.""" async def test_session(self) -> None: """Test authenticating to secure KNX device.""" transport_mock = Mock() ecdh_public_key = bytes(16) session = Session( transport_mock, ecdh_client_public_key=ecdh_public_key, ) session.timeout_in_seconds = 0 assert session.awaited_response_class == SessionResponse # Expected KNX/IP-Frame: exp_knxipframe = KNXIPFrame.init_from_body( SessionRequest(ecdh_client_public_key=ecdh_public_key) ) await session.start() transport_mock.send.assert_called_with(exp_knxipframe) # Response KNX/IP-Frame with wrong type wrong_knxipframe = KNXIPFrame.init_from_body(SessionStatus()) with patch("logging.Logger.warning") as mock_warning: session.response_rec_callback(wrong_knxipframe, HPAI(), None) mock_warning.assert_called_with("Could not understand knxipframe") assert session.success is False # Correct Response KNX/IP-Frame: res_knxipframe = KNXIPFrame.init_from_body(SessionResponse(secure_session_id=5)) session.response_rec_callback(res_knxipframe, HPAI(), None) assert session.success is True assert session.response.secure_session_id == 5 xknx-3.6.0/test/io_tests/request_response_tests/tunnelling_test.py000066400000000000000000000060251475530762600257620ustar00rootroot00000000000000"""Unit test for KNX/IP Tunnelling Request/Response.""" from unittest.mock import patch from xknx.cemi import CEMIFrame, CEMILData, CEMIMessageCode from xknx.dpt import DPTArray from xknx.io.request_response import Tunnelling from xknx.io.transport import UDPTransport from xknx.knxip import ( HPAI, ConnectionStateRequest, ErrorCode, KNXIPFrame, TunnellingAck, TunnellingRequest, ) from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueWrite class TestTunnelling: """Test class for xknx/io/Tunnelling objects.""" async def test_tunnelling(self) -> None: """Test tunnelling from KNX bus.""" data_endpoint = ("192.168.1.2", 4567) udp_transport = UDPTransport(("192.168.1.1", 0), ("192.168.1.2", 1234)) raw_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=CEMILData.init_from_telegram( Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x1, 0x2, 0x3))), ) ), ).to_knx() tunnelling_request = TunnellingRequest( communication_channel_id=23, sequence_counter=42, raw_cemi=raw_cemi, ) tunnelling = Tunnelling(udp_transport, data_endpoint, tunnelling_request) tunnelling.timeout_in_seconds = 0 assert tunnelling.awaited_response_class == TunnellingAck exp_knxipframe = KNXIPFrame.init_from_body(tunnelling_request) with ( patch("xknx.io.transport.UDPTransport.send") as mock_udp_send, patch("xknx.io.transport.UDPTransport.getsockname") as mock_udp_getsockname, ): mock_udp_getsockname.return_value = ("192.168.1.3", 4321) await tunnelling.start() mock_udp_send.assert_called_with(exp_knxipframe, addr=data_endpoint) # Response KNX/IP-Frame with wrong type wrong_knxipframe = KNXIPFrame.init_from_body(ConnectionStateRequest()) with patch("logging.Logger.warning") as mock_warning: tunnelling.response_rec_callback(wrong_knxipframe, HPAI(), None) mock_warning.assert_called_with("Could not understand knxipframe") # Response KNX/IP-Frame with error: err_knxipframe = KNXIPFrame.init_from_body( TunnellingAck(status_code=ErrorCode.E_CONNECTION_ID) ) with patch("logging.Logger.debug") as mock_warning: tunnelling.response_rec_callback(err_knxipframe, HPAI(), None) mock_warning.assert_called_with( "Error: KNX bus responded to request of type '%s' with error in '%s': %s", type(tunnelling).__name__, type(err_knxipframe.body).__name__, ErrorCode.E_CONNECTION_ID, ) # Correct Response KNX/IP-Frame: res_knxipframe = KNXIPFrame.init_from_body(TunnellingAck()) tunnelling.response_rec_callback(res_knxipframe, HPAI(), None) assert tunnelling.success xknx-3.6.0/test/io_tests/resources/000077500000000000000000000000001475530762600173515ustar00rootroot00000000000000xknx-3.6.0/test/io_tests/resources/testcase.knxkeys000066400000000000000000000025641475530762600226110ustar00rootroot00000000000000 xknx-3.6.0/test/io_tests/routing_test.py000066400000000000000000000154331475530762600204450ustar00rootroot00000000000000"""Tests for KNX/IP routing connections.""" import asyncio from unittest.mock import AsyncMock, Mock, call, patch from xknx import XKNX from xknx.cemi import CEMIFrame, CEMILData, CEMIMessageCode from xknx.io import Routing from xknx.io.routing import ( BUSY_DECREMENT_TIME, BUSY_INCREMENT_COOLDOWN, BUSY_SLOWDURATION_TIME_FACTOR, ROUTING_INDICATION_WAIT_TIME, _RoutingFlowControl, ) from xknx.knxip import KNXIPFrame, RoutingBusy, RoutingIndication from xknx.telegram import IndividualAddress, Telegram, TelegramDirection, tpci from ..conftest import EventLoopClockAdvancer class TestRouting: """Test class for xknx/io/Routing objects.""" def setup_method(self) -> None: """Set up test class.""" # pylint: disable=attribute-defined-outside-init self.xknx = XKNX() self.cemi_received_mock = AsyncMock() @patch("xknx.io.Routing._send_knxipframe") async def test_request_received_callback(self, send_knxipframe_mock: Mock) -> None: """Test Routing for responding to a transport layer connection.""" routing = Routing( self.xknx, individual_address=None, cemi_received_callback=self.xknx.knxip_interface.cemi_received, local_ip="192.168.1.1", ) self.xknx.knxip_interface._interface = routing # set current address so management telegram is processed self.xknx.current_address = IndividualAddress("1.0.255") # L_Data.ind T_Connect from 1.0.250 to 1.0.255 (xknx tunnel endpoint) # communication_channel_id: 0x02 sequence_counter: 0x81 raw_ind = bytes.fromhex("0610 0530 0010 2900b06010fa10ff0080") _cemi = CEMIFrame.from_knx(raw_ind[6:]) test_telegram = _cemi.data.telegram() test_telegram.direction = TelegramDirection.INCOMING response_telegram = Telegram( destination_address=IndividualAddress(test_telegram.source_address), tpci=tpci.TDisconnect(), ) response_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=CEMILData.init_from_telegram( telegram=response_telegram, src_addr=IndividualAddress("1.0.255"), ), ) response_frame = KNXIPFrame.init_from_body( RoutingIndication(raw_cemi=response_cemi.to_knx()) ) routing.transport.data_received_callback(raw_ind, ("192.168.1.2", 3671)) await asyncio.sleep(0) assert send_knxipframe_mock.call_args_list == [ call(response_frame), ] await asyncio.sleep(0) # await local L_Data.con @patch("logging.Logger.warning") async def test_routing_lost_message(self, logging_mock: Mock) -> None: """Test class for received RoutingLostMessage frames.""" routing = Routing( self.xknx, individual_address=None, cemi_received_callback=AsyncMock(), local_ip="192.168.1.1", ) raw = bytes((0x06, 0x10, 0x05, 0x31, 0x00, 0x0A, 0x04, 0x00, 0x00, 0x05)) routing.transport.data_received_callback(raw, ("192.168.1.2", 3671)) logging_mock.assert_called_once_with( "RoutingLostMessage received from %s - %s lost messages.", "192.168.1.2", 5, ) class TestFlowControl: """Test class for KNXnet/IP routing flow control.""" async def test_basic_throttling(self, time_travel: EventLoopClockAdvancer) -> None: """Test throttling outgoing frames.""" flow_control = _RoutingFlowControl() mock = Mock() async def test_send() -> None: async with flow_control.throttle(): mock() # first send is called immediately task = asyncio.create_task(test_send()) await asyncio.sleep(0) assert mock.call_count == 1 assert task.done() mock.reset_mock() # second send is throttled task = asyncio.create_task(test_send()) await asyncio.sleep(0) assert mock.call_count == 0 await time_travel(ROUTING_INDICATION_WAIT_TIME / 4) assert not task.done() await time_travel(ROUTING_INDICATION_WAIT_TIME / 4 * 3) assert task.done() assert mock.call_count == 1 mock.reset_mock() # later send is called immediately await time_travel(ROUTING_INDICATION_WAIT_TIME) task = asyncio.create_task(test_send()) await asyncio.sleep(0) assert mock.call_count == 1 assert task.done() mock.reset_mock() @patch("random.random") async def test_routing_busy( self, random_mock: Mock, time_travel: EventLoopClockAdvancer ) -> None: """Test throttling on received RoutingBusy frame.""" flow_control = _RoutingFlowControl() mock = Mock() test_wait_time_ms = 100 random_mock.return_value = 0.5 async def test_send() -> None: async with flow_control.throttle(): mock() test_busy = RoutingBusy(wait_time=test_wait_time_ms) flow_control.handle_routing_busy(test_busy) task = asyncio.create_task(test_send()) await asyncio.sleep(0) assert mock.call_count == 0 await time_travel(test_wait_time_ms / 1000) assert mock.call_count == 1 assert task.done() # no slowduration for just 1 RoutingBusy assert flow_control._timer_task.done() mock.reset_mock() # multiple RoutingBusy frames flow_control.handle_routing_busy(test_busy) # after cooldown - with different wait times updating wait time for 2x time # not counting one frame due to cooldown time await time_travel(BUSY_INCREMENT_COOLDOWN) flow_control.handle_routing_busy(RoutingBusy(wait_time=test_wait_time_ms // 2)) flow_control.handle_routing_busy(RoutingBusy(wait_time=test_wait_time_ms * 2)) assert flow_control._received_busy_frames == 1 # add second busy frame after cooldown has passed await time_travel(BUSY_INCREMENT_COOLDOWN) flow_control.handle_routing_busy(RoutingBusy(wait_time=test_wait_time_ms // 2)) assert flow_control._received_busy_frames == 2 task = asyncio.create_task(test_send()) assert mock.call_count == 0 await time_travel(test_wait_time_ms * 2 / 1000 + 2 * 0.5) # add random time assert mock.call_count == 1 assert task.done() # slowduration assert not flow_control._timer_task.done() await time_travel(2 * BUSY_SLOWDURATION_TIME_FACTOR) # _received_busy_frames 2 await time_travel(BUSY_DECREMENT_TIME) # and decrement time assert not flow_control._timer_task.done() await time_travel(BUSY_DECREMENT_TIME) # and second decrement time assert flow_control._timer_task.done() xknx-3.6.0/test/io_tests/secure_group_test.py000066400000000000000000000542641475530762600214650ustar00rootroot00000000000000"""Test Secure Group.""" import asyncio from unittest.mock import Mock, patch import pytest from xknx.cemi import CEMIFrame, CEMILData, CEMIMessageCode from xknx.exceptions import IPSecureError from xknx.io.const import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT, XKNX_SERIAL_NUMBER from xknx.io.ip_secure import SecureGroup from xknx.knxip import HPAI, KNXIPFrame, RoutingIndication, SecureWrapper, TimerNotify from xknx.telegram import GroupAddress, Telegram, apci from ..conftest import EventLoopClockAdvancer ONE_HOUR_MS = 60 * 60 * 1000 @patch("xknx.io.transport.udp_transport.UDPTransport.connect") @patch("xknx.io.transport.udp_transport.UDPTransport.send") class TestSecureGroup: """Test class for xknx/io/SecureGroup objects.""" mock_addr = ("127.0.0.1", 12345) mock_backbone_key = bytes.fromhex( "0a a2 27 b4 fd 7a 32 31 9b a9 96 0a c0 36 ce 0e" "5c 45 07 b5 ae 55 16 1f 10 78 b1 dc fb 3c b6 31" ) def assert_timer_notify( self, knxipframe: KNXIPFrame, timer_value: int | None = None, serial_number: bytes | None = None, message_tag: bytes | None = None, mac: bytes | None = None, ) -> bytes: """Assert that knxipframe is a TimerNotify.""" assert isinstance(knxipframe.body, TimerNotify) if timer_value is not None: assert knxipframe.body.timer_value == timer_value if serial_number is not None: assert knxipframe.body.serial_number == serial_number if message_tag is not None: assert knxipframe.body.message_tag == message_tag if mac is not None: assert knxipframe.body.message_authentication_code == mac return knxipframe.body.message_tag async def test_no_synchronize( self, mock_super_send: Mock, mock_super_connect: Mock, time_travel: EventLoopClockAdvancer, ) -> None: """Test synchronisazion not answered.""" secure_group = SecureGroup( local_addr=self.mock_addr, remote_addr=(DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT), backbone_key=self.mock_backbone_key, latency_ms=1000, ) secure_timer = secure_group.secure_timer connect_task = asyncio.create_task(secure_group.connect()) await time_travel(0) mock_super_connect.assert_called_once() self.assert_timer_notify( mock_super_send.call_args[0][0], serial_number=XKNX_SERIAL_NUMBER, ) mock_super_send.reset_mock() # synchronize timed out await time_travel( secure_timer.max_delay_time_follower_update_notify + 2 * secure_timer.latency_tolerance_ms / 1000 ) assert connect_task.done() # we are timekeeper so we send TimerNotify after time_keeper_periodic assert secure_timer.timekeeper assert not secure_timer.sched_update await time_travel(secure_timer.min_delay_time_keeper_periodic_notify - 0.01) mock_super_send.assert_not_called() await time_travel(secure_timer.sync_latency_tolerance_ms / 1000 * 3 + 0.02) self.assert_timer_notify( mock_super_send.call_args[0][0], serial_number=XKNX_SERIAL_NUMBER, ) mock_super_send.reset_mock() # test response to invalid timer as timekeeper timer_invalid = KNXIPFrame.init_from_body( TimerNotify( timer_value=0, serial_number=bytes.fromhex("00 fa 12 34 56 78"), message_tag=bytes.fromhex("12 34"), message_authentication_code=bytes.fromhex( "3195051bb981941d57e6c5b55355f341" ), ) ) secure_group.handle_knxipframe(timer_invalid, HPAI(*self.mock_addr)) assert secure_timer.timekeeper assert secure_timer.sched_update await time_travel(secure_timer.min_delay_time_keeper_update_notify - 0.01) mock_super_send.assert_not_called() await time_travel(secure_timer.sync_latency_tolerance_ms / 1000 * 1 + 0.02) self.assert_timer_notify( mock_super_send.call_args[0][0], message_tag=bytes.fromhex("12 34"), serial_number=bytes.fromhex("00 fa 12 34 56 78"), ) # stop secure_group.stop() assert secure_timer._notify_timer_handle is None async def test_synchronize( self, mock_super_send: Mock, mock_super_connect: Mock, time_travel: EventLoopClockAdvancer, ) -> None: """Test synchronisazion.""" secure_group = SecureGroup( local_addr=self.mock_addr, remote_addr=(DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT), backbone_key=self.mock_backbone_key, latency_ms=1000, ) secure_timer = secure_group.secure_timer connect_task = asyncio.create_task(secure_group.connect()) await time_travel(0) mock_super_connect.assert_called_once() _message_tag = self.assert_timer_notify( mock_super_send.call_args[0][0], serial_number=XKNX_SERIAL_NUMBER, ) mock_super_send.reset_mock() # incoming timer_update = KNXIPFrame.init_from_body( TimerNotify( timer_value=ONE_HOUR_MS, serial_number=XKNX_SERIAL_NUMBER, message_tag=_message_tag, ) ) with patch("xknx.io.ip_secure.SecureSequenceTimer.verify_timer_notify_mac"): # TimerNotify MAC is random so we don't verify it in tests secure_group.handle_knxipframe(timer_update, HPAI(*self.mock_addr)) await time_travel(0) _leeway_for_ci = 50 # ms assert ( ONE_HOUR_MS <= secure_timer.current_timer_value() <= ONE_HOUR_MS + _leeway_for_ci ) assert connect_task.done() # nothing sent until time_follower_periodic assert not secure_timer.timekeeper await time_travel(secure_timer.min_delay_time_follower_periodic_notify - 0.01) mock_super_send.assert_not_called() await time_travel(secure_timer.sync_latency_tolerance_ms / 1000 * 10 + 0.02) self.assert_timer_notify( mock_super_send.call_args[0][0], serial_number=XKNX_SERIAL_NUMBER, ) mock_super_send.reset_mock() secure_group.stop() assert secure_timer._notify_timer_handle is None @patch("xknx.io.ip_secure.SecureSequenceTimer._notify_timer_expired") @patch("xknx.io.ip_secure.SecureSequenceTimer._monotonic_ms") async def test_received_timer_notify( self, mock_monotonic_ms: Mock, _mock_notify_timer_expired: Mock, # we don't want to actually send here mock_super_send: Mock, mock_super_connect: Mock, time_travel: EventLoopClockAdvancer, ) -> None: """Test handling of received TimerNotify frames.""" mock_monotonic_ms.return_value = 0 secure_group = SecureGroup( local_addr=self.mock_addr, remote_addr=(DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT), backbone_key=self.mock_backbone_key, latency_ms=1000, ) secure_timer = secure_group.secure_timer with patch.object( secure_timer, "reschedule", wraps=secure_timer.reschedule ) as mock_reschedule: # TimerNotify with invalid MAC shall be discarded timer_invalid_mac = KNXIPFrame.init_from_body( TimerNotify( timer_value=ONE_HOUR_MS, serial_number=bytes.fromhex("00 fa 12 34 56 78"), message_tag=bytes.fromhex("12 34"), ) ) secure_group.handle_knxipframe(timer_invalid_mac, HPAI(*self.mock_addr)) mock_reschedule.assert_not_called() # E1 timer_newer = KNXIPFrame.init_from_body( TimerNotify( timer_value=ONE_HOUR_MS, serial_number=bytes.fromhex("00 fa 12 34 56 78"), message_tag=bytes.fromhex("12 34"), message_authentication_code=bytes.fromhex( "d2fad5657a5788a36cdd8a3ef84c90ab" ), ) ) secure_group.handle_knxipframe(timer_newer, HPAI(*self.mock_addr)) mock_reschedule.assert_called_once() assert secure_timer.current_timer_value() == ONE_HOUR_MS assert not secure_timer.timekeeper assert not secure_timer.sched_update mock_reschedule.reset_mock() # E2 timer_exact = KNXIPFrame.init_from_body( TimerNotify( timer_value=ONE_HOUR_MS, serial_number=bytes.fromhex("00 fa 12 34 56 78"), message_tag=bytes.fromhex("12 34"), message_authentication_code=bytes.fromhex( "d2fad5657a5788a36cdd8a3ef84c90ab" ), ) ) secure_group.handle_knxipframe(timer_exact, HPAI(*self.mock_addr)) mock_reschedule.assert_called_once() assert secure_timer.current_timer_value() == ONE_HOUR_MS assert not secure_timer.timekeeper assert not secure_timer.sched_update mock_reschedule.reset_mock() timer_valid = KNXIPFrame.init_from_body( TimerNotify( timer_value=ONE_HOUR_MS - secure_timer.sync_latency_tolerance_ms + 1, serial_number=bytes.fromhex("00 fa 12 34 56 78"), message_tag=bytes.fromhex("12 34"), message_authentication_code=bytes.fromhex( "7572b70b4f986d7ae68891a00c0d46d3" ), ) ) secure_group.handle_knxipframe(timer_valid, HPAI(*self.mock_addr)) mock_reschedule.assert_called_once() assert secure_timer.current_timer_value() == ONE_HOUR_MS assert not secure_timer.timekeeper assert not secure_timer.sched_update mock_reschedule.reset_mock() # E3 timer_tolerable_1 = KNXIPFrame.init_from_body( TimerNotify( timer_value=ONE_HOUR_MS - secure_timer.sync_latency_tolerance_ms, serial_number=bytes.fromhex("00 fa 12 34 56 78"), message_tag=bytes.fromhex("12 34"), message_authentication_code=bytes.fromhex( "3d70cc44607ad9b4a4425de95101a54c" ), ) ) secure_group.handle_knxipframe(timer_tolerable_1, HPAI(*self.mock_addr)) mock_reschedule.assert_not_called() timer_tolerable_2 = KNXIPFrame.init_from_body( TimerNotify( timer_value=ONE_HOUR_MS - secure_timer.latency_tolerance_ms + 1, serial_number=bytes.fromhex("00 fa 12 34 56 78"), message_tag=bytes.fromhex("12 34"), message_authentication_code=bytes.fromhex( "66b8b2e52196bcce3dce2da7e0dc50ec" ), ) ) secure_group.handle_knxipframe(timer_tolerable_2, HPAI(*self.mock_addr)) mock_reschedule.assert_not_called() # E4 timer_invalid = KNXIPFrame.init_from_body( TimerNotify( timer_value=ONE_HOUR_MS - secure_timer.latency_tolerance_ms, serial_number=bytes.fromhex("00 fa 12 34 56 78"), message_tag=bytes.fromhex("12 34"), message_authentication_code=bytes.fromhex( "873d9a5a25b8508446bbd5ff1bb4b465" ), ) ) secure_group.handle_knxipframe(timer_invalid, HPAI(*self.mock_addr)) mock_reschedule.assert_called_once_with( update=(bytes.fromhex("12 34"), bytes.fromhex("00 fa 12 34 56 78")) ) assert not secure_timer.timekeeper assert secure_timer.sched_update mock_reschedule.reset_mock() # E4 from sched_update secure_group.handle_knxipframe(timer_invalid, HPAI(*self.mock_addr)) mock_reschedule.assert_not_called() @patch("xknx.io.ip_secure.SecureSequenceTimer._notify_timer_expired") @patch("xknx.io.ip_secure.SecureSequenceTimer._monotonic_ms") @patch("xknx.io.transport.udp_transport.UDPTransport.handle_knxipframe") async def test_received_secure_wrapper( self, mock_super_handle_knxipframe: Mock, mock_monotonic_ms: Mock, _mock_notify_timer_expired: Mock, # we don't want to actually send here mock_super_send: Mock, mock_super_connect: Mock, time_travel: EventLoopClockAdvancer, ) -> None: """Test handling of received SecureWrapper frames.""" mock_monotonic_ms.return_value = 0 secure_group = SecureGroup( local_addr=self.mock_addr, remote_addr=(DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT), backbone_key=self.mock_backbone_key, latency_ms=1000, ) secure_timer = secure_group.secure_timer secure_timer.timer_authenticated = True with ( patch.object( secure_timer, "reschedule", wraps=secure_timer.reschedule ) as mock_reschedule, patch.object(secure_group, "decrypt_frame") as mock_decrypt, ): mock_decrypt.return_value = True # we are only interested in timer value # E5 wrapper_newer = KNXIPFrame.init_from_body( SecureWrapper( sequence_information=ONE_HOUR_MS.to_bytes(6, "big"), ) ) secure_group.handle_knxipframe(wrapper_newer, HPAI(*self.mock_addr)) mock_super_handle_knxipframe.assert_called_once() mock_super_handle_knxipframe.reset_mock() mock_reschedule.assert_called_once() assert secure_timer.current_timer_value() == ONE_HOUR_MS assert not secure_timer.sched_update mock_reschedule.reset_mock() # E6 wrapper_exact = KNXIPFrame.init_from_body( SecureWrapper(sequence_information=ONE_HOUR_MS.to_bytes(6, "big")) ) secure_group.handle_knxipframe(wrapper_exact, HPAI(*self.mock_addr)) mock_super_handle_knxipframe.assert_called_once() mock_super_handle_knxipframe.reset_mock() mock_reschedule.assert_called_once() assert secure_timer.current_timer_value() == ONE_HOUR_MS assert not secure_timer.timekeeper assert not secure_timer.sched_update mock_reschedule.reset_mock() wrapper_valid = KNXIPFrame.init_from_body( SecureWrapper( sequence_information=( ONE_HOUR_MS - secure_timer.sync_latency_tolerance_ms + 1 ).to_bytes(6, "big") ) ) secure_group.handle_knxipframe(wrapper_valid, HPAI(*self.mock_addr)) mock_super_handle_knxipframe.assert_called_once() mock_super_handle_knxipframe.reset_mock() mock_reschedule.assert_called_once() assert secure_timer.current_timer_value() == ONE_HOUR_MS assert not secure_timer.timekeeper assert not secure_timer.sched_update mock_reschedule.reset_mock() # E7 wrapper_tolerable_1 = KNXIPFrame.init_from_body( SecureWrapper( sequence_information=( ONE_HOUR_MS - secure_timer.sync_latency_tolerance_ms ).to_bytes(6, "big"), ) ) secure_group.handle_knxipframe(wrapper_tolerable_1, HPAI(*self.mock_addr)) mock_super_handle_knxipframe.assert_called_once() mock_super_handle_knxipframe.reset_mock() mock_reschedule.assert_not_called() wrapper_tolerable_2 = KNXIPFrame.init_from_body( SecureWrapper( sequence_information=( ONE_HOUR_MS - secure_timer.latency_tolerance_ms + 1 ).to_bytes(6, "big"), serial_number=bytes.fromhex("00 fa 12 34 56 78"), message_tag=bytes.fromhex("12 34"), ) ) secure_group.handle_knxipframe(wrapper_tolerable_2, HPAI(*self.mock_addr)) mock_super_handle_knxipframe.assert_called_once() mock_super_handle_knxipframe.reset_mock() mock_reschedule.assert_not_called() # E8 wrapper_invalid = KNXIPFrame.init_from_body( SecureWrapper( sequence_information=( ONE_HOUR_MS - secure_timer.latency_tolerance_ms ).to_bytes(6, "big"), serial_number=bytes.fromhex("00 fa 12 34 56 78"), message_tag=bytes.fromhex("12 34"), ) ) secure_group.handle_knxipframe(wrapper_invalid, HPAI(*self.mock_addr)) mock_reschedule.assert_called_once_with( update=(bytes.fromhex("12 34"), bytes.fromhex("00 fa 12 34 56 78")) ) assert not secure_timer.timekeeper assert secure_timer.sched_update mock_reschedule.reset_mock() # E8 from sched_update secure_group.handle_knxipframe(wrapper_invalid, HPAI(*self.mock_addr)) mock_super_handle_knxipframe.assert_not_called() mock_reschedule.assert_not_called() @patch("xknx.io.ip_secure.SecureSequenceTimer._notify_timer_expired") @patch("xknx.io.ip_secure.SecureSequenceTimer._monotonic_ms") async def test_send_secure_wrapper( self, mock_monotonic_ms: Mock, _mock_notify_timer_expired: Mock, # we don't want to actually send here mock_super_send: Mock, mock_super_connect: Mock, time_travel: EventLoopClockAdvancer, ) -> None: """Test sending SecureWrapper frames.""" mock_monotonic_ms.return_value = 0 secure_group = SecureGroup( local_addr=self.mock_addr, remote_addr=(DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT), backbone_key=self.mock_backbone_key, latency_ms=1000, ) secure_timer = secure_group.secure_timer secure_timer.timer_authenticated = True raw_test_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=CEMILData.init_from_telegram( Telegram( destination_address=GroupAddress("1/2/3"), payload=apci.GroupValueRead(), ) ), ).to_knx() with patch.object( secure_timer, "reschedule", wraps=secure_timer.reschedule ) as mock_reschedule: assert not secure_timer.sched_update secure_group.send( KNXIPFrame.init_from_body(RoutingIndication(raw_cemi=raw_test_cemi)) ) mock_reschedule.assert_called_once() mock_reschedule.reset_mock() # no reschedule when sched_update is true secure_timer.reschedule(update=(bytes(2), bytes(6))) assert secure_timer.sched_update mock_reschedule.reset_mock() secure_group.send( KNXIPFrame.init_from_body(RoutingIndication(raw_cemi=raw_test_cemi)) ) mock_reschedule.assert_not_called() @patch("xknx.io.ip_secure.SecureSequenceTimer._notify_timer_expired") @patch("xknx.io.ip_secure.SecureSequenceTimer._monotonic_ms") async def test_send_secure_wrapper_timer_overflow( self, mock_monotonic_ms: Mock, _mock_notify_timer_expired: Mock, # we don't want to actually send here mock_super_send: Mock, mock_super_connect: Mock, ) -> None: """Test raising when secure timer overflows.""" mock_monotonic_ms.return_value = 0xFF_FF_FF_FF_FF_FF + 1 secure_group = SecureGroup( local_addr=self.mock_addr, remote_addr=(DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT), backbone_key=self.mock_backbone_key, latency_ms=1000, ) secure_timer = secure_group.secure_timer secure_timer.timer_authenticated = True raw_test_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=CEMILData.init_from_telegram( Telegram( destination_address=GroupAddress("1/2/3"), payload=apci.GroupValueRead(), ) ), ).to_knx() with pytest.raises(IPSecureError): secure_group.send( KNXIPFrame.init_from_body(RoutingIndication(raw_cemi=raw_test_cemi)) ) async def test_receive_plain_frames( self, mock_super_send: Mock, mock_super_connect: Mock, ) -> None: """Test class for KNXnet/IP secure routing.""" frame_received_mock = Mock() secure_group = SecureGroup( local_addr=self.mock_addr, remote_addr=(DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT), backbone_key=self.mock_backbone_key, latency_ms=1000, ) secure_group.register_callback(frame_received_mock) plain_routing_indication = bytes.fromhex("0610 0530 0010 2900b06010fa10ff0080") secure_group.data_received_callback( plain_routing_indication, ("192.168.1.2", 3671) ) frame_received_mock.assert_not_called() plain_search_response = bytes.fromhex( "0610020c006608010a0100280e57360102001000000000082d40834de000170c" "000ab3274a3247697261204b4e582f49502d526f757465720000000000000000" "000000000e02020203020402050207010901140700dc10f1fffe10f2ffff10f3" "ffff10f4ffff" ) secure_group.data_received_callback( plain_search_response, ("192.168.1.2", 3671) ) frame_received_mock.assert_called_once() xknx-3.6.0/test/io_tests/secure_session_test.py000066400000000000000000000401631475530762600220050ustar00rootroot00000000000000"""Test Secure Session.""" import asyncio from unittest.mock import Mock, patch from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey import pytest from xknx.exceptions import CommunicationError, CouldNotParseKNXIP from xknx.io.const import SESSION_KEEPALIVE_RATE from xknx.io.ip_secure import SecureSession from xknx.knxip import ( HPAI, KNXIPFrame, SecureWrapper, SessionRequest, SessionResponse, SessionStatus, ) from xknx.knxip.knxip_enum import SecureSessionStatusCode from ..conftest import EventLoopClockAdvancer class TestSecureSession: """Test class for xknx/io/SecureSession objects.""" mock_addr = ("127.0.0.1", 12345) mock_private_key = X25519PrivateKey.from_private_bytes( bytes.fromhex( "b8 fa bd 62 66 5d 8b 9e 8a 9d 8b 1f 4b ca 42 c8" "c2 78 9a 61 10 f5 0e 9d d7 85 b3 ed e8 83 f3 78" ) ) mock_public_key = bytes.fromhex( "0a a2 27 b4 fd 7a 32 31 9b a9 96 0a c0 36 ce 0e" "5c 45 07 b5 ae 55 16 1f 10 78 b1 dc fb 3c b6 31" ) mock_server_public_key = bytes.fromhex( "bd f0 99 90 99 23 14 3e f0 a5 de 0b 3b e3 68 7b" "c5 bd 3c f5 f9 e6 f9 01 69 9c d8 70 ec 1f f8 24" ) mock_session_id = 1 mock_device_authentication_password = "trustme" mock_user_id = 1 mock_user_password = "secret" mock_serial_number = bytes.fromhex("00 fa 12 34 56 78") mock_message_tag = bytes.fromhex("af fe") def setup_method(self) -> None: """Set up test class.""" # pylint: disable=attribute-defined-outside-init self.patch_serial_number = patch( "xknx.io.ip_secure.XKNX_SERIAL_NUMBER", self.mock_serial_number ) self.patch_serial_number.start() self.patch_message_tag = patch( "xknx.io.ip_secure.MESSAGE_TAG_TUNNELLING", self.mock_message_tag ) self.patch_message_tag.start() self.session = SecureSession( remote_addr=self.mock_addr, user_id=self.mock_user_id, user_password=self.mock_user_password, device_authentication_password=self.mock_device_authentication_password, ) def teardown_method(self) -> None: """Tear down test class.""" self.patch_serial_number.stop() self.patch_message_tag.stop() @patch("xknx.io.transport.tcp_transport.TCPTransport.connect") @patch("xknx.io.transport.tcp_transport.TCPTransport.send") @patch( "xknx.io.ip_secure.generate_ecdh_key_pair", return_value=(mock_private_key, mock_public_key), ) async def test_lifecycle( self, _mock_generate: Mock, mock_super_send: Mock, mock_super_connect: Mock, time_travel: EventLoopClockAdvancer, ) -> None: """Test connection, handshake, keepalive and shutdown.""" connect_task = asyncio.create_task(self.session.connect()) await time_travel(0) mock_super_connect.assert_called_once() # outgoing session_request_frame = KNXIPFrame.init_from_body( SessionRequest(ecdh_client_public_key=self.mock_public_key) ) mock_super_send.assert_called_once_with( session_request_frame, None, # None for addr in TCP transport ) mock_super_send.reset_mock() # incoming session_response_frame = KNXIPFrame.init_from_body( SessionResponse( secure_session_id=1, ecdh_server_public_key=self.mock_server_public_key, message_authentication_code=bytes.fromhex( "a9 22 50 5a aa 43 61 63 57 0b d5 49 4c 2d f2 a3" ), ) ) self.session.handle_knxipframe(session_response_frame, HPAI(*self.mock_addr)) await time_travel(0) # outgoing encrypted_authenticate_frame = KNXIPFrame.init_from_body( SecureWrapper( secure_session_id=self.mock_session_id, sequence_information=bytes.fromhex("00 00 00 00 00 00"), serial_number=self.mock_serial_number, message_tag=self.mock_message_tag, encrypted_data=bytes.fromhex( "79 15 a4 f3 6e 6e 42 08" "d2 8b 4a 20 7d 8f 35 c0" "d1 38 c2 6a 7b 5e 71 69" ), message_authentication_code=bytes.fromhex( "52 db a8 e7 e4 bd 80 bd 7d 86 8a 3a e7 87 49 de" ), ) ) mock_super_send.assert_called_once_with( encrypted_authenticate_frame, None, # None for addr in TCP transport ) mock_super_send.reset_mock() # incoming encrypted_session_status_frame = KNXIPFrame.init_from_body( SecureWrapper( secure_session_id=self.mock_session_id, sequence_information=bytes.fromhex("00 00 00 00 00 00"), serial_number=bytes.fromhex("00 fa aa aa aa aa"), message_tag=self.mock_message_tag, encrypted_data=bytes.fromhex("26 15 6d b5 c7 49 88 8f"), message_authentication_code=bytes.fromhex( "a3 73 c3 e0 b4 bd e4 49 7c 39 5e 4b 1c 2f 46 a1" ), ) ) self.session.handle_knxipframe( encrypted_session_status_frame, HPAI(*self.mock_addr) ) await connect_task assert self.session.initialized is True assert not self.session._keepalive_task.done() # handle incoming SessionStatus (unencrypted for sake of simplicity) session_status_close_frame = KNXIPFrame.init_from_body( SessionStatus(status=SecureSessionStatusCode.STATUS_CLOSE) ) with patch.object(self.session, "transport") as mock_transport: self.session.handle_knxipframe( session_status_close_frame, HPAI(*self.mock_addr) ) mock_transport.close.assert_called_once() # keepalive SessionStatus (not specific for sake of simplicity) await time_travel(SESSION_KEEPALIVE_RATE) mock_super_send.assert_called_once() mock_super_send.reset_mock() # SessionStatus CLOSE sent on graceful disconnect with ( patch.object(self.session, "send", wraps=self.session.send) as mock_send, patch.object(self.session, "transport") as mock_transport, ): self.session.stop() mock_send.assert_called_once_with(session_status_close_frame) mock_super_send.assert_called_once() mock_transport.close.assert_called_once() assert self.session._keepalive_task is None def test_uninitialized(self) -> None: """Test for raising when an encrypted Frame arrives at an uninitialized Session.""" secure_wrapper_frame = KNXIPFrame.init_from_body( SecureWrapper( secure_session_id=self.mock_session_id, sequence_information=bytes.fromhex("00 00 00 00 00 00"), serial_number=bytes.fromhex("00 fa aa aa aa aa"), message_tag=self.mock_message_tag, encrypted_data=bytes.fromhex("26 15 6d b5 c7 49 88 8f"), message_authentication_code=bytes.fromhex( "a3 73 c3 e0 b4 bd e4 49 7c 39 5e 4b 1c 2f 46 a1" ), ) ) with pytest.raises(CouldNotParseKNXIP): self.session.handle_knxipframe(secure_wrapper_frame, HPAI(*self.mock_addr)) @patch("xknx.io.transport.tcp_transport.TCPTransport.connect") @patch("xknx.io.transport.tcp_transport.TCPTransport.send") @patch( "xknx.io.ip_secure.generate_ecdh_key_pair", return_value=(mock_private_key, mock_public_key), ) async def test_invalid_frames( self, _mock_generate: Mock, mock_super_send: Mock, mock_super_connect: Mock, time_travel: EventLoopClockAdvancer, ) -> None: """Test handling invalid frames.""" callback_mock = Mock() self.session.register_callback(callback_mock) # setup session connect_task = asyncio.create_task(self.session.connect()) await time_travel(0) session_response_frame = KNXIPFrame.init_from_body( SessionResponse( secure_session_id=1, ecdh_server_public_key=self.mock_server_public_key, message_authentication_code=bytes.fromhex( "a9 22 50 5a aa 43 61 63 57 0b d5 49 4c 2d f2 a3" ), ) ) self.session.handle_knxipframe(session_response_frame, HPAI(*self.mock_addr)) await time_travel(0) callback_mock.reset_mock() encrypted_session_status_frame = KNXIPFrame.init_from_body( SecureWrapper( secure_session_id=self.mock_session_id, sequence_information=bytes.fromhex("00 00 00 00 00 00"), serial_number=bytes.fromhex("00 fa aa aa aa aa"), message_tag=self.mock_message_tag, encrypted_data=bytes.fromhex("26 15 6d b5 c7 49 88 8f"), message_authentication_code=bytes.fromhex( "a3 73 c3 e0 b4 bd e4 49 7c 39 5e 4b 1c 2f 46 a1" ), ) ) self.session.handle_knxipframe( encrypted_session_status_frame, HPAI(*self.mock_addr) ) await connect_task assert self.session.initialized callback_mock.assert_called_once() callback_mock.reset_mock() # receive sequence_information 0 again self.session.handle_knxipframe( encrypted_session_status_frame, HPAI(*self.mock_addr) ) await time_travel(0) callback_mock.assert_not_called() # receive invalid message_authentication_code # (which is invalid brecause the sequence_information is changed) wrong_session_status_frame = KNXIPFrame.init_from_body( SecureWrapper( secure_session_id=self.mock_session_id, sequence_information=bytes.fromhex("00 00 00 00 00 01"), serial_number=bytes.fromhex("00 fa aa aa aa aa"), message_tag=self.mock_message_tag, encrypted_data=bytes.fromhex("26 15 6d b5 c7 49 88 8f"), message_authentication_code=bytes.fromhex( "a3 73 c3 e0 b4 bd e4 49 7c 39 5e 4b 1c 2f 46 a1" ), ) ) self.session.handle_knxipframe( wrong_session_status_frame, HPAI(*self.mock_addr) ) await time_travel(0) callback_mock.assert_not_called() # async teardown self.session.stop() assert self.session.initialized is False @patch("xknx.io.transport.tcp_transport.TCPTransport.connect") @patch("xknx.io.transport.tcp_transport.TCPTransport.send") @patch( "xknx.io.ip_secure.generate_ecdh_key_pair", return_value=(mock_private_key, mock_public_key), ) async def test_invalid_session_response( self, _mock_generate: Mock, mock_super_send: Mock, mock_super_connect: Mock, time_travel: EventLoopClockAdvancer, ) -> None: """Test handling invalid session response.""" connect_task = asyncio.create_task(self.session.connect()) await time_travel(0) session_response_frame = KNXIPFrame.init_from_body( SessionResponse( secure_session_id=1, ecdh_server_public_key=self.mock_server_public_key, message_authentication_code=bytes.fromhex( "ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff" ), ) ) with pytest.raises(CommunicationError): self.session.handle_knxipframe( session_response_frame, HPAI(*self.mock_addr) ) await connect_task # only SessionRequest, no SessionAuthenticate mock_super_send.assert_called_once() @patch("xknx.io.transport.tcp_transport.TCPTransport.connect") @patch("xknx.io.transport.tcp_transport.TCPTransport.send") @patch( "xknx.io.ip_secure.generate_ecdh_key_pair", return_value=(mock_private_key, mock_public_key), ) async def test_no_authentication( self, _mock_generate: Mock, mock_super_send: Mock, mock_super_connect: Mock, time_travel: EventLoopClockAdvancer, ) -> None: """Test handling initializing session without verifying server authenticity.""" self.session._device_authentication_code = None connect_task = asyncio.create_task(self.session.connect()) await time_travel(0) mock_super_send.reset_mock() invalid_session_response_frame = KNXIPFrame.init_from_body( SessionResponse( secure_session_id=1, ecdh_server_public_key=self.mock_server_public_key, message_authentication_code=bytes.fromhex( "ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff" ), ) ) self.session.handle_knxipframe( invalid_session_response_frame, HPAI(*self.mock_addr) ) await time_travel(0) # outgoing encrypted_authenticate_frame = KNXIPFrame.init_from_body( SecureWrapper( secure_session_id=self.mock_session_id, sequence_information=bytes.fromhex("00 00 00 00 00 00"), serial_number=self.mock_serial_number, message_tag=self.mock_message_tag, encrypted_data=bytes.fromhex( "79 15 a4 f3 6e 6e 42 08" "d2 8b 4a 20 7d 8f 35 c0" "d1 38 c2 6a 7b 5e 71 69" ), message_authentication_code=bytes.fromhex( "52 db a8 e7 e4 bd 80 bd 7d 86 8a 3a e7 87 49 de" ), ) ) mock_super_send.assert_called_once_with( encrypted_authenticate_frame, None, # None for addr in TCP transport ) # incoming encrypted_session_status_frame = KNXIPFrame.init_from_body( SecureWrapper( secure_session_id=self.mock_session_id, sequence_information=bytes.fromhex("00 00 00 00 00 00"), serial_number=bytes.fromhex("00 fa aa aa aa aa"), message_tag=self.mock_message_tag, encrypted_data=bytes.fromhex("26 15 6d b5 c7 49 88 8f"), message_authentication_code=bytes.fromhex( "a3 73 c3 e0 b4 bd e4 49 7c 39 5e 4b 1c 2f 46 a1" ), ) ) self.session.handle_knxipframe( encrypted_session_status_frame, HPAI(*self.mock_addr) ) await connect_task assert self.session.initialized is True # async teardown self.session.stop() assert self.session.initialized is False @patch("xknx.io.transport.tcp_transport.TCPTransport.connect") @patch("xknx.io.transport.tcp_transport.TCPTransport.send") @patch( "xknx.io.ip_secure.generate_ecdh_key_pair", return_value=(mock_private_key, mock_public_key), ) async def test_invalid_authentication( self, _mock_generate: Mock, mock_super_send: Mock, mock_super_connect: Mock, time_travel: EventLoopClockAdvancer, ) -> None: """Test handling no session status while authenticating.""" connect_task = asyncio.create_task(self.session.connect()) await time_travel(0) session_response_frame = KNXIPFrame.init_from_body( SessionResponse( secure_session_id=1, ecdh_server_public_key=self.mock_server_public_key, message_authentication_code=bytes.fromhex( "a9 22 50 5a aa 43 61 63 57 0b d5 49 4c 2d f2 a3" ), ) ) self.session.handle_knxipframe(session_response_frame, HPAI(*self.mock_addr)) with pytest.raises(CommunicationError): await time_travel(10) await connect_task xknx-3.6.0/test/io_tests/self_description_test.py000066400000000000000000000155331475530762600223130ustar00rootroot00000000000000"""Unit test for KNX/IP Tunnelling Request/Response.""" import asyncio from unittest.mock import Mock, patch from xknx.io.self_description import DescriptionQuery, request_description from xknx.io.transport.udp_transport import UDPTransport from xknx.knxip import HPAI, DescriptionRequest, KNXIPFrame, SearchRequestExtended from xknx.telegram import IndividualAddress from ..conftest import EventLoopClockAdvancer class TestSelfDescription: """Test class for KNX/IP self description.""" # Core v1 device MDT KNX IP Router SCN-IP100.02 description_response_v1_raw = bytes.fromhex( "06 10 02 04 00 46 36 01 02 00 20 00 00 00 00" "83 47 7f 01 24 e0 00 17 0c cc 1b e0 80 04 c4" "4d 44 54 20 4b 4e 58 20 49 50 20 52 6f 75 74" "65 72 00 00 00 00 00 00 00 00 00 00 00 00 00" "0a 02 02 01 03 01 04 01 05 01" ) # Core v2 responses from a KNX IP Router 752 secure description_response_raw = bytes.fromhex( "06 10 02 04 00 4e 36 01 02 00 50 00 00 00 00" "c5 01 04 d5 9d e0 00 17 0d 00 24 6d 02 94 c4" "4b 4e 58 20 49 50 20 52 6f 75 74 65 72 20 37" "35 32 20 73 65 63 75 72 65 00 00 00 00 00 00" "0a 02 02 02 03 02 04 02 05 02 08 08 00 00 00" "37 09 1a" ) search_response_extended_raw = bytes.fromhex( "06 10 02 0c 00 84 08 01 0a 01 01 5f 0e 57 36" "01 02 00 50 00 00 00 00 c5 01 04 d5 9d e0 00" "17 0d 00 24 6d 02 94 c4 4b 4e 58 20 49 50 20" "52 6f 75 74 65 72 20 37 35 32 20 73 65 63 75" "72 65 00 00 00 00 00 00 0c 02 02 02 03 02 04" "02 05 02 09 01 08 06 03 01 04 01 05 01 24 07" "00 37 50 01 ff fd 50 02 ff fd 50 03 ff fd 50" "04 ff fd 50 05 ff fd 50 06 ff fd 50 07 ff fd" "50 08 ff fd 08 08 00 00 00 37 09 1a" ) async def test_description_query(self, time_travel: EventLoopClockAdvancer) -> None: """Test DescriptionQuery class.""" local_addr = ("127.0.0.1", 12345) remote_addr = ("127.0.0.2", 54321) transport_mocked = UDPTransport(local_addr=local_addr, remote_addr=remote_addr) transport_mocked.getsockname = Mock(return_value=local_addr) transport_mocked.send = Mock() description_request = KNXIPFrame.init_from_body( DescriptionRequest(control_endpoint=HPAI(*local_addr)) ) description_query = DescriptionQuery( transport=transport_mocked, local_hpai=HPAI(*local_addr) ) task = asyncio.create_task(description_query.start()) await time_travel(0) transport_mocked.send.assert_called_once_with(description_request) transport_mocked.data_received_callback( raw=self.description_response_raw, source=remote_addr ) await task assert description_query.gateway_descriptor is not None assert description_query.gateway_descriptor.name == "KNX IP Router 752 secure" async def test_request_description_v1( self, time_travel: EventLoopClockAdvancer, ) -> None: """Test request_description function with Core v1 device.""" local_addr = ("127.0.0.1", 12345) remote_addr = ("127.0.0.2", 54321) with ( patch( "xknx.io.self_description.UDPTransport.connect" ) as transport_connect_mock, patch( "xknx.io.self_description.UDPTransport.getsockname", return_value=local_addr, ), patch("xknx.io.self_description.UDPTransport.send") as transport_send_mock, patch("xknx.io.self_description.UDPTransport.stop") as transport_stop_mock, patch( "xknx.io.self_description.DescriptionQuery", wraps=DescriptionQuery ) as description_query_mock, ): task = asyncio.create_task(request_description(remote_addr[0])) await time_travel(0) transport_connect_mock.assert_called_once_with() assert transport_send_mock.call_count == 1 assert isinstance( transport_send_mock.call_args[0][0].body, DescriptionRequest ) _transport = description_query_mock.call_args.kwargs["transport"] _transport.data_received_callback( self.description_response_v1_raw, remote_addr ) gateway = await task transport_stop_mock.assert_called_once() assert transport_send_mock.call_count == 1 assert gateway.core_version == 1 assert gateway.name == "MDT KNX IP Router" assert gateway.tunnelling_requires_secure is None assert gateway.individual_address == IndividualAddress("2.0.0") async def test_request_description_extended( self, time_travel: EventLoopClockAdvancer, ) -> None: """Test request_description function with Core v2 device.""" local_addr = ("127.0.0.1", 12345) remote_addr = ("127.0.0.2", 54321) with ( patch( "xknx.io.self_description.UDPTransport.connect" ) as transport_connect_mock, patch( "xknx.io.self_description.UDPTransport.getsockname", return_value=local_addr, ), patch("xknx.io.self_description.UDPTransport.send") as transport_send_mock, patch("xknx.io.self_description.UDPTransport.stop") as transport_stop_mock, patch( "xknx.io.self_description.DescriptionQuery", wraps=DescriptionQuery ) as description_query_mock, ): task = asyncio.create_task(request_description(remote_addr[0])) await time_travel(0) transport_connect_mock.assert_called_once_with() assert transport_send_mock.call_count == 1 assert isinstance( transport_send_mock.call_args[0][0].body, DescriptionRequest ) _transport = description_query_mock.call_args.kwargs["transport"] _transport.data_received_callback( self.description_response_raw, remote_addr ) await time_travel(0) assert transport_send_mock.call_count == 2 assert isinstance( transport_send_mock.call_args[0][0].body, SearchRequestExtended ) _transport = description_query_mock.call_args.kwargs["transport"] _transport.data_received_callback( self.search_response_extended_raw, remote_addr ) gateway = await task transport_stop_mock.assert_called_once() assert transport_send_mock.call_count == 2 assert gateway.core_version == 2 assert gateway.name == "KNX IP Router 752 secure" assert gateway.tunnelling_requires_secure is True assert gateway.individual_address == IndividualAddress("5.0.0") xknx-3.6.0/test/io_tests/transport_tests/000077500000000000000000000000001475530762600206155ustar00rootroot00000000000000xknx-3.6.0/test/io_tests/transport_tests/__init__.py000066400000000000000000000000531475530762600227240ustar00rootroot00000000000000"""Unit tests for the Transport module.""" xknx-3.6.0/test/io_tests/transport_tests/ip_transport_test.py000066400000000000000000000037741475530762600247650ustar00rootroot00000000000000"""Unit test for KNX/IP transport base class.""" from unittest.mock import Mock, patch from xknx.io.transport import KNXIPTransport from xknx.knxip import ( HPAI, ConnectionStateRequest, ConnectionStateResponse, KNXIPFrame, KNXIPServiceType, ) class TestKNXIPTransport: """Test class for KNXIPTransport base class.""" @patch.multiple(KNXIPTransport, __abstractmethods__=set()) def test_callback(self) -> None: """Test if callback is called correctly.""" transport = KNXIPTransport() transport.callbacks = [] callback_mock = Mock() # Registering callback callback_instance = transport.register_callback( callback_mock, [KNXIPServiceType.CONNECTIONSTATE_RESPONSE] ) assert len(transport.callbacks) == 1 # Handle KNX/IP frame with different service type wrong_service_type_frame = KNXIPFrame.init_from_body( ConnectionStateRequest(), ) transport.handle_knxipframe(wrong_service_type_frame, HPAI()) callback_mock.assert_not_called() # Handle KNX/IP frame with correct service type correct_service_type_frame = KNXIPFrame.init_from_body( ConnectionStateResponse(), ) transport.handle_knxipframe(correct_service_type_frame, HPAI()) callback_mock.assert_called_once_with( correct_service_type_frame, HPAI(), transport ) # Unregistering callback callback_mock.reset_mock() transport.unregister_callback(callback_instance) assert not transport.callbacks transport.handle_knxipframe(correct_service_type_frame, HPAI()) callback_mock.assert_not_called() # Registering callback for any service type callback_instance = transport.register_callback(callback_mock, None) transport.handle_knxipframe(correct_service_type_frame, HPAI()) callback_mock.assert_called_once_with( correct_service_type_frame, HPAI(), transport ) xknx-3.6.0/test/io_tests/tunnel_test.py000066400000000000000000000547321475530762600202700ustar00rootroot00000000000000"""Test for KNX/IP Tunnelling connections.""" import asyncio from copy import deepcopy from unittest.mock import AsyncMock, Mock, call, patch import pytest from xknx import XKNX from xknx.cemi import CEMIFrame, CEMILData, CEMIMessageCode from xknx.dpt import DPTArray from xknx.exceptions import CommunicationError from xknx.io import TCPTunnel, UDPTunnel from xknx.io.const import CONNECTIONSTATE_REQUEST_TIMEOUT, HEARTBEAT_RATE from xknx.io.gateway_scanner import GatewayDescriptor from xknx.knxip import ( HPAI, ConnectionStateRequest, ConnectionStateResponse, ConnectRequest, ConnectResponse, ConnectResponseData, DescriptionRequest, DescriptionResponse, DisconnectRequest, DisconnectResponse, ErrorCode, KNXIPFrame, TunnellingAck, TunnellingRequest, ) from xknx.knxip.knxip_enum import HostProtocol from xknx.telegram import ( GroupAddress, IndividualAddress, Telegram, TelegramDirection, tpci, ) from xknx.telegram.apci import GroupValueWrite from ..conftest import EventLoopClockAdvancer class TestUDPTunnel: """Test class for xknx/io/Tunnel objects.""" def setup_method(self) -> None: """Set up test class.""" # pylint: disable=attribute-defined-outside-init self.xknx = XKNX() self.cemi_received_mock = Mock() self.tunnel = UDPTunnel( self.xknx, gateway_ip="192.168.1.2", gateway_port=3671, local_ip="192.168.1.1", local_port=0, cemi_received_callback=self.cemi_received_mock, auto_reconnect=False, auto_reconnect_wait=3, route_back=False, ) @pytest.mark.parametrize( "raw", [ # L_Data.ind GroupValueWrite from 1.1.22 to to 5/1/22 with DPT9 payload 0C 3F # communication_channel_id: 0x02 sequence_counter: 0x21 bytes.fromhex("0610 0420 0017 04 02 21 00 2900bcd011162916030080 0c 3f"), # L_Data.ind T_Connect from 1.0.250 to 1.0.255 (xknx tunnel endpoint) # communication_channel_id: 0x02 sequence_counter: 0x21 bytes.fromhex("0610 0420 0014 04 02 21 00 2900b06010fa10ff0080"), # # communication_channel_id: 0x02 sequence_counter: 0x21 bytes.fromhex("0610 0420 0013 04 02 21 00 2900b06010fa10ff00"), ], ) @patch("xknx.io.UDPTunnel._send_tunnelling_ack") async def test_tunnel_request_received( self, send_ack_mock: Mock, raw: bytes ) -> None: """Test Tunnel for calling send_ack on frames.""" raw_cemi = raw[10:] self.tunnel.expected_sequence_number = 0x21 self.tunnel.transport.data_received_callback(raw, ("192.168.1.2", 3671)) await asyncio.sleep(0) self.cemi_received_mock.assert_called_once_with(raw_cemi) send_ack_mock.assert_called_once_with(raw[7], raw[8]) @patch("xknx.io.UDPTunnel._send_tunnelling_ack") @patch("xknx.io.UDPTunnel.send_cemi") async def test_tunnel_request_received_callback( self, send_cemi_mock: Mock, send_ack_mock: Mock, ) -> None: """Test Tunnel for responding to point-to-point connection.""" self.tunnel.cemi_received_callback = self.xknx.knxip_interface.cemi_received self.xknx.knxip_interface._interface = self.tunnel # set current address so management telegram is processed self.xknx.current_address = IndividualAddress("1.0.255") # L_Data.ind T_Connect from 1.0.250 to 1.0.255 (xknx tunnel endpoint) - ETS Line-Scan # communication_channel_id: 0x02 sequence_counter: 0x81 raw_ind = bytes.fromhex("0610 0420 0014 04 02 81 00 2900b06010fa10ff0080") test_cemi = CEMIFrame.from_knx(raw_ind[10:]) test_telegram = test_cemi.data.telegram() test_telegram.direction = TelegramDirection.INCOMING self.tunnel.expected_sequence_number = 0x81 response_telegram = Telegram( destination_address=IndividualAddress(test_telegram.source_address), tpci=tpci.TDisconnect(), ) self.tunnel.transport.data_received_callback(raw_ind, ("192.168.1.2", 3671)) await asyncio.sleep(0) response_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_REQ, data=CEMILData.init_from_telegram( response_telegram, src_addr=IndividualAddress("1.0.255"), ), ) assert send_cemi_mock.call_args_list == [ call(response_cemi), ] send_ack_mock.assert_called_once_with(raw_ind[7], raw_ind[8]) async def test_repeated_tunnel_request( self, time_travel: EventLoopClockAdvancer ) -> None: """Test Tunnel for receiving repeated TunnellingRequest frames.""" self.tunnel.transport.send = Mock() self.tunnel.communication_channel = 1 self.tunnel.expected_sequence_number = 10 test_telegram = Telegram( destination_address=GroupAddress(1), payload=GroupValueWrite(DPTArray((1,))), ) cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=CEMILData.init_from_telegram(test_telegram), ) test_frame = KNXIPFrame.init_from_body( TunnellingRequest( communication_channel_id=1, sequence_counter=10, raw_cemi=cemi.to_knx(), ) ) test_ack = KNXIPFrame.init_from_body(TunnellingAck(sequence_counter=10)) test_frame_9 = deepcopy(test_frame) test_frame_9.body.sequence_counter = 9 # first frame - ACK and processed self.tunnel._request_received(test_frame, None, None) await time_travel(0) assert self.tunnel.transport.send.call_args_list == [call(test_ack, addr=None)] self.tunnel.transport.send.reset_mock() assert self.tunnel.expected_sequence_number == 11 assert self.cemi_received_mock.call_count == 1 # same sequence number as before - ACK, not processed self.tunnel._request_received(test_frame, None, None) await time_travel(0) assert self.tunnel.transport.send.call_args_list == [call(test_ack, addr=None)] self.tunnel.transport.send.reset_mock() assert self.tunnel.expected_sequence_number == 11 assert self.cemi_received_mock.call_count == 1 # wrong sequence number - no ACK, not processed # reconnect if `auto_reconnect` was True with pytest.raises(CommunicationError): self.tunnel._request_received(test_frame_9, None, None) await time_travel(0) self.tunnel.transport.send.assert_not_called() self.tunnel.transport.send.reset_mock() assert self.tunnel.expected_sequence_number == 11 assert self.cemi_received_mock.call_count == 1 async def test_tunnel_send_retry(self, time_travel: EventLoopClockAdvancer) -> None: """Test tunnel resends the telegram when no ACK was received.""" self.tunnel.transport.send = Mock() self.tunnel.communication_channel = 1 self.tunnel.sequence_number = 23 self.tunnel.expected_sequence_number = 15 test_telegram = Telegram( destination_address=GroupAddress(1), payload=GroupValueWrite(DPTArray((1,))), ) test_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_REQ, data=CEMILData.init_from_telegram( test_telegram, src_addr=self.tunnel._src_address, ), ) request = KNXIPFrame.init_from_body( TunnellingRequest( communication_channel_id=self.tunnel.communication_channel, sequence_counter=self.tunnel.sequence_number, raw_cemi=test_cemi.to_knx(), ) ) test_ack = KNXIPFrame.init_from_body( TunnellingAck(sequence_counter=self.tunnel.sequence_number) ) confirmation_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_CON, data=CEMILData.init_from_telegram(test_telegram), ) confirmation = KNXIPFrame.init_from_body( TunnellingRequest( communication_channel_id=1, sequence_counter=15, raw_cemi=confirmation_cemi.to_knx(), ) ) confirmation_ack = KNXIPFrame.init_from_body(TunnellingAck(sequence_counter=15)) task = asyncio.create_task(self.tunnel.send_cemi(test_cemi)) await time_travel(0) assert self.tunnel.transport.send.call_args_list == [call(request, addr=None)] self.tunnel.transport.send.reset_mock() # no ACK received, resend same telegram await time_travel(1) assert self.tunnel.transport.send.call_args_list == [call(request, addr=None)] self.tunnel.transport.send.reset_mock() self.tunnel.transport.handle_knxipframe(test_ack, HPAI()) await time_travel(0) assert task.done() await task # L_Data.con ACK for UDP tunneling self.tunnel.transport.handle_knxipframe(confirmation, HPAI()) await time_travel(0) assert self.tunnel.transport.send.call_args_list == [ call(confirmation_ack, addr=None) ] self.tunnel.transport.send.reset_mock() # Test raise after 2 missed ACKs (reconnect if `auto_reconnect` was True) with pytest.raises(CommunicationError): task = asyncio.create_task(self.tunnel.send_cemi(test_cemi)) # no ACKs received, for 2x wait time (with advancing the loop in between) await time_travel(1) await time_travel(1) assert self.tunnel.transport.send.call_count == 2 self.tunnel.transport.send.reset_mock() await task @pytest.mark.parametrize( ("route_back", "data_endpoint_addr", "local_endpoint"), [ (False, ("192.168.1.2", 56789), HPAI("192.168.1.1", 12345)), (True, None, HPAI()), ], ) async def test_tunnel_connect_send_disconnect( self, time_travel: EventLoopClockAdvancer, route_back: bool, data_endpoint_addr: tuple[str, int] | None, local_endpoint: HPAI, ) -> None: """Test initiating a tunnelling connection.""" local_addr = ("192.168.1.1", 12345) remote_addr = ("192.168.1.2", 3671) self.tunnel.route_back = route_back gateway_data_endpoint = ( HPAI(*data_endpoint_addr) if data_endpoint_addr else HPAI() ) self.tunnel.transport.connect = AsyncMock() self.tunnel.transport.getsockname = Mock(return_value=local_addr) self.tunnel.transport.send = Mock() self.tunnel.transport.stop = Mock() # Connect connect_request = ConnectRequest( control_endpoint=local_endpoint, data_endpoint=local_endpoint, ) connect_frame = KNXIPFrame.init_from_body(connect_request) connection_task = asyncio.create_task(self.tunnel.connect()) await time_travel(0) self.tunnel.transport.connect.assert_called_once() self.tunnel.transport.send.assert_called_once_with(connect_frame) connect_response_frame = KNXIPFrame.init_from_body( ConnectResponse( communication_channel=23, data_endpoint=gateway_data_endpoint, crd=ConnectResponseData(individual_address=IndividualAddress(7)), ) ) self.tunnel.transport.handle_knxipframe(connect_response_frame, remote_addr) await connection_task assert self.tunnel._data_endpoint_addr == data_endpoint_addr assert self.tunnel._src_address == IndividualAddress(7) # Send - use data endpoint self.tunnel.transport.send.reset_mock() test_telegram = Telegram( destination_address=GroupAddress(1), payload=GroupValueWrite(DPTArray((1,))), ) test_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_REQ, data=CEMILData.init_from_telegram( test_telegram, src_addr=IndividualAddress(7), ), ) test_telegram_frame = KNXIPFrame.init_from_body( TunnellingRequest( communication_channel_id=23, sequence_counter=0, raw_cemi=test_cemi.to_knx(), ) ) send_task = asyncio.create_task(self.tunnel.send_cemi(test_cemi)) await time_travel(0) self.tunnel.transport.send.assert_called_once_with( test_telegram_frame, addr=data_endpoint_addr ) # skip ack and confirmation assert not send_task.done() # Disconnect self.tunnel.transport.send.reset_mock() disconnect_request = DisconnectRequest( communication_channel_id=23, control_endpoint=local_endpoint, ) disconnect_frame = KNXIPFrame.init_from_body(disconnect_request) disconnection_task = asyncio.create_task(self.tunnel.disconnect()) await time_travel(0) self.tunnel.transport.send.assert_called_once_with(disconnect_frame) disconnect_response_frame = KNXIPFrame.init_from_body( DisconnectResponse(communication_channel_id=23) ) self.tunnel.transport.handle_knxipframe(disconnect_response_frame, remote_addr) await disconnection_task assert self.tunnel._data_endpoint_addr is None self.tunnel.transport.stop.assert_called_once() async def test_tunnel_request_description( self, time_travel: EventLoopClockAdvancer ) -> None: """Test tunnel requesting and returning description of connected interface.""" local_addr = ("192.168.1.1", 12345) self.tunnel.transport.send = Mock() self.tunnel.transport.getsockname = Mock(return_value=local_addr) description_request = KNXIPFrame.init_from_body( DescriptionRequest(control_endpoint=self.tunnel.local_hpai) ) description_response = KNXIPFrame.init_from_body(DescriptionResponse()) task = asyncio.create_task(self.tunnel.request_description()) await time_travel(0) self.tunnel.transport.send.assert_called_once_with(description_request) self.tunnel.transport.handle_knxipframe(description_response, HPAI()) await time_travel(0) assert task.done() assert isinstance(task.result(), GatewayDescriptor) assert self.tunnel.transport.send.call_count == 1 await task class TestTCPTunnel: """Test class for xknx/io/TCPTunnel objects.""" def setup_method(self) -> None: """Set up test class.""" # pylint: disable=attribute-defined-outside-init self.xknx = XKNX() self.cemi_received_mock = AsyncMock() self.tunnel = TCPTunnel( self.xknx, gateway_ip="192.168.1.2", gateway_port=3671, cemi_received_callback=self.cemi_received_mock, auto_reconnect=False, auto_reconnect_wait=3, ) async def test_tunnel_heartbeat(self, time_travel: EventLoopClockAdvancer) -> None: """Test tunnel sends heartbeat frame.""" local_addr = ("192.168.1.1", 12345) remote_hpai = HPAI( ip_addr="192.168.1.2", port=3671, protocol=HostProtocol.IPV4_TCP ) self.tunnel.transport.connect = AsyncMock() self.tunnel.transport.getsockname = Mock(return_value=(local_addr)) self.tunnel.transport.send = Mock() self.tunnel.transport.stop = Mock() self.tunnel._tunnel_lost = Mock() # Connect connection_task = asyncio.create_task(self.tunnel.connect()) await time_travel(0) self.tunnel.transport.connect.assert_called_once() connect_response_frame = KNXIPFrame.init_from_body( ConnectResponse( communication_channel=23, data_endpoint=HPAI(protocol=HostProtocol.IPV4_TCP), crd=ConnectResponseData(individual_address=IndividualAddress(7)), ) ) self.tunnel.transport.handle_knxipframe(connect_response_frame, remote_hpai) await connection_task self.tunnel.transport.send.reset_mock() # Send heartbeat heartbeat_request = KNXIPFrame.init_from_body( ConnectionStateRequest( communication_channel_id=23, control_endpoint=HPAI(protocol=HostProtocol.IPV4_TCP), ) ) heartbeat_response = KNXIPFrame.init_from_body( ConnectionStateResponse( communication_channel_id=23, status_code=ErrorCode.E_NO_ERROR, ) ) await time_travel(HEARTBEAT_RATE) self.tunnel.transport.send.assert_called_once_with(heartbeat_request) self.tunnel.transport.send.reset_mock() self.tunnel.transport.handle_knxipframe(heartbeat_response, remote_hpai) # test no retry-heartbeat was sent await time_travel(CONNECTIONSTATE_REQUEST_TIMEOUT) self.tunnel.transport.send.assert_not_called() # next regular heartbeat await time_travel(HEARTBEAT_RATE - CONNECTIONSTATE_REQUEST_TIMEOUT) self.tunnel.transport.send.assert_called_once_with(heartbeat_request) self.tunnel.transport.send.reset_mock() self.tunnel._tunnel_lost.assert_not_called() async def test_tunnel_heartbeat_no_answer( self, time_travel: EventLoopClockAdvancer ) -> None: """Test tunnel sends heartbeat frame.""" local_addr = ("192.168.1.1", 12345) remote_hpai = HPAI( ip_addr="192.168.1.2", port=3671, protocol=HostProtocol.IPV4_TCP ) self.tunnel.transport.connect = AsyncMock() self.tunnel.transport.getsockname = Mock(return_value=(local_addr)) self.tunnel.transport.send = Mock() self.tunnel.transport.stop = Mock() self.tunnel._tunnel_lost = Mock() # Connect connection_task = asyncio.create_task(self.tunnel.connect()) await time_travel(0) self.tunnel.transport.connect.assert_called_once() connect_response_frame = KNXIPFrame.init_from_body( ConnectResponse( communication_channel=23, data_endpoint=HPAI(protocol=HostProtocol.IPV4_TCP), crd=ConnectResponseData(individual_address=IndividualAddress(7)), ) ) self.tunnel.transport.handle_knxipframe(connect_response_frame, remote_hpai) await connection_task self.tunnel.transport.send.reset_mock() # Send heartbeat heartbeat_request = KNXIPFrame.init_from_body( ConnectionStateRequest( communication_channel_id=23, control_endpoint=HPAI(protocol=HostProtocol.IPV4_TCP), ) ) await time_travel(HEARTBEAT_RATE) self.tunnel.transport.send.assert_called_once_with(heartbeat_request) self.tunnel.transport.send.reset_mock() # no answer - repeat 3 times await time_travel(CONNECTIONSTATE_REQUEST_TIMEOUT) self.tunnel.transport.send.assert_called_once_with(heartbeat_request) self.tunnel.transport.send.reset_mock() await time_travel(CONNECTIONSTATE_REQUEST_TIMEOUT) self.tunnel.transport.send.assert_called_once_with(heartbeat_request) self.tunnel.transport.send.reset_mock() await time_travel(CONNECTIONSTATE_REQUEST_TIMEOUT) self.tunnel.transport.send.assert_called_once_with(heartbeat_request) self.tunnel.transport.send.reset_mock() await time_travel(CONNECTIONSTATE_REQUEST_TIMEOUT) # no answer - tunnel lost self.tunnel._tunnel_lost.assert_called_once() async def test_tunnel_heartbeat_error( self, time_travel: EventLoopClockAdvancer ) -> None: """Test tunnel sends heartbeat frame.""" local_addr = ("192.168.1.1", 12345) remote_hpai = HPAI( ip_addr="192.168.1.2", port=3671, protocol=HostProtocol.IPV4_TCP ) self.tunnel.transport.connect = AsyncMock() self.tunnel.transport.getsockname = Mock(return_value=(local_addr)) self.tunnel.transport.send = Mock() self.tunnel.transport.stop = Mock() self.tunnel._tunnel_lost = Mock() # Connect connection_task = asyncio.create_task(self.tunnel.connect()) await time_travel(0) self.tunnel.transport.connect.assert_called_once() connect_response_frame = KNXIPFrame.init_from_body( ConnectResponse( communication_channel=23, data_endpoint=HPAI(protocol=HostProtocol.IPV4_TCP), crd=ConnectResponseData(individual_address=IndividualAddress(7)), ) ) self.tunnel.transport.handle_knxipframe(connect_response_frame, remote_hpai) await connection_task self.tunnel.transport.send.reset_mock() # Send heartbeat heartbeat_request = KNXIPFrame.init_from_body( ConnectionStateRequest( communication_channel_id=23, control_endpoint=HPAI(protocol=HostProtocol.IPV4_TCP), ) ) heartbeat_error_response = KNXIPFrame.init_from_body( ConnectionStateResponse( communication_channel_id=23, status_code=ErrorCode.E_CONNECTION_ID, ) ) await time_travel(HEARTBEAT_RATE) self.tunnel.transport.send.assert_called_once_with(heartbeat_request) self.tunnel.transport.send.reset_mock() self.tunnel.transport.handle_knxipframe(heartbeat_error_response, remote_hpai) # repeat 3 times await time_travel(0) self.tunnel.transport.send.assert_called_once_with(heartbeat_request) self.tunnel.transport.send.reset_mock() self.tunnel.transport.handle_knxipframe(heartbeat_error_response, remote_hpai) await time_travel(0) self.tunnel.transport.send.assert_called_once_with(heartbeat_request) self.tunnel.transport.send.reset_mock() self.tunnel.transport.handle_knxipframe(heartbeat_error_response, remote_hpai) await time_travel(0) self.tunnel.transport.send.assert_called_once_with(heartbeat_request) self.tunnel.transport.send.reset_mock() self.tunnel.transport.handle_knxipframe(heartbeat_error_response, remote_hpai) await time_travel(0) # third retry had an error - tunnel lost self.tunnel._tunnel_lost.assert_called_once() xknx-3.6.0/test/knxip_tests/000077500000000000000000000000001475530762600160615ustar00rootroot00000000000000xknx-3.6.0/test/knxip_tests/__init__.py000066400000000000000000000000471475530762600201730ustar00rootroot00000000000000"""Unit tests for the KNXIP module.""" xknx-3.6.0/test/knxip_tests/body_test.py000066400000000000000000000013451475530762600204320ustar00rootroot00000000000000"""Unit test for KNX/IP Body base class.""" from unittest.mock import patch from xknx.knxip import KNXIPBody, KNXIPBodyResponse class TestKNXIPBody: """Test base class for KNX/IP bodys.""" @patch.multiple(KNXIPBody, __abstractmethods__=set()) def test_body_attributes(self) -> None: """Test attributes of KNXIPBody base class.""" body = KNXIPBody() assert hasattr(body, "SERVICE_TYPE") @patch.multiple(KNXIPBodyResponse, __abstractmethods__=set()) def test_response_attributes(self) -> None: """Test attributes of KNXIPBodyResponse base class.""" response = KNXIPBodyResponse() assert hasattr(response, "SERVICE_TYPE") assert hasattr(response, "status_code") xknx-3.6.0/test/knxip_tests/connect_request_test.py000066400000000000000000000140151475530762600226740ustar00rootroot00000000000000"""Unit test for KNX/IP ConnectRequests.""" import pytest from xknx.exceptions import CouldNotParseKNXIP from xknx.knxip import ( HPAI, ConnectRequest, ConnectRequestInformation, ConnectRequestType, KNXIPFrame, TunnellingLayer, ) from xknx.telegram import IndividualAddress class TestKNXIPConnectRequest: """Test class for KNX/IP ConnectRequests.""" def test_tunnel_connect_request(self) -> None: """Test parsing and streaming connection request KNX/IP packet.""" raw = bytes.fromhex( "06 10 02 05 00 1A 08 01 C0 A8 2A 01 84 95 08 01" "C0 A8 2A 01 CC A9 04 04 02 00" ) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, ConnectRequest) assert knxipframe.body.control_endpoint == HPAI( ip_addr="192.168.42.1", port=33941 ) assert knxipframe.body.data_endpoint == HPAI(ip_addr="192.168.42.1", port=52393) assert ( knxipframe.body.cri.connection_type is ConnectRequestType.TUNNEL_CONNECTION ) assert knxipframe.body.cri.knx_layer is TunnellingLayer.DATA_LINK_LAYER assert knxipframe.body.cri.individual_address is None cri = ConnectRequestInformation( connection_type=ConnectRequestType.TUNNEL_CONNECTION, knx_layer=TunnellingLayer.DATA_LINK_LAYER, individual_address=None, ) connect_request = ConnectRequest( control_endpoint=HPAI(ip_addr="192.168.42.1", port=33941), data_endpoint=HPAI(ip_addr="192.168.42.1", port=52393), cri=cri, ) knxipframe2 = KNXIPFrame.init_from_body(connect_request) assert knxipframe2.to_knx() == raw def test_mgmt_connect_request(self) -> None: """Test parsing and streaming connection request KNX/IP packet.""" raw = bytes.fromhex( "06 10 02 05 00 18 08 01 C0 A8 2A 01 84 95 08 01 C0 A8 2A 01 CC A9 02 03" ) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, ConnectRequest) assert knxipframe.body.control_endpoint == HPAI( ip_addr="192.168.42.1", port=33941 ) assert knxipframe.body.data_endpoint == HPAI(ip_addr="192.168.42.1", port=52393) assert ( knxipframe.body.cri.connection_type is ConnectRequestType.DEVICE_MGMT_CONNECTION ) assert knxipframe.body.cri.individual_address is None cri = ConnectRequestInformation( connection_type=ConnectRequestType.DEVICE_MGMT_CONNECTION, individual_address=None, ) connect_request = ConnectRequest( control_endpoint=HPAI(ip_addr="192.168.42.1", port=33941), data_endpoint=HPAI(ip_addr="192.168.42.1", port=52393), cri=cri, ) knxipframe2 = KNXIPFrame.init_from_body(connect_request) assert knxipframe2.to_knx() == raw def test_connect_request_extended_cri(self) -> None: """Test parsing and streaming connection request KNX/IP packet.""" raw = bytes.fromhex( "06 10 02 05 00 1C 08 01 C0 A8 2A 01 84 95 08 01" "C0 A8 2A 01 CC A9 06 04 02 00 01 02" ) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, ConnectRequest) assert knxipframe.body.control_endpoint == HPAI( ip_addr="192.168.42.1", port=33941 ) assert knxipframe.body.data_endpoint == HPAI(ip_addr="192.168.42.1", port=52393) assert ( knxipframe.body.cri.connection_type is ConnectRequestType.TUNNEL_CONNECTION ) assert knxipframe.body.cri.knx_layer is TunnellingLayer.DATA_LINK_LAYER assert knxipframe.body.cri.individual_address == IndividualAddress("0.1.2") cri = ConnectRequestInformation( connection_type=ConnectRequestType.TUNNEL_CONNECTION, knx_layer=TunnellingLayer.DATA_LINK_LAYER, individual_address=IndividualAddress("0.1.2"), ) connect_request = ConnectRequest( control_endpoint=HPAI(ip_addr="192.168.42.1", port=33941), data_endpoint=HPAI(ip_addr="192.168.42.1", port=52393), cri=cri, ) knxipframe2 = KNXIPFrame.init_from_body(connect_request) assert knxipframe2.to_knx() == raw def test_from_knx_wrong_length_of_cri(self) -> None: """Test parsing and streaming wrong ConnectRequest.""" raw = bytes.fromhex( "06 10 02 05 00 1A 08 01 C0 A8 2A 01 84 95 08 01" "C0 A8 2A 01 CC A9 02 04 02 00" ) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) def test_from_knx_wrong_length_of_cri2(self) -> None: """Test parsing and streaming wrong ConnectRequest.""" raw = bytes.fromhex( "06 10 02 05 00 18 08 01 C0 A8 2A 01 84 95 08 01" + "C0 A8 2A 01 CC A9 03 03" ) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) def test_from_knx_wrong_length_of_cri3(self) -> None: """Test parsing and streaming wrong ConnectRequest.""" raw = bytes.fromhex( "06 10 02 05 00 18 08 01 C0 A8 2A 01 84 95 08 01" + "C0 A8 2A 01 CC A9 01 03" ) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) def test_from_knx_wrong_length_of_cri4(self) -> None: """Test parsing and streaming wrong ConnectRequest.""" raw = bytes.fromhex( "06 10 02 05 00 19 08 01 C0 A8 2A 01 84 95 08 01" + "C0 A8 2A 01 CC A9 03 03 01" ) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) def test_from_knx_wrong_cri(self) -> None: """Test parsing and streaming wrong ConnectRequest.""" raw = bytes.fromhex( "06 10 02 05 00 1A 08 01 C0 A8 2A 01 84 95 08 01" + "C0 A8 2A 01 CC A9 04 04 02" ) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) xknx-3.6.0/test/knxip_tests/connect_response_test.py000066400000000000000000000152701475530762600230460ustar00rootroot00000000000000"""Unit test for KNX/IP ConnectResponses.""" import pytest from xknx.exceptions import CouldNotParseKNXIP from xknx.knxip import ( HPAI, ConnectRequestType, ConnectResponse, ConnectResponseData, ErrorCode, KNXIPFrame, ) from xknx.telegram import IndividualAddress class TestKNXIPConnectResponse: """Test class for KNX/IP ConnectResponses.""" def test_tunnel_connect_response(self) -> None: """Test parsing and streaming connection response KNX/IP packet.""" raw = bytes.fromhex( "06 10 02 06 00 14 01 00 08 01 C0 A8 2A 0A 0E 57 04 04 11 FF" ) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, ConnectResponse) assert knxipframe.body.communication_channel == 1 assert knxipframe.body.status_code == ErrorCode.E_NO_ERROR assert knxipframe.body.data_endpoint == HPAI(ip_addr="192.168.42.10", port=3671) assert knxipframe.body.crd.request_type == ConnectRequestType.TUNNEL_CONNECTION assert knxipframe.body.crd.individual_address.raw == 4607 connect_response = ConnectResponse( communication_channel=1, status_code=ErrorCode.E_NO_ERROR, data_endpoint=HPAI(ip_addr="192.168.42.10", port=3671), crd=ConnectResponseData( request_type=ConnectRequestType.TUNNEL_CONNECTION, individual_address=IndividualAddress(4607), ), ) knxipframe2 = KNXIPFrame.init_from_body(connect_response) assert knxipframe2.to_knx() == raw def test_mgmt_connect_response(self) -> None: """Test parsing and streaming connection response KNX/IP packet.""" raw = bytes.fromhex("06 10 02 06 00 12 01 00 08 01 C0 A8 2A 0A 0E 57 02 03") knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, ConnectResponse) assert knxipframe.body.communication_channel == 1 assert knxipframe.body.status_code == ErrorCode.E_NO_ERROR assert knxipframe.body.data_endpoint == HPAI(ip_addr="192.168.42.10", port=3671) assert ( knxipframe.body.crd.request_type == ConnectRequestType.DEVICE_MGMT_CONNECTION ) assert knxipframe.body.crd.individual_address is None connect_response = ConnectResponse( communication_channel=1, status_code=ErrorCode.E_NO_ERROR, data_endpoint=HPAI(ip_addr="192.168.42.10", port=3671), crd=ConnectResponseData( request_type=ConnectRequestType.DEVICE_MGMT_CONNECTION, ), ) knxipframe2 = KNXIPFrame.init_from_body(connect_response) assert knxipframe2.to_knx() == raw def test_from_knx_wrong_crd(self) -> None: """Test parsing and streaming wrong ConnectRequest (wrong CRD length byte).""" raw = bytes.fromhex( "06 10 02 06 00 14 01 00 08 01 C0 A8 2A 0A 0E 57 03 04 11 FF" ) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) def test_from_knx_wrong_crd2(self) -> None: """Test parsing and streaming wrong ConnectRequest (wrong CRD length).""" raw = bytes.fromhex("06 10 02 06 00 12 01 00 08 01 C0 A8 2A 0A 0E 57 04 04") with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) def test_from_knx_wrong_crd3(self) -> None: """Test parsing and streaming wrong ConnectRequest (CRD length too small).""" raw = bytes.fromhex("06 10 02 06 00 12 01 00 08 01 C0 A8 2A 0A 0E 57 01 04") with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) def test_from_knx_wrong_crd4(self) -> None: """Test parsing and streaming wrong ConnectRequest (wrong CRD length).""" raw = bytes.fromhex("06 10 02 06 00 12 01 00 08 01 C0 A8 2A 0A 0E 57 04 03") with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) def test_from_knx_wrong_crd5(self) -> None: """Test parsing and streaming wrong ConnectRequest (wrong CRD length).""" raw = bytes.fromhex("06 10 02 06 00 13 01 00 08 01 C0 A8 2A 0A 0E 57 03 03 01") with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) def test_connect_response_connection_error_gira(self) -> None: """ Test parsing and streaming connection response KNX/IP packet with error e_no_more_connections. HPAI and CRD normal. This was received from Gira devices (2020). """ raw = bytes.fromhex( "06 10 02 06 00 14 C0 24 08 01 0A 01 00 29 0E 57 04 04 00 00" ) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, ConnectResponse) assert knxipframe.body.status_code == ErrorCode.E_NO_MORE_CONNECTIONS assert knxipframe.body.communication_channel == 192 connect_response = ConnectResponse( communication_channel=192, status_code=ErrorCode.E_NO_MORE_CONNECTIONS, data_endpoint=HPAI(ip_addr="10.1.0.41", port=3671), crd=ConnectResponseData( request_type=ConnectRequestType.TUNNEL_CONNECTION, individual_address=IndividualAddress(0), ), ) knxipframe2 = KNXIPFrame.init_from_body(connect_response) assert knxipframe2.to_knx() == raw def test_connect_response_connection_error_lox(self) -> None: """ Test parsing and streaming connection response KNX/IP packet with error e_no_more_connections. HPAI given, CRD all zero. This was received from Loxone device (2020). """ raw = bytes.fromhex( "06 10 02 06 00 14 00 24 08 01 C0 A8 01 01 0E 57 00 00 00 00" ) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, ConnectResponse) assert knxipframe.body.status_code == ErrorCode.E_NO_MORE_CONNECTIONS assert knxipframe.body.communication_channel == 0 # no further tests: the current API can't (and shouldn't) create such odd packets def test_connect_response_connection_error_mdt(self) -> None: """ Test parsing and streaming connection response KNX/IP packet with error e_no_more_connections. HPAI and CRD all zero. This was received from MDT device (2020). """ raw = bytes.fromhex("06 10 02 06 00 08 00 24 00 00 00 00 00 00 00 00 00 00") knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, ConnectResponse) assert knxipframe.body.status_code == ErrorCode.E_NO_MORE_CONNECTIONS assert knxipframe.body.communication_channel == 0 # no further tests: the current API can't (and shouldn't) create such odd packets xknx-3.6.0/test/knxip_tests/connectionstate_request_test.py000066400000000000000000000025071475530762600244460ustar00rootroot00000000000000"""Unit test for KNX/IP ConnectionStateRequests.""" import pytest from xknx.exceptions import CouldNotParseKNXIP from xknx.knxip import HPAI, ConnectionStateRequest, KNXIPFrame class TestKNXIPConnectionStateRequest: """Test class for KNX/IP ConnectionStateRequests.""" def test_connection_state_request(self) -> None: """Test parsing and streaming connection state request KNX/IP packet.""" raw = bytes.fromhex("06 10 02 07 00 10 15 00 08 01 C0 A8 C8 0C C3 B4") knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, ConnectionStateRequest) assert knxipframe.body.communication_channel_id == 21 assert knxipframe.body.control_endpoint == HPAI( ip_addr="192.168.200.12", port=50100 ) connectionstate_request = ConnectionStateRequest( communication_channel_id=21, control_endpoint=HPAI(ip_addr="192.168.200.12", port=50100), ) knxipframe2 = KNXIPFrame.init_from_body(connectionstate_request) assert knxipframe2.to_knx() == raw def test_from_knx_wrong_info(self) -> None: """Test parsing and streaming wrong ConnectionStateRequest.""" raw = bytes((0x06, 0x10, 0x02, 0x07, 0x00, 0x010)) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) xknx-3.6.0/test/knxip_tests/connectionstate_response_test.py000066400000000000000000000024431475530762600246130ustar00rootroot00000000000000"""Unit test for KNX/IP ConnectionStateResponses.""" import pytest from xknx.exceptions import CouldNotParseKNXIP from xknx.knxip import ConnectionStateResponse, ErrorCode, KNXIPFrame class TestKNXIPConnectionStateResponse: """Test class for KNX/IP ConnectionStateResponses.""" def test_disconnect_response(self) -> None: """Test parsing and streaming connection state response KNX/IP packet.""" raw = bytes((0x06, 0x10, 0x02, 0x08, 0x00, 0x08, 0x15, 0x21)) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, ConnectionStateResponse) assert knxipframe.body.communication_channel_id == 21 assert knxipframe.body.status_code == ErrorCode.E_CONNECTION_ID connectionstate_response = ConnectionStateResponse( communication_channel_id=21, status_code=ErrorCode.E_CONNECTION_ID, ) knxipframe2 = KNXIPFrame.init_from_body(connectionstate_response) assert knxipframe2.to_knx() == raw def test_from_knx_wrong_header(self) -> None: """Test parsing and streaming wrong ConnectionStateResponse (wrong header length).""" raw = bytes((0x06, 0x10, 0x02, 0x08, 0x00, 0x08, 0x15)) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) xknx-3.6.0/test/knxip_tests/description_request_test.py000066400000000000000000000014411475530762600235650ustar00rootroot00000000000000"""Unit test for KNX/IP DescriptionRequest objects.""" from xknx.knxip import HPAI, DescriptionRequest, KNXIPFrame class TestKNXIPDescriptionRequest: """Test class for KNX/IP DescriptionRequest objects.""" def test_description_request(self) -> None: """Test parsing and streaming DescriptionRequest KNX/IP packet.""" raw = bytes.fromhex("06 10 02 03 00 0E 08 01 7F 00 00 02 0E 57") knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, DescriptionRequest) assert knxipframe.body.control_endpoint == HPAI(ip_addr="127.0.0.2", port=3671) knxipframe2 = KNXIPFrame.init_from_body( DescriptionRequest(control_endpoint=HPAI(ip_addr="127.0.0.2", port=3671)) ) assert knxipframe2.to_knx() == raw xknx-3.6.0/test/knxip_tests/description_response_test.py000066400000000000000000000047001475530762600237340ustar00rootroot00000000000000"""Unit test for KNX/IP DescriptionResponse objects.""" from xknx.knxip import ( DescriptionResponse, DIBDeviceInformation, DIBServiceFamily, DIBSuppSVCFamilies, KNXIPFrame, ) class TestKNXIPDescriptionResponse: """Test class for KNX/IP DescriptionResponse objects.""" def test_description_response(self) -> None: """Test parsing and streaming DescriptionResponse KNX/IP packet.""" raw = bytes.fromhex( "06 10 02 04 00 48 36 01 02 00 10 00 00 00 00 08" "2d 40 83 4d e0 00 17 0c 00 0a b3 27 4a 32 4b 4e" "58 2f 49 50 2d 52 6f 75 74 65 72 00 00 00 00 00" "00 00 00 00 00 00 00 00 00 00 00 00 0c 02 02 02" "03 02 04 02 05 02 07 01", ) knxipframe, rest = KNXIPFrame.from_knx(raw) assert not rest assert knxipframe.header.total_length == 72 assert knxipframe.to_knx() == raw assert isinstance(knxipframe.body, DescriptionResponse) assert len(knxipframe.body.dibs) == 2 # Specific testing of parsing and serializing of # DIBDeviceInformation and DIBSuppSVCFamilies is # done within knxip_dib_test.py assert isinstance(knxipframe.body.dibs[0], DIBDeviceInformation) assert isinstance(knxipframe.body.dibs[1], DIBSuppSVCFamilies) assert knxipframe.body.device_name == "KNX/IP-Router" assert knxipframe.body.dibs[1].supports(DIBServiceFamily.CORE, version=2) assert knxipframe.body.dibs[1].supports( DIBServiceFamily.DEVICE_MANAGEMENT, version=2 ) assert knxipframe.body.dibs[1].supports(DIBServiceFamily.ROUTING, version=2) assert knxipframe.body.dibs[1].supports(DIBServiceFamily.TUNNELING, version=2) assert knxipframe.body.dibs[1].supports( DIBServiceFamily.REMOTE_CONFIGURATION_DIAGNOSIS ) assert not knxipframe.body.dibs[1].supports(DIBServiceFamily.OBJECT_SERVER) description_response = DescriptionResponse() description_response.dibs.append(knxipframe.body.dibs[0]) description_response.dibs.append(knxipframe.body.dibs[1]) knxipframe2 = KNXIPFrame.init_from_body(description_response) assert knxipframe2.to_knx() == raw def test_unknown_device_name(self) -> None: """Test device_name if no DIBDeviceInformation is present.""" description_response = DescriptionResponse() assert description_response.device_name == "UNKNOWN" xknx-3.6.0/test/knxip_tests/device_configuration_ack_test.py000066400000000000000000000032451475530762600245020ustar00rootroot00000000000000"""Unit test for KNX/IP DeviceConfigurationAck objects.""" import pytest from xknx.exceptions import CouldNotParseKNXIP from xknx.knxip import DeviceConfigurationAck, ErrorCode, KNXIPFrame class TestKNXIPDeviceConfigurationAck: """Test class for KNX/IP DeviceConfigurationAck objects.""" def test_device_configuration_ack(self) -> None: """Test parsing and streaming device configuration ACK KNX/IP packet.""" raw = bytes((0x06, 0x10, 0x03, 0x11, 0x00, 0x0A, 0x04, 0x2A, 0x17, 0x00)) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, DeviceConfigurationAck) assert knxipframe.body.communication_channel_id == 42 assert knxipframe.body.sequence_counter == 23 assert knxipframe.body.status_code == ErrorCode.E_NO_ERROR device_configuration_ack = DeviceConfigurationAck( communication_channel_id=42, sequence_counter=23, ) knxipframe2 = KNXIPFrame.init_from_body(device_configuration_ack) assert knxipframe2.to_knx() == raw def test_from_knx_wrong_ack_information(self) -> None: """Test parsing and streaming wrong DeviceConfigurationAck (wrong length byte).""" raw = bytes((0x06, 0x10, 0x03, 0x11, 0x00, 0x0A, 0x03, 0x2A, 0x17, 0x00)) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) def test_from_knx_wrong_ack_information2(self) -> None: """Test parsing and streaming wrong DeviceConfigurationAck (wrong length).""" raw = bytes((0x06, 0x10, 0x03, 0x11, 0x00, 0x0A, 0x04, 0x2A, 0x17)) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) xknx-3.6.0/test/knxip_tests/device_configuration_request_test.py000066400000000000000000000055201475530762600254320ustar00rootroot00000000000000"""Unit test for KNX/IP DeviceConfigurationRequest objects.""" import pytest from xknx.cemi import CEMIFrame, CEMIMessageCode, CEMIMPropInfo, CEMIMPropReadRequest from xknx.exceptions import CouldNotParseKNXIP from xknx.knxip import DeviceConfigurationRequest, KNXIPFrame from xknx.profile.const import ResourceKNXNETIPPropertyId, ResourceObjectType class TestKNXIPDeviceConfigurationRequest: """Test class for KNX/IP DeviceConfigurationRequest objects.""" def test_device_configuration_request(self) -> None: """Test parsing and streaming device configuration ACK KNX/IP packet.""" raw = bytes.fromhex("06 10 03 10 00 11 04 2A 17 00 FC 00 0B 01 34 10 01") knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, DeviceConfigurationRequest) assert knxipframe.body.communication_channel_id == 42 assert knxipframe.body.sequence_counter == 23 assert isinstance(knxipframe.body.raw_cemi, bytes) incoming_cemi = CEMIFrame.from_knx(knxipframe.body.raw_cemi) assert incoming_cemi.code == CEMIMessageCode.M_PROP_READ_REQ assert incoming_cemi.data == CEMIMPropReadRequest( property_info=CEMIMPropInfo( object_type=ResourceObjectType.OBJECT_KNXNETIP_PARAMETER, object_instance=1, property_id=ResourceKNXNETIPPropertyId.PID_KNX_INDIVIDUAL_ADDRESS, start_index=1, number_of_elements=1, ) ) outgoing_cemi = CEMIFrame( code=CEMIMessageCode.M_PROP_READ_REQ, data=CEMIMPropReadRequest( property_info=CEMIMPropInfo( object_type=ResourceObjectType.OBJECT_KNXNETIP_PARAMETER, object_instance=1, property_id=ResourceKNXNETIPPropertyId.PID_KNX_INDIVIDUAL_ADDRESS, start_index=1, number_of_elements=1, ) ), ) device_configuration_req = DeviceConfigurationRequest( communication_channel_id=42, sequence_counter=23, raw_cemi=outgoing_cemi.to_knx(), ) knxipframe2 = KNXIPFrame.init_from_body(device_configuration_req) assert knxipframe2.to_knx() == raw def test_from_knx_wrong_header(self) -> None: """Test parsing and streaming wrong DeviceConfigurationRequest (wrong length byte).""" raw = bytes((0x06, 0x10, 0x03, 0x10, 0x00, 0x15, 0x03)) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) def test_from_knx_wrong_header2(self) -> None: """Test parsing and streaming wrong DeviceConfigurationRequest (wrong length).""" raw = bytes((0x06, 0x10, 0x03, 0x10, 0x00, 0x15, 0x04)) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) xknx-3.6.0/test/knxip_tests/dib_test.py000066400000000000000000000150351475530762600202340ustar00rootroot00000000000000"""Unit test for KNX/IP DIB objects.""" import pytest from xknx.exceptions import CouldNotParseKNXIP from xknx.knxip import ( DIB, DIBDeviceInformation, DIBGeneric, DIBSecuredServiceFamilies, DIBServiceFamily, DIBSuppSVCFamilies, DIBTunnelingInfo, DIBTypeCode, KNXMedium, ) from xknx.telegram import IndividualAddress class TestKNXIPDIB: """Test class for KNX/IP DIB objects.""" def test_dib_base(self) -> None: """Test parsing and streaming KNX/IP DIB packet.""" raw = bytes( (0x0C, 0x02, 0x02, 0x01, 0x03, 0x02, 0x04, 0x01, 0x05, 0x01, 0x07, 0x01) ) dib = DIBGeneric() assert dib.from_knx(raw) == 12 assert dib.dtc == DIBTypeCode.SUPP_SVC_FAMILIES assert dib.to_knx() == raw assert dib.calculated_length() == 12 def test_dib_wrong_input(self) -> None: """Test parsing of wrong KNX/IP DIB packet.""" raw = (0x08, 0x01, 0xC0, 0xA8, 0x2A) with pytest.raises(CouldNotParseKNXIP): DIBGeneric().from_knx(raw) def test_device_info(self) -> None: """Test parsing of device info.""" raw = bytes.fromhex( "36 01 02 00 11 00 23 42 13 37 13 37 13 37 E0 00" "17 0C 00 01 02 03 04 05 47 69 72 61 20 4B 4E 58" "2F 49 50 2D 52 6F 75 74 65 72 00 00 00 00 00 00" "00 00 00 00 00 00" ) dib = DIB.determine_dib(raw) assert isinstance(dib, DIBDeviceInformation) assert dib.from_knx(raw) == DIBDeviceInformation.LENGTH assert dib.knx_medium == KNXMedium.TP1 assert dib.programming_mode is False assert dib.individual_address == IndividualAddress("1.1.0") assert dib.name == "Gira KNX/IP-Router" assert dib.mac_address == "00:01:02:03:04:05" assert dib.multicast_address == "224.0.23.12" assert dib.serial_number == "13:37:13:37:13:37" assert dib.project_number == 564 assert dib.installation_number == 2 assert dib.to_knx() == raw def test_dib_sup_svc_families_router(self) -> None: """Test parsing of svc families.""" raw = bytes( (0x0C, 0x02, 0x02, 0x01, 0x03, 0x02, 0x04, 0x01, 0x05, 0x01, 0x07, 0x01) ) dib = DIB.determine_dib(raw) assert isinstance(dib, DIBSuppSVCFamilies) assert dib.from_knx(raw) == 12 assert dib.families == [ DIBSuppSVCFamilies.Family(DIBServiceFamily.CORE, 1), DIBSuppSVCFamilies.Family(DIBServiceFamily.DEVICE_MANAGEMENT, 2), DIBSuppSVCFamilies.Family(DIBServiceFamily.TUNNELING, 1), DIBSuppSVCFamilies.Family(DIBServiceFamily.ROUTING, 1), DIBSuppSVCFamilies.Family( DIBServiceFamily.REMOTE_CONFIGURATION_DIAGNOSIS, 1 ), ] assert dib.to_knx() == raw assert dib.supports(DIBServiceFamily.CORE) assert dib.supports(DIBServiceFamily.DEVICE_MANAGEMENT) assert dib.supports(DIBServiceFamily.DEVICE_MANAGEMENT, version=2) assert dib.supports(DIBServiceFamily.TUNNELING) assert not dib.supports(DIBServiceFamily.TUNNELING, version=2) assert dib.supports(DIBServiceFamily.ROUTING, version=1) assert dib.version(DIBServiceFamily.CORE) == 1 assert dib.version(DIBServiceFamily.DEVICE_MANAGEMENT) == 2 assert dib.version(DIBServiceFamily.TUNNELING) == 1 assert dib.version(DIBServiceFamily.ROUTING) == 1 def test_dib_sup_svc_families_interface(self) -> None: """Test parsing of svc families.""" raw = bytes((0x0A, 0x02, 0x02, 0x02, 0x03, 0x02, 0x04, 0x02, 0x07, 0x01)) dib = DIB.determine_dib(raw) assert isinstance(dib, DIBSuppSVCFamilies) assert dib.from_knx(raw) == 10 assert dib.families == [ DIBSuppSVCFamilies.Family(DIBServiceFamily.CORE, 2), DIBSuppSVCFamilies.Family(DIBServiceFamily.DEVICE_MANAGEMENT, 2), DIBSuppSVCFamilies.Family(DIBServiceFamily.TUNNELING, 2), DIBSuppSVCFamilies.Family( DIBServiceFamily.REMOTE_CONFIGURATION_DIAGNOSIS, 1 ), ] assert dib.to_knx() == raw assert dib.supports(DIBServiceFamily.TUNNELING) assert dib.supports(DIBServiceFamily.TUNNELING, version=2) assert not dib.supports(DIBServiceFamily.ROUTING) assert not dib.supports(DIBServiceFamily.ROUTING, version=2) assert dib.version(DIBServiceFamily.CORE) == 2 assert dib.version(DIBServiceFamily.DEVICE_MANAGEMENT) == 2 assert dib.version(DIBServiceFamily.TUNNELING) == 2 assert dib.version(DIBServiceFamily.ROUTING) is None def test_dib_secured_service_families(self) -> None: """Test parsing of secured service families.""" raw = bytes((0x08, 0x06, 0x03, 0x01, 0x04, 0x01, 0x05, 0x01)) dib = DIB.determine_dib(raw) assert isinstance(dib, DIBSecuredServiceFamilies) assert dib.from_knx(raw) == 8 assert dib.families == [ DIBSuppSVCFamilies.Family(DIBServiceFamily.DEVICE_MANAGEMENT, 1), DIBSuppSVCFamilies.Family(DIBServiceFamily.TUNNELING, 1), DIBSuppSVCFamilies.Family(DIBServiceFamily.ROUTING, 1), ] assert dib.to_knx() == raw assert dib.supports(DIBServiceFamily.TUNNELING) assert dib.supports(DIBServiceFamily.TUNNELING, version=1) assert dib.supports(DIBServiceFamily.ROUTING) assert not dib.supports(DIBServiceFamily.ROUTING, version=2) assert dib.supports(DIBServiceFamily.DEVICE_MANAGEMENT) def test_dib_tunneling_info(self) -> None: """Test parsing of tunneling info.""" raw = ( b"\x24\x07\x00\xf8\x40\x01\x00\x05\x40\x02\x00\x05\x40\x03\x00\x05" b"\x40\x04\x00\x05\x40\x05\x00\x05\x40\x06\x00\x05\x40\x07\x00\x05" b"\x40\x08\x00\x06" ) dib = DIB.determine_dib(raw) assert isinstance(dib, DIBTunnelingInfo) assert dib.from_knx(raw) == 36 assert dib.max_apdu_length == 248 assert len(dib.slots) == 8 for address in ["4.0.1", "4.0.2", "4.0.3", "4.0.4", "4.0.5", "4.0.6", "4.0.7"]: assert dib.slots[IndividualAddress(address)].usable assert not dib.slots[IndividualAddress(address)].authorized assert dib.slots[IndividualAddress(address)].free assert dib.slots[IndividualAddress("4.0.8")].usable assert dib.slots[IndividualAddress("4.0.8")].authorized assert not dib.slots[IndividualAddress("4.0.8")].free assert dib.to_knx() == raw xknx-3.6.0/test/knxip_tests/disconnect_request_test.py000066400000000000000000000024341475530762600233760ustar00rootroot00000000000000"""Unit test for KNX/IP DisconnectRequest objects.""" import pytest from xknx.exceptions import CouldNotParseKNXIP from xknx.knxip import HPAI, DisconnectRequest, KNXIPFrame class TestKNXIPDisconnectRequest: """Test class for KNX/IP DisconnectRequest objects.""" def test_disconnect_request(self) -> None: """Test parsing and streaming DisconnectRequest KNX/IP packet.""" raw = bytes.fromhex("06 10 02 09 00 10 15 00 08 01 C0 A8 C8 0C C3 B4") knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, DisconnectRequest) assert knxipframe.body.communication_channel_id == 21 assert knxipframe.body.control_endpoint == HPAI( ip_addr="192.168.200.12", port=50100 ) disconnect_request = DisconnectRequest( communication_channel_id=21, control_endpoint=HPAI(ip_addr="192.168.200.12", port=50100), ) knxipframe2 = KNXIPFrame.init_from_body(disconnect_request) assert knxipframe2.to_knx() == raw def test_from_knx_wrong_length(self) -> None: """Test parsing and streaming wrong DisconnectRequest.""" raw = bytes((0x06, 0x10, 0x02, 0x09, 0x00, 0x10)) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) xknx-3.6.0/test/knxip_tests/disconnect_response_test.py000066400000000000000000000024011475530762600235360ustar00rootroot00000000000000"""Unit test for KNX/IP DisconnectResponse objects.""" import pytest from xknx.exceptions import CouldNotParseKNXIP from xknx.knxip import DisconnectResponse, ErrorCode, KNXIPFrame class TestKNXIPDisconnectResponse: """Test class for KNX/IP DisconnectResponse objects.""" def test_disconnect_response(self) -> None: """Test parsing and streaming DisconnectResponse KNX/IP packet.""" raw = bytes((0x06, 0x10, 0x02, 0x0A, 0x00, 0x08, 0x15, 0x25)) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, DisconnectResponse) assert knxipframe.body.communication_channel_id == 21 assert knxipframe.body.status_code == ErrorCode.E_NO_MORE_UNIQUE_CONNECTIONS disconnect_response = DisconnectResponse( communication_channel_id=21, status_code=ErrorCode.E_NO_MORE_UNIQUE_CONNECTIONS, ) knxipframe2 = KNXIPFrame.init_from_body(disconnect_response) assert knxipframe2.to_knx() == raw def test_from_knx_wrong_length(self) -> None: """Test parsing and streaming wrong DisconnectResponse.""" raw = bytes((0x06, 0x10, 0x02, 0x0A, 0x00, 0x08, 0x15)) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) xknx-3.6.0/test/knxip_tests/header_test.py000066400000000000000000000045361475530762600207320ustar00rootroot00000000000000"""Unit test for KNX/IP TunnellingAck objects.""" import pytest from xknx.exceptions import CouldNotParseKNXIP from xknx.knxip import DisconnectRequest, KNXIPHeader, KNXIPServiceType class TestKNXIPHeader: """Test class for KNX/IP Header objects.""" def test_from_knx(self) -> None: """Test parsing and streaming wrong Header (wrong length byte).""" raw = bytes((0x06, 0x10, 0x04, 0x21, 0x00, 0x0A)) header = KNXIPHeader() assert header.from_knx(raw) == 6 assert header.HEADERLENGTH == 6 assert header.PROTOCOLVERSION == 16 assert header.service_type_ident == KNXIPServiceType.TUNNELLING_ACK assert header.total_length == 10 assert header.to_knx() == raw def test_set_length(self) -> None: """Test setting length.""" header = KNXIPHeader() header.set_length(DisconnectRequest()) # 6 (header) + 2 + 8 (HPAI length) assert header.total_length == 16 def test_set_length_error(self) -> None: """Test setting length with wrong type.""" header = KNXIPHeader() with pytest.raises(TypeError): header.set_length(2) def test_from_knx_wrong_header(self) -> None: """Test parsing and streaming wrong Header (wrong length).""" raw = bytes((0x06, 0x10, 0x04, 0x21, 0x00)) header = KNXIPHeader() with pytest.raises(CouldNotParseKNXIP): header.from_knx(raw) def test_from_knx_wrong_header2(self) -> None: """Test parsing and streaming wrong Header (wrong length byte).""" raw = bytes((0x05, 0x10, 0x04, 0x21, 0x00, 0x0A)) header = KNXIPHeader() with pytest.raises(CouldNotParseKNXIP): header.from_knx(raw) def test_from_knx_wrong_header3(self) -> None: """Test parsing and streaming wrong Header (wrong protocol version).""" raw = bytes((0x06, 0x11, 0x04, 0x21, 0x00, 0x0A)) header = KNXIPHeader() with pytest.raises(CouldNotParseKNXIP): header.from_knx(raw) def test_from_knx_wrong_header4(self) -> None: """Test parsing and streaming wrong Header (unsupported service type).""" # 0x0000 as service type raw = bytes((0x06, 0x10, 0x00, 0x00, 0x00, 0x0A)) header = KNXIPHeader() with pytest.raises(CouldNotParseKNXIP): header.from_knx(raw) xknx-3.6.0/test/knxip_tests/hpai_test.py000066400000000000000000000050141475530762600204130ustar00rootroot00000000000000"""Unit test for KNX/IP HPAI objects.""" import pytest from xknx.exceptions import ConversionError, CouldNotParseKNXIP from xknx.knxip import HPAI, HostProtocol class TestKNXIPHPAI: """Test class for KNX/IP HPAI objects.""" def test_hpai_udp(self) -> None: """Test parsing and streaming HPAI KNX/IP fragment.""" raw = bytes((0x08, 0x01, 0xC0, 0xA8, 0x2A, 0x01, 0x84, 0x95)) hpai = HPAI() assert hpai.from_knx(raw) == 8 assert hpai.ip_addr == "192.168.42.1" assert hpai.port == 33941 assert hpai.protocol == HostProtocol.IPV4_UDP assert not hpai.route_back hpai2 = HPAI(ip_addr="192.168.42.1", port=33941) assert hpai2.to_knx() == raw assert not hpai2.route_back def test_hpai_tcp(self) -> None: """Test parsing and streaming HPAI KNX/IP fragment.""" raw = bytes((0x08, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)) hpai = HPAI() assert hpai.from_knx(raw) == 8 assert hpai.ip_addr == "0.0.0.0" assert hpai.port == 0 assert hpai.protocol == HostProtocol.IPV4_TCP assert hpai.route_back hpai2 = HPAI(ip_addr="0.0.0.0", port=0, protocol=HostProtocol.IPV4_TCP) assert hpai2.to_knx() == raw def test_from_knx_wrong_input1(self) -> None: """Test parsing of wrong HPAI KNX/IP packet (wrong length).""" raw = bytes((0x08, 0x01, 0xC0, 0xA8, 0x2A)) with pytest.raises(CouldNotParseKNXIP): HPAI().from_knx(raw) def test_from_knx_wrong_input2(self) -> None: """Test parsing of wrong HPAI KNX/IP packet (wrong length byte).""" raw = bytes((0x09, 0x01, 0xC0, 0xA8, 0x2A, 0x01, 0x84, 0x95)) with pytest.raises(CouldNotParseKNXIP): HPAI().from_knx(raw) def test_from_knx_wrong_input3(self) -> None: """Test parsing of wrong HPAI KNX/IP packet (wrong HPAI type).""" raw = bytes((0x08, 0x03, 0xC0, 0xA8, 0x2A, 0x01, 0x84, 0x95)) with pytest.raises(CouldNotParseKNXIP): HPAI().from_knx(raw) def test_to_knx_wrong_ip(self) -> None: """Test serializing HPAI to KNV/IP with wrong ip type.""" hpai = HPAI(ip_addr=127001) with pytest.raises(ConversionError): hpai.to_knx() def test_route_back(self) -> None: """Test route_back property.""" hpai = HPAI() assert hpai.ip_addr == "0.0.0.0" assert hpai.port == 0 assert hpai.route_back hpai.ip_addr = "10.1.2.3" assert not hpai.route_back xknx-3.6.0/test/knxip_tests/knxip_test.py000066400000000000000000000027641475530762600206340ustar00rootroot00000000000000"""Unit test for KNX/IP base class.""" import pytest from xknx.exceptions import CouldNotParseKNXIP, IncompleteKNXIPFrame from xknx.knxip import KNXIPFrame, KNXIPHeader from xknx.knxip.knxip_enum import KNXIPServiceType class TestKNXIPFrame: """Test class for KNX/IP base class.""" def test_wrong_init(self) -> None: """Testing init method with wrong service_type_ident.""" header = KNXIPHeader() header.service_type_ident = KNXIPServiceType.REMOTE_DIAG_RESPONSE with pytest.raises(CouldNotParseKNXIP): # this is not yet implemented in xknx KNXIPFrame.from_knx(header.to_knx()) def test_double_frame(self) -> None: """Test parsing KNX/IP frame from streaming data containing two frames.""" raw = bytes.fromhex( "06 10 04 20 00 15 04 02 51 00 29 00 bc e0 10 fa" "09 2d 01 00 80" "06 10 04 20 00 15 04 02 52 00 29 00 bc e0 10 1f" "08 2d 01 00 80" ) # both frames have length 21 frame_1, rest_1 = KNXIPFrame.from_knx(raw) frame_2, _rest_2 = KNXIPFrame.from_knx(rest_1) assert frame_1.header.total_length == 21 assert frame_2.header.total_length == 21 def test_parsing_too_short_knxip(self) -> None: """Test parsing and streaming connection state request KNX/IP packet.""" raw = bytes.fromhex("06 10 02 07 00 10 15 00 08 01 C0 A8 C8 0C C3") with pytest.raises(IncompleteKNXIPFrame): KNXIPFrame.from_knx(raw) xknx-3.6.0/test/knxip_tests/routing_busy_test.py000066400000000000000000000027711475530762600222320ustar00rootroot00000000000000"""Unit test for KNX/IP RoutingBusy objects.""" import pytest from xknx.exceptions import CouldNotParseKNXIP from xknx.knxip import KNXIPFrame, RoutingBusy class TestKNXIPRoutingBusy: """Test class for KNX/IP RoutingBusy objects.""" def test_routing_busy(self) -> None: """Test parsing and streaming RoutingBusy KNX/IP packet.""" raw = bytes( (0x06, 0x10, 0x05, 0x32, 0x00, 0x0C, 0x06, 0x00, 0x00, 0x64, 0x00, 0x00) ) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, RoutingBusy) assert knxipframe.body.device_state == 0 assert knxipframe.body.wait_time == 100 assert knxipframe.body.control_field == 0 routing_busy = RoutingBusy(wait_time=100) knxipframe2 = KNXIPFrame.init_from_body(routing_busy) assert knxipframe2.to_knx() == raw def test_from_knx_wrong_busy_information(self) -> None: """Test parsing and streaming wrong RoutingBusy (wrong length byte).""" raw = bytes( (0x06, 0x10, 0x05, 0x32, 0x00, 0x0C, 0x08, 0x00, 0x00, 0x64, 0x00, 0x00) ) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) def test_from_knx_wrong_busy_information2(self) -> None: """Test parsing and streaming wrong RoutingBusy (wrong length).""" raw = bytes((0x06, 0x10, 0x05, 0x32, 0x00, 0x0C, 0x06, 0x00, 0x00, 0x64, 0x00)) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) xknx-3.6.0/test/knxip_tests/routing_indication_test.py000066400000000000000000000061471475530762600233720ustar00rootroot00000000000000"""Unit test for KNX/IP RountingIndication objects.""" from xknx.cemi import CEMIFrame, CEMILData, CEMIMessageCode from xknx.dpt import DPTActiveEnergy from xknx.knxip import KNXIPFrame, RoutingIndication from xknx.telegram import GroupAddress, IndividualAddress, Telegram from xknx.telegram.apci import GroupValueWrite class TestKNXIPRountingIndication: """Class for KNX/IP RoutingIndication test.""" def test_from_knx(self) -> None: """Test parsing and streaming CEMIFrame KNX/IP packet (payload=0xf0).""" raw = bytes.fromhex("06 10 05 30 00 12 29 00 bc d0 12 02 01 51 02 00 40 f0") knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, RoutingIndication) assert isinstance(knxipframe.body.raw_cemi, bytes) assert len(knxipframe.body.raw_cemi) == 12 def test_from_knx_to_knx(self) -> None: """Test parsing and streaming CEMIFrame KNX/IP.""" raw = bytes.fromhex("06 10 05 30 00 12 29 00 bc d0 12 02 01 51 02 00 40 f0") knxipframe, _ = KNXIPFrame.from_knx(raw) assert knxipframe.header.to_knx() == raw[:6] assert knxipframe.body.to_knx() == raw[6:] assert knxipframe.to_knx() == raw def test_telegram_set(self) -> None: """Test parsing and streaming CEMIFrame KNX/IP packet with DPTArray as payload.""" telegram = Telegram( destination_address=GroupAddress(337), payload=GroupValueWrite( DPTActiveEnergy().to_knx(0x12345678), ), ) cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=CEMILData.init_from_telegram( telegram, src_addr=IndividualAddress("1.2.2"), ), ) assert isinstance(cemi.data, CEMILData) cemi.data.hops = 5 routing_indication = RoutingIndication(raw_cemi=cemi.to_knx()) knxipframe = KNXIPFrame.init_from_body(routing_indication) raw = bytes.fromhex( "06 10 05 30 00 15 29 00 bc d0 12 02 01 51 05 00 80 12 34 56 78" ) assert knxipframe.header.to_knx() == raw[:6] assert knxipframe.body.to_knx() == raw[6:] assert knxipframe.to_knx() == raw def test_end_to_end_routing_indication(self) -> None: """Test parsing and streaming RoutingIndication KNX/IP packet.""" # Switch off Kitchen-L1 raw = bytes.fromhex("06 10 05 30 00 11 29 00 bc d0 ff f9 01 49 01 00 80") knxipframe, _ = KNXIPFrame.from_knx(raw) raw_cemi = knxipframe.body.raw_cemi routing_indication = RoutingIndication(raw_cemi=raw_cemi) knxipframe2 = KNXIPFrame.init_from_body(routing_indication) assert knxipframe2.header.to_knx() == raw[:6] assert knxipframe2.body.to_knx() == raw[6:] assert knxipframe2.to_knx() == raw def test_from_knx_invalid_cemi(self) -> None: """Test parsing and streaming CEMIFrame KNX/IP packet with unsupported CEMICode.""" routing_indication = RoutingIndication() raw = bytes([43, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0]) assert routing_indication.from_knx(raw) == 11 xknx-3.6.0/test/knxip_tests/routing_lost_message_test.py000066400000000000000000000027421475530762600237330ustar00rootroot00000000000000"""Unit test for KNX/IP RoutingLostMessage objects.""" import pytest from xknx.exceptions import CouldNotParseKNXIP from xknx.knxip import KNXIPFrame, RoutingLostMessage class TestKNXIPRoutingLostMessage: """Test class for KNX/IP RoutingLostMessage objects.""" def test_routing_lost_message(self) -> None: """Test parsing and streaming RoutingLostMessage KNX/IP packet.""" raw = bytes((0x06, 0x10, 0x05, 0x31, 0x00, 0x0A, 0x04, 0x00, 0x00, 0x05)) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, RoutingLostMessage) assert knxipframe.body.device_state == 0 assert knxipframe.body.lost_messages == 5 routing_lost_message = RoutingLostMessage(lost_messages=5) knxipframe2 = KNXIPFrame.init_from_body(routing_lost_message) assert knxipframe2.to_knx() == raw def test_from_knx_wrong_lost_message_information(self) -> None: """Test parsing and streaming wrong RoutingLostMessage (wrong length byte).""" raw = bytes((0x06, 0x10, 0x05, 0x31, 0x00, 0x0A, 0x06, 0x00, 0x00, 0x05)) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) def test_from_knx_wrong_lost_message_information2(self) -> None: """Test parsing and streaming wrong RoutingLostMessage (wrong length).""" raw = bytes((0x06, 0x10, 0x05, 0x31, 0x00, 0x0A, 0x04, 0x00, 0x00)) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) xknx-3.6.0/test/knxip_tests/search_request_extended_test.py000066400000000000000000000055211475530762600243720ustar00rootroot00000000000000"""Unit test for KNX/IP SearchRequest Extended objects.""" from xknx.knxip import HPAI, KNXIPFrame, SearchRequestExtended from xknx.knxip.srp import SRP class TestKNXIPSearchRequestExtended: """Test class for KNX/IP SearchRequest Extended objects.""" def test_search_request_extended(self) -> None: """Test parsing and streaming SearchRequest Extended KNX/IP packet.""" raw = bytes.fromhex("06 10 02 0b 00 0e 08 01 e0 00 17 0C 0E 57") knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, SearchRequestExtended) assert knxipframe.body.discovery_endpoint == HPAI( ip_addr="224.0.23.12", port=3671 ) knxipframe2 = KNXIPFrame.init_from_body( SearchRequestExtended( discovery_endpoint=HPAI(ip_addr="224.0.23.12", port=3671) ) ) assert knxipframe2.to_knx() == raw def test_search_request_extended_srp(self) -> None: """Test parsing and streaming SearchRequest Extended with SRPs KNX/IP packet.""" raw = bytes.fromhex("06 10 02 0b 00 10 08 01 e0 00 17 0c 0e 57 02 81") knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, SearchRequestExtended) assert knxipframe.body.discovery_endpoint == HPAI( ip_addr="224.0.23.12", port=3671 ) assert len(knxipframe.body.srps) == 1 assert knxipframe.body.srps[0] == SRP.with_programming_mode() knxipframe2 = KNXIPFrame.init_from_body( SearchRequestExtended( discovery_endpoint=HPAI(ip_addr="224.0.23.12", port=3671), srps=[SRP.with_programming_mode()], ) ) assert knxipframe2.to_knx() == raw def test_search_request_extended_multiple_srp(self) -> None: """Test parsing and streaming SearchRequest Extended with SRPs KNX/IP packet.""" raw = bytes.fromhex( "06 10 02 0b 00 18 08 01 e0 00 17 0c 0e 57 02 81 08 82 01 02 03 04 05 06" ) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, SearchRequestExtended) assert knxipframe.body.discovery_endpoint == HPAI( ip_addr="224.0.23.12", port=3671 ) assert len(knxipframe.body.srps) == 2 assert knxipframe.body.srps[0] == SRP.with_programming_mode() assert knxipframe.body.srps[1] == SRP.with_mac_address( bytes([1, 2, 3, 4, 5, 6]) ) knxipframe2 = KNXIPFrame.init_from_body( SearchRequestExtended( discovery_endpoint=HPAI(ip_addr="224.0.23.12", port=3671), srps=[ SRP.with_programming_mode(), SRP.with_mac_address(bytes([1, 2, 3, 4, 5, 6])), ], ) ) assert knxipframe2.to_knx() == raw xknx-3.6.0/test/knxip_tests/search_request_test.py000066400000000000000000000014271475530762600225130ustar00rootroot00000000000000"""Unit test for KNX/IP SearchRequest objects.""" from xknx.knxip import HPAI, KNXIPFrame, SearchRequest class TestKNXIPSearchRequest: """Test class for KNX/IP SearchRequest objects.""" def test_search_request(self) -> None: """Test parsing and streaming SearchRequest KNX/IP packet.""" raw = bytes.fromhex("06 10 02 01 00 0E 08 01 E0 00 17 0C 0E 57") knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, SearchRequest) assert knxipframe.body.discovery_endpoint == HPAI( ip_addr="224.0.23.12", port=3671 ) knxipframe2 = KNXIPFrame.init_from_body( SearchRequest(discovery_endpoint=HPAI(ip_addr="224.0.23.12", port=3671)) ) assert knxipframe2.to_knx() == raw xknx-3.6.0/test/knxip_tests/search_response_extended_test.py000066400000000000000000000044471475530762600245460ustar00rootroot00000000000000"""Unit test for KNX/IP SearchResponseExtended objects.""" from xknx.knxip import ( HPAI, DIBDeviceInformation, DIBServiceFamily, DIBSuppSVCFamilies, KNXIPFrame, SearchResponseExtended, ) class TestKNXIPSearchResponseExtended: """Test class for KNX/IP SearchResponse Extended objects.""" def test_search_response_extended(self) -> None: """Test parsing and streaming SearchResponse KNX/IP packet.""" raw = bytes.fromhex( "06 10 02 0C 00 50 08 01 C0 A8 2A 0A 0E 57 36 01" "02 00 11 00 00 00 11 22 33 44 55 66 E0 00 17 0C" "01 02 03 04 05 06 47 69 72 61 20 4B 4E 58 2F 49" "50 2D 52 6F 75 74 65 72 00 00 00 00 00 00 00 00" "00 00 00 00 0C 02 02 01 03 02 04 01 05 01 07 01" ) knxipframe, rest = KNXIPFrame.from_knx(raw) assert knxipframe.header.total_length == 80 assert not rest assert knxipframe.to_knx() == raw assert isinstance(knxipframe.body, SearchResponseExtended) assert knxipframe.body.control_endpoint == HPAI("192.168.42.10", 3671) assert len(knxipframe.body.dibs) == 2 # Specific testing of parsing and serializing of # DIBDeviceInformation and DIBSuppSVCFamilies is # done within knxip_dib_test.py assert isinstance(knxipframe.body.dibs[0], DIBDeviceInformation) assert isinstance(knxipframe.body.dibs[1], DIBSuppSVCFamilies) assert knxipframe.body.device_name == "Gira KNX/IP-Router" assert knxipframe.body.dibs[1].supports(DIBServiceFamily.ROUTING) assert knxipframe.body.dibs[1].supports(DIBServiceFamily.TUNNELING) assert not knxipframe.body.dibs[1].supports(DIBServiceFamily.OBJECT_SERVER) search_response = SearchResponseExtended( control_endpoint=HPAI(ip_addr="192.168.42.10", port=3671) ) search_response.dibs.append(knxipframe.body.dibs[0]) search_response.dibs.append(knxipframe.body.dibs[1]) knxipframe2 = KNXIPFrame.init_from_body(search_response) assert knxipframe2.to_knx() == raw def test_unknown_device_name(self) -> None: """Test device_name if no DIBDeviceInformation is present.""" search_response = SearchResponseExtended() assert search_response.device_name == "UNKNOWN" xknx-3.6.0/test/knxip_tests/search_response_test.py000066400000000000000000000043451475530762600226630ustar00rootroot00000000000000"""Unit test for KNX/IP SearchResponse objects.""" from xknx.knxip import ( HPAI, DIBDeviceInformation, DIBServiceFamily, DIBSuppSVCFamilies, KNXIPFrame, SearchResponse, ) class TestKNXIPSearchResponse: """Test class for KNX/IP SearchResponse objects.""" def test_search_response(self) -> None: """Test parsing and streaming SearchResponse KNX/IP packet.""" raw = bytes.fromhex( "06 10 02 02 00 50 08 01 C0 A8 2A 0A 0E 57 36 01" "02 00 11 00 00 00 11 22 33 44 55 66 E0 00 17 0C" "01 02 03 04 05 06 47 69 72 61 20 4B 4E 58 2F 49" "50 2D 52 6F 75 74 65 72 00 00 00 00 00 00 00 00" "00 00 00 00 0C 02 02 01 03 02 04 01 05 01 07 01" ) knxipframe, rest = KNXIPFrame.from_knx(raw) assert knxipframe.header.total_length == 80 assert not rest assert knxipframe.to_knx() == raw assert isinstance(knxipframe.body, SearchResponse) assert knxipframe.body.control_endpoint == HPAI("192.168.42.10", 3671) assert len(knxipframe.body.dibs) == 2 # Specific testing of parsing and serializing of # DIBDeviceInformation and DIBSuppSVCFamilies is # done within knxip_dib_test.py assert isinstance(knxipframe.body.dibs[0], DIBDeviceInformation) assert isinstance(knxipframe.body.dibs[1], DIBSuppSVCFamilies) assert knxipframe.body.device_name == "Gira KNX/IP-Router" assert knxipframe.body.dibs[1].supports(DIBServiceFamily.ROUTING) assert knxipframe.body.dibs[1].supports(DIBServiceFamily.TUNNELING) assert not knxipframe.body.dibs[1].supports(DIBServiceFamily.OBJECT_SERVER) search_response = SearchResponse( control_endpoint=HPAI(ip_addr="192.168.42.10", port=3671) ) search_response.dibs.append(knxipframe.body.dibs[0]) search_response.dibs.append(knxipframe.body.dibs[1]) knxipframe2 = KNXIPFrame.init_from_body(search_response) assert knxipframe2.to_knx() == raw def test_unknown_device_name(self) -> None: """Test device_name if no DIBDeviceInformation is present.""" search_response = SearchResponse() assert search_response.device_name == "UNKNOWN" xknx-3.6.0/test/knxip_tests/secure_wrapper_test.py000066400000000000000000000041311475530762600225170ustar00rootroot00000000000000"""Unit test for KNX/IP SecureWrapper objects.""" from xknx.knxip import KNXIPFrame, SecureWrapper class TestKNXIPSecureWrapper: """Test class for KNX/IP SecureWrapper objects.""" def test_secure_wrapper(self) -> None: """Test parsing and streaming secure wrapper KNX/IP packet.""" sequence_number = bytes.fromhex("00 00 00 00 00 00") knx_serial_number = bytes.fromhex("00 fa 12 34 56 78") message_tag = bytes.fromhex("af fe") encrypted_data = bytes.fromhex( "79 15 a4 f3 6e 6e 42 08" # SessionAuthenticate Frame "d2 8b 4a 20 7d 8f 35 c0" "d1 38 c2 6a 7b 5e 71 69" ) message_authentication_code = bytes.fromhex( "52 db a8 e7 e4 bd 80 bd 7d 86 8a 3a e7 87 49 de" ) raw = ( bytes.fromhex( "06 10 09 50 00 3e" # KNXnet/IP header "00 01" # Secure Session Identifier ) + sequence_number + knx_serial_number + message_tag + encrypted_data + message_authentication_code ) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, SecureWrapper) assert knxipframe.body.secure_session_id == 1 assert knxipframe.body.sequence_information == sequence_number assert knxipframe.body.serial_number == knx_serial_number assert knxipframe.body.message_tag == message_tag assert knxipframe.body.encrypted_data == encrypted_data assert ( knxipframe.body.message_authentication_code == message_authentication_code ) assert knxipframe.to_knx() == raw secure_wrapper = SecureWrapper( secure_session_id=1, sequence_information=sequence_number, serial_number=knx_serial_number, message_tag=message_tag, encrypted_data=encrypted_data, message_authentication_code=message_authentication_code, ) knxipframe2 = KNXIPFrame.init_from_body(secure_wrapper) assert knxipframe2.to_knx() == raw xknx-3.6.0/test/knxip_tests/session_authenticate_test.py000066400000000000000000000024141475530762600237140ustar00rootroot00000000000000"""Unit test for KNX/IP SessionAuthenticate objects.""" from xknx.knxip import KNXIPFrame, SessionAuthenticate class TestKNXIPSessionAuthenticate: """Test class for KNX/IP SessionAuthenticate objects.""" def test_session_authenticate(self) -> None: """Test parsing and streaming session authenticate KNX/IP packet.""" message_authentication_code = bytes.fromhex( "1f 1d 59 ea 9f 12 a1 52" # Message Authentication Code "e5 d9 72 7f 08 46 2c de" ) raw = ( bytes.fromhex( "06 10 09 53 00 18" # KNXnet/IP header "00 01" # User ID ) + message_authentication_code ) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, SessionAuthenticate) assert knxipframe.body.user_id == 1 assert ( knxipframe.body.message_authentication_code == message_authentication_code ) assert knxipframe.to_knx() == raw session_authenticate = SessionAuthenticate( user_id=1, message_authentication_code=message_authentication_code, ) knxipframe2 = KNXIPFrame.init_from_body(session_authenticate) assert knxipframe2.to_knx() == raw xknx-3.6.0/test/knxip_tests/session_request_test.py000066400000000000000000000024211475530762600227240ustar00rootroot00000000000000"""Unit test for KNX/IP SessionRequest objects.""" from xknx.knxip import HPAI, KNXIPFrame, SessionRequest from xknx.knxip.knxip_enum import HostProtocol class TestKNXIPSessionRequest: """Test class for KNX/IP SessionRequest objects.""" def test_session_request(self) -> None: """Test parsing and streaming session request KNX/IP packet.""" public_key = bytes.fromhex( "0a a2 27 b4 fd 7a 32 31" # Diffie-Hellman Client Public Value X "9b a9 96 0a c0 36 ce 0e" "5c 45 07 b5 ae 55 16 1f" "10 78 b1 dc fb 3c b6 31" ) raw = ( bytes.fromhex( "06 10 09 51 00 2e" # KNXnet/IP header "08 02 00 00 00 00 00 00" # HPAI TCPv4 ) + public_key ) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, SessionRequest) assert knxipframe.body.control_endpoint == HPAI(protocol=HostProtocol.IPV4_TCP) assert knxipframe.body.ecdh_client_public_key == public_key assert knxipframe.to_knx() == raw session_request = SessionRequest(ecdh_client_public_key=public_key) knxipframe2 = KNXIPFrame.init_from_body(session_request) assert knxipframe2.to_knx() == raw xknx-3.6.0/test/knxip_tests/session_response_test.py000066400000000000000000000032041475530762600230720ustar00rootroot00000000000000"""Unit test for KNX/IP SessionResponse objects.""" from xknx.knxip import KNXIPFrame, SessionResponse class TestKNXIPSessionResponse: """Test class for KNX/IP SessionResponse objects.""" def test_session_response(self) -> None: """Test parsing and streaming session response KNX/IP packet.""" public_key = bytes.fromhex( "bd f0 99 90 99 23 14 3e" # Diffie-Hellman Server Public Value Y "f0 a5 de 0b 3b e3 68 7b" "c5 bd 3c f5 f9 e6 f9 01" "69 9c d8 70 ec 1f f8 24" ) message_authentication_code = bytes.fromhex( "a9 22 50 5a aa 43 61 63" # Message Authentication Code "57 0b d5 49 4c 2d f2 a3" ) raw = ( bytes.fromhex( "06 10 09 52 00 38" # KNXnet/IP header "00 01" # Secure Session Identifier ) + public_key + message_authentication_code ) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, SessionResponse) assert knxipframe.body.secure_session_id == 1 assert knxipframe.body.ecdh_server_public_key == public_key assert ( knxipframe.body.message_authentication_code == message_authentication_code ) assert knxipframe.to_knx() == raw session_response = SessionResponse( secure_session_id=1, ecdh_server_public_key=public_key, message_authentication_code=message_authentication_code, ) knxipframe2 = KNXIPFrame.init_from_body(session_response) assert knxipframe2.to_knx() == raw xknx-3.6.0/test/knxip_tests/session_status_test.py000066400000000000000000000020441475530762600225600ustar00rootroot00000000000000"""Unit test for KNX/IP SessionStatus objects.""" from xknx.knxip import KNXIPFrame, SessionStatus from xknx.knxip.knxip_enum import SecureSessionStatusCode class TestKNXIPSessionStatus: """Test class for KNX/IP SessionStatus objects.""" def test_session_status(self) -> None: """Test parsing and streaming session status KNX/IP packet.""" raw = bytes.fromhex( "06 10 09 54 00 08" # KNXnet/IP header "00" # status code 00h STATUS_AUTHENTICATION_SUCCESS "00" # reserved ) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, SessionStatus) assert ( knxipframe.body.status == SecureSessionStatusCode.STATUS_AUTHENTICATION_SUCCESS ) assert knxipframe.to_knx() == raw session_status = SessionStatus( status=SecureSessionStatusCode.STATUS_AUTHENTICATION_SUCCESS ) knxipframe2 = KNXIPFrame.init_from_body(session_status) assert knxipframe2.to_knx() == raw xknx-3.6.0/test/knxip_tests/srp_test.py000066400000000000000000000061201475530762600202750ustar00rootroot00000000000000"""Unit test for KNX/IP SRP objects.""" import pytest from xknx.exceptions import ConversionError, CouldNotParseKNXIP from xknx.knxip import DIBServiceFamily, DIBTypeCode, SearchRequestParameterType from xknx.knxip.srp import SRP class TestKNXIPSRP: """Test class for KNX/IP SRP objects.""" def test_basic(self) -> None: """Test SRP with mac contains mac address.""" srp: SRP = SRP.with_mac_address(bytes([1, 2, 3, 4, 5, 6])) assert len(srp) == SRP.SRP_HEADER_SIZE + 6 assert srp.type == SearchRequestParameterType.SELECT_BY_MAC_ADDRESS srp: SRP = SRP.with_service(DIBServiceFamily.TUNNELING, 2) assert len(srp) == SRP.SRP_HEADER_SIZE + 2 assert srp.type == SearchRequestParameterType.SELECT_BY_SERVICE srp: SRP = SRP.with_programming_mode() assert len(srp) == SRP.SRP_HEADER_SIZE assert srp.type == SearchRequestParameterType.SELECT_BY_PROGRAMMING_MODE dibs: list[DIBTypeCode] = [ DIBTypeCode.SUPP_SVC_FAMILIES, DIBTypeCode.ADDITIONAL_DEVICE_INFO, ] srp: SRP = SRP.request_device_description(dibs) assert len(srp) == SRP.SRP_HEADER_SIZE + len(dibs) assert srp.type == SearchRequestParameterType.REQUEST_DIBS def test_invalid_payload_raises(self) -> None: """Test SRP with invalid data size raises.""" with pytest.raises(ConversionError): SRP.with_mac_address(bytes([1, 2, 3, 4, 5, 6, 7, 8])) with pytest.raises(ConversionError): SRP(SearchRequestParameterType.SELECT_BY_SERVICE, True) with pytest.raises(ConversionError): SRP(SearchRequestParameterType.REQUEST_DIBS, True) def test_invalid_payload_from_knx_raises(self) -> None: """Test from_knx with invalid data size raises.""" # size is too big with pytest.raises(CouldNotParseKNXIP): SRP.from_knx(bytes.fromhex("08 82")) # too small with pytest.raises(CouldNotParseKNXIP): SRP.from_knx(bytes.fromhex("08")) @pytest.mark.parametrize( ("srp", "expected_bytes"), [ ( SRP.with_mac_address(bytes([1, 2, 3, 4, 5, 6])), bytes.fromhex("08 82 01 02 03 04 05 06"), ), ( SRP.with_service(DIBServiceFamily.TUNNELING, 2), bytes.fromhex("04 83 04 02"), ), ( SRP.with_programming_mode(), bytes.fromhex("02 81"), ), ( SRP.request_device_description( [DIBTypeCode.SUPP_SVC_FAMILIES, DIBTypeCode.TUNNELING_INFO] ), bytes.fromhex("04 04 02 07"), ), ( SRP.request_device_description([DIBTypeCode.SUPP_SVC_FAMILIES]), bytes.fromhex("04 04 02 00"), ), ], ) def test_to_and_from_knx(self, srp: SRP, expected_bytes: bytes) -> None: """Test to and from KNX.""" assert bytes(srp) == expected_bytes assert SRP.from_knx(expected_bytes) == srp xknx-3.6.0/test/knxip_tests/timer_notify_test.py000066400000000000000000000030731475530762600222050ustar00rootroot00000000000000"""Unit test for KNX/IP TimerNotify objects.""" from xknx.knxip import KNXIPFrame, TimerNotify class TestKNXIPTimerNotify: """Test class for KNX/IP TimerNotify objects.""" def test_timer_notify(self) -> None: """Test parsing and streaming TimerNotify KNX/IP packet.""" message_authentication_code = bytes.fromhex( "72 12 a0 3a aa e4 9d a8 56 89 77 4c 1d 2b 4d a4" ) raw = ( bytes.fromhex( "06 10 09 55 00 24" # KNXnet/IP header - length 36 octets "c0 c1 c2 c3 c4 c5" # timer value "00 fa 12 34 56 78" # KNX serial number "af fe" # message tag ) + message_authentication_code ) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, TimerNotify) assert knxipframe.body.timer_value == 211938428830917 assert knxipframe.body.serial_number == bytes.fromhex("00 fa 12 34 56 78") assert knxipframe.body.message_tag == bytes.fromhex("af fe") assert ( knxipframe.body.message_authentication_code == message_authentication_code ) assert knxipframe.to_knx() == raw timer_notify = TimerNotify( timer_value=211938428830917, serial_number=bytes.fromhex("00 fa 12 34 56 78"), message_tag=bytes.fromhex("af fe"), message_authentication_code=message_authentication_code, ) knxipframe2 = KNXIPFrame.init_from_body(timer_notify) assert knxipframe2.to_knx() == raw xknx-3.6.0/test/knxip_tests/tunnelling_ack_test.py000066400000000000000000000030641475530762600224720ustar00rootroot00000000000000"""Unit test for KNX/IP TunnellingAck objects.""" import pytest from xknx.exceptions import CouldNotParseKNXIP from xknx.knxip import ErrorCode, KNXIPFrame, TunnellingAck class TestKNXIPTunnellingAck: """Test class for KNX/IP TunnellingAck objects.""" def test_tunnelling_ack(self) -> None: """Test parsing and streaming tunneling ACK KNX/IP packet.""" raw = bytes((0x06, 0x10, 0x04, 0x21, 0x00, 0x0A, 0x04, 0x2A, 0x17, 0x00)) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, TunnellingAck) assert knxipframe.body.communication_channel_id == 42 assert knxipframe.body.sequence_counter == 23 assert knxipframe.body.status_code == ErrorCode.E_NO_ERROR tunnelling_ack = TunnellingAck( communication_channel_id=42, sequence_counter=23, ) knxipframe2 = KNXIPFrame.init_from_body(tunnelling_ack) assert knxipframe2.to_knx() == raw def test_from_knx_wrong_ack_information(self) -> None: """Test parsing and streaming wrong TunnellingAck (wrong length byte).""" raw = bytes((0x06, 0x10, 0x04, 0x21, 0x00, 0x0A, 0x03, 0x2A, 0x17, 0x00)) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) def test_from_knx_wrong_ack_information2(self) -> None: """Test parsing and streaming wrong TunnellingAck (wrong length).""" raw = bytes((0x06, 0x10, 0x04, 0x21, 0x00, 0x0A, 0x04, 0x2A, 0x17)) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) xknx-3.6.0/test/knxip_tests/tunnelling_feature_test.py000066400000000000000000000121741475530762600233710ustar00rootroot00000000000000"""Unit test for KNX/IP TunnellingRequest objects.""" import pytest from xknx.exceptions import CouldNotParseKNXIP from xknx.knxip import ( ErrorCode, KNXIPFrame, TunnellingFeatureGet, TunnellingFeatureInfo, TunnellingFeatureResponse, TunnellingFeatureSet, TunnellingFeatureType, ) from xknx.management.application_layer_enum import ReturnCode class TestKNXIPTunnellingFeature: """Test class for KNX/IP TunnelingFeature objects.""" def test_get(self) -> None: """Test parsing and streaming connection tunneling feature get KNX/IP packet.""" raw = bytes.fromhex("06 10 04 22 00 0c 04 01 17 00 03 00") knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, TunnellingFeatureGet) assert knxipframe.body.communication_channel_id == 1 assert knxipframe.body.sequence_counter == 23 assert ( knxipframe.body.feature_type == TunnellingFeatureType.BUS_CONNECTION_STATUS ) assert len(knxipframe.body.data) == 0 tunnelling_request = TunnellingFeatureGet( communication_channel_id=1, sequence_counter=23, feature_type=TunnellingFeatureType.BUS_CONNECTION_STATUS, ) knxipframe2 = KNXIPFrame.init_from_body(tunnelling_request) assert knxipframe2.to_knx() == raw def test_info(self) -> None: """Test parsing and streaming connection tunneling feature info KNX/IP packet.""" raw = bytes.fromhex("06 10 04 25 00 0e 04 01 17 00 03 00 01 00") knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, TunnellingFeatureInfo) assert knxipframe.body.communication_channel_id == 1 assert knxipframe.body.sequence_counter == 23 assert ( knxipframe.body.feature_type == TunnellingFeatureType.BUS_CONNECTION_STATUS ) assert knxipframe.body.data == b"\x01\x00" tunnelling_request = TunnellingFeatureInfo( communication_channel_id=1, sequence_counter=23, feature_type=TunnellingFeatureType.BUS_CONNECTION_STATUS, data=b"\x01\x00", ) knxipframe2 = KNXIPFrame.init_from_body(tunnelling_request) assert knxipframe2.to_knx() == raw def test_response(self) -> None: """Test parsing and streaming connection tunneling feature response KNX/IP packet.""" raw = bytes.fromhex("06 10 04 23 00 0e 04 01 17 00 03 00 01 00") knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, TunnellingFeatureResponse) assert knxipframe.body.communication_channel_id == 1 assert knxipframe.body.sequence_counter == 23 assert knxipframe.body.status_code == ErrorCode.E_NO_ERROR assert ( knxipframe.body.feature_type == TunnellingFeatureType.BUS_CONNECTION_STATUS ) assert knxipframe.body.return_code == ReturnCode.E_SUCCESS assert knxipframe.body.data == b"\x01\x00" tunnelling_request = TunnellingFeatureResponse( communication_channel_id=1, sequence_counter=23, feature_type=TunnellingFeatureType.BUS_CONNECTION_STATUS, return_code=ReturnCode.E_SUCCESS, data=b"\x01\x00", ) knxipframe2 = KNXIPFrame.init_from_body(tunnelling_request) assert knxipframe2.to_knx() == raw def test_set(self) -> None: """Test parsing and streaming connection tunneling feature set KNX/IP packet.""" raw = bytes.fromhex("06 10 04 24 00 0e 04 01 17 00 08 00 01 00") knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, TunnellingFeatureSet) assert knxipframe.body.communication_channel_id == 1 assert knxipframe.body.sequence_counter == 23 assert ( knxipframe.body.feature_type == TunnellingFeatureType.INTERFACE_FEATURE_INFO_ENABLE ) assert knxipframe.body.data == b"\x01\x00" tunnelling_request = TunnellingFeatureSet( communication_channel_id=1, sequence_counter=23, feature_type=TunnellingFeatureType.INTERFACE_FEATURE_INFO_ENABLE, data=b"\x01\x00", ) knxipframe2 = KNXIPFrame.init_from_body(tunnelling_request) assert knxipframe2.to_knx() == raw def test_from_knx_wrong_header(self) -> None: """Test parsing and streaming wrong Tunnelling Feature (wrong header length byte).""" raw = bytes.fromhex("06 10 04 22 00 0c 06 01 17 00 03 00") with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) def test_get_with_data(self) -> None: """Test parsing and streaming wrong Get (unexpected data).""" raw = bytes.fromhex("06 10 04 22 00 0e 04 01 17 00 03 00 01 00") with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) def test_set_without_data(self) -> None: """Test parsing and streaming wrong Get (missing data).""" raw = bytes.fromhex("06 10 04 24 00 0c 04 01 17 00 03 00") with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) xknx-3.6.0/test/knxip_tests/tunnelling_request_test.py000066400000000000000000000045621475530762600234300ustar00rootroot00000000000000"""Unit test for KNX/IP TunnellingRequest objects.""" import pytest from xknx.cemi import CEMIFrame, CEMILData, CEMIMessageCode from xknx.dpt import DPTBinary from xknx.exceptions import CouldNotParseKNXIP from xknx.knxip import KNXIPFrame, TunnellingRequest from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueWrite class TestKNXIPTunnellingRequest: """Test class for KNX/IP TunnellingRequest objects.""" def test_connect_request(self) -> None: """Test parsing and streaming connection tunneling request KNX/IP packet.""" raw = bytes.fromhex( "06 10 04 20 00 15 04 01 17 00 11 00 BC E0 00 00 48 08 01 00 81" ) knxipframe, _ = KNXIPFrame.from_knx(raw) assert isinstance(knxipframe.body, TunnellingRequest) assert knxipframe.body.communication_channel_id == 1 assert knxipframe.body.sequence_counter == 23 assert isinstance(knxipframe.body.raw_cemi, bytes) incoming_cemi = CEMIFrame.from_knx(knxipframe.body.raw_cemi) assert incoming_cemi.data.telegram() == Telegram( destination_address=GroupAddress("9/0/8"), payload=GroupValueWrite(DPTBinary(1)), ) outgoing_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_REQ, data=CEMILData.init_from_telegram( Telegram( destination_address=GroupAddress("9/0/8"), payload=GroupValueWrite(DPTBinary(1)), ), ), ) tunnelling_request = TunnellingRequest( communication_channel_id=1, sequence_counter=23, raw_cemi=outgoing_cemi.to_knx(), ) knxipframe2 = KNXIPFrame.init_from_body(tunnelling_request) assert knxipframe2.to_knx() == raw def test_from_knx_wrong_header(self) -> None: """Test parsing and streaming wrong TunnellingRequest (wrong header length byte).""" raw = bytes((0x06, 0x10, 0x04, 0x20, 0x00, 0x15, 0x03)) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) def test_from_knx_wrong_header2(self) -> None: """Test parsing and streaming wrong TunnellingRequest (wrong header length).""" raw = bytes((0x06, 0x10, 0x04, 0x20, 0x00, 0x15, 0x04)) with pytest.raises(CouldNotParseKNXIP): KNXIPFrame.from_knx(raw) xknx-3.6.0/test/management_tests/000077500000000000000000000000001475530762600170445ustar00rootroot00000000000000xknx-3.6.0/test/management_tests/__init__.py000066400000000000000000000000541475530762600211540ustar00rootroot00000000000000"""Unit tests for the Management module.""" xknx-3.6.0/test/management_tests/management_test.py000066400000000000000000000232231475530762600225730ustar00rootroot00000000000000"""Test management handling.""" import asyncio from unittest.mock import AsyncMock, call, patch import pytest from xknx import XKNX from xknx.exceptions import ( CommunicationError, ConfirmationError, ManagementConnectionError, ManagementConnectionTimeout, ) from xknx.management.management import MANAGAMENT_ACK_TIMEOUT from xknx.telegram import ( GroupAddress, IndividualAddress, Telegram, TelegramDirection, apci, tpci, ) from ..conftest import EventLoopClockAdvancer async def test_connect() -> None: """Test establishing connections.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() ia_1 = IndividualAddress("4.0.1") ia_2 = IndividualAddress("4.0.2") def tg_connect(ia: IndividualAddress) -> Telegram: return Telegram( source_address=xknx.current_address, destination_address=ia, direction=TelegramDirection.OUTGOING, tpci=tpci.TConnect(), ) def tg_disconnect(ia: IndividualAddress) -> Telegram: return Telegram( source_address=xknx.current_address, destination_address=ia, direction=TelegramDirection.OUTGOING, tpci=tpci.TDisconnect(), ) await xknx.management.connect(ia_1) conn_2 = await xknx.management.connect(ia_2) with pytest.raises(ManagementConnectionError): # no 2 connections to the same IA await xknx.management.connect(ia_1) assert xknx.cemi_handler.send_telegram.call_args_list == [ call(tg_connect(ia_1)), call(tg_connect(ia_2)), ] xknx.cemi_handler.send_telegram.reset_mock() await xknx.management.disconnect(ia_1) await conn_2.disconnect() assert xknx.cemi_handler.send_telegram.call_args_list == [ call(tg_disconnect(ia_1)), call(tg_disconnect(ia_2)), ] # connect again doesn't raise await xknx.management.connect(ia_1) async def test_ack_timeout(time_travel: EventLoopClockAdvancer) -> None: """Test ACK timeout handling.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() _ia = IndividualAddress("4.0.1") conn = await xknx.management.connect(_ia) xknx.cemi_handler.send_telegram.reset_mock() device_desc_read = Telegram( destination_address=_ia, tpci=tpci.TDataConnected(0), payload=apci.DeviceDescriptorRead(descriptor=0), ) task = asyncio.create_task( conn.request( payload=apci.DeviceDescriptorRead(descriptor=0), expected=apci.DeviceDescriptorResponse, ) ) await asyncio.sleep(0) assert xknx.cemi_handler.send_telegram.call_args_list == [ call(device_desc_read), ] await time_travel(MANAGAMENT_ACK_TIMEOUT) # telegram repeated assert xknx.cemi_handler.send_telegram.call_args_list == [ call(device_desc_read), call(device_desc_read), ] await time_travel(MANAGAMENT_ACK_TIMEOUT) with pytest.raises(ManagementConnectionTimeout): # still no ACK -> timeout await task await conn.disconnect() async def test_failed_connect_disconnect() -> None: """Test failing connections.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() ia_1 = IndividualAddress("4.0.1") xknx.cemi_handler.send_telegram.side_effect = ConfirmationError("") with pytest.raises(ManagementConnectionError): await xknx.management.connect(ia_1) xknx.cemi_handler.send_telegram.side_effect = CommunicationError("") with pytest.raises(ManagementConnectionError): await xknx.management.connect(ia_1) xknx.cemi_handler.send_telegram.side_effect = None conn_1 = await xknx.management.connect(ia_1) xknx.cemi_handler.send_telegram.side_effect = ConfirmationError("") with pytest.raises(ManagementConnectionError): await xknx.management.disconnect(ia_1) xknx.cemi_handler.send_telegram.side_effect = None conn_1 = await xknx.management.connect(ia_1) xknx.cemi_handler.send_telegram.side_effect = CommunicationError("") with pytest.raises(ManagementConnectionError): await conn_1.disconnect() async def test_reject_incoming_connection() -> None: """Test rejecting incoming transport connections.""" # Note: incoming L_DATA.ind indication connection requests are rejected # L_DATA.req frames received from a tunnelling client are not yet supported xknx = XKNX() individual_address = IndividualAddress("4.0.10") connect = Telegram( source_address=individual_address, destination_address=xknx.current_address, direction=TelegramDirection.INCOMING, tpci=tpci.TConnect(), ) disconnect = Telegram( source_address=xknx.current_address, destination_address=individual_address, tpci=tpci.TDisconnect(), ) with patch("xknx.cemi.CEMIHandler.send_telegram") as send_telegram: xknx.cemi_handler.telegram_received(connect) await asyncio.sleep(0) assert send_telegram.call_args_list == [call(disconnect)] async def test_incoming_unexpected_numbered_telegram() -> None: """Test incoming unexpected numbered telegram is acked.""" xknx = XKNX() individual_address = IndividualAddress("4.0.10") device_desc_read = Telegram( source_address=individual_address, destination_address=xknx.current_address, direction=TelegramDirection.INCOMING, tpci=tpci.TDataConnected(0), payload=apci.DeviceDescriptorRead(descriptor=0), ) ack = Telegram( source_address=xknx.current_address, destination_address=individual_address, direction=TelegramDirection.OUTGOING, tpci=tpci.TAck(0), ) with patch("xknx.cemi.CEMIHandler.send_telegram") as send_telegram: xknx.cemi_handler.telegram_received(device_desc_read) await asyncio.sleep(0) assert send_telegram.call_args_list == [call(ack)] async def test_incoming_wrong_address() -> None: """Test incoming telegrams addressed to different devices.""" xknx = XKNX() individual_address = IndividualAddress("4.0.10") other_address = IndividualAddress("4.0.11") assert xknx.current_address != other_address connect = Telegram( source_address=individual_address, destination_address=other_address, direction=TelegramDirection.INCOMING, tpci=tpci.TConnect(), ) ack = Telegram( source_address=individual_address, destination_address=other_address, direction=TelegramDirection.INCOMING, tpci=tpci.TAck(0), ) disconnect = Telegram( source_address=individual_address, destination_address=other_address, direction=TelegramDirection.INCOMING, tpci=tpci.TDisconnect(), ) with patch("xknx.cemi.CEMIHandler.send_telegram") as send_telegram: xknx.cemi_handler.telegram_received(connect) xknx.cemi_handler.telegram_received(ack) xknx.cemi_handler.telegram_received(disconnect) await asyncio.sleep(0) send_telegram.assert_not_called() async def test_broadcast_message() -> None: """Test broadcast message sending.""" xknx = XKNX() test_telegram = Telegram( source_address=IndividualAddress("0.0.0"), destination_address=GroupAddress("0/0/0"), direction=TelegramDirection.OUTGOING, tpci=tpci.TDataBroadcast(), payload=apci.IndividualAddressRead(), ) with patch("xknx.cemi.CEMIHandler.send_telegram") as send_telegram: await xknx.management.send_broadcast(apci.IndividualAddressRead()) assert send_telegram.call_args_list == [call(test_telegram)] @pytest.mark.parametrize("rate_limit", [0, 1]) async def test_p2p_rate_limit( time_travel: EventLoopClockAdvancer, rate_limit: int ) -> None: """Test rate limit for P2P management connections.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() ia = IndividualAddress("4.0.1") def send_responses(index: int) -> None: ack = Telegram( source_address=ia, destination_address=IndividualAddress(0), direction=TelegramDirection.INCOMING, tpci=tpci.TAck(index), ) device_desc_resp = Telegram( source_address=ia, destination_address=IndividualAddress(0), direction=TelegramDirection.INCOMING, tpci=tpci.TDataConnected(index), payload=apci.DeviceDescriptorResponse(), ) xknx.management.process(ack) xknx.management.process(device_desc_resp) conn = await xknx.management.connect(ia, rate_limit) # create task and request data task = asyncio.create_task( conn.request( payload=apci.DeviceDescriptorRead(descriptor=0), expected=apci.DeviceDescriptorResponse, ) ) await asyncio.sleep(0) send_responses(0) await task xknx.cemi_handler.reset_mock() # create second task task = asyncio.create_task( conn.request( payload=apci.DeviceDescriptorRead(descriptor=0), expected=apci.DeviceDescriptorResponse, ) ) await asyncio.sleep(0) if rate_limit: await time_travel(0.5 / rate_limit) # the request is still queued assert not xknx.cemi_handler.send_telegram.call_args_list await time_travel(0.5 / rate_limit) # the requests should be sent now, the behaviour should match no rate limit assert xknx.cemi_handler.send_telegram.call_args_list == [ call( Telegram( destination_address=ia, tpci=tpci.TDataConnected(1), payload=apci.DeviceDescriptorRead(descriptor=0), ) ), ] send_responses(1) await task xknx-3.6.0/test/management_tests/procedures_test.py000066400000000000000000000626241475530762600226420ustar00rootroot00000000000000"""Test management procedures.""" import asyncio from unittest.mock import AsyncMock, call import pytest from xknx import XKNX from xknx.exceptions import ManagementConnectionError from xknx.management import procedures from xknx.management.management import MANAGAMENT_CONNECTION_TIMEOUT from xknx.telegram import ( GroupAddress, IndividualAddress, Telegram, TelegramDirection, apci, tpci, ) from ..conftest import EventLoopClockAdvancer async def test_dm_restart() -> None: """Test dm_restart.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() individual_address = IndividualAddress("4.0.10") connect = Telegram(destination_address=individual_address, tpci=tpci.TConnect()) restart = Telegram( destination_address=individual_address, tpci=tpci.TDataConnected(0), payload=apci.Restart(), ) disconnect = Telegram( destination_address=individual_address, tpci=tpci.TDisconnect(), ) await procedures.dm_restart(xknx, individual_address) assert xknx.cemi_handler.send_telegram.call_args_list == [ call(connect), call(restart), call(disconnect), ] async def test_nm_individual_address_check_success() -> None: """Test nm_individual_address_check.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() individual_address = IndividualAddress("4.0.10") connect = Telegram(destination_address=individual_address, tpci=tpci.TConnect()) device_desc_read = Telegram( destination_address=individual_address, tpci=tpci.TDataConnected(0), payload=apci.DeviceDescriptorRead(descriptor=0), ) ack = Telegram( source_address=individual_address, destination_address=IndividualAddress(0), direction=TelegramDirection.INCOMING, tpci=tpci.TAck(0), ) device_desc_resp = Telegram( source_address=individual_address, destination_address=IndividualAddress(0), direction=TelegramDirection.INCOMING, tpci=tpci.TDataConnected(0), payload=apci.DeviceDescriptorResponse(), ) task = asyncio.create_task( procedures.nm_individual_address_check(xknx, individual_address) ) await asyncio.sleep(0) assert xknx.cemi_handler.send_telegram.call_args_list == [ call(connect), call(device_desc_read), ] # receive response xknx.management.process(ack) xknx.management.process(device_desc_resp) assert await task async def test_nm_individual_address_check_refused() -> None: """Test nm_individual_address_check.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() individual_address = IndividualAddress("4.0.10") connect = Telegram(destination_address=individual_address, tpci=tpci.TConnect()) device_desc_read = Telegram( destination_address=individual_address, tpci=tpci.TDataConnected(0), payload=apci.DeviceDescriptorRead(descriptor=0), ) ack = Telegram( source_address=individual_address, destination_address=IndividualAddress(0), direction=TelegramDirection.INCOMING, tpci=tpci.TAck(0), ) disconnect = Telegram( source_address=individual_address, destination_address=IndividualAddress(0), direction=TelegramDirection.INCOMING, tpci=tpci.TDisconnect(), ) task = asyncio.create_task( procedures.nm_individual_address_check(xknx, individual_address) ) await asyncio.sleep(0) assert xknx.cemi_handler.send_telegram.call_args_list == [ call(connect), call(device_desc_read), ] xknx.management.process(disconnect) xknx.management.process(ack) assert await task async def test_nm_individual_address_read(time_travel: EventLoopClockAdvancer) -> None: """Test nm_individual_address_read.""" _timeout = 2 xknx = XKNX() xknx.cemi_handler = AsyncMock() individual_address_1 = IndividualAddress("1.1.4") individual_address_2 = IndividualAddress("15.15.255") task = asyncio.create_task( procedures.nm_individual_address_read(xknx=xknx, timeout=_timeout) ) address_broadcast = Telegram( GroupAddress("0/0/0"), payload=apci.IndividualAddressRead() ) address_reply_message_1 = Telegram( source_address=individual_address_1, destination_address=GroupAddress("0/0/0"), payload=apci.IndividualAddressResponse(), ) address_reply_message_2 = Telegram( source_address=individual_address_2, destination_address=GroupAddress("0/0/0"), payload=apci.IndividualAddressResponse(), ) await asyncio.sleep(0) assert xknx.cemi_handler.send_telegram.call_args_list == [ call(address_broadcast), ] xknx.management.process(address_reply_message_1) xknx.management.process(address_reply_message_2) await time_travel(_timeout) assert await task async def test_nm_individual_address_read_multiple() -> None: """Test nm_individual_address_read.""" _timeout = 2 xknx = XKNX() xknx.cemi_handler = AsyncMock() individual_address_1 = IndividualAddress("1.1.4") individual_address_2 = IndividualAddress("15.15.255") task = asyncio.create_task( procedures.nm_individual_address_read( xknx=xknx, timeout=_timeout, raise_if_multiple=True ) ) address_broadcast = Telegram( GroupAddress("0/0/0"), payload=apci.IndividualAddressRead() ) address_reply_message_1 = Telegram( source_address=individual_address_1, destination_address=GroupAddress("0/0/0"), payload=apci.IndividualAddressResponse(), ) address_reply_message_2 = Telegram( source_address=individual_address_2, destination_address=GroupAddress("0/0/0"), payload=apci.IndividualAddressResponse(), ) await asyncio.sleep(0) assert xknx.cemi_handler.send_telegram.call_args_list == [ call(address_broadcast), ] xknx.management.process(address_reply_message_1) xknx.management.process(address_reply_message_2) # no need to wait for _timeout due to `raise_if_multiple=True`` with pytest.raises(ManagementConnectionError): await task async def test_nm_individual_address_write(time_travel: EventLoopClockAdvancer) -> None: """Test nm_individual_address_write.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() individual_address_old = IndividualAddress("15.15.255") individual_address_new = IndividualAddress("1.1.4") connect = Telegram(destination_address=individual_address_new, tpci=tpci.TConnect()) device_desc_read = Telegram( destination_address=individual_address_new, tpci=tpci.TDataConnected(0), direction=TelegramDirection.OUTGOING, payload=apci.DeviceDescriptorRead(descriptor=0), ) address_reply_message = Telegram( source_address=individual_address_old, destination_address=GroupAddress("0/0/0"), direction=TelegramDirection.INCOMING, payload=apci.IndividualAddressResponse(), ) device_desc_resp = Telegram( source_address=individual_address_new, destination_address=IndividualAddress(0), direction=TelegramDirection.INCOMING, tpci=tpci.TDataConnected(0), payload=apci.DeviceDescriptorResponse(), ) ack = Telegram( source_address=individual_address_new, destination_address=IndividualAddress(0), direction=TelegramDirection.INCOMING, tpci=tpci.TAck(0), ) ack2 = Telegram( destination_address=individual_address_new, source_address=IndividualAddress(0), direction=TelegramDirection.OUTGOING, tpci=tpci.TAck(0), ) disconnect = Telegram( destination_address=individual_address_new, tpci=tpci.TDisconnect(), ) individual_address_read = Telegram( GroupAddress("0/0/0"), payload=apci.IndividualAddressRead() ) individual_address_write = Telegram( GroupAddress("0/0/0"), payload=apci.IndividualAddressWrite(address=individual_address_new), ) task = asyncio.create_task( procedures.nm_individual_address_write( xknx=xknx, individual_address=individual_address_new ) ) # make sure first request (address check) times out await time_travel(MANAGAMENT_CONNECTION_TIMEOUT) await time_travel(MANAGAMENT_CONNECTION_TIMEOUT) # send response to device in programming mode xknx.management.process(address_reply_message) # confirm device is up and running await time_travel(MANAGAMENT_CONNECTION_TIMEOUT) xknx.management.process(ack) xknx.management.process(device_desc_resp) assert xknx.cemi_handler.send_telegram.call_args_list == [ call(connect), call(device_desc_read), call(device_desc_read), # due to retransmit call(disconnect), call(individual_address_read), call(individual_address_write), call(connect), call(device_desc_read), call(ack2), ] await task async def test_nm_individual_address_write_two_devices_in_programming_mode( time_travel: EventLoopClockAdvancer, ) -> None: """Test nm_individual_address_write.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() individual_address_old = IndividualAddress("15.15.255") individual_address_new = IndividualAddress("1.1.4") connect = Telegram(destination_address=individual_address_new, tpci=tpci.TConnect()) device_desc_read = Telegram( destination_address=individual_address_new, tpci=tpci.TDataConnected(0), payload=apci.DeviceDescriptorRead(descriptor=0), ) address_reply_message = Telegram( source_address=individual_address_old, destination_address=GroupAddress("0/0/0"), direction=TelegramDirection.INCOMING, payload=apci.IndividualAddressResponse(), ) disconnect = Telegram( destination_address=individual_address_new, tpci=tpci.TDisconnect(), ) individual_address_read = Telegram( GroupAddress("0/0/0"), payload=apci.IndividualAddressRead() ) task = asyncio.create_task( procedures.nm_individual_address_write( xknx=xknx, individual_address=individual_address_new ) ) # make sure first request (address check) times out await time_travel(0) # start await time_travel(3) # first timeout await time_travel(3) # second timeout assert xknx.cemi_handler.send_telegram.call_args_list == [ call(connect), call(device_desc_read), call(device_desc_read), # due to retransmit call(disconnect), call(individual_address_read), ] # receive two responses from devices in programming mode xknx.management.process(address_reply_message) xknx.management.process(address_reply_message) with pytest.raises( ManagementConnectionError, match="More than one KNX device is in programming mode", ): await task assert len(xknx.cemi_handler.send_telegram.call_args_list) == 5 async def test_nm_individual_address_write_no_device_programming_mode( time_travel: EventLoopClockAdvancer, ) -> None: """Test nm_individual_address_write.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() individual_address_new = IndividualAddress("1.1.4") connect = Telegram(destination_address=individual_address_new, tpci=tpci.TConnect()) device_desc_read = Telegram( destination_address=individual_address_new, tpci=tpci.TDataConnected(0), payload=apci.DeviceDescriptorRead(descriptor=0), ) disconnect = Telegram( destination_address=individual_address_new, tpci=tpci.TDisconnect(), ) individual_address_read = Telegram( GroupAddress("0/0/0"), payload=apci.IndividualAddressRead() ) task = asyncio.create_task( procedures.nm_individual_address_write( xknx=xknx, individual_address=individual_address_new ) ) # make sure first request (address check) times out await time_travel(0) assert xknx.cemi_handler.send_telegram.call_args_list == [ call(connect), call(device_desc_read), ] # first timeout - retransmit DeviceDescriptorRead await time_travel(3) assert xknx.cemi_handler.send_telegram.call_args_list[2:] == [ call(device_desc_read), ] # retry also timed out await time_travel(3) assert xknx.cemi_handler.send_telegram.call_args_list[3:] == [ call(disconnect), call(individual_address_read), ] # IndividualAddressRead also times out await time_travel(3) with pytest.raises( ManagementConnectionError, match="No device in programming mode" ): await task assert len(xknx.cemi_handler.send_telegram.call_args_list) == 5 async def test_nm_individual_address_write_address_found( time_travel: EventLoopClockAdvancer, ) -> None: """Test nm_individual_address_write.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() individual_address = IndividualAddress("1.1.4") connect = Telegram(destination_address=individual_address, tpci=tpci.TConnect()) device_desc_read = Telegram( destination_address=individual_address, tpci=tpci.TDataConnected(0), payload=apci.DeviceDescriptorRead(descriptor=0), ) ack_in = Telegram( source_address=individual_address, destination_address=IndividualAddress(0), direction=TelegramDirection.INCOMING, tpci=tpci.TAck(0), ) ack_out = Telegram( source_address=IndividualAddress(0), destination_address=individual_address, tpci=tpci.TAck(0), ) device_desc_resp = Telegram( source_address=individual_address, destination_address=IndividualAddress(0), direction=TelegramDirection.INCOMING, tpci=tpci.TDataConnected(0), payload=apci.DeviceDescriptorResponse(), ) disconnect = Telegram( destination_address=individual_address, tpci=tpci.TDisconnect(), ) individual_address_read = Telegram( GroupAddress("0/0/0"), payload=apci.IndividualAddressRead() ) task = asyncio.create_task( procedures.nm_individual_address_write( xknx=xknx, individual_address=individual_address ) ) # first request (address check) succeeds await time_travel(0) xknx.management.process(ack_in) xknx.management.process(device_desc_resp) assert xknx.cemi_handler.send_telegram.call_args_list == [ call(connect), call(device_desc_read), call(ack_out), ] await time_travel(0) assert xknx.cemi_handler.send_telegram.call_args_list[3:] == [ call(disconnect), call(individual_address_read), ] # second request times out - no device in programming mode await time_travel(3) with pytest.raises( ManagementConnectionError, match="No device in programming mode" ): await task assert len(xknx.cemi_handler.send_telegram.call_args_list) == 5 async def test_nm_individual_address_write_programming_failed( time_travel: EventLoopClockAdvancer, ) -> None: """Test nm_individual_address_write.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() individual_address_old = IndividualAddress("15.15.255") individual_address_new = IndividualAddress("1.1.4") connect = Telegram(destination_address=individual_address_new, tpci=tpci.TConnect()) device_desc_read = Telegram( destination_address=individual_address_new, tpci=tpci.TDataConnected(0), direction=TelegramDirection.OUTGOING, payload=apci.DeviceDescriptorRead(descriptor=0), ) address_reply_message = Telegram( source_address=individual_address_old, destination_address=GroupAddress("0/0/0"), direction=TelegramDirection.INCOMING, payload=apci.IndividualAddressResponse(), ) disconnect = Telegram( destination_address=individual_address_new, tpci=tpci.TDisconnect(), ) individual_address_read = Telegram( GroupAddress("0/0/0"), payload=apci.IndividualAddressRead() ) individual_address_write = Telegram( GroupAddress("0/0/0"), payload=apci.IndividualAddressWrite(address=individual_address_new), ) task = asyncio.create_task( procedures.nm_individual_address_write( xknx=xknx, individual_address=individual_address_new ) ) # make sure first request (address check) times out await time_travel(MANAGAMENT_CONNECTION_TIMEOUT) await time_travel(MANAGAMENT_CONNECTION_TIMEOUT) # send response to device in programming mode xknx.management.process(address_reply_message) # device experienced error, so set connection request timeout await time_travel(MANAGAMENT_CONNECTION_TIMEOUT) await time_travel(MANAGAMENT_CONNECTION_TIMEOUT) await time_travel(MANAGAMENT_CONNECTION_TIMEOUT) assert xknx.cemi_handler.send_telegram.call_args_list == [ call(connect), call(device_desc_read), call(device_desc_read), # due to retransmit call(disconnect), call(individual_address_read), call(individual_address_write), call(connect), call(device_desc_read), call(device_desc_read), call(disconnect), ] with pytest.raises(ManagementConnectionError): await task async def test_nm_individual_address_write_address_found_other_in_programming_mode( time_travel: EventLoopClockAdvancer, ) -> None: """Test nm_individual_address_write.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() individual_address = IndividualAddress("1.1.5") individual_address_pgm = IndividualAddress("1.1.4") connect = Telegram(destination_address=individual_address, tpci=tpci.TConnect()) device_desc_read = Telegram( destination_address=individual_address, tpci=tpci.TDataConnected(0), payload=apci.DeviceDescriptorRead(descriptor=0), ) ack = Telegram( source_address=individual_address, destination_address=IndividualAddress(0), direction=TelegramDirection.INCOMING, tpci=tpci.TAck(0), ) ack2 = Telegram( source_address=IndividualAddress(0), destination_address=individual_address, tpci=tpci.TAck(0), ) device_desc_resp = Telegram( source_address=individual_address, destination_address=IndividualAddress(0), direction=TelegramDirection.INCOMING, tpci=tpci.TDataConnected(0), payload=apci.DeviceDescriptorResponse(), ) disconnect = Telegram( destination_address=individual_address, tpci=tpci.TDisconnect(), ) individual_address_read = Telegram( GroupAddress("0/0/0"), payload=apci.IndividualAddressRead() ) address_reply_message = Telegram( source_address=individual_address_pgm, destination_address=GroupAddress("0/0/0"), direction=TelegramDirection.INCOMING, payload=apci.IndividualAddressResponse(), ) task = asyncio.create_task( procedures.nm_individual_address_write( xknx=xknx, individual_address=individual_address ) ) # make sure first request (address check) times out await time_travel(0) xknx.management.process(ack) xknx.management.process(device_desc_resp) await time_travel(MANAGAMENT_CONNECTION_TIMEOUT) xknx.management.process(address_reply_message) assert xknx.cemi_handler.send_telegram.call_args_list == [ call(connect), call(device_desc_read), call(ack2), call(disconnect), call(individual_address_read), ] with pytest.raises(ManagementConnectionError): await task async def test_nm_individual_address_serial_number_read( time_travel: EventLoopClockAdvancer, ) -> None: """Test nm_individual_address_serial_number_read.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() individual_address = IndividualAddress("1.1.5") serial_number = b"aabbccddeeff" task = asyncio.create_task( procedures.nm_individual_address_serial_number_read( xknx=xknx, serial=serial_number ) ) read_address = Telegram( destination_address=GroupAddress("0/0/0"), payload=apci.IndividualAddressSerialRead(serial=serial_number), ) address_reply = Telegram( source_address=individual_address, destination_address=GroupAddress("0/0/0"), direction=TelegramDirection.INCOMING, payload=apci.IndividualAddressSerialResponse( address=individual_address, serial=serial_number ), ) await time_travel(0) assert xknx.cemi_handler.send_telegram.call_args_list == [ call(read_address), ] xknx.management.process(address_reply) assert await task == individual_address async def test_nm_individual_address_serial_number_read_fail( time_travel: EventLoopClockAdvancer, ) -> None: """Test nm_individual_address_serial_number_read.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() serial_number = b"aabbccddeeff" task = asyncio.create_task( procedures.nm_individual_address_serial_number_read( xknx=xknx, serial=serial_number ) ) read_address = Telegram( destination_address=GroupAddress("0/0/0"), payload=apci.IndividualAddressSerialRead(serial=serial_number), ) await time_travel(0) assert xknx.cemi_handler.send_telegram.call_args_list == [ call(read_address), ] await time_travel(3) assert await task is None async def test_nm_individual_address_serial_number_write( time_travel: EventLoopClockAdvancer, ) -> None: """Test nm_individual_address_serial_number_write.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() serial_number = b"aabbccddeeff" individual_address = IndividualAddress("1.1.5") task = asyncio.create_task( procedures.nm_individual_address_serial_number_write( xknx=xknx, serial=serial_number, individual_address=individual_address ) ) write_address = Telegram( destination_address=GroupAddress("0/0/0"), payload=apci.IndividualAddressSerialWrite( serial=serial_number, address=individual_address ), ) read_address = Telegram( destination_address=GroupAddress("0/0/0"), payload=apci.IndividualAddressSerialRead(serial=serial_number), ) address_reply = Telegram( source_address=individual_address, destination_address=GroupAddress("0/0/0"), direction=TelegramDirection.INCOMING, payload=apci.IndividualAddressSerialResponse( address=individual_address, serial=serial_number ), ) await time_travel(0) assert xknx.cemi_handler.send_telegram.call_args_list == [ call(write_address), call(read_address), ] xknx.management.process(address_reply) await task async def test_nm_individual_address_serial_number_write_fail_no_response( time_travel: EventLoopClockAdvancer, ) -> None: """Test nm_individual_address_serial_number_write.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() serial_number = b"aabbccddeeff" individual_address = IndividualAddress("1.1.5") task = asyncio.create_task( procedures.nm_individual_address_serial_number_write( xknx=xknx, serial=serial_number, individual_address=individual_address ) ) write_address = Telegram( destination_address=GroupAddress("0/0/0"), payload=apci.IndividualAddressSerialWrite( serial=serial_number, address=individual_address ), ) read_address = Telegram( destination_address=GroupAddress("0/0/0"), payload=apci.IndividualAddressSerialRead(serial=serial_number), ) await time_travel(0) assert xknx.cemi_handler.send_telegram.call_args_list == [ call(write_address), call(read_address), ] await time_travel(3) with pytest.raises(ManagementConnectionError): await task async def test_nm_individual_address_serial_number_write_fail_wrong_address( time_travel: EventLoopClockAdvancer, ) -> None: """Test nm_individual_address_serial_number_write.""" xknx = XKNX() xknx.cemi_handler = AsyncMock() serial_number = b"aabbccddeeff" individual_address_tx = IndividualAddress("1.1.5") individual_address_rx = IndividualAddress("1.1.6") task = asyncio.create_task( procedures.nm_individual_address_serial_number_write( xknx=xknx, serial=serial_number, individual_address=individual_address_tx ) ) write_address = Telegram( destination_address=GroupAddress("0/0/0"), payload=apci.IndividualAddressSerialWrite( serial=serial_number, address=individual_address_tx ), ) read_address = Telegram( destination_address=GroupAddress("0/0/0"), payload=apci.IndividualAddressSerialRead(serial=serial_number), ) address_reply = Telegram( source_address=individual_address_rx, destination_address=GroupAddress("0/0/0"), direction=TelegramDirection.INCOMING, payload=apci.IndividualAddressSerialResponse( address=individual_address_rx, serial=serial_number ), ) await time_travel(0) assert xknx.cemi_handler.send_telegram.call_args_list == [ call(write_address), call(read_address), ] xknx.management.process(address_reply) with pytest.raises(ManagementConnectionError): await task xknx-3.6.0/test/remote_value_tests/000077500000000000000000000000001475530762600174175ustar00rootroot00000000000000xknx-3.6.0/test/remote_value_tests/__init__.py000066400000000000000000000000551475530762600215300ustar00rootroot00000000000000"""Unit tests for the RemoteValue module.""" xknx-3.6.0/test/remote_value_tests/remote_value_by_length_test.py000066400000000000000000000057751475530762600255700ustar00rootroot00000000000000"""Unit test for RemoteValueByLength objects.""" import pytest from xknx import XKNX from xknx.dpt import ( DPTArray, DPTBase, DPTBinary, DPTColorXYY, DPTOpenClose, DPTPressure, DPTPressure2Byte, DPTTemperature, DPTValue1Count, ) from xknx.exceptions import ConversionError, CouldNotParseTelegram from xknx.remote_value.remote_value_by_length import RemoteValueByLength class TestRemoteValueByLength: """Test class for RemoteValueByLength objects.""" @pytest.mark.parametrize( "dpt_classes", [ (DPTOpenClose, DPTPressure), # DPTBinary payload invalid (DPTTemperature, DPTPressure2Byte), # similar payload_length (DPTColorXYY, DPTValue1Count), # non-numeric DPT ], ) def test_invalid_dpt_classes(self, dpt_classes: tuple[type[DPTBase]]) -> None: """Test if invalid DPT classes raise ConversionError.""" xknx = XKNX() with pytest.raises(ConversionError): RemoteValueByLength(xknx, dpt_classes=dpt_classes) # type: ignore[arg-type] @pytest.mark.parametrize("payload", [DPTBinary(0), DPTArray((0, 1, 2))]) def test_invalid_payload(self, payload: DPTArray | DPTBinary) -> None: """Test if invalid payloads raise CouldNotParseTelegram.""" xknx = XKNX() remote_value = RemoteValueByLength( xknx=xknx, dpt_classes=(DPTPressure, DPTPressure2Byte), ) with pytest.raises(CouldNotParseTelegram): remote_value.from_knx(payload) @pytest.mark.parametrize( ("first_dpt", "invalid_dpt"), [ (DPTPressure, DPTPressure2Byte), (DPTPressure2Byte, DPTPressure), ], ) def test_payload_valid_mode_assignment( self, first_dpt: type[DPTBase], invalid_dpt: type[DPTBase] ) -> None: """Test if DPT is assigned properly by payload length.""" test_value = 1 xknx = XKNX() remote_value = RemoteValueByLength( xknx=xknx, dpt_classes=(DPTPressure, DPTPressure2Byte), ) first_payload = first_dpt.to_knx(test_value) invalid_payload = invalid_dpt.to_knx(test_value) assert remote_value._internal_dpt_class is None assert remote_value.from_knx(first_payload) == test_value assert remote_value._internal_dpt_class == first_dpt with pytest.raises(CouldNotParseTelegram): # other DPT is invalid now remote_value.from_knx(invalid_payload) # to_knx works when initialized assert remote_value.to_knx(test_value) == first_payload def test_to_knx_uninitialized(self) -> None: """Test to_knx raising ConversionError when DPT is not known.""" xknx = XKNX() remote_value = RemoteValueByLength( xknx=xknx, dpt_classes=(DPTPressure, DPTPressure2Byte), ) assert remote_value._internal_dpt_class is None with pytest.raises(ConversionError): remote_value.to_knx(1) xknx-3.6.0/test/remote_value_tests/remote_value_climate_mode_test.py000066400000000000000000000316641475530762600262330ustar00rootroot00000000000000"""Unit test for RemoteValueClimateMode objects.""" import pytest from xknx import XKNX from xknx.dpt import DPTArray, DPTBinary from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode from xknx.exceptions import ConversionError, CouldNotParseTelegram from xknx.remote_value import ( RemoteValueBinaryHeatCool, RemoteValueBinaryOperationMode, RemoteValueControllerMode, RemoteValueOperationMode, ) from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueWrite class TestRemoteValueOperationMode: """Test class for RemoteValueOperationMode objects.""" def test_to_knx_operation_mode(self) -> None: """Test to_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueOperationMode(xknx) assert remote_value.to_knx(HVACOperationMode.COMFORT) == DPTArray((0x01,)) def test_to_knx_controller_mode(self) -> None: """Test to_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueControllerMode(xknx) assert remote_value.to_knx(HVACControllerMode.HEAT) == DPTArray((0x01,)) def test_to_knx_binary(self) -> None: """Test to_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueBinaryOperationMode( xknx, operation_mode=HVACOperationMode.COMFORT ) assert remote_value.to_knx(HVACOperationMode.COMFORT) == DPTBinary(True) assert remote_value.to_knx(HVACOperationMode.ECONOMY) == DPTBinary(False) def test_from_knx_binary_error(self) -> None: """Test from_knx function with invalid payload.""" xknx = XKNX() remote_value = RemoteValueBinaryOperationMode( xknx, operation_mode=HVACOperationMode.COMFORT ) with pytest.raises(CouldNotParseTelegram): remote_value.from_knx(DPTArray((0x9, 0xF))) def test_to_knx_heat_cool(self) -> None: """Test to_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueBinaryHeatCool( xknx, controller_mode=HVACControllerMode.HEAT ) assert remote_value.to_knx(HVACControllerMode.HEAT) == DPTBinary(True) assert remote_value.to_knx(HVACControllerMode.COOL) == DPTBinary(False) def test_to_knx_heat_cool_error(self) -> None: """Test to_knx function with wrong controller mode.""" xknx = XKNX() remote_value = RemoteValueBinaryHeatCool( xknx, controller_mode=HVACControllerMode.HEAT ) with pytest.raises(ConversionError): remote_value.to_knx(HVACOperationMode.STANDBY) def test_from_knx_operation_mode(self) -> None: """Test from_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueOperationMode(xknx) assert remote_value.from_knx(DPTArray((0x02,))) == HVACOperationMode.STANDBY def test_from_knx_controller_mode(self) -> None: """Test from_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueControllerMode(xknx) assert ( remote_value.from_knx(DPTArray((0x02,))) == HVACControllerMode.MORNING_WARMUP ) def test_from_knx_binary_heat_cool(self) -> None: """Test from_knx function with invalid payload.""" xknx = XKNX() remote_value = RemoteValueBinaryHeatCool( xknx, controller_mode=HVACControllerMode.HEAT ) with pytest.raises(CouldNotParseTelegram): remote_value.from_knx(DPTArray((0x9, 0xF))) def test_from_knx_binary(self) -> None: """Test from_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueBinaryOperationMode( xknx, operation_mode=HVACOperationMode.COMFORT ) assert remote_value.from_knx(DPTBinary(True)) == HVACOperationMode.COMFORT assert remote_value.from_knx(DPTBinary(False)) is None def test_from_knx_heat_cool(self) -> None: """Test from_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueBinaryHeatCool( xknx, controller_mode=HVACControllerMode.HEAT ) assert remote_value.from_knx(DPTBinary(True)) == HVACControllerMode.HEAT assert remote_value.from_knx(DPTBinary(False)) == HVACControllerMode.COOL def test_from_knx_unsupported_operation_mode(self) -> None: """Test from_knx function with unsupported operation.""" xknx = XKNX() with pytest.raises(ConversionError): RemoteValueBinaryHeatCool(xknx, controller_mode=HVACControllerMode.NODEM) def test_from_knx_unknown_operation_mode(self) -> None: """Test from_knx function with unsupported operation.""" xknx = XKNX() with pytest.raises(ConversionError): RemoteValueBinaryHeatCool(xknx, controller_mode=None) def test_to_knx_error_operation_mode(self) -> None: """Test to_knx function with wrong parameter.""" xknx = XKNX() remote_value = RemoteValueOperationMode(xknx) with pytest.raises(ConversionError): remote_value.to_knx(256) with pytest.raises(ConversionError): remote_value.to_knx("256") with pytest.raises(ConversionError): remote_value.to_knx(HVACControllerMode.HEAT) def test_to_knx_error_controller_mode(self) -> None: """Test to_knx function with wrong parameter.""" xknx = XKNX() remote_value = RemoteValueControllerMode(xknx) with pytest.raises(ConversionError): remote_value.to_knx(256) with pytest.raises(ConversionError): remote_value.to_knx("256") with pytest.raises(ConversionError): remote_value.to_knx(HVACOperationMode.ECONOMY) def test_to_knx_error_binary(self) -> None: """Test to_knx function with wrong parameter.""" xknx = XKNX() remote_value = RemoteValueBinaryOperationMode( xknx, operation_mode=HVACOperationMode.ECONOMY ) with pytest.raises(ConversionError): remote_value.to_knx(256) with pytest.raises(ConversionError): remote_value.to_knx(True) with pytest.raises(ConversionError): remote_value.to_knx(HVACControllerMode.HEAT) async def test_set_operation_mode(self) -> None: """Test setting value.""" xknx = XKNX() remote_value = RemoteValueOperationMode( xknx, group_address=GroupAddress("1/2/3") ) remote_value.set(HVACOperationMode.ECONOMY) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x03,))), ) remote_value.set(HVACOperationMode.BUILDING_PROTECTION) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x04,))), ) async def test_set_controller_mode(self) -> None: """Test setting value.""" xknx = XKNX() remote_value = RemoteValueControllerMode( xknx, group_address=GroupAddress("1/2/3") ) remote_value.set(HVACControllerMode.COOL) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x03,))), ) remote_value.set(HVACControllerMode.NIGHT_PURGE) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x04,))), ) async def test_set_binary(self) -> None: """Test setting value.""" xknx = XKNX() remote_value = RemoteValueBinaryOperationMode( xknx, group_address=GroupAddress("1/2/3"), operation_mode=HVACOperationMode.STANDBY, ) remote_value.set(HVACOperationMode.STANDBY) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(True)), ) remote_value.set(HVACOperationMode.BUILDING_PROTECTION) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(False)), ) def test_process_operation_mode(self) -> None: """Test process telegram.""" xknx = XKNX() remote_value = RemoteValueOperationMode( xknx, group_address=GroupAddress("1/2/3") ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x00,))), ) remote_value.process(telegram) assert remote_value.value == HVACOperationMode.AUTO def test_process_controller_mode(self) -> None: """Test process telegram.""" xknx = XKNX() remote_value = RemoteValueControllerMode( xknx, group_address=GroupAddress("1/2/3") ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x00,))), ) remote_value.process(telegram) assert remote_value.value == HVACControllerMode.AUTO def test_process_binary(self) -> None: """Test process telegram.""" xknx = XKNX() remote_value = RemoteValueBinaryOperationMode( xknx, group_address=GroupAddress("1/2/3"), operation_mode=HVACOperationMode.BUILDING_PROTECTION, ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(True)), ) remote_value.process(telegram) assert remote_value.value == HVACOperationMode.BUILDING_PROTECTION def test_to_process_error_operation_mode(self) -> None: """Test process erroneous telegram.""" xknx = XKNX() remote_value = RemoteValueOperationMode( xknx, group_address=GroupAddress("1/2/3") ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) assert remote_value.process(telegram) is False telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x64, 0x65))), ) assert remote_value.process(telegram) is False assert remote_value.value is None def test_to_process_error_binary_operation_mode(self) -> None: """Test processing invalid payload.""" xknx = XKNX() remote_value = RemoteValueBinaryOperationMode( xknx, group_address="1/2/3", operation_mode=HVACOperationMode.COMFORT, ) invalid_telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite( DPTArray((1,)), ), ) assert remote_value.process(telegram=invalid_telegram) is False def test_to_process_error_controller_mode(self) -> None: """Test process erroneous telegram.""" xknx = XKNX() remote_value = RemoteValueControllerMode( xknx, group_address=GroupAddress("1/2/3") ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) assert remote_value.process(telegram) is False telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x64, 0x65))), ) assert remote_value.process(telegram) is False assert remote_value.value is None def test_to_process_error_heat_cool(self) -> None: """Test process erroneous telegram.""" xknx = XKNX() remote_value = RemoteValueBinaryHeatCool( xknx, group_address=GroupAddress("1/2/3"), controller_mode=HVACControllerMode.COOL, ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x01,))), ) assert remote_value.process(telegram) is False telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x64, 0x65))), ) assert remote_value.process(telegram) is False assert remote_value.value is None xknx-3.6.0/test/remote_value_tests/remote_value_color_rgb_test.py000066400000000000000000000066311475530762600255550ustar00rootroot00000000000000"""Unit test for RemoteValueColorRGB objects.""" import pytest from xknx import XKNX from xknx.dpt import DPTArray, DPTBinary, RGBColor from xknx.exceptions import ConversionError from xknx.remote_value import RemoteValueColorRGB from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueWrite class TestRemoteValueColorRGB: """Test class for RemoteValueColorRGB objects.""" def test_to_knx(self) -> None: """Test to_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueColorRGB(xknx) assert remote_value.to_knx(RGBColor(100, 101, 102)) == DPTArray( (0x64, 0x65, 0x66) ) assert remote_value.to_knx(RGBColor(100, 101, 102)) == DPTArray( (0x64, 0x65, 0x66) ) def test_from_knx(self) -> None: """Test from_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueColorRGB(xknx) assert remote_value.from_knx(DPTArray((0x64, 0x65, 0x66))) == RGBColor( 100, 101, 102 ) def test_to_knx_error(self) -> None: """Test to_knx function with wrong parametern.""" xknx = XKNX() remote_value = RemoteValueColorRGB(xknx) with pytest.raises(ConversionError): remote_value.to_knx(RGBColor(100, 101, 256)) with pytest.raises(ConversionError): remote_value.to_knx(RGBColor(100, -1, 102)) with pytest.raises(ConversionError): remote_value.to_knx(RGBColor("100", 101, 102)) async def test_set(self) -> None: """Test setting value.""" xknx = XKNX() remote_value = RemoteValueColorRGB(xknx, group_address=GroupAddress("1/2/3")) remote_value.set(RGBColor(100, 101, 102)) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x64, 0x65, 0x66))), ) remote_value.set(RGBColor(100, 101, 104)) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x64, 0x65, 0x68))), ) def test_process(self) -> None: """Test process telegram.""" xknx = XKNX() remote_value = RemoteValueColorRGB(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x64, 0x65, 0x66))), ) remote_value.process(telegram) assert remote_value.value == RGBColor(100, 101, 102) def test_to_process_error(self) -> None: """Test process erroneous telegram.""" xknx = XKNX() remote_value = RemoteValueColorRGB(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) assert remote_value.process(telegram) is False telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x64, 0x65, 0x66, 0x67))), ) assert remote_value.process(telegram) is False assert remote_value.value is None xknx-3.6.0/test/remote_value_tests/remote_value_color_rgbw_test.py000066400000000000000000000100771475530762600257430ustar00rootroot00000000000000"""Unit test for RemoteValueColorRGBW objects.""" import pytest from xknx import XKNX from xknx.dpt import DPTArray, DPTBinary, RGBWColor from xknx.exceptions import ConversionError from xknx.remote_value import RemoteValueColorRGBW from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueWrite class TestRemoteValueColorRGBW: """Test class for RemoteValueColorRGBW objects.""" def test_to_knx(self) -> None: """Test to_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueColorRGBW(xknx) input_value = RGBWColor(100, 101, 102, 127) expected = DPTArray((0x64, 0x65, 0x66, 0x7F, 0x00, 0x0F)) assert remote_value.to_knx(input_value) == expected def test_from_knx(self) -> None: """Test from_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueColorRGBW(xknx) assert remote_value.from_knx( DPTArray((0x64, 0x65, 0x66, 0x7F, 0x00, 0x00)) ) == RGBWColor(None, None, None, None) assert remote_value.from_knx( DPTArray((0x64, 0x65, 0x66, 0x7F, 0x00, 0x0F)) ) == RGBWColor(100, 101, 102, 127) assert remote_value.from_knx( DPTArray((0x64, 0x65, 0x66, 0x7F, 0x00, 0x00)) ) == RGBWColor(100, 101, 102, 127) assert remote_value.from_knx( DPTArray((0xFF, 0x65, 0x66, 0xFF, 0x00, 0x09)) ) == RGBWColor(255, 101, 102, 255) assert remote_value.from_knx( DPTArray((0x64, 0x65, 0x66, 0x7F, 0x00, 0x01)) ) == RGBWColor(255, 101, 102, 127) def test_to_knx_error(self) -> None: """Test to_knx function with wrong parametern.""" xknx = XKNX() remote_value = RemoteValueColorRGBW(xknx) with pytest.raises(ConversionError): remote_value.to_knx((101, 102, 103, 104)) async def test_set(self) -> None: """Test setting value.""" xknx = XKNX() remote_value = RemoteValueColorRGBW(xknx, group_address=GroupAddress("1/2/3")) remote_value.set(RGBWColor(100, 101, 102, 103)) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x64, 0x65, 0x66, 0x67, 0x00, 0x0F))), ) remote_value.set(RGBWColor(100, 101, 104, 105)) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x64, 0x65, 0x68, 0x69, 0x00, 0x0F))), ) def test_process(self) -> None: """Test process telegram.""" xknx = XKNX() remote_value = RemoteValueColorRGBW(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x64, 0x65, 0x66, 0x67, 0x00, 0x0F))), ) remote_value.process(telegram) assert remote_value.value == RGBWColor(100, 101, 102, 103) def test_to_process_error(self) -> None: """Test process erroneous telegram.""" xknx = XKNX() remote_value = RemoteValueColorRGBW(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) assert remote_value.process(telegram) is False telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x64, 0x65, 0x66))), ) assert remote_value.process(telegram) is False telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite( DPTArray((0x00, 0x00, 0x0F, 0x64, 0x65, 0x66, 0x67)) ), ) assert remote_value.process(telegram) is False assert remote_value.value is None xknx-3.6.0/test/remote_value_tests/remote_value_color_xyy_test.py000066400000000000000000000074551475530762600256410ustar00rootroot00000000000000"""Unit test for RemoteValueColorXYY objects.""" import pytest from xknx import XKNX from xknx.dpt import DPTArray, DPTBinary, XYYColor from xknx.exceptions import ConversionError from xknx.remote_value import RemoteValueColorXYY from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueWrite class TestRemoteValueColorXYY: """Test class for RemoteValueColorXYY objects.""" def test_to_knx(self) -> None: """Test to_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueColorXYY(xknx) assert remote_value.to_knx(XYYColor((1, 0.9), 102)) == DPTArray( (0xFF, 0xFF, 0xE6, 0x66, 0x66, 0x03) ) assert remote_value.to_knx(XYYColor((1, 0), 102)) == DPTArray( (0xFF, 0xFF, 0x00, 0x00, 0x66, 0x03) ) def test_from_knx(self) -> None: """Test from_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueColorXYY(xknx) assert remote_value.from_knx( DPTArray((0x99, 0x99, 0x99, 0x99, 0x66, 0x03)) ) == XYYColor((0.6, 0.6), 102) def test_to_knx_error(self) -> None: """Test to_knx function with wrong parametern.""" xknx = XKNX() remote_value = RemoteValueColorXYY(xknx) with pytest.raises(ConversionError): remote_value.to_knx(XYYColor((2, 1), 1)) with pytest.raises(ConversionError): remote_value.to_knx(XYYColor((-1, 1), 2)) with pytest.raises(ConversionError): remote_value.to_knx(XYYColor((0.3, 0.5), 256)) with pytest.raises(ConversionError): remote_value.to_knx(XYYColor(("0.4", 0), 102)) with pytest.raises(ConversionError): remote_value.to_knx(XYYColor((1, 1), "102")) with pytest.raises(ConversionError): remote_value.to_knx(XYYColor((1,), 1)) async def test_set(self) -> None: """Test setting value.""" xknx = XKNX() remote_value = RemoteValueColorXYY(xknx, group_address=GroupAddress("1/2/3")) remote_value.set(XYYColor((1, 0.9), 102)) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0xFF, 0xFF, 0xE6, 0x66, 0x66, 0x03))), ) remote_value.set(XYYColor((1, 0.9), 255)) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0xFF, 0xFF, 0xE6, 0x66, 0xFF, 0x03))), ) def test_process(self) -> None: """Test process telegram.""" xknx = XKNX() remote_value = RemoteValueColorXYY(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0xFF, 0xFF, 0x66, 0x66, 0xFA, 0x03))), ) remote_value.process(telegram) assert remote_value.value == XYYColor((1, 0.4), 250) def test_to_process_error(self) -> None: """Test process erroneous telegram.""" xknx = XKNX() remote_value = RemoteValueColorXYY(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) assert remote_value.process(telegram) is False telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x64, 0x65, 0x66, 0x67))), ) assert remote_value.process(telegram) is False assert remote_value.value is None xknx-3.6.0/test/remote_value_tests/remote_value_datetime_test.py000066400000000000000000000032171475530762600253760ustar00rootroot00000000000000"""Unit test for RemoteValueDateTime objects.""" import pytest from xknx import XKNX from xknx.dpt import DPTArray from xknx.dpt.dpt_19 import KNXDateTime, KNXDayOfWeek from xknx.exceptions import CouldNotParseTelegram from xknx.remote_value import RemoteValueDateTime class TestRemoteValueDateTime: """Test class for RemoteValueDateTime objects.""" def test_from_knx(self) -> None: """Test parsing of RV with datetime object.""" xknx = XKNX() rv_datetime = RemoteValueDateTime(xknx) assert rv_datetime.from_knx( DPTArray((0x75, 0x0B, 0x1C, 0x17, 0x07, 0x18, 0x20, 0x80)) ) == KNXDateTime( 2017, 11, 28, 23, 7, 24, day_of_week=KNXDayOfWeek.ANY_DAY, external_sync=True, ) def test_to_knx(self) -> None: """Testing date time object.""" xknx = XKNX() rv_datetime = RemoteValueDateTime(xknx) array = rv_datetime.to_knx( KNXDateTime( 2017, 11, 28, 23, 7, 24, day_of_week=KNXDayOfWeek.ANY_DAY, external_sync=True, ) ) assert array.value == (0x75, 0x0B, 0x1C, 0x17, 0x07, 0x18, 0x20, 0x80) def test_payload_invalid(self) -> None: """Testing KNX/Byte representation of DPTDateTime object.""" xknx = XKNX() rv_datetime = RemoteValueDateTime(xknx) with pytest.raises(CouldNotParseTelegram): rv_datetime.from_knx(DPTArray((0x0B, 0x1C, 0x57, 0x07, 0x18, 0x20, 0x80))) xknx-3.6.0/test/remote_value_tests/remote_value_dpt_value_1_ucount_test.py000066400000000000000000000061001475530762600273740ustar00rootroot00000000000000"""Unit test for RemoteValueDptValue1Ucount objects.""" import pytest from xknx import XKNX from xknx.dpt import DPTArray, DPTBinary from xknx.exceptions import ConversionError from xknx.remote_value import RemoteValueDptValue1Ucount from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueWrite class TestRemoteValueDptValue1Ucount: """Test class for RemoteValueDptValue1Ucount objects.""" def test_to_knx(self) -> None: """Test to_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueDptValue1Ucount(xknx) assert remote_value.to_knx(10) == DPTArray((0x0A,)) def test_from_knx(self) -> None: """Test from_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueDptValue1Ucount(xknx) assert remote_value.from_knx(DPTArray((0x0A,))) == 10 def test_to_knx_error(self) -> None: """Test to_knx function with wrong parametern.""" xknx = XKNX() remote_value = RemoteValueDptValue1Ucount(xknx) with pytest.raises(ConversionError): remote_value.to_knx(256) with pytest.raises(ConversionError): remote_value.to_knx("256") async def test_set(self) -> None: """Test setting value.""" xknx = XKNX() remote_value = RemoteValueDptValue1Ucount( xknx, group_address=GroupAddress("1/2/3") ) remote_value.set(10) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x0A,))), ) remote_value.set(11) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x0B,))), ) def test_process(self) -> None: """Test process telegram.""" xknx = XKNX() remote_value = RemoteValueDptValue1Ucount( xknx, group_address=GroupAddress("1/2/3") ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x0A,))), ) remote_value.process(telegram) assert remote_value.value == 10 def test_to_process_error(self) -> None: """Test process erroneous telegram.""" xknx = XKNX() remote_value = RemoteValueDptValue1Ucount( xknx, group_address=GroupAddress("1/2/3") ) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) assert remote_value.process(telegram) is False telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x64, 0x65))), ) assert remote_value.process(telegram) is False assert remote_value.value is None xknx-3.6.0/test/remote_value_tests/remote_value_raw_test.py000066400000000000000000000161361475530762600243770ustar00rootroot00000000000000"""Unit test for RemoteValueRaw objects.""" import pytest from xknx import XKNX from xknx.dpt import DPTArray, DPTBinary from xknx.exceptions import ConversionError from xknx.remote_value import RemoteValueRaw from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueWrite class TestRemoteValueRaw: """Test class for RemoteValueRaw objects.""" def test_to_knx(self) -> None: """Test to_knx function with normal operation.""" xknx = XKNX() rv_0 = RemoteValueRaw(xknx, payload_length=0) rv_1 = RemoteValueRaw(xknx, payload_length=1) rv_2 = RemoteValueRaw(xknx, payload_length=2) assert rv_0.to_knx(1) == DPTBinary(True) assert rv_0.to_knx(4) == DPTBinary(4) assert rv_1.to_knx(100) == DPTArray((0x64,)) assert rv_2.to_knx(100) == DPTArray((0x00, 0x64)) def test_from_knx(self) -> None: """Test from_knx function with normal operation.""" xknx = XKNX() rv_0 = RemoteValueRaw(xknx, payload_length=0) rv_1 = RemoteValueRaw(xknx, payload_length=1) rv_2 = RemoteValueRaw(xknx, payload_length=2) assert rv_0.from_knx(DPTBinary(True)) == 1 assert rv_0.from_knx(DPTBinary(0x4)) == 4 assert rv_1.from_knx(DPTArray((0x64,))) == 100 assert rv_2.from_knx(DPTArray((0x00, 0x64))) == 100 with pytest.raises(ConversionError): assert rv_1.from_knx(DPTArray((256,))) async def test_set(self) -> None: """Test setting value.""" xknx = XKNX() rv_0 = RemoteValueRaw(xknx, payload_length=0, group_address="1/2/3") rv_1 = RemoteValueRaw(xknx, payload_length=1, group_address="1/2/3") rv_2 = RemoteValueRaw(xknx, payload_length=2, group_address="1/2/3") rv_0.set(0) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(False)), ) rv_0.set(63) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(0x3F)), ) rv_1.set(0) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x00,))), ) rv_1.set(63) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x3F,))), ) rv_2.set(0) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x00, 0x00))), ) rv_2.set(63) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x00, 0x3F))), ) def test_process(self) -> None: """Test process telegram.""" xknx = XKNX() rv_0 = RemoteValueRaw(xknx, payload_length=0, group_address="1/0/0") rv_1 = RemoteValueRaw(xknx, payload_length=1, group_address="1/1/1") rv_2 = RemoteValueRaw(xknx, payload_length=2, group_address="1/2/2") telegram = Telegram( destination_address=GroupAddress("1/0/0"), payload=GroupValueWrite(DPTBinary(0)), ) rv_0.process(telegram) assert rv_0.value == 0 telegram = Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueWrite(DPTArray((0x64,))), ) rv_1.process(telegram) assert rv_1.value == 100 telegram = Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPTArray((0x12, 0x34))), ) rv_2.process(telegram) assert rv_2.value == 4660 def test_to_process_error(self) -> None: """Test process erroneous telegram.""" xknx = XKNX() rv_0 = RemoteValueRaw(xknx, payload_length=0, group_address="1/0/0") rv_1 = RemoteValueRaw(xknx, payload_length=1, group_address="1/1/1") rv_2 = RemoteValueRaw(xknx, payload_length=2, group_address="1/2/2") telegram = Telegram( destination_address=GroupAddress("1/0/0"), payload=GroupValueWrite(DPTArray((0x01,))), ) assert rv_0.process(telegram) is False telegram = Telegram( destination_address=GroupAddress("1/0/0"), payload=GroupValueWrite(DPTArray((0x64, 0x65))), ) assert rv_0.process(telegram) is False assert rv_0.value is None telegram = Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueWrite(DPTBinary(1)), ) assert rv_1.process(telegram) is False telegram = Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueWrite(DPTArray((0x64, 0x65))), ) assert rv_1.process(telegram) is False assert rv_1.value is None telegram = Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPTBinary(1)), ) assert rv_2.process(telegram) is False telegram = Telegram( destination_address=GroupAddress("1/2/2"), payload=GroupValueWrite(DPTArray((0x64,))), ) assert rv_2.process(telegram) is False assert rv_2.value is None def test_to_knx_error(self) -> None: """Test to_knx function with wrong parameters.""" xknx = XKNX() rv_0 = RemoteValueRaw(xknx, payload_length=0, group_address="1/0/0") rv_1 = RemoteValueRaw(xknx, payload_length=1, group_address="1/1/1") rv_2 = RemoteValueRaw(xknx, payload_length=2, group_address="1/2/2") with pytest.raises(ConversionError): rv_0.to_knx(-1) with pytest.raises(ConversionError): rv_0.to_knx(64) with pytest.raises(ConversionError): rv_0.to_knx(5.5) with pytest.raises(ConversionError): rv_0.to_knx("a") with pytest.raises(ConversionError): rv_1.to_knx(-1) with pytest.raises(ConversionError): rv_1.to_knx(256) with pytest.raises(ConversionError): rv_1.to_knx(5.5) with pytest.raises(ConversionError): rv_1.to_knx("a") with pytest.raises(ConversionError): rv_2.to_knx(-1) with pytest.raises(ConversionError): rv_2.to_knx(65536) with pytest.raises(ConversionError): rv_2.to_knx(5.5) with pytest.raises(ConversionError): rv_2.to_knx("a") xknx-3.6.0/test/remote_value_tests/remote_value_scaling_test.py000066400000000000000000000152741475530762600252300ustar00rootroot00000000000000"""Unit test for RemoteValueScaling objects.""" from xknx import XKNX from xknx.remote_value import RemoteValueScaling class TestRemoteValueScaling: """Test class for RemoteValueScaling objects.""" def test_calc_0_10(self) -> None: """Test if from/to calculations work with small range.""" assert RemoteValueScaling._calc_to_knx(0, 10, 0) == 0 assert RemoteValueScaling._calc_to_knx(0, 10, 1) == 26 assert RemoteValueScaling._calc_to_knx(0, 10, 9) == 230 assert RemoteValueScaling._calc_to_knx(0, 10, 10) == 255 assert RemoteValueScaling._calc_from_knx(0, 10, 0) == 0 assert RemoteValueScaling._calc_from_knx(0, 10, 1) == 0 assert RemoteValueScaling._calc_from_knx(0, 10, 12) == 0 assert RemoteValueScaling._calc_from_knx(0, 10, 13) == 1 assert RemoteValueScaling._calc_from_knx(0, 10, 254) == 10 assert RemoteValueScaling._calc_from_knx(0, 10, 255) == 10 def test_calc_0_100(self) -> None: """Test if from/to calculations work range 0-100 with many test cases.""" assert RemoteValueScaling._calc_to_knx(0, 100, 0) == 0 assert RemoteValueScaling._calc_to_knx(0, 100, 1) == 3 assert RemoteValueScaling._calc_to_knx(0, 100, 2) == 5 assert RemoteValueScaling._calc_to_knx(0, 100, 3) == 8 assert RemoteValueScaling._calc_to_knx(0, 100, 30) == 76 assert RemoteValueScaling._calc_to_knx(0, 100, 50) == 128 assert RemoteValueScaling._calc_to_knx(0, 100, 70) == 178 assert RemoteValueScaling._calc_to_knx(0, 100, 97) == 247 assert RemoteValueScaling._calc_to_knx(0, 100, 98) == 250 assert RemoteValueScaling._calc_to_knx(0, 100, 99) == 252 assert RemoteValueScaling._calc_to_knx(0, 100, 100) == 255 assert RemoteValueScaling._calc_from_knx(0, 100, 0) == 0 assert RemoteValueScaling._calc_from_knx(0, 100, 1) == 0 assert RemoteValueScaling._calc_from_knx(0, 100, 2) == 1 assert RemoteValueScaling._calc_from_knx(0, 100, 3) == 1 assert RemoteValueScaling._calc_from_knx(0, 100, 4) == 2 assert RemoteValueScaling._calc_from_knx(0, 100, 5) == 2 assert RemoteValueScaling._calc_from_knx(0, 100, 76) == 30 assert RemoteValueScaling._calc_from_knx(0, 100, 128) == 50 assert RemoteValueScaling._calc_from_knx(0, 100, 178) == 70 assert RemoteValueScaling._calc_from_knx(0, 100, 251) == 98 assert RemoteValueScaling._calc_from_knx(0, 100, 252) == 99 assert RemoteValueScaling._calc_from_knx(0, 100, 253) == 99 assert RemoteValueScaling._calc_from_knx(0, 100, 254) == 100 assert RemoteValueScaling._calc_from_knx(0, 100, 255) == 100 def test_calc_0_1000(self) -> None: """Test if from/to calculations work with large range.""" assert RemoteValueScaling._calc_to_knx(0, 1000, 0) == 0 assert RemoteValueScaling._calc_to_knx(0, 1000, 1) == 0 assert RemoteValueScaling._calc_to_knx(0, 1000, 2) == 1 assert RemoteValueScaling._calc_to_knx(0, 1000, 3) == 1 assert RemoteValueScaling._calc_to_knx(0, 1000, 500) == 128 assert RemoteValueScaling._calc_to_knx(0, 1000, 997) == 254 assert RemoteValueScaling._calc_to_knx(0, 1000, 998) == 254 assert RemoteValueScaling._calc_to_knx(0, 1000, 999) == 255 assert RemoteValueScaling._calc_to_knx(0, 1000, 1000) == 255 assert RemoteValueScaling._calc_from_knx(0, 1000, 0) == 0 assert RemoteValueScaling._calc_from_knx(0, 1000, 1) == 4 assert RemoteValueScaling._calc_from_knx(0, 1000, 2) == 8 assert RemoteValueScaling._calc_from_knx(0, 1000, 128) == 502 assert RemoteValueScaling._calc_from_knx(0, 1000, 251) == 984 assert RemoteValueScaling._calc_from_knx(0, 1000, 252) == 988 assert RemoteValueScaling._calc_from_knx(0, 1000, 253) == 992 assert RemoteValueScaling._calc_from_knx(0, 1000, 254) == 996 assert RemoteValueScaling._calc_from_knx(0, 1000, 255) == 1000 def test_calc_100_0(self) -> None: """Test if from/to calculations work with negative range.""" assert RemoteValueScaling._calc_to_knx(100, 0, 0) == 255 assert RemoteValueScaling._calc_to_knx(100, 0, 1) == 252 assert RemoteValueScaling._calc_to_knx(100, 0, 2) == 250 assert RemoteValueScaling._calc_to_knx(100, 0, 3) == 247 assert RemoteValueScaling._calc_to_knx(100, 0, 30) == 178 assert RemoteValueScaling._calc_to_knx(100, 0, 50) == 128 assert RemoteValueScaling._calc_to_knx(100, 0, 70) == 76 assert RemoteValueScaling._calc_to_knx(100, 0, 97) == 8 assert RemoteValueScaling._calc_to_knx(100, 0, 98) == 5 assert RemoteValueScaling._calc_to_knx(100, 0, 99) == 3 assert RemoteValueScaling._calc_to_knx(100, 0, 100) == 0 assert RemoteValueScaling._calc_from_knx(100, 0, 0) == 100 assert RemoteValueScaling._calc_from_knx(100, 0, 1) == 100 assert RemoteValueScaling._calc_from_knx(100, 0, 2) == 99 assert RemoteValueScaling._calc_from_knx(100, 0, 3) == 99 assert RemoteValueScaling._calc_from_knx(100, 0, 4) == 98 assert RemoteValueScaling._calc_from_knx(100, 0, 5) == 98 assert RemoteValueScaling._calc_from_knx(100, 0, 76) == 70 assert RemoteValueScaling._calc_from_knx(100, 0, 128) == 50 assert RemoteValueScaling._calc_from_knx(100, 0, 178) == 30 assert RemoteValueScaling._calc_from_knx(100, 0, 251) == 2 assert RemoteValueScaling._calc_from_knx(100, 0, 252) == 1 assert RemoteValueScaling._calc_from_knx(100, 0, 253) == 1 assert RemoteValueScaling._calc_from_knx(100, 0, 254) == 0 assert RemoteValueScaling._calc_from_knx(100, 0, 255) == 0 def test_calc_100_200(self) -> None: """Test if from/to calculations work with range not starting at zero.""" assert RemoteValueScaling._calc_to_knx(100, 200, 100) == 0 assert RemoteValueScaling._calc_to_knx(100, 200, 130) == 76 assert RemoteValueScaling._calc_to_knx(100, 200, 150) == 128 assert RemoteValueScaling._calc_to_knx(100, 200, 170) == 178 assert RemoteValueScaling._calc_to_knx(100, 200, 200) == 255 assert RemoteValueScaling._calc_from_knx(100, 200, 0) == 100 assert RemoteValueScaling._calc_from_knx(100, 200, 76) == 130 assert RemoteValueScaling._calc_from_knx(100, 200, 128) == 150 assert RemoteValueScaling._calc_from_knx(100, 200, 178) == 170 assert RemoteValueScaling._calc_from_knx(100, 200, 255) == 200 def test_value_unit(self) -> None: """Test for the unit_of_measurement.""" xknx = XKNX() remote_value = RemoteValueScaling(xknx) assert remote_value.unit_of_measurement == "%" xknx-3.6.0/test/remote_value_tests/remote_value_scene_number_test.py000066400000000000000000000057261475530762600262560ustar00rootroot00000000000000"""Unit test for RemoteValueSceneNumber objects.""" import pytest from xknx import XKNX from xknx.dpt import DPTArray, DPTBinary from xknx.exceptions import ConversionError from xknx.remote_value import RemoteValueSceneNumber from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueWrite class TestRemoteValueSceneNumber: """Test class for RemoteValueSceneNumber objects.""" def test_to_knx(self) -> None: """Test to_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueSceneNumber(xknx) assert remote_value.to_knx(11) == DPTArray((0x0A,)) def test_from_knx(self) -> None: """Test from_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueSceneNumber(xknx) assert remote_value.from_knx(DPTArray((0x0A,))) == 11 def test_to_knx_error(self) -> None: """Test to_knx function with wrong parametern.""" xknx = XKNX() remote_value = RemoteValueSceneNumber(xknx) with pytest.raises(ConversionError): remote_value.to_knx(100) with pytest.raises(ConversionError): remote_value.to_knx("100") async def test_set(self) -> None: """Test setting value.""" xknx = XKNX() remote_value = RemoteValueSceneNumber(xknx, group_address=GroupAddress("1/2/3")) remote_value.set(11) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x0A,))), ) remote_value.set(12) assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x0B,))), ) def test_process(self) -> None: """Test process telegram.""" xknx = XKNX() remote_value = RemoteValueSceneNumber(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x0A,))), ) remote_value.process(telegram) assert remote_value.value == 11 def test_to_process_error(self) -> None: """Test process erroneous telegram.""" xknx = XKNX() remote_value = RemoteValueSceneNumber(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) assert remote_value.process(telegram) is False telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x64, 0x65))), ) assert remote_value.process(telegram) is False assert remote_value.value is None xknx-3.6.0/test/remote_value_tests/remote_value_sensor_test.py000066400000000000000000000061201475530762600251070ustar00rootroot00000000000000"""Unit test for RemoteValueSensor objects.""" import pytest from xknx import XKNX from xknx.dpt import DPTArray, DPTBase, DPTBinary, DPTValue1Ucount from xknx.exceptions import ConversionError, CouldNotParseTelegram from xknx.remote_value import RemoteValueNumeric, RemoteValueSensor class TestRemoteValueSensor: """Test class for RemoteValueSensor objects.""" def test_value_type(self) -> None: """Test initializing a value_type.""" xknx = XKNX() assert RemoteValueSensor(xknx=xknx, value_type="pulse") assert RemoteValueSensor(xknx=xknx, value_type=9) assert RemoteValueSensor(xknx=xknx, value_type="9.021") assert RemoteValueSensor(xknx=xknx, value_type="string") def test_wrong_value_type(self) -> None: """Test initializing with wrong value_type.""" xknx = XKNX() with pytest.raises(ConversionError): RemoteValueSensor(xknx=xknx, value_type="wrong_value_type") with pytest.raises(ConversionError): RemoteValueSensor(xknx=xknx, value_type="binary") with pytest.raises(ConversionError): RemoteValueSensor(xknx=xknx, value_type=1) with pytest.raises(ConversionError): RemoteValueSensor(xknx=xknx, value_type=2) with pytest.raises(ConversionError): RemoteValueSensor(xknx=xknx, value_type=None) with pytest.raises(ConversionError): RemoteValueSensor(xknx=xknx) def test_payload_length_defined(self) -> None: """Test if all members of DPTMAP implement payload_length.""" for dpt_class in DPTBase.__recursive_subclasses__(): assert isinstance(dpt_class.payload_length, int) def test_payload_invalid(self) -> None: """Test invalid payloads.""" xknx = XKNX() remote_value = RemoteValueSensor(xknx=xknx, value_type="pulse") assert remote_value.dpt_class == DPTValue1Ucount with pytest.raises(CouldNotParseTelegram): remote_value.from_knx(None) with pytest.raises(CouldNotParseTelegram): remote_value.from_knx(DPTArray((1, 2, 3, 4))) with pytest.raises(CouldNotParseTelegram): remote_value.from_knx(DPTBinary(1)) class TestRemoteValueNumeric: """Test class for RemoteValueNumeric objects.""" def test_value_type(self) -> None: """Test initializing a value_type.""" xknx = XKNX() assert RemoteValueNumeric(xknx=xknx, value_type="pulse") assert RemoteValueNumeric(xknx=xknx, value_type=9) assert RemoteValueNumeric(xknx=xknx, value_type="9.021") def test_wrong_value_type(self) -> None: """Test initializing with wrong value_type.""" xknx = XKNX() with pytest.raises(ConversionError): RemoteValueNumeric(xknx=xknx, value_type="string") with pytest.raises(ConversionError): RemoteValueNumeric(xknx=xknx, value_type=16) with pytest.raises(ConversionError): RemoteValueNumeric(xknx=xknx, value_type="binary") with pytest.raises(ConversionError): RemoteValueNumeric(xknx=xknx) xknx-3.6.0/test/remote_value_tests/remote_value_setepoint_shift_test.py000066400000000000000000000127701475530762600270150ustar00rootroot00000000000000"""Unit test for RemoteValueSetpointShift objects.""" import pytest from xknx import XKNX from xknx.dpt import DPTArray, DPTBinary, DPTTemperature, DPTValue1Count from xknx.exceptions import ConversionError, CouldNotParseTelegram from xknx.remote_value.remote_value_setpoint_shift import ( RemoteValueSetpointShift, SetpointShiftMode, ) class TestRemoteValueSetpointShift: """Test class for RemoteValueSetpointShift objects.""" def test_payload_valid_mode_assignment(self) -> None: """Test if setpoint_shift_mode is assigned properly by payload length.""" xknx = XKNX() remote_value = RemoteValueSetpointShift(xknx=xknx) dpt_6_payload = DPTValue1Count.to_knx(1) dpt_9_payload = DPTTemperature.to_knx(1) with pytest.raises(CouldNotParseTelegram): remote_value.from_knx(DPTBinary(0)) with pytest.raises(CouldNotParseTelegram): remote_value.from_knx(DPTArray((0, 1, 2))) # DPT 6 - payload_length 1 assert remote_value._internal_dpt_class is None # default setpoint_shift_step = 0.1 assert remote_value.from_knx(dpt_6_payload) == 0.1 assert remote_value._internal_dpt_class == SetpointShiftMode.DPT6010.value with pytest.raises(CouldNotParseTelegram): # DPT 9 is invalid now remote_value.from_knx(dpt_9_payload) remote_value._internal_dpt_class = None # DPT 9 - payload_length 2 assert remote_value.from_knx(dpt_9_payload) == 1 assert remote_value._internal_dpt_class == SetpointShiftMode.DPT9002.value with pytest.raises(CouldNotParseTelegram): # DPT 6 is invalid now remote_value.from_knx(dpt_6_payload) def test_payload_valid_preassigned_mode(self) -> None: """Test if setpoint_shift_mode is assigned properly by payload length.""" xknx = XKNX() remote_value_6 = RemoteValueSetpointShift( xknx=xknx, setpoint_shift_mode=SetpointShiftMode.DPT6010 ) remote_value_9 = RemoteValueSetpointShift( xknx=xknx, setpoint_shift_mode=SetpointShiftMode.DPT9002 ) dpt_6_payload = DPTValue1Count.to_knx(1) dpt_9_payload = DPTTemperature.to_knx(1) assert remote_value_6._internal_dpt_class == DPTValue1Count with pytest.raises(CouldNotParseTelegram): remote_value_6.from_knx(None) with pytest.raises(CouldNotParseTelegram): remote_value_6.from_knx(dpt_9_payload) with pytest.raises(CouldNotParseTelegram): remote_value_6.from_knx(DPTArray((1, 2, 3, 4))) with pytest.raises(CouldNotParseTelegram): remote_value_6.from_knx(DPTBinary(1)) assert remote_value_6.from_knx(dpt_6_payload) == 0.1 assert remote_value_9._internal_dpt_class == DPTTemperature with pytest.raises(CouldNotParseTelegram): remote_value_9.from_knx(None) with pytest.raises(CouldNotParseTelegram): remote_value_9.from_knx(dpt_6_payload) with pytest.raises(CouldNotParseTelegram): remote_value_9.from_knx(DPTArray((1, 2, 3))) with pytest.raises(CouldNotParseTelegram): remote_value_9.from_knx(DPTBinary(1)) assert remote_value_9.from_knx(dpt_9_payload) == 1 def test_to_knx_uninitialized(self) -> None: """Test to_knx raising ConversionError.""" xknx = XKNX() remote_value = RemoteValueSetpointShift(xknx=xknx) assert remote_value._internal_dpt_class is None with pytest.raises(ConversionError): remote_value.to_knx(1) def test_to_knx_dpt_6(self) -> None: """Test to_knx returning DPT 6.010 payload.""" xknx = XKNX() remote_value = RemoteValueSetpointShift( xknx=xknx, setpoint_shift_mode=SetpointShiftMode.DPT6010 ) assert remote_value.setpoint_shift_step == 0.1 assert remote_value.to_knx(1) == DPTArray((10,)) def test_to_knx_dpt_9(self) -> None: """Test to_knx returning DPT 9.002 payload.""" xknx = XKNX() remote_value = RemoteValueSetpointShift( xknx=xknx, setpoint_shift_mode=SetpointShiftMode.DPT9002 ) assert remote_value.to_knx(1) == DPTArray((0x00, 0x64)) def test_from_knx_uninitialized(self) -> None: """Test from_knx for uninitialized setpoint_shift_mode.""" xknx = XKNX() remote_value = RemoteValueSetpointShift(xknx=xknx) with pytest.raises(CouldNotParseTelegram): remote_value.from_knx(1) # assign DPT 9.002 mode assert remote_value.from_knx(DPTArray((0x00, 0x64))) == 1 assert remote_value.from_knx(DPTArray((0x07, 0xD0))) == 20 # wrong payload length raises, once assigned with pytest.raises(CouldNotParseTelegram): remote_value.from_knx(DPTArray((10,))) def test_from_knx_dpt_6(self) -> None: """Test from_knx for DPT 6.010 setpoint_shift_mode.""" xknx = XKNX() remote_value = RemoteValueSetpointShift( xknx=xknx, setpoint_shift_mode=SetpointShiftMode.DPT6010 ) assert remote_value.setpoint_shift_step == 0.1 assert remote_value.from_knx(DPTArray((10,))) == 1 def test_from_knx_dpt_9(self) -> None: """Test from_knx for DPT 9.002 setpoint_shift_mode.""" xknx = XKNX() remote_value = RemoteValueSetpointShift( xknx=xknx, setpoint_shift_mode=SetpointShiftMode.DPT9002 ) assert remote_value.from_knx(DPTArray((0x00, 0x64))) == 1 xknx-3.6.0/test/remote_value_tests/remote_value_step_test.py000066400000000000000000000074471475530762600245660ustar00rootroot00000000000000"""Unit test for RemoteValueStep objects.""" import pytest from xknx import XKNX from xknx.dpt import DPTArray, DPTBinary from xknx.exceptions import ConversionError from xknx.remote_value import RemoteValueStep from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueWrite class TestRemoteValueStep: """Test class for RemoteValueStep objects.""" def test_to_knx(self) -> None: """Test to_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueStep(xknx) assert remote_value.to_knx(RemoteValueStep.Direction.INCREASE) == DPTBinary(1) assert remote_value.to_knx(RemoteValueStep.Direction.DECREASE) == DPTBinary(0) def test_from_knx(self) -> None: """Test from_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueStep(xknx) assert remote_value.from_knx(DPTBinary(1)) == RemoteValueStep.Direction.INCREASE assert remote_value.from_knx(DPTBinary(0)) == RemoteValueStep.Direction.DECREASE def test_to_knx_invert(self) -> None: """Test to_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueStep(xknx, invert=True) assert remote_value.to_knx(RemoteValueStep.Direction.INCREASE) == DPTBinary(0) assert remote_value.to_knx(RemoteValueStep.Direction.DECREASE) == DPTBinary(1) def test_from_knx_invert(self) -> None: """Test from_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueStep(xknx, invert=True) assert remote_value.from_knx(DPTBinary(1)) == RemoteValueStep.Direction.DECREASE assert remote_value.from_knx(DPTBinary(0)) == RemoteValueStep.Direction.INCREASE def test_to_knx_error(self) -> None: """Test to_knx function with wrong parametern.""" xknx = XKNX() remote_value = RemoteValueStep(xknx) with pytest.raises(ConversionError): remote_value.to_knx(1) async def test_set(self) -> None: """Test setting value.""" xknx = XKNX() remote_value = RemoteValueStep(xknx, group_address=GroupAddress("1/2/3")) remote_value.decrease() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(0)), ) remote_value.increase() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) def test_process(self) -> None: """Test process telegram.""" xknx = XKNX() remote_value = RemoteValueStep(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(0)), ) assert remote_value.value is None remote_value.process(telegram) assert remote_value.value == RemoteValueStep.Direction.DECREASE def test_to_process_error(self) -> None: """Test process erroneous telegram.""" xknx = XKNX() remote_value = RemoteValueStep(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(0x01)), ) assert remote_value.process(telegram) is False telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(3)), ) assert remote_value.process(telegram) is False assert remote_value.value is None xknx-3.6.0/test/remote_value_tests/remote_value_string_test.py000066400000000000000000000116461475530762600251150ustar00rootroot00000000000000"""Unit test for RemoteValueString objects.""" import pytest from xknx import XKNX from xknx.dpt import DPTArray, DPTBinary, DPTLatin1, DPTString from xknx.exceptions import ConversionError from xknx.remote_value import RemoteValueString from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueWrite class TestRemoteValueString: """Test class for RemoteValueString objects.""" def test_to_knx(self) -> None: """Test to_knx function with normal operation.""" xknx = XKNX() dpt_array_string = DPTArray( ( 0x4B, 0x4E, 0x58, 0x20, 0x69, 0x73, 0x20, 0x4F, 0x4B, 0x00, 0x00, 0x00, 0x00, 0x00, ) ) remote_value_default = RemoteValueString(xknx) assert remote_value_default.dpt_class == DPTString assert remote_value_default.to_knx("KNX is OK") == dpt_array_string remote_value_ascii = RemoteValueString(xknx, value_type="string") assert remote_value_ascii.dpt_class == DPTString assert remote_value_ascii.to_knx("KNX is OK") == dpt_array_string remote_value_latin1 = RemoteValueString(xknx, value_type="latin_1") assert remote_value_latin1.dpt_class == DPTLatin1 assert remote_value_latin1.to_knx("KNX is OK") == dpt_array_string def test_from_knx(self) -> None: """Test from_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueString(xknx) assert ( remote_value.from_knx( DPTArray( ( 0x4B, 0x4E, 0x58, 0x20, 0x69, 0x73, 0x20, 0x4F, 0x4B, 0x00, 0x00, 0x00, 0x00, 0x00, ) ) ) == "KNX is OK" ) def test_to_knx_error(self) -> None: """Test to_knx function with wrong parametern.""" xknx = XKNX() remote_value = RemoteValueString(xknx) with pytest.raises(ConversionError): remote_value.to_knx("123456789012345") async def test_set(self) -> None: """Test setting value.""" xknx = XKNX() remote_value = RemoteValueString(xknx, group_address=GroupAddress("1/2/3")) remote_value.set("asdf") assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite( DPTArray((97, 115, 100, 102, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) ), ) remote_value.set("ASDF") assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite( DPTArray((65, 83, 68, 70, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) ), ) def test_process(self) -> None: """Test process telegram.""" xknx = XKNX() remote_value = RemoteValueString(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite( DPTArray( ( 0x41, 0x41, 0x41, 0x41, 0x41, 0x42, 0x42, 0x42, 0x42, 0x42, 0x43, 0x43, 0x43, 0x43, ) ) ), ) remote_value.process(telegram) assert remote_value.value == "AAAAABBBBBCCCC" def test_to_process_error(self) -> None: """Test process erroneous telegram.""" xknx = XKNX() remote_value = RemoteValueString(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) assert remote_value.process(telegram) is False telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x64, 0x65))), ) assert remote_value.process(telegram) is False assert remote_value.value is None xknx-3.6.0/test/remote_value_tests/remote_value_switch_test.py000066400000000000000000000101371475530762600251020ustar00rootroot00000000000000"""Unit test for RemoteValueSwitch objects.""" import pytest from xknx import XKNX from xknx.dpt import DPTArray, DPTBinary from xknx.exceptions import ConversionError from xknx.remote_value import RemoteValueSwitch from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueWrite class TestRemoteValueSwitch: """Test class for RemoteValueSwitch objects.""" def test_to_knx(self) -> None: """Test to_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueSwitch(xknx) assert remote_value.to_knx(True) == DPTBinary(True) assert remote_value.to_knx(False) == DPTBinary(False) def test_from_knx(self) -> None: """Test from_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueSwitch(xknx) assert remote_value.from_knx(DPTBinary(True)) is True assert remote_value.from_knx(DPTBinary(0)) is False def test_to_knx_invert(self) -> None: """Test to_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueSwitch(xknx, invert=True) assert remote_value.to_knx(True) == DPTBinary(0) assert remote_value.to_knx(False) == DPTBinary(1) def test_from_knx_invert(self) -> None: """Test from_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueSwitch(xknx, invert=True) assert remote_value.from_knx(DPTBinary(1)) is False assert remote_value.from_knx(DPTBinary(0)) is True def test_to_knx_error(self) -> None: """Test to_knx function with wrong parametern.""" xknx = XKNX() remote_value = RemoteValueSwitch(xknx) with pytest.raises(ConversionError): remote_value.to_knx(1) async def test_set(self) -> None: """Test setting value.""" xknx = XKNX() remote_value = RemoteValueSwitch(xknx, group_address=GroupAddress("1/2/3")) remote_value.on() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) remote_value.off() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(0)), ) def test_process(self) -> None: """Test process telegram.""" xknx = XKNX() remote_value = RemoteValueSwitch(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) assert remote_value.value is None remote_value.process(telegram) assert remote_value.telegram is not None assert remote_value.value is True def test_process_off(self) -> None: """Test process OFF telegram.""" xknx = XKNX() remote_value = RemoteValueSwitch(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(0)), ) assert remote_value.value is None remote_value.process(telegram) assert remote_value.telegram is not None assert remote_value.value is False def test_to_process_error(self) -> None: """Test process erroneous telegram.""" xknx = XKNX() remote_value = RemoteValueSwitch(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(0x01)), ) assert remote_value.process(telegram) is False telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(3)), ) assert remote_value.process(telegram) is False assert remote_value.value is None xknx-3.6.0/test/remote_value_tests/remote_value_temp_test.py000066400000000000000000000043071475530762600245500ustar00rootroot00000000000000"""Unit test for RemoteValueSceneNumber objects.""" import pytest from xknx import XKNX from xknx.dpt import DPTArray, DPTBinary from xknx.exceptions import ConversionError from xknx.remote_value import RemoteValueTemp from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueWrite class TestRemoteValueTemp: """Test class for RemoteValueTemp objects.""" def test_to_knx(self) -> None: """Test to_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueTemp(xknx) assert remote_value.to_knx(11) == DPTArray((0x04, 0x4C)) def test_from_knx(self) -> None: """Test from_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueTemp(xknx) assert remote_value.from_knx(DPTArray((0x04, 0x4C))) == 11 def test_to_knx_error(self) -> None: """Test to_knx function with wrong parametern.""" xknx = XKNX() remote_value = RemoteValueTemp(xknx) with pytest.raises(ConversionError): remote_value.to_knx(-300) with pytest.raises(ConversionError): remote_value.to_knx("abc") def test_process(self) -> None: """Test process telegram.""" xknx = XKNX() remote_value = RemoteValueTemp(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x04, 0x4C))), ) remote_value.process(telegram) assert remote_value.value == 11 def test_to_process_error(self) -> None: """Test process erroneous telegram.""" xknx = XKNX() remote_value = RemoteValueTemp(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) assert remote_value.process(telegram) is False telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray((0x64,))), ) assert remote_value.process(telegram) is False assert remote_value.value is None xknx-3.6.0/test/remote_value_tests/remote_value_test.py000066400000000000000000000311771475530762600235300ustar00rootroot00000000000000"""Unit test for RemoveValue objects.""" from unittest.mock import AsyncMock, Mock, patch import pytest from xknx import XKNX from xknx.dpt import DPT2ByteFloat, DPTArray, DPTBinary from xknx.exceptions import ConversionError, CouldNotParseTelegram from xknx.remote_value import RemoteValue, RemoteValueSwitch from xknx.telegram import GroupAddress, Telegram, TelegramDecodedData from xknx.telegram.apci import GroupValueWrite @patch.multiple(RemoteValue, __abstractmethods__=set()) class TestRemoteValue: """Test class for RemoteValue objects.""" async def test_get_set_value(self) -> None: """Test value getter and setter.""" xknx = XKNX() remote_value = RemoteValue(xknx) remote_value.to_knx = DPT2ByteFloat.to_knx remote_value.after_update_cb = Mock() assert remote_value.value is None remote_value.value = 2.2 assert remote_value.value == 2.2 # invalid value raises ConversionError with pytest.raises(ConversionError): remote_value.value = "a" # new value is used in response Telegram test_payload = remote_value.to_knx(2.2) remote_value._send = Mock() remote_value.respond() remote_value._send.assert_called_with(test_payload, response=True) # callback is not called when setting value programmatically remote_value.after_update_cb.assert_not_called() # no Telegram was sent to the queue assert xknx.telegrams.qsize() == 0 def test_set_value(self) -> None: """Test set_value awaitable.""" xknx = XKNX() remote_value = RemoteValue(xknx) remote_value.to_knx = DPT2ByteFloat.to_knx remote_value.after_update_cb = Mock() remote_value.update_value(3.3) assert remote_value.value == 3.3 remote_value.after_update_cb.assert_called_once() assert xknx.telegrams.qsize() == 0 # invalid value raises ConversionError with pytest.raises(ConversionError): remote_value.update_value("a") assert remote_value.value == 3.3 async def test_info_set_uninitialized(self) -> None: """Test for info if RemoteValue is not initialized.""" xknx = XKNX() remote_value = RemoteValue(xknx) with patch("logging.Logger.info") as mock_info: remote_value.set(23) mock_info.assert_called_with( "Setting value of uninitialized device: %s - %s (value: %s)", "Unknown", "Unknown", 23, ) async def test_info_set_unwritable(self) -> None: """Test for warning if RemoteValue is read only.""" xknx = XKNX() remote_value = RemoteValue(xknx, group_address_state=GroupAddress("1/2/3")) with patch("logging.Logger.warning") as mock_warning: remote_value.set(23) mock_warning.assert_called_with( "Attempted to set value for non-writable device: %s - %s (value: %s)", "Unknown", "Unknown", 23, ) def test_default_value_unit(self) -> None: """Test for the default value of unit_of_measurement.""" xknx = XKNX() remote_value = RemoteValue(xknx) assert remote_value.unit_of_measurement is None async def test_process_unsupported_payload_type(self) -> None: """Test if exception is raised when processing telegram with unsupported payload type.""" xknx = XKNX() remote_value = RemoteValue(xknx) with patch( "xknx.remote_value.RemoteValue.has_group_address" ) as patch_has_group_address: patch_has_group_address.return_value = True telegram = Telegram( destination_address=GroupAddress("1/2/1"), payload=object() ) with pytest.raises( CouldNotParseTelegram, match=r".*payload not a GroupValueWrite or GroupValueResponse.*", ): remote_value.process(telegram) def test_process_unsupported_payload(self) -> None: """Test warning is logged when processing telegram with unsupported payload.""" xknx = XKNX() remote_value = RemoteValue(xknx) telegram = Telegram( destination_address=GroupAddress("1/2/1"), payload=GroupValueWrite(DPTArray((0x01, 0x02))), ) with ( patch( "xknx.remote_value.RemoteValue.has_group_address" ) as patch_has_group_address, patch("xknx.remote_value.RemoteValue.from_knx") as patch_from, patch("logging.Logger.warning") as mock_warning, ): patch_has_group_address.return_value = True patch_from.side_effect = ConversionError("TestError") assert remote_value.process(telegram) is False mock_warning.assert_called_once_with( "Can not process %s for %s - %s: %s", telegram, "Unknown", "Unknown", ConversionError("TestError"), ) async def test_read_state(self) -> None: """Test read state while waiting for the result.""" xknx = XKNX() remote_value = RemoteValueSwitch(xknx, group_address_state="1/2/3") with patch("xknx.core.ValueReader.read", new_callable=AsyncMock) as patch_read: telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) patch_read.return_value = telegram await remote_value.read_state(wait_for_result=True) patch_read.assert_called_once() # RemoteValue.value is updated by RemoteValue.process called from Device / TelegramQueue async def test_read_state_none(self) -> None: """Test read state while waiting for the result but got None.""" xknx = XKNX() remote_value = RemoteValueSwitch(xknx, group_address_state="1/2/3") with ( patch("xknx.core.ValueReader.read", new_callable=AsyncMock) as patch_read, patch("logging.Logger.warning") as mock_warning, ): patch_read.return_value = None await remote_value.read_state(wait_for_result=True) patch_read.assert_called_once() mock_warning.assert_called_once_with( "Could not sync group address '%s' (%s - %s)", GroupAddress("1/2/3"), "Unknown", "State", ) def test_unpacking_passive_address(self) -> None: """Test if passive group addresses are properly unpacked.""" xknx = XKNX() remote_value_1 = RemoteValue(xknx, group_address=["1/2/3", "1/1/1"]) assert remote_value_1.group_address == GroupAddress("1/2/3") assert remote_value_1.group_address_state is None assert remote_value_1.passive_group_addresses == [GroupAddress("1/1/1")] assert remote_value_1.has_group_address(GroupAddress("1/2/3")) assert remote_value_1.has_group_address(GroupAddress("1/1/1")) remote_value_2 = RemoteValue(xknx, group_address_state=["1/2/3", "1/1/1"]) assert remote_value_2.group_address is None assert remote_value_2.group_address_state == GroupAddress("1/2/3") assert remote_value_2.passive_group_addresses == [GroupAddress("1/1/1")] assert remote_value_2.has_group_address(GroupAddress("1/2/3")) assert remote_value_2.has_group_address(GroupAddress("1/1/1")) remote_value_3 = RemoteValue( xknx, group_address=["1/2/3", "1/1/1", "1/1/10"], group_address_state=["2/3/4", "2/2/2", "2/2/20"], ) assert remote_value_3.group_address == GroupAddress("1/2/3") assert remote_value_3.group_address_state == GroupAddress("2/3/4") assert remote_value_3.passive_group_addresses == [ GroupAddress("1/1/1"), GroupAddress("1/1/10"), GroupAddress("2/2/2"), GroupAddress("2/2/20"), ] assert remote_value_3.has_group_address(GroupAddress("1/2/3")) assert remote_value_3.has_group_address(GroupAddress("1/1/1")) assert remote_value_3.has_group_address(GroupAddress("1/1/10")) assert remote_value_3.has_group_address(GroupAddress("2/3/4")) assert remote_value_3.has_group_address(GroupAddress("2/2/2")) assert remote_value_3.has_group_address(GroupAddress("2/2/20")) assert not remote_value_3.has_group_address(GroupAddress("0/0/1")) # test empty list remote_value_4 = RemoteValue(xknx, group_address=[]) assert remote_value_4.group_address is None # test None in list remote_value_5 = RemoteValue( xknx, group_address=["1/2/3", "1/1/1", "1/1/10"], group_address_state=[None, "2/2/2", "2/2/20"], ) assert remote_value_5.group_address == GroupAddress("1/2/3") assert remote_value_5.group_address_state is None assert remote_value_5.passive_group_addresses == [ GroupAddress("1/1/1"), GroupAddress("1/1/10"), GroupAddress("2/2/2"), GroupAddress("2/2/20"), ] remote_value_6 = RemoteValue( xknx, group_address=None, group_address_state=["1/1/1", None, "2/2/2", "2/2/20"], ) assert remote_value_6.group_address is None assert remote_value_6.group_address_state == GroupAddress("1/1/1") assert remote_value_6.passive_group_addresses == [ GroupAddress("2/2/2"), GroupAddress("2/2/20"), ] def test_process_passive_address(self) -> None: """Test if passive group addresses are processed.""" xknx = XKNX() remote_value = RemoteValue(xknx, group_address=["1/2/3", "1/1/1"]) remote_value.dpt_class = DPT2ByteFloat assert remote_value.writable assert not remote_value.readable # RemoteValue is initialized with only passive group address assert remote_value.initialized test_payload = DPTArray((0x01, 0x02)) telegram = Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueWrite(test_payload), ) assert remote_value.process(telegram) assert remote_value.telegram.payload.value == test_payload def test_to_from_knx(self) -> None: """Test to_knx and from_knx raises when not set properly.""" xknx = XKNX() remote_value = RemoteValue(xknx, group_address="1/1/1") with pytest.raises(NotImplementedError): remote_value.value = 3.3 # without to_knx method test_payload = DPTArray((0x01, 0x02)) telegram = Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueWrite(test_payload), ) with pytest.raises(NotImplementedError): remote_value.process(telegram) # doesn't raise with `dpt_class` set remote_value.dpt_class = DPT2ByteFloat remote_value.value = 3.3 remote_value.process(telegram) def test_pre_decoded_telegram(self) -> None: """Test if pre-decoded Telegram is processed.""" xknx = XKNX() remote_value = RemoteValue(xknx, group_address="1/1/1") remote_value.dpt_class = DPT2ByteFloat test_payload = "invalid for testing" telegram = Telegram( destination_address=GroupAddress("1/1/1"), payload=GroupValueWrite(test_payload), decoded_data=TelegramDecodedData(transcoder=DPT2ByteFloat, value=3.3), ) assert remote_value.process(telegram) assert remote_value.value == 3.3 def test_eq(self) -> None: """Test __eq__ operator.""" xknx = XKNX() remote_value1 = RemoteValue(xknx, group_address=GroupAddress("1/1/1")) remote_value2 = RemoteValue(xknx, group_address=GroupAddress("1/1/1")) remote_value3 = RemoteValue(xknx, group_address=GroupAddress("1/1/2")) remote_value4 = RemoteValue(xknx, group_address=GroupAddress("1/1/1")) remote_value4.fnord = "fnord" def _callback() -> None: pass remote_value5 = RemoteValue( xknx, group_address=GroupAddress("1/1/1"), after_update_cb=_callback() ) assert remote_value1 == remote_value2 assert remote_value2 == remote_value1 assert remote_value1 != remote_value3 assert remote_value3 != remote_value1 assert remote_value1 != remote_value4 assert remote_value4 != remote_value1 assert remote_value1 == remote_value5 assert remote_value5 == remote_value1 xknx-3.6.0/test/remote_value_tests/remote_value_updown_test.py000066400000000000000000000074331475530762600251220ustar00rootroot00000000000000"""Unit test for RemoteValueUpDown objects.""" import pytest from xknx import XKNX from xknx.dpt import DPTArray, DPTBinary from xknx.exceptions import ConversionError from xknx.remote_value import RemoteValueUpDown from xknx.telegram import GroupAddress, Telegram from xknx.telegram.apci import GroupValueWrite class TestRemoteValueUpDown: """Test class for RemoteValueUpDown objects.""" def test_to_knx(self) -> None: """Test to_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueUpDown(xknx) assert remote_value.to_knx(RemoteValueUpDown.Direction.UP) == DPTBinary(0) assert remote_value.to_knx(RemoteValueUpDown.Direction.DOWN) == DPTBinary(1) def test_from_knx(self) -> None: """Test from_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueUpDown(xknx) assert remote_value.from_knx(DPTBinary(0)) == RemoteValueUpDown.Direction.UP assert remote_value.from_knx(DPTBinary(1)) == RemoteValueUpDown.Direction.DOWN def test_to_knx_invert(self) -> None: """Test to_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueUpDown(xknx, invert=True) assert remote_value.to_knx(RemoteValueUpDown.Direction.UP) == DPTBinary(1) assert remote_value.to_knx(RemoteValueUpDown.Direction.DOWN) == DPTBinary(0) def test_from_knx_invert(self) -> None: """Test from_knx function with normal operation.""" xknx = XKNX() remote_value = RemoteValueUpDown(xknx, invert=True) assert remote_value.from_knx(DPTBinary(0)) == RemoteValueUpDown.Direction.DOWN assert remote_value.from_knx(DPTBinary(1)) == RemoteValueUpDown.Direction.UP def test_to_knx_error(self) -> None: """Test to_knx function with wrong parametern.""" xknx = XKNX() remote_value = RemoteValueUpDown(xknx) with pytest.raises(ConversionError): remote_value.to_knx(1) async def test_set(self) -> None: """Test setting value.""" xknx = XKNX() remote_value = RemoteValueUpDown(xknx, group_address=GroupAddress("1/2/3")) remote_value.down() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) remote_value.up() assert xknx.telegrams.qsize() == 1 telegram = xknx.telegrams.get_nowait() assert telegram == Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(0)), ) def test_process(self) -> None: """Test process telegram.""" xknx = XKNX() remote_value = RemoteValueUpDown(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)), ) assert remote_value.value is None remote_value.process(telegram) assert remote_value.value == RemoteValueUpDown.Direction.DOWN def test_to_process_error(self) -> None: """Test process erroneous telegram.""" xknx = XKNX() remote_value = RemoteValueUpDown(xknx, group_address=GroupAddress("1/2/3")) telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(0x01)), ) assert remote_value.process(telegram) is False telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(3)), ) assert remote_value.process(telegram) is False assert remote_value.value is None xknx-3.6.0/test/secure_tests/000077500000000000000000000000001475530762600162165ustar00rootroot00000000000000xknx-3.6.0/test/secure_tests/__init__.py000066400000000000000000000000501475530762600203220ustar00rootroot00000000000000"""Unit tests for the Secure module.""" xknx-3.6.0/test/secure_tests/data_secure_test.py000066400000000000000000000434171475530762600221170ustar00rootroot00000000000000"""Tests for KNX Data Secure.""" import asyncio from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from xknx import XKNX from xknx.cemi import CEMIFrame, CEMILData, CEMIMessageCode from xknx.dpt import DPTArray from xknx.exceptions import DataSecureError from xknx.secure.data_secure_asdu import ( SecurityAlgorithmIdentifier, SecurityALService, SecurityControlField, ) from xknx.secure.keyring import Keyring, sync_load_keyring from xknx.telegram import ( GroupAddress, IndividualAddress, Telegram, TelegramDirection, apci, tpci, ) @pytest.fixture(name="test_group_response_cemi") def fixture_test_group_response_cemi() -> CEMIFrame: """Return a CEMI frame for a group response telegram.""" # src = 4.0.9; dst = 0/4/0; GroupValueResponse; value=(116, 41, 41) # A+C; seq_num=155806854986 return CEMIFrame.from_knx( bytes.fromhex("29003ce0400904001103f110002446cfef4ac085e7092ab062b44d") ) @pytest.fixture(name="test_point_to_point_cemi") def fixture_test_point_to_point_cemi() -> CEMIFrame: """Return a CEMI frame for a group response telegram.""" # Property Value Write PID_GRP_KEY_TABLE connectionless # Object Idx = 5, PropId = 35h, Element Count = 1, Index = 1 # Data = 20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F # A+C # from AN158 v07 KNX Data Security AS - Annex A example return CEMIFrame.from_knx( bytes.fromhex( "29 00 b0 60 ff 67 ff 00 22 03 f1 90 00 00 00 00" "00 04 67 67 24 2a 23 08 ca 76 a1 17 74 21 4e e4" "cf 5d 94 90 9f 74 3d 05 0d 8f c1 68" ) ) class TestDataSecure: """Test class for KNX Data Secure.""" secure_test_keyring: Keyring @classmethod def setup_class(cls) -> None: """Set up any state specific to the execution of the given class.""" secure_test_keyfile = Path(__file__).parent / "resources/SecureTest.knxkeys" cls.secure_test_keyring = sync_load_keyring(secure_test_keyfile, "test") def setup_method(self) -> None: """Set up test methods.""" # pylint: disable=attribute-defined-outside-init self.xknx = XKNX() self.xknx.knxip_interface = AsyncMock() self.xknx.current_address = IndividualAddress("5.0.1") self.xknx.cemi_handler.data_secure_init(TestDataSecure.secure_test_keyring) self.data_secure = self.xknx.cemi_handler.data_secure @patch("xknx.secure.data_secure.DataSecure.outgoing_cemi") @patch("xknx.secure.data_secure.DataSecure.received_cemi") async def test_data_secure_init( self, mock_ds_received_cemi: MagicMock, mock_ds_outgoing_cemi: MagicMock ) -> None: """Test DataSecure init and passing frames from CEMIHandler to DataSecure.""" assert self.data_secure is not None assert len(self.data_secure._group_key_table) == 4 for ga_raw in [1024, 1027, 1028, 1029]: assert GroupAddress(ga_raw) in self.data_secure._group_key_table assert len(self.data_secure._individual_address_table) == 5 for ia_raw in ["4.0.0", "4.0.1", "4.0.9", "5.0.0", "5.0.1"]: assert ( IndividualAddress(ia_raw) in self.data_secure._individual_address_table ) # this is based on clock milliseconds assert self.data_secure._sequence_number_sending > 0 test_telegram = Telegram( destination_address=GroupAddress("0/4/0"), payload=apci.GroupValueRead(), ) task = asyncio.create_task(self.xknx.cemi_handler.send_telegram(test_telegram)) await asyncio.sleep(0) # Frame is passed to DataSecure class. Encryption is not tested here. mock_ds_outgoing_cemi.assert_called_once() assert isinstance( mock_ds_outgoing_cemi.call_args.kwargs["cemi_data"], CEMILData ) self.xknx.cemi_handler._l_data_confirmation_event.set() await task test_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=CEMILData.init_from_telegram(test_telegram), ) # Reuse incoming plain APDU. Decryption is not tested here mock_ds_received_cemi.return_value = test_cemi.data with patch.object( self.xknx.cemi_handler, "telegram_received" ) as mock_telegram_received: # suppress forwarding to telegras/management self.xknx.cemi_handler.handle_cemi_frame(test_cemi) mock_ds_received_cemi.assert_called_once() assert isinstance( mock_ds_received_cemi.call_args.kwargs["cemi_data"], CEMILData ) mock_telegram_received.assert_called_once() def test_data_secure_init_invalid_system_time(self) -> None: """Test DataSecure init with invalid system time.""" with ( patch("time.time", return_value=1515108203.0), # 2018-01-04T23:23:23+00:00 pytest.raises(DataSecureError, match=r"Initial sequence number out of .*"), ): self.xknx.cemi_handler.data_secure_init(TestDataSecure.secure_test_keyring) def test_data_secure_group_send(self) -> None: """Test outgoing DataSecure group communication.""" self.data_secure._sequence_number_sending = 160170101607 test_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_REQ, data=CEMILData.init_from_telegram( Telegram( destination_address=GroupAddress("0/4/0"), payload=apci.GroupValueRead(), ), src_addr=self.xknx.current_address, ), ) secured_frame_data = self.data_secure.outgoing_cemi(test_cemi.data) assert isinstance(secured_frame_data, CEMILData) assert isinstance(secured_frame_data.payload, apci.SecureAPDU) secured_asdu = secured_frame_data.payload.secured_data assert int.from_bytes(secured_asdu.sequence_number_bytes, "big") == 160170101607 assert secured_asdu.secured_apdu == bytes.fromhex("cd18") assert secured_asdu.message_authentication_code == bytes.fromhex("4afe5744") # sequence number sending was incremented assert self.data_secure._sequence_number_sending == 160170101608 assert secured_frame_data.to_knx() == bytes.fromhex( "bce0500104000e03f11000254ae1cb67cd184afe5744" ) def test_data_secure_group_receive( self, test_group_response_cemi: CEMIFrame ) -> None: """Test incoming DataSecure group communication.""" assert ( self.data_secure._individual_address_table[IndividualAddress("4.0.9")] == 155806854915 ) assert isinstance(test_group_response_cemi.data, CEMILData) assert test_group_response_cemi.data.src_addr == IndividualAddress("4.0.9") assert isinstance(test_group_response_cemi.data.payload, apci.SecureAPDU) plain_frame_data = self.data_secure.received_cemi(test_group_response_cemi.data) assert isinstance(plain_frame_data, CEMILData) assert plain_frame_data.payload == apci.GroupValueResponse( DPTArray((116, 41, 41)) ) # individual_address_table sequence number was updated assert ( self.data_secure._individual_address_table[IndividualAddress("4.0.9")] == 155806854986 ) def test_data_secure_individual_receive_tool_key( self, test_point_to_point_cemi: CEMIFrame ) -> None: """Test incoming DataSecure point-to-point communication via tool key.""" self.xknx.current_address = IndividualAddress("15.15.0") assert isinstance(test_point_to_point_cemi.data.payload, apci.SecureAPDU) with pytest.raises( DataSecureError, match=r"System broadcast and tool access not supported.*" ): self.data_secure.received_cemi(test_point_to_point_cemi.data) # don't raise through handle_cemi_frame() assert ( self.xknx.cemi_handler.handle_cemi_frame(test_point_to_point_cemi) is None ) def test_data_secure_individual_receive( self, test_point_to_point_cemi: CEMIFrame ) -> None: """Test incoming DataSecure point-to-point communication.""" self.xknx.current_address = IndividualAddress("15.15.0") assert isinstance(test_point_to_point_cemi.data.payload, apci.SecureAPDU) # don't use tool key or system broadcast # further validation is skipped so we can use the same test data test_point_to_point_cemi.data.payload.scf.tool_access = False test_point_to_point_cemi.data.payload.scf.system_broadcast = False with pytest.raises( DataSecureError, match=r"Secure Point-to-Point communication not supported.*", ): self.data_secure.received_cemi(test_point_to_point_cemi.data) # don't raise through handle_cemi_frame() assert ( self.xknx.cemi_handler.handle_cemi_frame(test_point_to_point_cemi) is None ) def test_data_secure_group_receive_unknown_source( self, test_group_response_cemi: CEMIFrame ) -> None: """Test incoming DataSecure group communication from unknown source.""" test_group_response_cemi.data.src_addr = IndividualAddress("1.2.3") with pytest.raises( DataSecureError, match=r"Source address not found in Security Individual Address Table.*", ): self.data_secure.received_cemi(test_group_response_cemi.data) def test_data_secure_group_receive_unknown_destination( self, test_group_response_cemi: CEMIFrame ) -> None: """Test incoming DataSecure group communication for unknown destination.""" test_group_response_cemi.data.dst_addr = GroupAddress("1/2/3") with pytest.raises( DataSecureError, match=r"No key found for group address.*", ): self.data_secure.received_cemi(test_group_response_cemi.data) def test_data_secure_group_receive_wrong_sequence_number( self, test_group_response_cemi: CEMIFrame ) -> None: """Test incoming DataSecure group communication with wrong sequence number.""" seq_num = 155806854986 assert ( test_group_response_cemi.data.payload.secured_data.sequence_number_bytes == seq_num.to_bytes(6, "big") ) # sequence number already used self.data_secure._individual_address_table[IndividualAddress("4.0.9")] = seq_num with pytest.raises( DataSecureError, match=r"Sequence number too low.*", ): self.data_secure.received_cemi(test_group_response_cemi.data) def test_data_secure_group_receive_wrong_mac( self, test_group_response_cemi: CEMIFrame ) -> None: """Test incoming DataSecure group communication with wrong MAC.""" test_group_response_cemi.data.payload.secured_data.message_authentication_code = bytes( 4 ) with pytest.raises( DataSecureError, match=r"Data Secure MAC verification failed.*", ): self.data_secure.received_cemi(test_group_response_cemi.data) def test_data_secure_group_receive_plain_frame(self) -> None: """Test incoming DataSecure group communication with plain frame.""" src_addr = IndividualAddress("4.0.9") test_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=CEMILData.init_from_telegram( Telegram( destination_address=GroupAddress("0/4/0"), direction=TelegramDirection.INCOMING, payload=apci.GroupValueResponse(DPTArray((116, 41, 41))), ), src_addr=src_addr, ), ) assert src_addr in self.data_secure._individual_address_table with pytest.raises( DataSecureError, match=r"Discarding frame with plain APDU for secure group address.*", ): self.data_secure.received_cemi(test_cemi.data) def test_non_secure_group_receive_plain_frame(self) -> None: """Test incoming non-secure group communication with plain frame.""" dst_addr = GroupAddress("1/2/3") test_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=CEMILData.init_from_telegram( Telegram( destination_address=dst_addr, direction=TelegramDirection.INCOMING, payload=apci.GroupValueResponse(DPTArray((116, 41, 41))), ), src_addr=IndividualAddress("4.0.9"), ), ) assert dst_addr not in self.data_secure._group_key_table assert self.data_secure.received_cemi(test_cemi.data) == test_cemi.data def test_non_secure_group_send_plain_frame(self) -> None: """Test outgoing non-secure group communication with plain frame.""" dst_addr = GroupAddress("1/2/3") test_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_REQ, data=CEMILData.init_from_telegram( Telegram( destination_address=dst_addr, direction=TelegramDirection.OUTGOING, payload=apci.GroupValueResponse(DPTArray((116, 41, 41))), ), src_addr=self.xknx.current_address, ), ) assert dst_addr not in self.data_secure._group_key_table assert self.data_secure.outgoing_cemi(test_cemi.data) == test_cemi.data def test_non_secure_individual_receive_plain_frame(self) -> None: """Test incoming non-secure group communication with plain frame.""" src_addr = IndividualAddress("1.2.3") test_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=CEMILData.init_from_telegram( Telegram( destination_address=self.xknx.current_address, direction=TelegramDirection.INCOMING, payload=apci.PropertyValueWrite( object_index=5, property_id=0x35, count=1, start_index=1, data=bytes.fromhex( "20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F" ), ), tpci=tpci.TDataIndividual(), ), src_addr=IndividualAddress("4.0.9"), ), ) assert src_addr not in self.data_secure._individual_address_table assert self.data_secure.received_cemi(test_cemi.data) == test_cemi.data def test_non_secure_individual_send_plain_frame(self) -> None: """Test outgoing non-secure group communication with plain frame.""" dst_addr = IndividualAddress("1.2.3") test_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_REQ, data=CEMILData.init_from_telegram( Telegram( destination_address=dst_addr, direction=TelegramDirection.INCOMING, payload=apci.PropertyValueWrite( object_index=5, property_id=0x35, count=1, start_index=1, data=bytes.fromhex( "20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F" ), ), tpci=tpci.TDataIndividual(), ), src_addr=self.xknx.current_address, ), ) assert dst_addr not in self.data_secure._individual_address_table assert self.data_secure.outgoing_cemi(test_cemi.data) == test_cemi.data def test_data_secure_authentication_only(self) -> None: """Test frame de-/serialization for DataSecure authentication only.""" # This is currently not used from xknx and I also don't know if it is used # in any ETS or runtime KNX communication. Therefore a very generic test. dst_addr = GroupAddress("0/4/0") test_telegram = Telegram( destination_address=dst_addr, direction=TelegramDirection.OUTGOING, payload=apci.GroupValueWrite(DPTArray((1, 2))), ) test_cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_REQ, data=CEMILData.init_from_telegram( telegram=test_telegram, src_addr=self.xknx.current_address, ), ) scf = SecurityControlField( algorithm=SecurityAlgorithmIdentifier.CCM_AUTHENTICATION, service=SecurityALService.S_A_DATA, system_broadcast=False, tool_access=False, ) key = self.data_secure._group_key_table[dst_addr] outgoing_signed_cemi_data = self.data_secure._secure_data_cemi( key=key, scf=scf, cemi_data=test_cemi.data ) assert outgoing_signed_cemi_data.payload.secured_data is not None # create new cemi to avoid mixed bytearray / byte parts incoming_cemi = CEMIFrame.from_knx( b"\x11\x00" + outgoing_signed_cemi_data.to_knx() ) assert isinstance(incoming_cemi.data, CEMILData) # receive same cemi - fake individual address table entry self.data_secure._individual_address_table[incoming_cemi.data.src_addr] = 1 assert self.data_secure.received_cemi(incoming_cemi.data) == test_cemi.data # Test wrong MAC self.data_secure._individual_address_table[incoming_cemi.data.src_addr] = 1 incoming_cemi.data.payload.secured_data.message_authentication_code = bytes(4) with pytest.raises( DataSecureError, match=r"Data Secure MAC verification failed.*", ): self.data_secure.received_cemi(incoming_cemi.data) xknx-3.6.0/test/secure_tests/ip_secure_test.py000066400000000000000000000103111475530762600216010ustar00rootroot00000000000000"""Unit test for IP Secure primitives.""" from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from xknx.secure.security_primitives import ( calculate_message_authentication_code_cbc, decrypt_ctr, derive_device_authentication_password, derive_user_password, encrypt_data_ctr, generate_ecdh_key_pair, ) class TestIPSecure: """Test class for IP Secure primitives.""" def test_calculate_message_authentication_code_cbc(self) -> None: """Test calculate message authentication code CBC.""" # SessionResponse from example in KNX specification AN159v06 assert calculate_message_authentication_code_cbc( key=derive_device_authentication_password("trustme"), additional_data=bytes.fromhex( "06 10 09 52 00 38 00 01 b7 52 be 24 64 59 26 0f" "6b 0c 48 01 fb d5 a6 75 99 f8 3b 40 57 b3 ef 1e" "79 e4 69 ac 17 23 4e 15" ), ) == bytes.fromhex("da 3d c6 af 79 89 6a a6 ee 75 73 d6 99 50 c2 83") # RoutingIndication from example in KNX specification AN159v06 assert calculate_message_authentication_code_cbc( key=bytes.fromhex("00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f"), additional_data=bytes.fromhex("06 10 09 50 00 37 00 00"), payload=bytes.fromhex("06 10 05 30 00 11 29 00 bc d0 11 59 0a de 01 00 81"), block_0=bytes.fromhex("c0 c1 c2 c3 c4 c5 00 fa 12 34 56 78 af fe 00 11"), ) == bytes.fromhex("bd 0a 29 4b 95 25 54 b2 35 39 20 4c 22 71 d2 6b") def test_encrypt_data_ctr(self) -> None: """Test encrypt data with AES-CTR.""" # RoutingIndication from example in KNX specification AN159v06 key = bytes.fromhex("00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f") counter_0 = bytes.fromhex("c0 c1 c2 c3 c4 c5 00 fa 12 34 56 78 af fe ff 00") mac_cbc = bytes.fromhex("bd 0a 29 4b 95 25 54 b2 35 39 20 4c 22 71 d2 6b") payload = bytes.fromhex("06 10 05 30 00 11 29 00 bc d0 11 59 0a de 01 00 81") encrypted_data, mac = encrypt_data_ctr( key=key, counter_0=counter_0, mac_cbc=mac_cbc, payload=payload ) assert encrypted_data == bytes.fromhex( "b7 ee 7e 8a 1c 2f 7b ba be c7 75 fd 6e 10 d0 bc 4b" ) assert mac == bytes.fromhex("72 12 a0 3a aa e4 9d a8 56 89 77 4c 1d 2b 4d a4") def test_decrypt_ctr(self) -> None: """Test decrypt data with AES-CTR.""" # RoutingIndication from example in KNX specification AN159v06 key = bytes.fromhex("00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f") counter_0 = bytes.fromhex("c0 c1 c2 c3 c4 c5 00 fa 12 34 56 78 af fe ff 00") mac = bytes.fromhex("72 12 a0 3a aa e4 9d a8 56 89 77 4c 1d 2b 4d a4") payload = bytes.fromhex("b7 ee 7e 8a 1c 2f 7b ba be c7 75 fd 6e 10 d0 bc 4b") decrypted_data, mac_tr = decrypt_ctr( key=key, counter_0=counter_0, mac=mac, payload=payload, ) assert decrypted_data == bytes.fromhex( "06 10 05 30 00 11 29 00 bc d0 11 59 0a de 01 00 81" ) assert mac_tr == bytes.fromhex( "bd 0a 29 4b 95 25 54 b2 35 39 20 4c 22 71 d2 6b" ) def test_derive_device_authentication_password(self) -> None: """Test derive device authentication password.""" assert derive_device_authentication_password("trustme") == bytes.fromhex( "e1 58 e4 01 20 47 bd 6c c4 1a af bc 5c 04 c1 fc" ) def test_derive_user_password(self) -> None: """Test derive user password.""" assert derive_user_password("secret") == bytes.fromhex( "03 fc ed b6 66 60 25 1e c8 1a 1a 71 69 01 69 6a" ) def test_generate_ecdh_key_pair(self) -> None: """Test generate ECDH key pair.""" private_key, public_key_bytes = generate_ecdh_key_pair() assert isinstance(private_key, X25519PrivateKey) assert isinstance(public_key_bytes, bytes) assert len(public_key_bytes) == 32 private_key_2, public_key_bytes_2 = generate_ecdh_key_pair() assert private_key != private_key_2 assert public_key_bytes != public_key_bytes_2 xknx-3.6.0/test/secure_tests/keyring_test.py000066400000000000000000000223501475530762600213010ustar00rootroot00000000000000"""Unit test for keyring reader.""" from pathlib import Path import pytest from xknx.exceptions.exception import InvalidSecureConfiguration from xknx.secure.keyring import ( InterfaceType, Keyring, XMLDevice, XMLInterface, sync_load_keyring, verify_keyring_signature, ) from xknx.telegram import GroupAddress, IndividualAddress class TestKeyRing: """Test class for keyring.""" keyring_test_file = Path(__file__).parent / "resources/keyring.knxkeys" testcase_file: str = Path(__file__).parent / "resources/testcase.knxkeys" special_chars_file = ( Path(__file__).parent / "resources/special_chars_secure_tunnel.knxkeys" ) data_secure_ip = ( Path(__file__).parent / "resources/DataSecure_only_one_interface.knxkeys" ) data_secure_usb = Path(__file__).parent / "resources/DataSecure_usb.knxkeys" @staticmethod def assert_interface( keyring: Keyring, password: str, ia: IndividualAddress ) -> None: """Verify password for given user.""" matched = False if interface := keyring.get_tunnel_interface_by_individual_address(ia): matched = True assert interface.decrypted_password == password assert matched def test_load_keyring_test(self) -> None: """Test load keyring from knxkeys file.""" keyring = sync_load_keyring(self.keyring_test_file, "pwd") TestKeyRing.assert_interface(keyring, "user4", IndividualAddress("1.1.4")) TestKeyRing.assert_interface(keyring, "@zvI1G&_", IndividualAddress("1.1.6")) TestKeyRing.assert_interface(keyring, "ZvDY-:g#", IndividualAddress("1.1.7")) TestKeyRing.assert_interface(keyring, "user2", IndividualAddress("1.1.2")) assert keyring.backbone.multicast_address == "224.0.23.12" assert keyring.backbone.latency == 1000 assert keyring.backbone.decrypted_key == bytes.fromhex( "96f034fccf510760cbd63da0f70d4a9d" ) def test_load_testcase_file(self) -> None: """Test load keyring from knxkeys file.""" keyring = sync_load_keyring(self.testcase_file, "password") TestKeyRing.assert_interface(keyring, "user1", IndividualAddress("1.0.1")) TestKeyRing.assert_interface(keyring, "user2", IndividualAddress("1.0.11")) TestKeyRing.assert_interface(keyring, "user3", IndividualAddress("1.0.12")) TestKeyRing.assert_interface(keyring, "user4", IndividualAddress("1.0.13")) assert keyring.devices[0].decrypted_management_password == "commissioning" assert keyring.backbone.multicast_address == "224.0.23.12" assert keyring.backbone.latency == 1000 assert keyring.backbone.decrypted_key == bytes.fromhex( "cf89fd0f18f4889783c7ef44ee1f5e14" ) interface: XMLInterface = keyring.interfaces[0] device: XMLDevice = keyring.get_device_by_interface(interface) assert device is not None assert device.decrypted_authentication == "authenticationcode" def test_load_special_chars_file(self) -> None: """Test load keyring from knxkeys file.""" keyring = sync_load_keyring(self.special_chars_file, "test") TestKeyRing.assert_interface(keyring, "tunnel_2", IndividualAddress("1.0.2")) TestKeyRing.assert_interface(keyring, "tunnel_3", IndividualAddress("1.0.3")) TestKeyRing.assert_interface(keyring, "tunnel_4", IndividualAddress("1.0.4")) TestKeyRing.assert_interface(keyring, "tunnel_5", IndividualAddress("1.0.5")) TestKeyRing.assert_interface(keyring, "tunnel_6", IndividualAddress("1.0.6")) assert keyring.backbone is None def test_load_data_secure_ip(self) -> None: """Test load keyring from knxkeys file.""" keyring = sync_load_keyring(self.data_secure_ip, "test") assert len(keyring.interfaces) == 1 tunnel = keyring.interfaces[0] assert tunnel is not None assert tunnel.password is None assert tunnel.decrypted_password is None assert tunnel.user_id is None assert tunnel.type is InterfaceType.TUNNELING assert len(tunnel.group_addresses) == 3 assert tunnel.group_addresses[GroupAddress("0/0/1")] == [ IndividualAddress("1.0.1"), IndividualAddress("1.0.2"), ] assert tunnel.group_addresses[GroupAddress("0/0/3")] == [ IndividualAddress("1.0.1"), IndividualAddress("1.0.2"), ] assert tunnel.group_addresses[GroupAddress("31/7/255")] == [] assert keyring.backbone is None def test_load_data_secure_usb(self) -> None: """Test load keyring from knxkeys file.""" keyring = sync_load_keyring(self.data_secure_usb, "test") assert len(keyring.interfaces) == 1 interface = keyring.interfaces[0] assert interface is not None assert interface.password is None assert interface.decrypted_password is None assert interface.user_id is None assert interface.host is None assert interface.type is InterfaceType.USB assert len(interface.group_addresses) == 1 assert interface.group_addresses[GroupAddress("31/7/255")] == [ IndividualAddress("1.0.4") ] assert keyring.backbone is None def test_verify_signature(self) -> None: """Test signature verification.""" assert verify_keyring_signature(self.keyring_test_file, "pwd") assert verify_keyring_signature(self.testcase_file, "password") assert verify_keyring_signature(self.special_chars_file, "test") def test_invalid_signature(self) -> None: """Test invalid signature throws error.""" with pytest.raises(InvalidSecureConfiguration): sync_load_keyring(self.testcase_file, "wrong_password") def test_raises_error(self) -> None: """Test raises error if password is wrong.""" with pytest.raises(InvalidSecureConfiguration): sync_load_keyring( self.testcase_file, "wrong_password", validate_signature=False ) def test_keyring_get_methods_full(self) -> None: """Test keyring get_* methods for full project export.""" keyring = sync_load_keyring(self.keyring_test_file, "pwd") test_interfaces = keyring.get_tunnel_interfaces_by_host( host=IndividualAddress("1.1.10") ) assert len(test_interfaces) == 1 test_interface = test_interfaces[0] test_device = keyring.get_device_by_interface(interface=test_interface) assert test_device.individual_address == IndividualAddress("1.1.10") test_host = keyring.get_tunnel_host_by_interface( tunnelling_slot=IndividualAddress("1.1.8") ) assert test_host == IndividualAddress("1.1.0") test_host = keyring.get_tunnel_host_by_interface( tunnelling_slot=IndividualAddress("1.1.10") ) assert test_host is None test_interface = keyring.get_tunnel_interface_by_host_and_user_id( host=IndividualAddress("1.1.0"), user_id=4 ) assert test_interface.individual_address == IndividualAddress("1.1.7") test_interface = keyring.get_tunnel_interface_by_individual_address( tunnelling_slot=IndividualAddress("1.1.8") ) assert test_interface.user_id == 8 test_interface = keyring.get_tunnel_interface_by_individual_address( tunnelling_slot=IndividualAddress("1.1.20") ) assert test_interface.user_id is None assert test_interface.host == IndividualAddress("1.1.10") # this doesn't check for `type`, but there are no other than TUNNELLING interfaces in this keyring test_interface = keyring.get_interface_by_individual_address( individual_address=IndividualAddress("1.1.20") ) assert test_interface.host == IndividualAddress("1.1.10") full_ga_key_table = keyring.get_data_secure_group_keys() assert len(full_ga_key_table) == 1 individual_ga_key_table = keyring.get_data_secure_group_keys( receiver=IndividualAddress("1.1.7") ) assert len(individual_ga_key_table) == 0 ia_seq_nums = keyring.get_data_secure_senders() assert len(ia_seq_nums) == 5 def test_keyring_get_methods_one_interface(self) -> None: """Test keyring get_* methods for partial export.""" keyring = sync_load_keyring(self.data_secure_ip, "test") full_ga_key_table = keyring.get_data_secure_group_keys() assert len(full_ga_key_table) == 3 individual_ga_key_table = keyring.get_data_secure_group_keys( receiver=IndividualAddress("1.0.4") ) assert len(individual_ga_key_table) == 3 ia_seq_nums = keyring.get_data_secure_senders() assert ia_seq_nums == { IndividualAddress("1.0.1"): 0, IndividualAddress("1.0.2"): 0, } def test_keyring_metadata(self) -> None: """Test keyring metadata parsing.""" keyring = sync_load_keyring(self.data_secure_ip, "test") assert keyring.project_name == "DataSecure_only" assert keyring.created_by == "ETS 5.7.7 (Build 1428)" assert keyring.created == "2023-02-06T21:17:09" assert keyring.xmlns == "http://knx.org/xml/keyring/1" xknx-3.6.0/test/secure_tests/resources/000077500000000000000000000000001475530762600202305ustar00rootroot00000000000000xknx-3.6.0/test/secure_tests/resources/DataSecure_only_one_interface.knxkeys000066400000000000000000000012431475530762600276100ustar00rootroot00000000000000 xknx-3.6.0/test/secure_tests/resources/DataSecure_usb.knxkeys000066400000000000000000000007001475530762600245340ustar00rootroot00000000000000 xknx-3.6.0/test/secure_tests/resources/SecureTest.knxkeys000066400000000000000000000061551475530762600237430ustar00rootroot00000000000000 xknx-3.6.0/test/secure_tests/resources/keyring.knxkeys000066400000000000000000000057001475530762600233200ustar00rootroot00000000000000 xknx-3.6.0/test/secure_tests/resources/special_chars_secure_tunnel.knxkeys000066400000000000000000000027561475530762600274130ustar00rootroot00000000000000 xknx-3.6.0/test/secure_tests/resources/testcase.knxkeys000066400000000000000000000025641475530762600234700ustar00rootroot00000000000000 xknx-3.6.0/test/secure_tests/util_test.py000066400000000000000000000042511475530762600206060ustar00rootroot00000000000000"""Tests for secure util primitives.""" import pytest from xknx.secure.util import byte_pad, bytes_xor, sha256_hash @pytest.mark.parametrize( ("value_a", "value_b", "result"), [ ( "01010101", "10101010", "11111111", ), ( "000011111111111111110000", "000000000000111111110000", "000011111111000000000000", ), ( "000000000000000000000000000000001", "000011111111111111110000101010101", "000011111111111111110000101010100", ), ], ) def test_byte_xor( value_a: tuple[str, str, str], value_b: tuple[str, str, str], result: tuple[str, str, str], ) -> None: """Test byte xor.""" len_a = (len(value_a) + 7) // 8 len_b = (len(value_b) + 7) // 8 len_res = max(len_a, len_b) assert bytes_xor( int(value_a, 2).to_bytes(len_a, "big"), int(value_b, 2).to_bytes(len_b, "big"), ) == int(result, 2).to_bytes(len_res, "big") def test_byte_xor_error() -> None: """Test byte xor error.""" with pytest.raises(ValueError): bytes_xor(bytes([1]), bytes([0, 1])) @pytest.mark.parametrize( ("block_size", "data", "result"), [ (4, bytes([23]), bytes([23, 0, 0, 0])), (4, bytes([1, 23, 0, 0]), bytes([1, 23, 0, 0])), (16, bytes([123]), bytes([123, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])), (16, bytes(16), bytes(16)), (16, bytes(17), bytes(32)), (16, bytes(32), bytes(32)), (16, bytes(47), bytes(48)), ], ) def test_byte_pad(block_size: int, data: bytes, result: bytes) -> None: """Test byte pad.""" assert byte_pad(data=data, block_size=block_size) == result def test_sha256_hash() -> None: """Test sha256 hash.""" # Data from SessionResponse example in KNX specification AN159v06 assert sha256_hash( bytes.fromhex( "d8 01 52 52 17 61 8f 0d a9 0a 4f f2 21 48 ae e0" "ff 4c 19 b4 30 e8 08 12 23 ff e9 9c 81 a9 8b 05" ) ) == bytes.fromhex( "28 94 26 c2 91 25 35 ba 98 27 9a 4d 18 43 c4 87" "7f 6d 2d c3 7e 40 dc 4b eb fe 40 31 d4 73 3b 30" ) xknx-3.6.0/test/str_test.py000066400000000000000000000770721475530762600157440ustar00rootroot00000000000000"""Unit test for String representations.""" from typing import Any from unittest.mock import patch from xknx import XKNX from xknx.cemi import ( CEMIFrame, CEMILData, CEMIMessageCode, CEMIMPropInfo, CEMIMPropReadResponse, ) from xknx.devices import ( BinarySensor, Climate, ClimateMode, Cover, DateTimeDevice, ExposeSensor, Fan, Light, Notification, Scene, Sensor, Switch, Weather, ) from xknx.dpt import DPTArray, DPTBinary from xknx.exceptions import ( ConversionError, CouldNotParseAddress, CouldNotParseKNXIP, CouldNotParseTelegram, DeviceIllegalValue, IncompleteKNXIPFrame, ) from xknx.io.gateway_scanner import GatewayDescriptor from xknx.knxip import ( HPAI, ConnectionStateRequest, ConnectionStateResponse, ConnectRequest, ConnectRequestType, ConnectResponse, ConnectResponseData, DIBDeviceInformation, DIBGeneric, DIBServiceFamily, DIBSuppSVCFamilies, DisconnectRequest, DisconnectResponse, HostProtocol, KNXIPFrame, KNXIPHeader, KNXMedium, RoutingIndication, SearchRequest, SearchResponse, SearchResponseExtended, TunnellingAck, TunnellingFeatureGet, TunnellingFeatureInfo, TunnellingFeatureResponse, TunnellingFeatureSet, TunnellingFeatureType, TunnellingRequest, ) from xknx.profile.const import ResourceKNXNETIPPropertyId, ResourceObjectType from xknx.remote_value import RemoteValue from xknx.telegram import GroupAddress, IndividualAddress, Telegram, TelegramDirection from xknx.telegram.apci import GroupValueWrite class TestStringRepresentations: """Test class for Configuration logic.""" @patch.multiple(RemoteValue, __abstractmethods__=set()) def test_remote_value(self) -> None: """Test string representation of remote value.""" xknx = XKNX() remote_value: RemoteValue[Any] = RemoteValue( xknx, group_address="1/2/3", device_name="MyDevice", group_address_state="1/2/4", ) assert ( str(remote_value) == ' />' ) remote_value.to_knx = lambda value: value # to bypass NotImplementedError remote_value.value = 34 assert ( str(remote_value) == ' />" ) remote_value_passive: RemoteValue[Any] = RemoteValue( xknx, group_address=["1/2/3", "1/2/4", "i-test"], device_name="MyDevice", ) assert ( str(remote_value_passive) == " />" ) def test_binary_sensor(self) -> None: """Test string representation of binary sensor object.""" xknx = XKNX() binary_sensor = BinarySensor(xknx, name="Fnord", group_address_state="1/2/3") assert ( str(binary_sensor) == ' state=None />' ) def test_climate(self) -> None: """Test string representation of climate object.""" xknx = XKNX() climate = Climate( xknx, name="Wohnzimmer", group_address_temperature="1/2/1", group_address_target_temperature="1/2/2", group_address_setpoint_shift="1/2/3", group_address_setpoint_shift_state="1/2/4", temperature_step=0.1, setpoint_shift_max=20, setpoint_shift_min=-20, group_address_on_off="1/2/14", group_address_on_off_state="1/2/15", group_address_fan_speed="1/2/16", group_address_fan_speed_state="1/2/17", group_address_swing="1/2/18", group_address_swing_state="1/2/19", group_address_horizontal_swing="1/2/20", group_address_horizontal_swing_state="1/2/21", ) assert ( str(climate) == ' " "target_temperature=<1/2/2, None, [], None /> " 'temperature_step="0.1" ' "setpoint_shift=<1/2/3, 1/2/4, [], None /> " 'setpoint_shift_max="20" setpoint_shift_min="-20" ' "group_address_on_off=<1/2/14, 1/2/15, [], None /> " "group_address_fan_speed=<1/2/16, 1/2/17, [], None /> " "group_address_swing=<1/2/18, 1/2/19, [], None /> " "group_address_horizontal_swing=<1/2/20, 1/2/21, [], None /> />" ) def test_climate_mode(self) -> None: """Test string representation of climate mode object.""" xknx = XKNX() climate_mode = ClimateMode( xknx, name="Wohnzimmer Mode", group_address_operation_mode="1/2/5", group_address_operation_mode_state="1/2/6", group_address_operation_mode_protection="1/2/7", group_address_operation_mode_economy="1/2/8", group_address_operation_mode_comfort="1/2/9", group_address_controller_status="1/2/10", group_address_controller_status_state="1/2/11", group_address_controller_mode="1/2/12", group_address_controller_mode_state="1/2/13", ) assert ( str(climate_mode) == ' " "controller_mode=<1/2/12, 1/2/13, [], None /> " "controller_status=<1/2/10, 1/2/11, [], None /> />" ) def test_cover(self) -> None: """Test string representation of cover object.""" xknx = XKNX() cover = Cover( xknx, name="Rolladen", group_address_long="1/2/2", group_address_short="1/2/3", group_address_stop="1/2/4", group_address_position="1/2/5", group_address_position_state="1/2/6", group_address_angle="1/2/7", group_address_angle_state="1/2/8", group_address_locked_state="1/2/9", travel_time_down=8, travel_time_up=10, ) assert ( str(cover) == ' " "step=<1/2/3, None, [], None /> " "stop_=<1/2/4, None, [], None /> " "position_current= " "position_target=<1/2/5, None, [], None /> " "angle=<1/2/7, 1/2/8, [], None /> " "locked= " 'travel_time_down="8" ' 'travel_time_up="10" />' ) def test_fan(self) -> None: """Test string representation of fan object.""" xknx = XKNX() fan = Fan( xknx, name="Dunstabzug", group_address_speed="1/2/3", group_address_speed_state="1/2/4", group_address_oscillation="1/2/5", group_address_oscillation_state="1/2/6", ) assert ( str(fan) == ' oscillation=<1/2/5, 1/2/6, [], None /> />' ) def test_light(self) -> None: """Test string representation of non dimmable light object.""" xknx = XKNX() light = Light( xknx, name="Licht", group_address_switch="1/2/3", group_address_switch_state="1/2/4", ) assert str(light) == ' />' def test_light_dimmable(self) -> None: """Test string representation of dimmable light object.""" xknx = XKNX() light = Light( xknx, name="Licht", group_address_switch="1/2/3", group_address_switch_state="1/2/4", group_address_brightness="1/2/5", group_address_brightness_state="1/2/6", ) assert ( str(light) == ' " "brightness=<1/2/5, 1/2/6, [], None /> />" ) def test_light_color(self) -> None: """Test string representation of dimmable light object.""" xknx = XKNX() light = Light( xknx, name="Licht", group_address_switch="1/2/3", group_address_switch_state="1/2/4", group_address_color="1/2/5", group_address_color_state="1/2/6", ) assert ( str(light) == ' " "color=<1/2/5, 1/2/6, [], None /> />" ) async def test_notification(self) -> None: """Test string representation of notification object.""" xknx = XKNX() notification = Notification( xknx, name="Alarm", group_address="1/2/3", group_address_state="1/2/4" ) assert ( str(notification) == ' />' ) await notification.set("Einbrecher im Haus") notification.process(xknx.telegrams.get_nowait()) assert ( str(notification) == ' />" ) def test_scene(self) -> None: """Test string representation of scene object.""" xknx = XKNX() scene = Scene(xknx, name="Romantic", group_address="1/2/3", scene_number=23) assert ( str(scene) == ' scene_number="23" />' ) async def test_sensor(self) -> None: """Test string representation of sensor object.""" xknx = XKNX() sensor = Sensor( xknx, name="MeinSensor", group_address_state="1/2/3", value_type="percent" ) assert ( str(sensor) == ' value=None unit="%"/>' ) # self.loop.run_until_complete(sensor.sensor_value.set(25)) telegram = Telegram( destination_address=GroupAddress("1/2/3"), direction=TelegramDirection.INCOMING, payload=GroupValueWrite(DPTArray(0x40)), ) sensor.process_group_write(telegram) assert ( str(sensor) == ' value=25 unit="%"/>' ) async def test_expose_sensor(self) -> None: """Test string representation of expose sensor object.""" xknx = XKNX() sensor = ExposeSensor( xknx, name="MeinSensor", group_address="1/2/3", value_type="percent" ) assert ( str(sensor) == ' value=None unit="%"/>' ) await sensor.set(25) sensor.process(xknx.telegrams.get_nowait()) assert ( str(sensor) == ' value=25 unit="%"/>' ) def test_switch(self) -> None: """Test string representation of switch object.""" xknx = XKNX() switch = Switch( xknx, name="Schalter", group_address="1/2/3", group_address_state="1/2/4" ) assert ( str(switch) == ' />' ) async def test_weather(self) -> None: """Test string representation of switch object.""" xknx = XKNX() weather = Weather( xknx, "Home", group_address_temperature="7/0/1", group_address_brightness_south="7/0/5", group_address_brightness_east="7/0/4", group_address_brightness_west="7/0/3", group_address_wind_speed="7/0/2", group_address_wind_bearing="7/0/6", group_address_day_night="7/0/7", group_address_rain_alarm="7/0/0", group_address_frost_alarm="7/0/8", group_address_air_pressure="7/0/9", group_address_humidity="7/0/9", group_address_wind_alarm="7/0/10", ) assert ( str(weather) == ' ' "brightness_south= brightness_north= " "brightness_west= " "brightness_east= wind_speed= " "wind_bearing= rain_alarm= " "wind_alarm= frost_alarm= " "day_night= air_pressure= " "humidity= />" ) telegram = Telegram( destination_address=GroupAddress("7/0/10"), direction=TelegramDirection.INCOMING, payload=GroupValueWrite(DPTBinary(1)), ) weather.process_group_write(telegram) assert ( str(weather) == ' ' "brightness_south= brightness_north= " "brightness_west= " "brightness_east= wind_speed= " "wind_bearing= rain_alarm= " "wind_alarm= " "frost_alarm= day_night= " "air_pressure= humidity= />" ) def test_datetime(self) -> None: """Test string representation of datetime object.""" xknx = XKNX() date_time = DateTimeDevice( xknx, name="Zeit", group_address="1/2/3", localtime=False ) assert ( str(date_time) == ' />' ) def test_could_not_parse_telegramn_exception(self) -> None: """Test string representation of CouldNotParseTelegram exception.""" exception = CouldNotParseTelegram(description="Fnord") assert str(exception) == '' def test_could_not_parse_telegramn_exception_parameter(self) -> None: """Test string representation of CouldNotParseTelegram exception.""" exception = CouldNotParseTelegram(description="Fnord", one="one", two="two") assert ( str(exception) == '' ) def test_could_not_parse_knxip_exception(self) -> None: """Test string representation of CouldNotParseKNXIP exception.""" exception = CouldNotParseKNXIP(description="Fnord") assert str(exception) == '' def test_conversion_error_exception(self) -> None: """Test string representation of ConversionError exception.""" exception = ConversionError(description="Fnord") assert str(exception) == '' def test_conversion_error_exception_parameter(self) -> None: """Test string representation of ConversionError exception.""" exception = ConversionError(description="Fnord", one="one", two="two") assert ( str(exception) == '' ) def test_could_not_parse_address_exception(self) -> None: """Test string representation of CouldNotParseAddress exception.""" exception = CouldNotParseAddress(address="1/2/1000") assert str(exception) == '' def test_device_illegal_value_exception(self) -> None: """Test string representation of DeviceIllegalValue exception.""" exception = DeviceIllegalValue(value=12, description="Fnord exceeded") assert ( str(exception) == '' ) def test_incomplete_knxip_frame_excetpion(self) -> None: """Test string representation of IncompleteKNXIPFrame exception.""" exception = IncompleteKNXIPFrame("Hello") assert str(exception) == '' def test_address(self) -> None: """Test string representation of address object.""" address = GroupAddress("1/2/3") assert repr(address) == 'GroupAddress("1/2/3")' assert str(address) == "1/2/3" def test_dpt_array(self) -> None: """Test string representation of DPTBinary.""" dpt_array = DPTArray([0x01, 0x02]) assert str(dpt_array) == '' def test_dpt_binary(self) -> None: """Test string representation of DPTBinary.""" dpt_binary = DPTBinary(7) assert str(dpt_binary) == '' def test_telegram(self) -> None: """Test string representation of Telegram.""" telegram = Telegram( destination_address=GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(7)), ) assert ( str(telegram) == '" />" />' ) def test_dib_generic(self) -> None: """Test string representation of DIBGeneric.""" dib = DIBGeneric() dib.dtc = 0x01 dib.data = [0x02, 0x03, 0x04] assert str(dib) == '' def test_dib_supp_svc_families(self) -> None: """Test string representation of DIBSuppSVCFamilies.""" dib = DIBSuppSVCFamilies() dib.families.append(DIBSuppSVCFamilies.Family(DIBServiceFamily.CORE, "1")) dib.families.append( DIBSuppSVCFamilies.Family(DIBServiceFamily.DEVICE_MANAGEMENT, "2") ) assert ( str(dib) == '' ) def test_dib_device_informatio(self) -> None: """Test string representation of DIBDeviceInformation.""" dib = DIBDeviceInformation() dib.knx_medium = KNXMedium.TP1 dib.programming_mode = False dib.individual_address = IndividualAddress("1.1.0") dib.name = "Gira KNX/IP-Router" dib.mac_address = "00:01:02:03:04:05" dib.multicast_address = "224.0.23.12" dib.serial_number = "13:37:13:37:13:37" dib.project_number = 564 dib.installation_number = 2 assert ( str(dib) == "' ) def test_hpai(self) -> None: """Test string representation of HPAI.""" hpai_udp = HPAI(ip_addr="192.168.42.1", port=33941) assert str(hpai_udp) == "192.168.42.1:33941/udp" assert repr(hpai_udp) == "HPAI('192.168.42.1', 33941, HostProtocol.IPV4_UDP)" hpai_tcp = HPAI(ip_addr="10.1.4.1", port=3671, protocol=HostProtocol.IPV4_TCP) assert str(hpai_tcp) == "10.1.4.1:3671/tcp" assert repr(hpai_tcp) == "HPAI('10.1.4.1', 3671, HostProtocol.IPV4_TCP)" def test_header(self) -> None: """Test string representation of KNX/IP-Header.""" header = KNXIPHeader() header.total_length = 42 assert ( str(header) == '" ) def test_connect_request(self) -> None: """Test string representation of KNX/IP ConnectRequest.""" connect_request = ConnectRequest() connect_request.request_type = ConnectRequestType.TUNNEL_CONNECTION connect_request.control_endpoint = HPAI(ip_addr="192.168.42.1", port=33941) connect_request.data_endpoint = HPAI(ip_addr="192.168.42.2", port=33942) assert ( str(connect_request) == '" />' ) def test_connect_response(self) -> None: """Test string representatoin of KNX/IP ConnectResponse.""" connect_response = ConnectResponse() connect_response.communication_channel = 13 connect_response.data_endpoint = HPAI(ip_addr="192.168.42.1", port=33941) connect_response.crd = ConnectResponseData( request_type=ConnectRequestType.TUNNEL_CONNECTION, individual_address=IndividualAddress("1.2.3"), ) assert ( str(connect_response) == '" />' ) def test_disconnect_request(self) -> None: """Test string representation of KNX/IP DisconnectRequest.""" disconnect_request = DisconnectRequest() disconnect_request.communication_channel_id = 13 disconnect_request.control_endpoint = HPAI(ip_addr="192.168.42.1", port=33941) assert ( str(disconnect_request) == '' ) def test_disconnect_response(self) -> None: """Test string representation of KNX/IP DisconnectResponse.""" disconnect_response = DisconnectResponse() disconnect_response.communication_channel_id = 23 assert ( str(disconnect_response) == '' ) def test_connectionstate_request(self) -> None: """Test string representation of KNX/IP ConnectionStateRequest.""" connectionstate_request = ConnectionStateRequest() connectionstate_request.communication_channel_id = 23 connectionstate_request.control_endpoint = HPAI( ip_addr="192.168.42.1", port=33941 ) assert ( str(connectionstate_request) == '' ) def test_connectionstate_response(self) -> None: """Test string representation of KNX/IP ConnectionStateResponse.""" connectionstate_response = ConnectionStateResponse() connectionstate_response.communication_channel_id = 23 assert ( str(connectionstate_response) == '' ) def test_search_reqeust(self) -> None: """Test string representation of KNX/IP SearchRequest.""" search_request = SearchRequest() assert ( str(search_request) == '' ) def test_search_response(self) -> None: """Test string representation of KNX/IP SearchResponse.""" search_response = SearchResponse() search_response.control_endpoint = HPAI(ip_addr="192.168.42.1", port=33941) search_response.dibs.append(DIBGeneric()) search_response.dibs.append(DIBGeneric()) assert ( str(search_response) == ',\n' '\n' ']" />' ) def test_search_response_extended(self) -> None: """Test string representation of KNX/IP SearchResponseExtended.""" search_response = SearchResponseExtended() search_response.control_endpoint = HPAI(ip_addr="192.168.42.1", port=33941) search_response.dibs.append(DIBGeneric()) search_response.dibs.append(DIBGeneric()) assert ( str(search_response) == ',\n' '\n' ']" />' ) def test_tunnelling_request(self) -> None: """Test string representation of KNX/IP TunnellingRequest.""" tunnelling_request = TunnellingRequest() tunnelling_request.communication_channel_id = 23 tunnelling_request.sequence_counter = 42 assert ( str(tunnelling_request) == '' ) def test_tunnelling_ack(self) -> None: """Test string representation of KNX/IP TunnellingAck.""" tunnelling_ack = TunnellingAck() tunnelling_ack.communication_channel_id = 23 tunnelling_ack.sequence_counter = 42 assert ( str(tunnelling_ack) == '' ) def test_tunnelling_feature_get(self) -> None: """Test string representation of KNX/IP TunnellingFeatureGet.""" tunnelling_feature = TunnellingFeatureGet() tunnelling_feature.communication_channel_id = 23 tunnelling_feature.sequence_counter = 42 tunnelling_feature.feature_type = TunnellingFeatureType.BUS_CONNECTION_STATUS assert ( str(tunnelling_feature) == '' ) def test_tunnelling_feature_info(self) -> None: """Test string representation of KNX/IP TunnellingFeatureInfo.""" tunnelling_feature = TunnellingFeatureInfo() tunnelling_feature.communication_channel_id = 23 tunnelling_feature.sequence_counter = 42 tunnelling_feature.feature_type = TunnellingFeatureType.BUS_CONNECTION_STATUS tunnelling_feature.data = b"\x01\x00" assert ( str(tunnelling_feature) == '' ) def test_tunnelling_feature_response(self) -> None: """Test string representation of KNX/IP TunnellingFeatureResponse.""" tunnelling_feature = TunnellingFeatureResponse() tunnelling_feature.communication_channel_id = 23 tunnelling_feature.sequence_counter = 42 tunnelling_feature.feature_type = TunnellingFeatureType.BUS_CONNECTION_STATUS tunnelling_feature.data = b"\x01\x00" assert ( str(tunnelling_feature) == '' ) def test_tunnelling_feature_set(self) -> None: """Test string representation of KNX/IP TunnellingFeatureSet.""" tunnelling_feature = TunnellingFeatureSet() tunnelling_feature.communication_channel_id = 23 tunnelling_feature.sequence_counter = 42 tunnelling_feature.feature_type = ( TunnellingFeatureType.INTERFACE_FEATURE_INFO_ENABLE ) tunnelling_feature.data = b"\x01\x00" assert ( str(tunnelling_feature) == '' ) def test_cemi_ldata_frame(self) -> None: """Test string representation of KNX/IP CEMI Frame.""" cemi_frame = CEMIFrame( code=CEMIMessageCode.L_DATA_IND, data=CEMILData.init_from_telegram( telegram=Telegram( destination_address=GroupAddress("1/2/5"), payload=GroupValueWrite(DPTBinary(7)), ), src_addr=IndividualAddress("1.2.3"), ), ) assert ( str(cemi_frame) == '" />")" />' ) def test_cemi_mprop_frame(self) -> None: """Test string representation of KNX/IP CEMI Frame.""" cemi_frame = CEMIFrame( code=CEMIMessageCode.M_PROP_READ_REQ, data=CEMIMPropReadResponse( property_info=CEMIMPropInfo( object_type=ResourceObjectType.OBJECT_KNXNETIP_PARAMETER, property_id=ResourceKNXNETIPPropertyId.PID_KNX_INDIVIDUAL_ADDRESS, ), data=IndividualAddress("1.2.3").to_knx(), ), ) assert ( str(cemi_frame) == '' ) def test_knxip_frame(self) -> None: """Test string representation of KNX/IP Frame.""" search_request = SearchRequest() knxipframe = KNXIPFrame.init_from_body(search_request) assert ( str(knxipframe) == ' body="" />' ) # # Gateway Scanner # def test_gateway_descriptor(self) -> None: """Test string representation of GatewayDescriptor.""" gateway_descriptor = GatewayDescriptor( name="KNX-Interface", ip_addr="192.168.2.3", port=1234, local_interface="en1", local_ip="192.168.2.50", supports_tunnelling=True, supports_routing=False, individual_address=IndividualAddress("1.1.1"), ) assert str(gateway_descriptor) == "1.1.1 - KNX-Interface @ 192.168.2.3:1234" # # Routing Indication # def test_routing_indication_str(self) -> None: """Test string representation of GatewayDescriptor.""" routing_indication = RoutingIndication() assert str(routing_indication) == '' xknx-3.6.0/test/telegram_tests/000077500000000000000000000000001475530762600165305ustar00rootroot00000000000000xknx-3.6.0/test/telegram_tests/__init__.py000066400000000000000000000000521475530762600206360ustar00rootroot00000000000000"""Unit tests for the Telegram module.""" xknx-3.6.0/test/telegram_tests/address_filter_test.py000066400000000000000000000174201475530762600231370ustar00rootroot00000000000000"""Unit test for Address class.""" import pytest from xknx.exceptions import ConversionError from xknx.telegram import AddressFilter from xknx.telegram.address import GroupAddress, InternalGroupAddress class TestAddressFilter: """Test class for Address.""" def test_range_initialization(self) -> None: """Test Initialization of AddresFilter.Range.""" assert AddressFilter.Range("*").get_range() == (0, 65535) assert AddressFilter.Range("5").get_range() == (5, 5) assert AddressFilter.Range("0").get_range() == (0, 0) assert AddressFilter.Range("3-5").get_range() == (3, 5) assert AddressFilter.Range("5-3").get_range() == (3, 5) assert AddressFilter.Range("-5").get_range() == (0, 5) assert AddressFilter.Range("5-").get_range() == (5, 65535) assert AddressFilter.Range("70-100").get_range() == (70, 100) def test_range_test(self) -> None: """Test matching within AddressFilter.Range.""" range_filter = AddressFilter.Range("2-16") assert range_filter.match(10) assert range_filter.match(2) assert range_filter.match(16) assert not range_filter.match(1) assert not range_filter.match(17) def test_level_filter_test(self) -> None: """Test matching within AddressFilter.LevelFilter.""" level_filter = AddressFilter.LevelFilter("2,4,8-10,13") assert not level_filter.match(1) assert level_filter.match(2) assert not level_filter.match(3) assert level_filter.match(4) assert not level_filter.match(5) assert level_filter.match(9) def test_address_filter_level3_3(self) -> None: """Test AddressFilter 3rd part of level3 addresses.""" af1 = AddressFilter("1/2/3") assert af1.match("1/2/3") assert not af1.match("1/2/4") assert not af1.match("1/2/1") af2 = AddressFilter("1/2/2-3,5-") assert not af2.match("1/2/1") assert af2.match("1/2/3") assert not af2.match("1/2/4") assert af2.match("1/2/6") af3 = AddressFilter("1/2/*") assert af3.match("1/2/3") assert af3.match("1/2/5") def test_address_filter_level3_2(self) -> None: """Test AddressFilter 2nd part of level3 addresses.""" af1 = AddressFilter("1/2/3") assert af1.match("1/2/3") assert not af1.match("1/3/3") assert not af1.match("1/1/3") af2 = AddressFilter("1/2-/3") assert not af2.match("1/1/3") assert af2.match("1/2/3") assert af2.match("1/5/3") af3 = AddressFilter("1/*/3") assert af3.match("1/4/3") assert af3.match("1/7/3") def test_address_filter_level3_1(self) -> None: """Test AddressFilter 1st part of level3 addresses.""" af1 = AddressFilter("4/2/3") assert af1.match("4/2/3") assert not af1.match("2/2/3") assert not af1.match("10/2/3") af2 = AddressFilter("2-/4/3") assert not af2.match("1/4/3") assert af2.match("2/4/3") assert af2.match("10/4/3") af3 = AddressFilter("*/5/5") assert af3.match("2/5/5") assert af3.match("8/5/5") def test_address_filter_level2_2(self) -> None: """Test AddressFilter 2nd part of level2 addresses.""" af1 = AddressFilter("2/3") assert af1.match("2/3") assert not af1.match("2/4") assert not af1.match("2/1") af2 = AddressFilter("2/3-4,7-") assert not af2.match("2/2") assert af2.match("2/3") assert not af2.match("2/6") assert af2.match("2/8") af3 = AddressFilter("2/*") assert af3.match("2/3") assert af3.match("2/5") def test_address_filter_level2_1(self) -> None: """Test AddressFilter 1st part of level2 addresses.""" af1 = AddressFilter("4/2") assert af1.match("4/2") assert not af1.match("2/2") assert not af1.match("10/2") af2 = AddressFilter("2-3,8-/4") assert not af2.match("1/4") assert af2.match("2/4") assert not af2.match("7/4") assert af2.match("10/4") af3 = AddressFilter("*/5") assert af3.match("2/5") assert af3.match("8/5") def test_address_filter_free(self) -> None: """Test AddressFilter free format addresses.""" af1 = AddressFilter("4") assert af1.match("4") assert not af1.match("1") assert not af1.match("10") af2 = AddressFilter("1,4,7-") assert af2.match("1") assert not af2.match("2") assert af2.match("4") assert not af2.match("6") assert af2.match("60") af3 = AddressFilter("*") assert af3.match("2") assert af3.match("8") def test_address_combined(self) -> None: """Test AddressFilter with complex filters.""" af1 = AddressFilter("2-/2,3,5-/*") assert af1.match("2/3/8") assert af1.match("4/7/10") assert af1.match("2/7/10") assert not af1.match("1/7/10") assert not af1.match("2/4/10") assert not af1.match("2/1/10") def test_initialize_wrong_format(self) -> None: """Test if wrong address format raises exception.""" with pytest.raises(ConversionError): AddressFilter("2-/2,3/4/5/1,5-/*") def test_adjust_range(self) -> None: """Test helper function _adjust_range.""" assert ( AddressFilter.Range._adjust_range(GroupAddress.MAX_FREE + 1) == GroupAddress.MAX_FREE ) assert AddressFilter.Range._adjust_range(-1) == 0 def test_internal_address_filter_exact(self) -> None: """Test AddressFilter for InternalGroupAddress.""" af1 = AddressFilter("i-1") assert af1.match("i1") assert af1.match("i 1") assert af1.match("i-1") assert af1.match(InternalGroupAddress("i-1")) assert not af1.match("1") assert not af1.match(GroupAddress(1)) def test_internal_address_filter_wildcard(self) -> None: """Test AddressFilter with wildcards for InternalGroupAddress.""" af1 = AddressFilter("i-?") assert af1.match("i1") assert af1.match("i 2") assert af1.match("i-3") assert af1.match(InternalGroupAddress("i-4")) assert not af1.match("1") assert not af1.match(GroupAddress(1)) assert not af1.match("i11") assert not af1.match("i 11") assert not af1.match("i-11") assert not af1.match(InternalGroupAddress("i-11")) af2 = AddressFilter("i-t?st") assert af2.match("it1st") assert af2.match("i t2st") assert af2.match("i-test") assert af2.match(InternalGroupAddress("i-test")) assert not af2.match("1") assert not af2.match(GroupAddress(1)) assert not af2.match("i11") assert not af2.match("i tst") assert not af2.match("i-teest") assert not af2.match(InternalGroupAddress("i-tst")) af3 = AddressFilter("i-*") assert af3.match("i1") assert af3.match("i asdf") assert af3.match("i-3sdf") assert af3.match(InternalGroupAddress("i-4")) assert not af3.match("1") assert not af3.match(GroupAddress(1)) assert af3.match("i11") assert af3.match("i 11??") assert af3.match("i-11*") assert af3.match(InternalGroupAddress("i-11")) af4 = AddressFilter("i-t*t") assert af4.match("it1t") assert af4.match("i t22t") assert af4.match("i-testt") assert af4.match("i-tt") assert af4.match(InternalGroupAddress("i-t333t")) assert not af4.match("1") assert not af4.match(GroupAddress(1)) assert not af4.match("i testx") assert not af4.match("i-11test") assert not af4.match(InternalGroupAddress("i-11")) xknx-3.6.0/test/telegram_tests/address_test.py000066400000000000000000000315471475530762600216000ustar00rootroot00000000000000"""Unit test for Address class.""" from typing import Any import pytest from xknx.exceptions import CouldNotParseAddress from xknx.telegram.address import ( GroupAddress, GroupAddressType, IndividualAddress, InternalGroupAddress, parse_device_group_address, ) _broadcast_group_addresses: list[str | int] = ["0/0/0", "0/0", "0", 0] device_group_addresses_valid: dict[Any, int] = { "0/1": 1, "0/11": 11, "0/111": 111, "0/1111": 1111, "0/2047": 2047, "0/0/1": 1, "0/0/11": 11, "0/0/111": 111, "0/0/255": 255, "0/1/11": 267, "0/1/111": 367, "0/7/255": 2047, "1/0": 2048, "1/0/0": 2048, "1/1/111": 2415, "1/7/255": 4095, "31/7/255": 65535, "1": 1, 65535: 65535, GroupAddress("1/1/111"): 2415, } group_addresses_valid = { bc_addr: 0 for bc_addr in _broadcast_group_addresses } | device_group_addresses_valid group_addresses_invalid: list[Any] = [ "0/2049", "0/8/0", "0/0/256", "32/0", "0/0a", "a0/0", "abc", "1.1.1", "0.0", IndividualAddress("11.11.111"), 65536, (0xAB, 0xCD), -1, [], None, ] device_group_addresses_invalid: list[Any] = [ *group_addresses_invalid, *_broadcast_group_addresses, ] individual_addresses_valid: dict[Any, int] = { "0.0.0": 0, "123": 123, "1.0.0": 4096, "1.1.0": 4352, "1.1.1": 4353, "1.1.11": 4363, "1.1.111": 4463, "1.11.111": 7023, "11.11.111": 47983, IndividualAddress("11.11.111"): 47983, "15.15.255": 65535, 0: 0, 65535: 65535, } individual_addresses_invalid: list[Any] = [ "15.15.256", "16.0.0", "0.16.0", "15.15.255a", "a15.15.255", "abc", "1/1/1", "0/0", GroupAddress("1/1/111"), 65536, (0xAB, 0xCD), -1, [], None, ] internal_group_addresses_valid: dict[str | InternalGroupAddress, str] = { "i 123": "i-123", "i-123": "i-123", "i_123": "i-123", "I 123": "i-123", "i abc": "i-abc", "i-abc": "i-abc", "i_abc": "i-abc", "I-abc": "i-abc", "i123": "i-123", "iabc": "i-abc", "IABC": "i-ABC", "i abc ": "i-abc", "i asdf 123 adsf ": "i-asdf 123 adsf", "i-1/2/3": "i-1/2/3", InternalGroupAddress("i-123"): "i-123", } internal_group_addresses_invalid: list[str | None] = [ "i", "i-", "i ", "i ", "i- ", "a", None, ] class TestIndividualAddress: """Test class for IndividualAddress.""" @pytest.mark.parametrize( ("address_test", "address_raw"), individual_addresses_valid.items() ) def test_with_valid(self, address_test: Any, address_raw: int) -> None: """Test with some valid addresses.""" assert IndividualAddress(address_test).raw == address_raw @pytest.mark.parametrize("address_test", individual_addresses_invalid) def test_with_invalid(self, address_test: Any) -> None: """Test with some invalid addresses.""" with pytest.raises(CouldNotParseAddress): IndividualAddress(address_test) def test_with_int(self) -> None: """Test initialization with free format address as integer.""" assert IndividualAddress(49552).raw == 49552 def test_from_knx(self) -> None: """Test initialization with Bytes.""" ia = IndividualAddress.from_knx(bytes((0x12, 0x34))) assert ia.raw == 0x1234 def test_is_line(self) -> None: """Test if `IndividualAddress.is_line` works like excepted.""" assert IndividualAddress("1.0.0").is_line assert not IndividualAddress("1.0.1").is_line def test_is_device(self) -> None: """Test if `IndividualAddress.is_device` works like excepted.""" assert IndividualAddress("1.0.1").is_device assert not IndividualAddress("1.0.0").is_device def test_to_knx(self) -> None: """Test if `IndividualAddress.to_knx()` generates valid byte objects.""" assert IndividualAddress("0.0.0").to_knx() == bytes((0x0, 0x0)) assert IndividualAddress("15.15.255").to_knx() == bytes((0xFF, 0xFF)) def test_equal(self) -> None: """Test if the equal operator works in all cases.""" assert IndividualAddress("1.0.0") == IndividualAddress(4096) assert IndividualAddress("1.0.0") != IndividualAddress("1.1.1") assert IndividualAddress("1.0.0") is not None assert IndividualAddress("1.0.0") != "example" assert IndividualAddress("1.1.1") != GroupAddress("1/1/1") assert IndividualAddress(250) != GroupAddress(250) assert IndividualAddress(250) != 250 def test_representation(self) -> None: """Test string representation of address.""" assert repr(IndividualAddress("2.3.4")) == 'IndividualAddress("2.3.4")' class TestGroupAddress: """Test class for GroupAddress.""" @pytest.mark.parametrize( ("address_test", "address_raw"), group_addresses_valid.items() ) def test_with_valid(self, address_test: str | int, address_raw: int) -> None: """ Test if the class constructor generates valid raw values. This test checks: * all allowed input variants (strings, tuples, integers) * for conversation errors * for upper/lower limits still working, to avoid off-by-one errors """ assert GroupAddress(address_test).raw == address_raw @pytest.mark.parametrize("address_test", group_addresses_invalid) def test_with_invalid(self, address_test: Any) -> None: """ Test if constructor raises an exception for all known invalid cases. Checks: * addresses or parts of it too high/low * invalid input variants (lists instead of tuples) * invalid strings """ with pytest.raises(CouldNotParseAddress): GroupAddress(address_test) def test_main(self) -> None: """ Test if `GroupAddress.main` works. Checks: * Main group part of a strings returns the right value * Return `None` on `GroupAddressType.FREE` """ assert GroupAddress("1/0").main == 1 assert GroupAddress("15/0").main == 15 assert GroupAddress("31/0/0").main == 31 GroupAddress.address_format = GroupAddressType.FREE assert GroupAddress("1/0").main is None def test_middle(self) -> None: """ Test if `GroupAddress.middle` works. Checks: * Middle group part of a strings returns the right value * Return `None` if not `GroupAddressType.LONG` """ GroupAddress.address_format = GroupAddressType.LONG assert GroupAddress("1/0/1").middle == 0 assert GroupAddress("1/7/1").middle == 7 GroupAddress.address_format = GroupAddressType.SHORT assert GroupAddress("1/0").middle is None GroupAddress.address_format = GroupAddressType.FREE assert GroupAddress("1/0").middle is None def test_sub(self) -> None: """ Test if `GroupAddress.sub` works. Checks: * Sub group part of a strings returns the right value * Return never `None` """ GroupAddress.address_format = GroupAddressType.SHORT assert GroupAddress("1/0").sub == 0 assert GroupAddress("31/0").sub == 0 assert GroupAddress("1/2047").sub == 2047 assert GroupAddress("31/2047").sub == 2047 GroupAddress.address_format = GroupAddressType.LONG assert GroupAddress("1/0/0").sub == 0 assert GroupAddress("1/0/255").sub == 255 GroupAddress.address_format = GroupAddressType.FREE assert GroupAddress("0/0").sub == 0 assert GroupAddress("1/0").sub == 2048 assert GroupAddress("31/2047").sub == 65535 def test_from_knx(self) -> None: """Test initialization with Bytes.""" ga = GroupAddress.from_knx(bytes((0x12, 0x34))) assert ga.raw == 0x1234 def test_to_knx(self) -> None: """Test if `GroupAddress.to_knx()` generates valid byte tuples.""" assert GroupAddress("0/0").to_knx() == bytes((0x0, 0x0)) assert GroupAddress("31/2047").to_knx() == bytes((0xFF, 0xFF)) def test_equal(self) -> None: """Test if the equal operator works in all cases.""" assert GroupAddress("1/0") == GroupAddress(2048) assert GroupAddress("1/1") != GroupAddress("1/1/0") assert GroupAddress("1/0") is not None assert GroupAddress("1/0") != "example" assert GroupAddress(1) != IndividualAddress(1) assert GroupAddress(1) != 1 @pytest.mark.parametrize( ("initializer", "string_free", "string_short", "string_long"), [ ("0", "0", "0/0", "0/0/0"), ("1/4/70", "3142", "1/1094", "1/4/70"), ], ) def test_representation( self, initializer: str, string_free: str, string_short: str, string_long: str ) -> None: """Test string representation of address.""" group_address = GroupAddress(initializer) GroupAddress.address_format = GroupAddressType.FREE assert str(group_address) == string_free assert repr(group_address) == f'GroupAddress("{string_free}")' GroupAddress.address_format = GroupAddressType.SHORT assert str(group_address) == string_short assert repr(group_address) == f'GroupAddress("{string_short}")' GroupAddress.address_format = GroupAddressType.LONG assert str(group_address) == string_long assert repr(group_address) == f'GroupAddress("{string_long}")' class TestInternalGroupAddress: """Test class for InternalGroupAddress.""" @pytest.mark.parametrize( ("address_test", "address_raw"), internal_group_addresses_valid.items() ) def test_with_valid( self, address_test: str | InternalGroupAddress, address_raw: str ) -> None: """Test if the class constructor generates valid raw values.""" assert InternalGroupAddress(address_test).raw == address_raw @pytest.mark.parametrize( "address_test", [ *internal_group_addresses_invalid, *group_addresses_valid, *group_addresses_invalid, *individual_addresses_valid, *individual_addresses_invalid, ], ) def test_with_invalid(self, address_test: Any) -> None: """Test if constructor raises an exception for all known invalid cases.""" with pytest.raises(CouldNotParseAddress): InternalGroupAddress(address_test) def test_equal(self) -> None: """Test if the equal operator works in all cases.""" assert InternalGroupAddress("i 123") == InternalGroupAddress("i 123") assert InternalGroupAddress("i-asdf") == InternalGroupAddress("i asdf") assert InternalGroupAddress("i-asdf") == InternalGroupAddress("Iasdf") assert InternalGroupAddress("i-1") != InternalGroupAddress("i-2") assert InternalGroupAddress("i-1") is not None assert InternalGroupAddress("i-example") != "example" assert InternalGroupAddress("i-0") != GroupAddress(0) assert InternalGroupAddress("i-1") != IndividualAddress(1) assert InternalGroupAddress("i-1") != 1 def test_representation(self) -> None: """Test string representation of address.""" assert repr(InternalGroupAddress("i0")) == 'InternalGroupAddress("i-0")' assert repr(InternalGroupAddress("i-0")) == 'InternalGroupAddress("i-0")' assert repr(InternalGroupAddress("i 0")) == 'InternalGroupAddress("i-0")' class TestParseDestinationAddress: """Test class for parsing destination addresses.""" @pytest.mark.parametrize("address_test", device_group_addresses_valid) def test_parse_device_group_address(self, address_test: Any) -> None: """Test if the function returns GroupAddress objects.""" assert isinstance(parse_device_group_address(address_test), GroupAddress) @pytest.mark.parametrize("address_test", internal_group_addresses_valid) def test_parse_device_internal_group_address( self, address_test: str | InternalGroupAddress ) -> None: """Test if the function returns InternalGroupAddress objects.""" assert isinstance( parse_device_group_address(address_test), InternalGroupAddress ) @pytest.mark.parametrize( "address_test", [ *internal_group_addresses_invalid, *device_group_addresses_invalid, ], ) def test_parse_device_invalid(self, address_test: Any) -> None: """Test if the function raises CouldNotParseAddress on invalid values.""" with pytest.raises(CouldNotParseAddress): parse_device_group_address(address_test) def test_parse_device_invalid_group_address_message(self) -> None: """Test if the error message is from GroupAddress, not InternalGroupAddress for strings.""" with pytest.raises(CouldNotParseAddress, match=r".*Sub group out of range.*"): parse_device_group_address("0/0/700") xknx-3.6.0/test/telegram_tests/apci_test.py000066400000000000000000001434231475530762600210640ustar00rootroot00000000000000"""Unit test for APCI objects.""" import pytest from xknx.dpt import DPTArray, DPTBinary from xknx.exceptions import ConversionError from xknx.telegram.address import IndividualAddress from xknx.telegram.apci import ( APCI, ADCRead, ADCResponse, AuthorizeRequest, AuthorizeResponse, DeviceDescriptorRead, DeviceDescriptorResponse, FunctionPropertyCommand, FunctionPropertyStateRead, FunctionPropertyStateResponse, GroupValueRead, GroupValueResponse, GroupValueWrite, IndividualAddressRead, IndividualAddressResponse, IndividualAddressSerialRead, IndividualAddressSerialResponse, IndividualAddressSerialWrite, IndividualAddressWrite, MemoryExtendedRead, MemoryExtendedReadResponse, MemoryExtendedWrite, MemoryExtendedWriteResponse, MemoryRead, MemoryResponse, MemoryWrite, PropertyDescriptionRead, PropertyDescriptionResponse, PropertyValueRead, PropertyValueResponse, PropertyValueWrite, Restart, UserManufacturerInfoRead, UserManufacturerInfoResponse, UserMemoryRead, UserMemoryResponse, UserMemoryWrite, ) class TestAPCI: """Test class for APCI objects.""" def test_resolve_apci_unsupported(self) -> None: """Test resolve_apci for unsupported services.""" with pytest.raises( ConversionError, match=r".*Class not implemented for APCI.*" ): # Unsupported user service. APCI.from_knx(bytes((0x02, 0xC3))) with pytest.raises( ConversionError, match=r".*Class not implemented for APCI.*" ): # Unsupported extended service. APCI.from_knx(bytes((0x03, 0xC0))) class TestGroupValueRead: """Test class for GroupValueRead objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = GroupValueRead() assert payload.calculated_length() == 1 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x00, 0x00])) assert payload == GroupValueRead() def test_to_knx(self) -> None: """Test the to_knx method.""" payload = GroupValueRead() assert payload.to_knx() == bytes([0x00, 0x00]) def test_str(self) -> None: """Test the __str__ method.""" payload = GroupValueRead() assert str(payload) == "" class TestGroupValueWrite: """Test class for GroupValueWrite objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload_a = GroupValueWrite(DPTArray((0x01, 0x02, 0x03))) payload_b = GroupValueWrite(DPTBinary(1)) assert payload_a.calculated_length() == 4 assert payload_b.calculated_length() == 1 def test_from_knx(self) -> None: """Test the from_knx method.""" payload_a = APCI.from_knx(bytes([0x00, 0x80, 0x05, 0x04, 0x03, 0x02, 0x01])) payload_b = APCI.from_knx(bytes([0x00, 0x82])) assert payload_a == GroupValueWrite(DPTArray((0x05, 0x04, 0x03, 0x02, 0x01))) assert payload_b == GroupValueWrite(DPTBinary(0x02)) def test_to_knx(self) -> None: """Test the to_knx method.""" payload_a = GroupValueWrite(DPTArray((0x01, 0x02, 0x03))) payload_b = GroupValueWrite(DPTBinary(1)) assert payload_a.to_knx() == bytes([0x00, 0x80, 0x01, 0x02, 0x03]) assert payload_b.to_knx() == bytes([0x00, 0x81]) def test_str(self) -> None: """Test the __str__ method.""" payload = GroupValueWrite(DPTBinary(1)) assert str(payload) == '" />' class TestGroupValueResponse: """Test class for GroupValueResponse objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload_a = GroupValueResponse(DPTArray((0x01, 0x02, 0x03))) payload_b = GroupValueResponse(DPTBinary(1)) assert payload_a.calculated_length() == 4 assert payload_b.calculated_length() == 1 def test_from_knx(self) -> None: """Test the from_knx method.""" payload_a = APCI.from_knx(bytes([0x00, 0x40, 0x05, 0x04, 0x03, 0x02, 0x01])) payload_b = APCI.from_knx(bytes([0x00, 0x42])) assert payload_a == GroupValueResponse(DPTArray((0x05, 0x04, 0x03, 0x02, 0x01))) assert payload_b == GroupValueResponse(DPTBinary(0x02)) def test_to_knx(self) -> None: """Test the to_knx method.""" payload_a = GroupValueResponse(DPTArray((0x01, 0x02, 0x03))) payload_b = GroupValueResponse(DPTBinary(1)) assert payload_a.to_knx() == bytes([0x00, 0x40, 0x01, 0x02, 0x03]) assert payload_b.to_knx() == bytes([0x00, 0x41]) def test_str(self) -> None: """Test the __str__ method.""" payload = GroupValueResponse(DPTBinary(1)) assert str(payload) == '" />' class TestIndividualAddressWrite: """Test class for IndividualAddressWrite objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = IndividualAddressWrite(IndividualAddress("1.2.3")) assert payload.calculated_length() == 3 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x00, 0xC0, 0x12, 0x03])) assert payload == IndividualAddressWrite(IndividualAddress("1.2.3")) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = IndividualAddressWrite(IndividualAddress("1.2.3")) assert payload.to_knx() == bytes([0x00, 0xC0, 0x12, 0x03]) def test_str(self) -> None: """Test the __str__ method.""" payload = IndividualAddressWrite(IndividualAddress("1.2.3")) assert str(payload) == '' class TestIndividualAddressRead: """Test class for IndividualAddressRead objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = IndividualAddressRead() assert payload.calculated_length() == 1 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x01, 0x00])) assert payload == IndividualAddressRead() def test_to_knx(self) -> None: """Test the to_knx method.""" payload = IndividualAddressRead() assert payload.to_knx() == bytes([0x01, 0x00]) def test_str(self) -> None: """Test the __str__ method.""" payload = IndividualAddressRead() assert str(payload) == "" class TestIndividualAddressResponse: """Test class for IndividualAddressResponse objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = IndividualAddressResponse() assert payload.calculated_length() == 1 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x01, 0x40])) assert payload == IndividualAddressResponse() def test_to_knx(self) -> None: """Test the to_knx method.""" payload = IndividualAddressResponse() assert payload.to_knx() == bytes([0x01, 0x40]) def test_str(self) -> None: """Test the __str__ method.""" payload = IndividualAddressResponse() assert str(payload) == "" class TestADCRead: """Test class for ADCRead objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = ADCRead(channel=2, count=4) assert payload.calculated_length() == 2 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x01, 0x82, 0x04])) assert payload == ADCRead(channel=2, count=4) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = ADCRead(channel=1, count=3) assert payload.to_knx() == bytes([0x01, 0x81, 0x03]) def test_str(self) -> None: """Test the __str__ method.""" payload = ADCRead(channel=1, count=3) assert str(payload) == '' class TestADCResponse: """Test class for ADCResponse objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = ADCResponse(channel=2, count=4, value=1023) assert payload.calculated_length() == 4 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x01, 0xC2, 0x04, 0x03, 0xFF])) assert payload == ADCResponse(channel=2, count=4, value=1023) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = ADCResponse(channel=1, count=3, value=456) assert payload.to_knx() == bytes([0x01, 0xC1, 0x03, 0x01, 0xC8]) def test_str(self) -> None: """Test the __str__ method.""" payload = ADCResponse(channel=1, count=3, value=456) assert str(payload) == '' class TestMemoryExtendedWrite: """Test class for MemoryExtendedWrite objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = MemoryExtendedWrite( address=0x123456, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) assert payload.calculated_length() == 8 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx( bytes([0x01, 0xFB, 0x03, 0x12, 0x34, 0x56, 0xAA, 0xBB, 0xCC]) ) assert payload == MemoryExtendedWrite( address=0x123456, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = MemoryExtendedWrite( address=0x123456, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) assert payload.to_knx() == bytes( [0x01, 0xFB, 0x03, 0x12, 0x34, 0x56, 0xAA, 0xBB, 0xCC] ) def test_to_knx_conversion_error(self) -> None: """Test the to_knx method for conversion errors.""" payload = MemoryExtendedWrite( address=0xAABBCCDD, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) with pytest.raises(ConversionError, match=r".*Address.*"): payload.to_knx() payload = MemoryExtendedWrite( address=0x123456, count=256, data=bytes([0xAA, 0xBB, 0xCC]) ) with pytest.raises(ConversionError, match=r".*Count.*"): payload.to_knx() def test_str(self) -> None: """Test the __str__ method.""" payload = MemoryExtendedWrite( address=0x123456, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) assert ( str(payload) == '' ) class TestMemoryExtendedWriteResponse: """Test class for MemoryExtendedWrite objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = MemoryExtendedWriteResponse(return_code=0, address=0x123456) assert payload.calculated_length() == 5 def test_calculated_lengt_with_confirmation_data(self) -> None: """Test the test_calculated_length method.""" payload = MemoryExtendedWriteResponse( return_code=0, address=0x123456, confirmation_data=bytes([0xAA, 0xBB]) ) assert payload.calculated_length() == 7 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x01, 0xFC, 0x00, 0x12, 0x34, 0x56])) assert payload == MemoryExtendedWriteResponse( return_code=0, address=0x123456, confirmation_data=b"" ) def test_from_knx_with_confirmation_data(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x01, 0xFC, 0x01, 0x12, 0x34, 0x56, 0xAA, 0xBB])) assert payload == MemoryExtendedWriteResponse( return_code=1, address=0x123456, confirmation_data=bytes([0xAA, 0xBB]) ) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = MemoryExtendedWriteResponse(return_code=0, address=0x123456) assert payload.to_knx() == bytes([0x01, 0xFC, 0x00, 0x12, 0x34, 0x56]) def test_to_knx_with_confirmation_data(self) -> None: """Test the to_knx method.""" payload = MemoryExtendedWriteResponse( return_code=1, address=0x123456, confirmation_data=bytes([0xAA, 0xBB]) ) assert payload.to_knx() == bytes( [0x01, 0xFC, 0x01, 0x12, 0x34, 0x56, 0xAA, 0xBB] ) def test_to_knx_conversion_error(self) -> None: """Test the to_knx method for conversion errors.""" payload = MemoryExtendedWriteResponse(return_code=0, address=0xAABBCCDD) with pytest.raises(ConversionError, match=r".*Address.*"): payload.to_knx() payload = MemoryExtendedWriteResponse(return_code=0x100, address=0x123456) with pytest.raises(ConversionError, match=r".*Return code.*"): payload.to_knx() def test_str(self) -> None: """Test the __str__ method.""" payload = MemoryExtendedWriteResponse(return_code=0, address=0x123456) assert ( str(payload) == '' ) def test_str_with_confirmation_data(self) -> None: """Test the __str__ method.""" payload = MemoryExtendedWriteResponse( return_code=1, address=0x123456, confirmation_data=bytes([0xAA, 0xBB]) ) assert ( str(payload) == '' ) class TestMemoryExtendedRead: """Test class for MemoryExtendedRead objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = MemoryExtendedRead(address=0x123456, count=3) assert payload.calculated_length() == 5 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x01, 0xFD, 0x03, 0x12, 0x34, 0x56])) assert payload == MemoryExtendedRead(address=0x123456, count=3) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = MemoryExtendedRead(address=0x123456, count=3) assert payload.to_knx() == bytes([0x01, 0xFD, 0x03, 0x12, 0x34, 0x56]) def test_to_knx_conversion_error(self) -> None: """Test the to_knx method for conversion errors.""" payload = MemoryExtendedRead(address=0xAABBCCDD, count=3) with pytest.raises(ConversionError, match=r".*Address.*"): payload.to_knx() payload = MemoryExtendedRead(address=0x123456, count=256) with pytest.raises(ConversionError, match=r".*Count.*"): payload.to_knx() def test_str(self) -> None: """Test the __str__ method.""" payload = MemoryExtendedRead(address=0x123456, count=3) assert str(payload) == '' class TestMemoryExtendedReadResponse: """Test class for MemoryExtendedReadResponse objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = MemoryExtendedReadResponse( return_code=0, address=0x123456, data=bytes([0xAA, 0xBB, 0xCC]) ) assert payload.calculated_length() == 8 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx( bytes([0x01, 0xFE, 0x00, 0x12, 0x34, 0x56, 0xAA, 0xBB, 0xCC]) ) assert payload == MemoryExtendedReadResponse( return_code=0, address=0x123456, data=bytes([0xAA, 0xBB, 0xCC]) ) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = MemoryExtendedReadResponse( return_code=0, address=0x123456, data=bytes([0xAA, 0xBB, 0xCC]) ) assert payload.to_knx() == bytes( [0x01, 0xFE, 0x00, 0x12, 0x34, 0x56, 0xAA, 0xBB, 0xCC] ) def test_to_knx_conversion_error(self) -> None: """Test the to_knx method for conversion errors.""" payload = MemoryExtendedReadResponse( return_code=0, address=0xAABBCCDD, data=bytes([0xAA, 0xBB, 0xCC]) ) with pytest.raises(ConversionError, match=r".*Address.*"): payload.to_knx() payload = MemoryExtendedReadResponse( return_code=0x100, address=0x123456, data=bytes([0xAA, 0xBB, 0xCC]) ) with pytest.raises(ConversionError, match=r".*Return code.*"): payload.to_knx() def test_str(self) -> None: """Test the __str__ method.""" payload = MemoryExtendedReadResponse( return_code=0, address=0x123456, data=bytes([0xAA, 0xBB, 0xCC]) ) assert ( str(payload) == '' ) class TestMemoryRead: """Test class for MemoryRead objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = MemoryRead(address=0x1234, count=11) assert payload.calculated_length() == 3 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x02, 0x0B, 0x12, 0x34])) assert payload == MemoryRead(address=0x1234, count=11) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = MemoryRead(address=0x1234, count=11) assert payload.to_knx() == bytes([0x02, 0x0B, 0x12, 0x34]) def test_to_knx_conversion_error(self) -> None: """Test the to_knx method for conversion errors.""" payload = MemoryRead(address=0xAABBCCDD, count=11) with pytest.raises(ConversionError, match=r".*Address.*"): payload.to_knx() payload = MemoryRead(address=0x1234, count=255) with pytest.raises(ConversionError, match=r".*Count.*"): payload.to_knx() def test_str(self) -> None: """Test the __str__ method.""" payload = MemoryRead(address=0x1234, count=11) assert str(payload) == '' class TestMemoryWrite: """Test class for MemoryWrite objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = MemoryWrite(address=0x1234, count=3, data=bytes([0xAA, 0xBB, 0xCC])) assert payload.calculated_length() == 6 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x02, 0x83, 0x12, 0x34, 0xAA, 0xBB, 0xCC])) assert payload == MemoryWrite( address=0x1234, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = MemoryWrite(address=0x1234, count=3, data=bytes([0xAA, 0xBB, 0xCC])) assert payload.to_knx() == bytes([0x02, 0x83, 0x12, 0x34, 0xAA, 0xBB, 0xCC]) def test_to_knx_conversion_error(self) -> None: """Test the to_knx method for conversion errors.""" payload = MemoryWrite( address=0xAABBCCDD, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) with pytest.raises(ConversionError, match=r".*Address.*"): payload.to_knx() payload = MemoryWrite(address=0x1234, count=255, data=bytes([0xAA, 0xBB, 0xCC])) with pytest.raises(ConversionError, match=r".*Count.*"): payload.to_knx() def test_str(self) -> None: """Test the __str__ method.""" payload = MemoryWrite(address=0x1234, count=3, data=bytes([0xAA, 0xBB, 0xCC])) assert ( str(payload) == '' ) class TestMemoryResponse: """Test class for MemoryResponse objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = MemoryResponse( address=0x1234, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) assert payload.calculated_length() == 6 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x02, 0x43, 0x12, 0x34, 0xAA, 0xBB, 0xCC])) assert payload == MemoryResponse( address=0x1234, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = MemoryResponse( address=0x1234, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) assert payload.to_knx() == bytes([0x02, 0x43, 0x12, 0x34, 0xAA, 0xBB, 0xCC]) def test_to_knx_conversion_error(self) -> None: """Test the to_knx method for conversion errors.""" payload = MemoryResponse( address=0xAABBCCDD, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) with pytest.raises(ConversionError, match=r".*Address.*"): payload.to_knx() payload = MemoryResponse( address=0x1234, count=255, data=bytes([0xAA, 0xBB, 0xCC]) ) with pytest.raises(ConversionError, match=r".*Count.*"): payload.to_knx() def test_str(self) -> None: """Test the __str__ method.""" payload = MemoryResponse( address=0x1234, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) assert ( str(payload) == '' ) class TestDeviceDescriptorRead: """Test class for DeviceDescriptorRead objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = DeviceDescriptorRead(0) assert payload.calculated_length() == 1 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x03, 0x0D])) assert payload == DeviceDescriptorRead(13) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = DeviceDescriptorRead(13) assert payload.to_knx() == bytes([0x03, 0x0D]) def test_to_knx_conversion_error(self) -> None: """Test the to_knx method for conversion errors.""" payload = DeviceDescriptorRead(255) with pytest.raises(ConversionError, match=r".*Descriptor.*"): payload.to_knx() def test_str(self) -> None: """Test the __str__ method.""" payload = DeviceDescriptorRead(0) assert str(payload) == '' class TestDeviceDescriptorResponse: """Test class for DeviceDescriptorResponse objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = DeviceDescriptorResponse(descriptor=0, value=123) assert payload.calculated_length() == 3 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x03, 0x4D, 0x00, 0x7B])) assert payload == DeviceDescriptorResponse(descriptor=13, value=123) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = DeviceDescriptorResponse(descriptor=13, value=123) assert payload.to_knx() == bytes([0x03, 0x4D, 0x00, 0x7B]) def test_to_knx_conversion_error(self) -> None: """Test the to_knx method for conversion errors.""" payload = DeviceDescriptorResponse(255) with pytest.raises(ConversionError, match=r".*Descriptor.*"): payload.to_knx() def test_str(self) -> None: """Test the __str__ method.""" payload = DeviceDescriptorResponse(descriptor=0, value=123) assert str(payload) == '' class TestUserMemoryRead: """Test class for UserMemoryRead objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = UserMemoryRead() assert payload.calculated_length() == 4 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x02, 0xC0, 0x1B, 0x23, 0x45])) assert payload == UserMemoryRead(address=0x12345, count=11) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = UserMemoryRead(address=0x12345, count=11) assert payload.to_knx() == bytes([0x02, 0xC0, 0x1B, 0x23, 0x45]) def test_to_knx_conversion_error(self) -> None: """Test the to_knx method for conversion errors.""" payload = UserMemoryRead(address=0xAABBCCDD, count=11) with pytest.raises(ConversionError, match=r".*Address.*"): payload.to_knx() payload = UserMemoryRead(address=0x12345, count=255) with pytest.raises(ConversionError, match=r".*Count.*"): payload.to_knx() def test_str(self) -> None: """Test the __str__ method.""" payload = UserMemoryRead(address=0x12345, count=11) assert str(payload) == '' class TestUserMemoryWrite: """Test class for UserMemoryWrite objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = UserMemoryWrite( address=0x12345, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) assert payload.calculated_length() == 7 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x02, 0xC2, 0x13, 0x23, 0x45, 0xAA, 0xBB, 0xCC])) assert payload == UserMemoryWrite( address=0x12345, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = UserMemoryWrite( address=0x12345, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) assert payload.to_knx() == bytes( [0x02, 0xC2, 0x13, 0x23, 0x45, 0xAA, 0xBB, 0xCC] ) def test_to_knx_conversion_error(self) -> None: """Test the to_knx method for conversion errors.""" payload = UserMemoryWrite( address=0xAABBCCDD, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) with pytest.raises(ConversionError, match=r".*Address.*"): payload.to_knx() payload = UserMemoryWrite( address=0x12345, count=255, data=bytes([0xAA, 0xBB, 0xCC]) ) with pytest.raises(ConversionError, match=r".*Count.*"): payload.to_knx() def test_str(self) -> None: """Test the __str__ method.""" payload = UserMemoryWrite( address=0x12345, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) assert ( str(payload) == '' ) class TestUserMemoryResponse: """Test class for UserMemoryResponse objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = UserMemoryResponse( address=0x12345, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) assert payload.calculated_length() == 7 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x02, 0xC1, 0x13, 0x23, 0x45, 0xAA, 0xBB, 0xCC])) assert payload == UserMemoryResponse( address=0x12345, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = UserMemoryResponse( address=0x12345, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) assert payload.to_knx() == bytes( [0x02, 0xC1, 0x13, 0x23, 0x45, 0xAA, 0xBB, 0xCC] ) def test_to_knx_conversion_error(self) -> None: """Test the to_knx method for conversion errors.""" payload = UserMemoryResponse( address=0xAABBCCDD, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) with pytest.raises(ConversionError, match=r".*Address.*"): payload.to_knx() payload = UserMemoryResponse( address=0x12345, count=255, data=bytes([0xAA, 0xBB, 0xCC]) ) with pytest.raises(ConversionError, match=r".*Count.*"): payload.to_knx() def test_str(self) -> None: """Test the __str__ method.""" payload = UserMemoryResponse( address=0x12345, count=3, data=bytes([0xAA, 0xBB, 0xCC]) ) assert ( str(payload) == '' ) class TestRestart: """Test class for Restart objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = Restart() assert payload.calculated_length() == 1 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x03, 0x80])) assert payload == Restart() def test_to_knx(self) -> None: """Test the to_knx method.""" payload = Restart() assert payload.to_knx() == bytes([0x03, 0x80]) def test_str(self) -> None: """Test the __str__ method.""" payload = Restart() assert str(payload) == "" class TestUserManufacturerInfoRead: """Test class for UserManufacturerInfoRead objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = UserManufacturerInfoRead() assert payload.calculated_length() == 1 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x02, 0xC5])) assert payload == UserManufacturerInfoRead() def test_to_knx(self) -> None: """Test the to_knx method.""" payload = UserManufacturerInfoRead() assert payload.to_knx() == bytes([0x02, 0xC5]) def test_str(self) -> None: """Test the __str__ method.""" payload = UserManufacturerInfoRead() assert str(payload) == "" class TestUserManufacturerInfoResponse: """Test class for UserManufacturerInfoResponse objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = UserManufacturerInfoResponse(manufacturer_id=123, data=b"\x12\x34") assert payload.calculated_length() == 4 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x02, 0xC6, 0x7B, 0x12, 0x34])) assert payload == UserManufacturerInfoResponse( manufacturer_id=123, data=b"\x12\x34" ) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = UserManufacturerInfoResponse(manufacturer_id=123, data=b"\x12\x34") assert payload.to_knx() == bytes([0x02, 0xC6, 0x7B, 0x12, 0x34]) def test_str(self) -> None: """Test the __str__ method.""" payload = UserManufacturerInfoResponse(manufacturer_id=123, data=b"\x12\x34") assert ( str(payload) == '' ) class TestFunctionPropertyCommand: """Test class for FunctionPropertyCommand objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = FunctionPropertyCommand( object_index=1, property_id=4, data=b"\x12\x34" ) assert payload.calculated_length() == 5 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x02, 0xC7, 0x01, 0x04, 0x12, 0x34])) assert payload == FunctionPropertyCommand( object_index=1, property_id=4, data=b"\x12\x34" ) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = FunctionPropertyCommand( object_index=1, property_id=4, data=b"\x12\x34" ) assert payload.to_knx() == bytes([0x02, 0xC7, 0x01, 0x04, 0x12, 0x34]) def test_str(self) -> None: """Test the __str__ method.""" payload = FunctionPropertyCommand( object_index=1, property_id=4, data=b"\x12\x34" ) assert ( str(payload) == '' ) class TestFunctionPropertyStateRead: """Test class for FunctionPropertyStateRead objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = FunctionPropertyStateRead( object_index=1, property_id=4, data=b"\x12\x34" ) assert payload.calculated_length() == 5 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x02, 0xC8, 0x01, 0x04, 0x12, 0x34])) assert payload == FunctionPropertyStateRead( object_index=1, property_id=4, data=b"\x12\x34" ) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = FunctionPropertyStateRead( object_index=1, property_id=4, data=b"\x12\x34" ) assert payload.to_knx() == bytes([0x02, 0xC8, 0x01, 0x04, 0x12, 0x34]) def test_str(self) -> None: """Test the __str__ method.""" payload = FunctionPropertyStateRead( object_index=1, property_id=4, data=b"\x12\x34" ) assert ( str(payload) == '' ) class TestFunctionPropertyStateResponse: """Test class for FunctionPropertyStateResponse objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = FunctionPropertyStateResponse( object_index=1, property_id=4, return_code=8, data=b"\x12\x34" ) assert payload.calculated_length() == 6 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x02, 0xC9, 0x01, 0x04, 0x08, 0x12, 0x34])) assert payload == FunctionPropertyStateResponse( object_index=1, property_id=4, return_code=8, data=b"\x12\x34" ) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = FunctionPropertyStateResponse( object_index=1, property_id=4, return_code=8, data=b"\x12\x34" ) assert payload.to_knx() == bytes([0x02, 0xC9, 0x01, 0x04, 0x08, 0x12, 0x34]) def test_str(self) -> None: """Test the __str__ method.""" payload = FunctionPropertyStateResponse( object_index=1, property_id=4, return_code=8, data=b"\x12\x34" ) assert ( str(payload) == '' ) class TestAuthorizeRequest: """Test class for AuthorizeRequest objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = AuthorizeRequest(key=12345678) assert payload.calculated_length() == 6 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x03, 0xD1, 0x00, 0x00, 0xBC, 0x61, 0x4E])) assert payload == AuthorizeRequest(key=12345678) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = AuthorizeRequest(key=12345678) assert payload.to_knx() == bytes([0x03, 0xD1, 0x00, 0x00, 0xBC, 0x61, 0x4E]) def test_str(self) -> None: """Test the __str__ method.""" payload = AuthorizeRequest(key=12345678) assert str(payload) == '' class TestAuthorizeResponse: """Test class for AuthorizeResponse objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = AuthorizeResponse(level=123) assert payload.calculated_length() == 2 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x03, 0xD2, 0x7B])) assert payload == AuthorizeResponse(level=123) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = AuthorizeResponse(level=123) assert payload.to_knx() == bytes([0x03, 0xD2, 0x7B]) def test_str(self) -> None: """Test the __str__ method.""" payload = AuthorizeResponse(level=123) assert str(payload) == '' class TestPropertyValueRead: """Test class for PropertyValueRead objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = PropertyValueRead( object_index=1, property_id=4, count=2, start_index=8 ) assert payload.calculated_length() == 5 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x03, 0xD5, 0x01, 0x04, 0x20, 0x08])) assert payload == PropertyValueRead( object_index=1, property_id=4, count=2, start_index=8 ) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = PropertyValueRead( object_index=1, property_id=4, count=2, start_index=8 ) assert payload.to_knx() == bytes([0x03, 0xD5, 0x01, 0x04, 0x20, 0x08]) def test_to_knx_conversion_error(self) -> None: """Test the to_knx method for conversion errors.""" payload = PropertyValueRead( object_index=1, property_id=4, count=32, start_index=8 ) with pytest.raises(ConversionError, match=r".*Count.*"): payload.to_knx() def test_str(self) -> None: """Test the __str__ method.""" payload = PropertyValueRead( object_index=1, property_id=4, count=2, start_index=8 ) assert ( str(payload) == '' ) class TestPropertyValueWrite: """Test class for PropertyValueWrite objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = PropertyValueWrite( object_index=1, property_id=4, count=2, start_index=8, data=b"\x12\x34" ) assert payload.calculated_length() == 7 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x03, 0xD7, 0x01, 0x04, 0x20, 0x08, 0x12, 0x34])) assert payload == PropertyValueWrite( object_index=1, property_id=4, count=2, start_index=8, data=b"\x12\x34" ) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = PropertyValueWrite( object_index=1, property_id=4, count=2, start_index=8, data=b"\x12\x34" ) assert payload.to_knx() == bytes( [0x03, 0xD7, 0x01, 0x04, 0x20, 0x08, 0x12, 0x34] ) def test_to_knx_conversion_error(self) -> None: """Test the to_knx method for conversion errors.""" payload = PropertyValueWrite( object_index=1, property_id=4, count=32, start_index=8, data=b"\x12\x34" ) with pytest.raises(ConversionError, match=r".*Count.*"): payload.to_knx() def test_str(self) -> None: """Test the __str__ method.""" payload = PropertyValueWrite( object_index=1, property_id=4, count=2, start_index=8, data=b"\x12\x34" ) assert ( str(payload) == '' ) class TestPropertyValueResponse: """Test class for PropertyValueResponse objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = PropertyValueResponse( object_index=1, property_id=4, count=2, start_index=8, data=b"\x12\x34" ) assert payload.calculated_length() == 7 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x03, 0xD6, 0x01, 0x04, 0x20, 0x08, 0x12, 0x34])) assert payload == PropertyValueResponse( object_index=1, property_id=4, count=2, start_index=8, data=b"\x12\x34" ) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = PropertyValueResponse( object_index=1, property_id=4, count=2, start_index=8, data=b"\x12\x34" ) assert payload.to_knx() == bytes( [0x03, 0xD6, 0x01, 0x04, 0x20, 0x08, 0x12, 0x34] ) def test_to_knx_conversion_error(self) -> None: """Test the to_knx method for conversion errors.""" payload = PropertyValueResponse( object_index=1, property_id=4, count=32, start_index=8, data=b"\x12\x34" ) with pytest.raises(ConversionError, match=r".*Count.*"): payload.to_knx() def test_str(self) -> None: """Test the __str__ method.""" payload = PropertyValueResponse( object_index=1, property_id=4, count=2, start_index=8, data=b"\x12\x34" ) assert ( str(payload) == '' ) class TestPropertyDescriptionRead: """Test class for PropertyDescriptionRead objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = PropertyDescriptionRead( object_index=1, property_id=4, property_index=8 ) assert payload.calculated_length() == 4 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x03, 0xD8, 0x01, 0x04, 0x08])) assert payload == PropertyDescriptionRead( object_index=1, property_id=4, property_index=8 ) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = PropertyDescriptionRead( object_index=1, property_id=4, property_index=8 ) assert payload.to_knx() == bytes([0x03, 0xD8, 0x01, 0x04, 0x08]) def test_str(self) -> None: """Test the __str__ method.""" payload = PropertyDescriptionRead( object_index=1, property_id=4, property_index=8 ) assert ( str(payload) == '' ) class TestPropertyDescriptionResponse: """Test class for PropertyDescriptionResponse objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = PropertyDescriptionResponse( object_index=1, property_id=4, property_index=8, type_=3, max_count=5, access=7, ) assert payload.calculated_length() == 8 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx( bytes([0x03, 0xD9, 0x01, 0x04, 0x08, 0x03, 0x00, 0x05, 0x07]) ) assert payload == PropertyDescriptionResponse( object_index=1, property_id=4, property_index=8, type_=3, max_count=5, access=7, ) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = PropertyDescriptionResponse( object_index=1, property_id=4, property_index=8, type_=3, max_count=5, access=7, ) assert payload.to_knx() == bytes( [0x03, 0xD9, 0x01, 0x04, 0x08, 0x03, 0x00, 0x05, 0x07] ) def test_to_knx_conversion_error(self) -> None: """Test the to_knx method for conversion errors.""" payload = PropertyDescriptionResponse( object_index=1, property_id=4, property_index=8, type_=3, max_count=4096, access=7, ) with pytest.raises(ConversionError, match=r".*Max count.*"): payload.to_knx() def test_str(self) -> None: """Test the __str__ method.""" payload = PropertyDescriptionResponse( object_index=1, property_id=4, property_index=8, type_=3, max_count=5, access=7, ) assert ( str(payload) == '' ) class TestIndividualAddressSerialRead: """Test class for IndividualAddressSerialRead objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = IndividualAddressSerialRead(b"\xaa\xbb\xcc\x11\x22\x33") assert payload.calculated_length() == 7 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx(bytes([0x03, 0xDC, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33])) assert payload == IndividualAddressSerialRead(b"\xaa\xbb\xcc\x11\x22\x33") def test_to_knx(self) -> None: """Test the to_knx method.""" payload = IndividualAddressSerialRead(b"\xaa\xbb\xcc\x11\x22\x33") assert payload.to_knx() == bytes( [0x03, 0xDC, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33] ) def test_str(self) -> None: """Test the __str__ method.""" payload = IndividualAddressSerialRead(b"\xaa\xbb\xcc\x11\x22\x33") assert str(payload) == '' class TestIndividualAddressSerialResponse: """Test class for IndividualAddressSerialResponse objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = IndividualAddressSerialResponse( serial=b"\xaa\xbb\xcc\x11\x22\x33", address=IndividualAddress("1.2.3") ) assert payload.calculated_length() == 11 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx( bytes( [0x03, 0xDD, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33, 0x12, 0x03, 0x00, 0x00] ) ) assert payload == IndividualAddressSerialResponse( serial=b"\xaa\xbb\xcc\x11\x22\x33", address=IndividualAddress("1.2.3") ) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = IndividualAddressSerialResponse( serial=b"\xaa\xbb\xcc\x11\x22\x33", address=IndividualAddress("1.2.3") ) assert payload.to_knx() == bytes( [0x03, 0xDD, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33, 0x12, 0x03, 0x00, 0x00] ) def test_str(self) -> None: """Test the __str__ method.""" payload = IndividualAddressSerialResponse( serial=b"\xaa\xbb\xcc\x11\x22\x33", address=IndividualAddress("1.2.3") ) assert ( str(payload) == '' ) class TestIndividualAddressSerialWrite: """Test class for IndividualAddressSerialWrite objects.""" def test_calculated_length(self) -> None: """Test the test_calculated_length method.""" payload = IndividualAddressSerialWrite( serial=b"\xaa\xbb\xcc\x11\x22\x33", address=IndividualAddress("1.2.3") ) assert payload.calculated_length() == 13 def test_from_knx(self) -> None: """Test the from_knx method.""" payload = APCI.from_knx( bytes( [ 0x03, 0xDE, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33, 0x12, 0x03, 0x00, 0x00, 0x00, 0x00, ] ) ) assert payload == IndividualAddressSerialWrite( serial=b"\xaa\xbb\xcc\x11\x22\x33", address=IndividualAddress("1.2.3") ) def test_to_knx(self) -> None: """Test the to_knx method.""" payload = IndividualAddressSerialWrite( serial=b"\xaa\xbb\xcc\x11\x22\x33", address=IndividualAddress("1.2.3") ) assert payload.to_knx() == bytes( [ 0x03, 0xDE, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33, 0x12, 0x03, 0x00, 0x00, 0x00, 0x00, ] ) def test_str(self) -> None: """Test the __str__ method.""" payload = IndividualAddressSerialWrite( serial=b"\xaa\xbb\xcc\x11\x22\x33", address=IndividualAddress("1.2.3") ) assert ( str(payload) == '' ) xknx-3.6.0/test/telegram_tests/telegram_test.py000066400000000000000000000025221475530762600217420ustar00rootroot00000000000000"""Unit test for Telegram objects.""" from xknx.dpt import DPTBinary from xknx.telegram import GroupAddress, IndividualAddress, Telegram, TelegramDirection from xknx.telegram.apci import GroupValueRead, GroupValueWrite from xknx.telegram.tpci import TConnect, TDisconnect class TestTelegram: """Test class for Telegram objects.""" # # EQUALITY # def test_telegram_equal(self) -> None: """Test equals operator.""" assert Telegram(GroupAddress("1/2/3"), payload=GroupValueRead()) == Telegram( GroupAddress("1/2/3"), payload=GroupValueRead() ) def test_telegram_not_equal(self) -> None: """Test not equals operator.""" assert Telegram(GroupAddress("1/2/3"), payload=GroupValueRead()) != Telegram( GroupAddress("1/2/4"), payload=GroupValueRead() ) assert Telegram(GroupAddress("1/2/3"), payload=GroupValueRead()) != Telegram( GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1)) ) assert Telegram(GroupAddress("1/2/3"), payload=GroupValueRead()) != Telegram( GroupAddress("1/2/3"), TelegramDirection.INCOMING, payload=GroupValueRead(), ) assert Telegram(IndividualAddress(1), tpci=TConnect()) != Telegram( IndividualAddress(1), tpci=TDisconnect() ) xknx-3.6.0/test/telegram_tests/tpci_test.py000066400000000000000000000043411475530762600211020ustar00rootroot00000000000000"""Unit test for TPCI objects.""" import pytest from xknx.exceptions import ConversionError from xknx.telegram.tpci import ( TPCI, TAck, TConnect, TDataBroadcast, TDataConnected, TDataGroup, TDataIndividual, TDataTagGroup, TDisconnect, TNak, ) @pytest.mark.parametrize( ("tpci_int", "dst_is_group_address", "dst_is_zero", "tpci_expected"), [ (0b00000000, True, False, TDataGroup()), (0b00000000, True, True, TDataBroadcast()), (0b00000100, True, False, TDataTagGroup()), (0b00000000, False, False, TDataIndividual()), (0b00000000, False, True, TDataIndividual()), (0b01011100, False, False, TDataConnected(sequence_number=0b0111)), (0b10000000, False, False, TConnect()), (0b10000000, False, True, TConnect()), (0b10000001, False, False, TDisconnect()), (0b11101010, False, False, TAck(sequence_number=10)), (0b11010011, False, False, TNak(sequence_number=4)), ], ) def test_tpci_resolve_encode( tpci_int: int, dst_is_group_address: bool, dst_is_zero: bool, tpci_expected: TPCI ) -> None: """Test resolving and encoding TPCI classes.""" assert ( TPCI.resolve( raw_tpci=tpci_int, dst_is_group_address=dst_is_group_address, dst_is_zero=dst_is_zero, ) == tpci_expected ) assert tpci_expected.to_knx() == tpci_int @pytest.mark.parametrize("dst_is_zero", [True, False]) @pytest.mark.parametrize( ("tpci_int", "dst_is_group_address"), [ # sequence_number for non-numbered (0b00100000, True), (0b00001000, False), (0b10000100, True), (0b10000100, False), # numbered for group addressed (0b01000000, True), ], ) def test_invalid_tpci( tpci_int: int, dst_is_group_address: bool, dst_is_zero: bool ) -> None: """Test resolving TPCI classes.""" with pytest.raises(ConversionError): TPCI.resolve( raw_tpci=tpci_int, dst_is_group_address=dst_is_group_address, dst_is_zero=dst_is_zero, ) def test_equality() -> None: """Test equality.""" assert TDataGroup() == TDataGroup() assert TDataGroup() != TDataIndividual() xknx-3.6.0/test/tools_tests/000077500000000000000000000000001475530762600160705ustar00rootroot00000000000000xknx-3.6.0/test/tools_tests/__init__.py000066400000000000000000000000471475530762600202020ustar00rootroot00000000000000"""Unit tests for the Tools module.""" xknx-3.6.0/test/tools_tests/tools_test.py000066400000000000000000000104761475530762600206510ustar00rootroot00000000000000"""Test xknx tools package.""" from typing import Any from unittest.mock import MagicMock, patch import pytest from xknx import XKNX from xknx.devices import NumericValue from xknx.dpt import DPTArray, DPTBinary, DPTTemperature from xknx.exceptions import ConversionError from xknx.telegram import GroupAddress, Telegram, TelegramDirection, apci from xknx.tools import ( group_value_read, group_value_response, group_value_write, read_group_value, ) def test_group_value_read() -> None: """Test group_value_read.""" xknx = XKNX() group_address = "1/2/3" read = Telegram( destination_address=GroupAddress(group_address), payload=apci.GroupValueRead(), ) group_value_read(xknx, "1/2/3") assert xknx.telegrams.qsize() == 1 assert xknx.telegrams.get_nowait() == read def test_group_value_response() -> None: """Test group_value_response.""" xknx = XKNX() group_address = "1/2/3" response = Telegram( destination_address=GroupAddress(group_address), payload=apci.GroupValueResponse(DPTBinary(1)), ) group_value_response(xknx, "1/2/3", True) assert xknx.telegrams.qsize() == 1 assert xknx.telegrams.get_nowait() == response @pytest.mark.parametrize( ("value", "value_type", "expected"), [ (50, "percent", DPTArray((0x80,))), (True, None, DPTBinary(1)), (False, None, DPTBinary(0)), (20.48, DPTTemperature, DPTArray((0x0C, 0x00))), (-100, 6, DPTArray((0x9C,))), ((0x0C, 0x00), None, DPTArray((0x0C, 0x00))), (DPTBinary(1), None, DPTBinary(1)), ], ) def test_group_value_write( value: Any, value_type: Any, expected: DPTArray | DPTBinary ) -> None: """Test group_value_write.""" xknx = XKNX() group_address = "1/2/3" write = Telegram( destination_address=GroupAddress(group_address), payload=apci.GroupValueWrite(expected), ) group_value_write(xknx, "1/2/3", value, value_type=value_type) assert xknx.telegrams.qsize() == 1 assert xknx.telegrams.get_nowait() == write @pytest.mark.parametrize( ("value", "value_type", "error_type"), [ (50, "unknown", ValueError), (50, 9.001, ValueError), # float is invalid (101, "percent", ConversionError), # too big ], ) def test_group_value_write_invalid( value: int, value_type: Any, error_type: type[Exception] ) -> None: """Test group_value_write.""" xknx = XKNX() with pytest.raises(error_type): group_value_write(xknx, "1/2/3", value, value_type=value_type) @pytest.mark.parametrize( ("value", "value_type", "expected"), [ (50, "percent", DPTArray((0x80,))), ((0x80,), None, DPTArray((0x80,))), (True, None, DPTBinary(1)), ], ) @patch("xknx.core.value_reader.ValueReader.read") async def test_read_group_value( value_reader_read_mock: MagicMock, value: Any, value_type: Any, expected: DPTArray | DPTBinary, ) -> None: """Test read_group_value.""" xknx = XKNX() test_group_address = "1/2/3" response_telegram = Telegram( destination_address=GroupAddress(test_group_address), direction=TelegramDirection.INCOMING, payload=apci.GroupValueResponse(expected), ) value_reader_read_mock.return_value = response_telegram response_value = await read_group_value( xknx, test_group_address, value_type=value_type ) # GroupValueRead telegram is not in queue because ValueReader.read is mocked. # This is tested in ValueReader tests. assert response_value == value async def test_tools_with_internal_addresses(xknx_no_interface: XKNX) -> None: """Test tools using internal addresses.""" xknx = xknx_no_interface await xknx.start() internal_address = "i-test" test_type = "1byte_unsigned" number = NumericValue( xknx, "Test", group_address=internal_address, value_type=test_type, respond_to_read=True, ) xknx.devices.async_add(number) assert number.resolve_state() is None group_value_write(xknx, internal_address, 1, value_type=test_type) await xknx.telegrams.join() assert number.resolve_state() == 1 response_value = await read_group_value( xknx, internal_address, value_type=test_type ) assert response_value == 1 await xknx.stop() xknx-3.6.0/test/xknx_test.py000066400000000000000000000074441475530762600161200ustar00rootroot00000000000000"""Unit test for XKNX Module.""" from pathlib import Path from unittest.mock import AsyncMock, Mock, patch import pytest from xknx import XKNX from xknx.exceptions import CommunicationError from xknx.io import ConnectionConfig, ConnectionType class TestXknxModule: """Test class for XKNX.""" def test_log_to_file(self) -> None: """Test logging enable.""" XKNX(log_directory="/tmp/") _path = Path("/tmp/xknx.log") assert _path.is_file() _path.unlink() def test_log_to_file_when_dir_does_not_exist(self) -> None: """Test logging enable with non existent directory.""" XKNX(log_directory="/xknx/is/fun") assert not Path("/xknx/is/fun/xknx.log").is_file() def test_register_telegram_cb(self) -> None: """Test register telegram callback.""" xknx = XKNX(telegram_received_cb=AsyncMock()) assert len(xknx.telegram_queue.telegram_received_cbs) == 1 def test_register_device_cb(self) -> None: """Test register telegram callback.""" xknx = XKNX(device_updated_cb=AsyncMock()) assert len(xknx.devices.device_updated_cbs) == 1 def test_register_connection_state_change_cb(self) -> None: """Test register con state callback.""" xknx = XKNX(connection_state_changed_cb=Mock()) assert len(xknx.connection_manager._connection_state_changed_cbs) == 1 @patch("xknx.io.KNXIPInterface._start", new_callable=AsyncMock) async def test_xknx_start(self, start_mock: AsyncMock) -> None: """Test xknx start.""" xknx = XKNX(state_updater=True) await xknx.start() start_mock.assert_called_once() await xknx.stop() @patch("xknx.io.KNXIPInterface._start", new_callable=AsyncMock) async def test_xknx_start_as_context_manager( self, ipinterface_mock: AsyncMock ) -> None: """Test xknx start.""" async with XKNX(state_updater=True) as xknx: assert xknx.started.is_set() ipinterface_mock.assert_called_once() @patch("xknx.io.KNXIPInterface._start", new_callable=AsyncMock) async def test_xknx_start_and_stop_with_dedicated_connection_config( self, start_mock: AsyncMock ) -> None: """Test xknx start and stop with connection config.""" connection_config = ConnectionConfig(connection_type=ConnectionType.TUNNELING) xknx = XKNX(connection_config=connection_config) await xknx.start() start_mock.assert_called_once() assert xknx.knxip_interface.connection_config == connection_config await xknx.stop() assert xknx.knxip_interface._interface is None assert xknx.telegram_queue._consumer_task.done() assert not xknx.state_updater.started @pytest.mark.parametrize( "connection_config", [ ConnectionConfig( connection_type=ConnectionType.ROUTING, local_ip="127.0.0.1" ), ConnectionConfig( connection_type=ConnectionType.TUNNELING, gateway_ip="127.0.0.2" ), ], ) @patch( "xknx.io.transport.UDPTransport.connect", new_callable=AsyncMock, side_effect=OSError, ) async def test_xknx_start_initial_connection_error( self, transport_connect_mock: AsyncMock, connection_config: ConnectionConfig ) -> None: """Test xknx start raising when socket can't be set up.""" xknx = XKNX( state_updater=True, connection_config=connection_config, ) with pytest.raises(CommunicationError): await xknx.start() transport_connect_mock.assert_called_once() assert xknx.telegram_queue._consumer_task is None # not started assert not xknx.state_updater.started assert not xknx.started.is_set() xknx-3.6.0/tox.ini000066400000000000000000000016771475530762600140550ustar00rootroot00000000000000[tox] envlist = py310, py311, py312, py313, ruff, typing, lint, pylint skip_missing_interpreters = True [gh-actions] python = 3.10: py310 3.11: py311 3.12: py312 3.13: py313, ruff, typing, lint, pylint [testenv] setenv = PYTHONPATH = {toxinidir} commands = pytest --cov xknx --cov-report= {posargs} deps = -rrequirements/testing.txt package = wheel wheel_build_env = .pkg [testenv:lint] basepython = python3 commands = pre-commit run codespell {posargs: --all-files} pre-commit run check-json {posargs: --all-files} pre-commit run trailing-whitespace {posargs: --all-files} [testenv:pylint] basepython = python3 commands = pylint xknx examples pylint --disable=protected-access,abstract-class-instantiated test [testenv:ruff] basepython = python3 commands = ruff check {posargs:.} ruff format {posargs:.} [testenv:typing] basepython = python3 commands = pre-commit run mypy {posargs: --all-files} xknx-3.6.0/xknx/000077500000000000000000000000001475530762600135175ustar00rootroot00000000000000xknx-3.6.0/xknx/__init__.py000066400000000000000000000001511475530762600156250ustar00rootroot00000000000000"""XKNX is a Python 3 library for KNX/IP protocol.""" from .xknx import XKNX __all__ = [ "XKNX", ] xknx-3.6.0/xknx/__version__.py000066400000000000000000000000531475530762600163500ustar00rootroot00000000000000"""XKNX version.""" __version__ = "3.6.0" xknx-3.6.0/xknx/cemi/000077500000000000000000000000001475530762600144345ustar00rootroot00000000000000xknx-3.6.0/xknx/cemi/__init__.py000066400000000000000000000005271475530762600165510ustar00rootroot00000000000000"""Module for handling CEMI Frames.""" # ruff: noqa: F401 from .cemi_frame import ( CEMIFrame, CEMILData, CEMIMPropInfo, CEMIMPropReadRequest, CEMIMPropReadResponse, CEMIMPropWriteRequest, CEMIMPropWriteResponse, ) from .cemi_handler import CEMIHandler from .const import CEMIErrorCode, CEMIFlags, CEMIMessageCode xknx-3.6.0/xknx/cemi/cemi_frame.py000066400000000000000000000522601475530762600171020ustar00rootroot00000000000000""" Module for serialization and deserialization of KNX/IP CEMI Frame. cEMI stands for Common External Message Interface A CEMI frame is the container to transport a KNX/IP Telegram to/from the KNX bus. Documentation within: Application Note 117/08 v02 KNX IP Communication Medium File: AN117 v02.01 KNX IP Communication Medium DV.pdf """ from __future__ import annotations from abc import ABC, abstractmethod from xknx.exceptions import ConversionError, CouldNotParseCEMI, UnsupportedCEMIMessage from xknx.profile.const import ResourceObjectType, ResourcePropertyId from xknx.telegram import GroupAddress, IndividualAddress, Telegram from xknx.telegram.apci import APCI from xknx.telegram.tpci import TPCI, TDataBroadcast from .const import CEMIErrorCode, CEMIFlags, CEMIMessageCode class CEMIInfo: """ Raw helper class for all CEMI additional info. As specified within KNX Chapter 3.6.3/4.1.4.3 "Additional information". """ def __init__(self, raw: bytes = bytes(0)) -> None: """Initialize CEMILInfo object.""" self.raw = raw def calculated_length(self) -> int: """Get length of CEMI info.""" return 1 + len(self.raw) @staticmethod def from_knx(raw: bytes) -> tuple[CEMIInfo, bytes]: """Parse/deserialize from CEMI raw data.""" length = raw[0] return CEMIInfo(raw[1 : length + 1]), raw[length + 1 :] def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return bytes((len(self.raw),)) + self.raw def __repr__(self) -> str: """Return object as readable string.""" return f'CEMIInfo("{self.raw.hex()}")' def __eq__(self, other: object) -> bool: """Equal operator.""" return self.__dict__ == other.__dict__ class CEMIData(ABC): """Base class for all CEMI data.""" @abstractmethod def calculated_length(self) -> int: """Get length of CEMI data.""" @abstractmethod def to_knx(self) -> bytes: """Serialize to CEMI raw data.""" @classmethod @abstractmethod def from_knx(cls, raw: bytes) -> CEMIData: """Parse/deserialize from KNX/IP raw data.""" @abstractmethod def __repr__(self) -> str: """Return object as readable string.""" def __eq__(self, other: object) -> bool: """Equal operator.""" if self.__class__ != other.__class__: return False return self.__dict__ == other.__dict__ class CEMILData(CEMIData): """Representation of CEMI Link Layer Data.""" def __init__( self, *, flags: int, src_addr: IndividualAddress, dst_addr: GroupAddress | IndividualAddress, tpci: TPCI, payload: APCI | None, ) -> None: """Initialize CEMILData object.""" self.flags = flags self.src_addr = src_addr self.dst_addr = dst_addr self.tpci = tpci self.payload = payload @property def hops(self) -> int: """Return hops.""" return (self.flags & 0x0070) >> 4 @hops.setter def hops(self, val: int) -> None: """Set hops.""" # Resetting hops self.flags &= 0xFFFF ^ 0x0070 # Setting new hops self.flags |= val << 4 def calculated_length(self) -> int: """Get length of KNX/IP body.""" if not self.tpci.control and self.payload is not None: return 8 + self.payload.calculated_length() if self.tpci.control and self.payload is None: return 8 raise TypeError("Data TPDU must have a payload; control TPDU must not.") @staticmethod def init_from_telegram( telegram: Telegram, src_addr: IndividualAddress | None = None, ) -> CEMILData: """Return CEMILData from a Telegram.""" flags = ( CEMIFlags.FRAME_TYPE_STANDARD | CEMIFlags.DO_NOT_REPEAT | CEMIFlags.BROADCAST | CEMIFlags.NO_ACK_REQUESTED | CEMIFlags.CONFIRM_NO_ERROR | CEMIFlags.HOP_COUNT_1ST ) if isinstance(telegram.destination_address, GroupAddress): flags |= CEMIFlags.DESTINATION_GROUP_ADDRESS if isinstance(telegram.tpci, TDataBroadcast): flags |= CEMIFlags.PRIORITY_SYSTEM else: flags |= CEMIFlags.PRIORITY_LOW elif isinstance(telegram.destination_address, IndividualAddress): flags |= ( CEMIFlags.DESTINATION_INDIVIDUAL_ADDRESS | CEMIFlags.PRIORITY_SYSTEM ) else: raise TypeError() return CEMILData( flags=flags, src_addr=src_addr or telegram.source_address, dst_addr=telegram.destination_address, tpci=telegram.tpci, payload=telegram.payload, ) def telegram(self) -> Telegram: """Return Telegram from a CEMILData.""" return Telegram( destination_address=self.dst_addr, payload=self.payload, source_address=self.src_addr, tpci=self.tpci, ) def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" if self.tpci.control: tpdu = self.tpci.to_knx().to_bytes(1, "big") npdu_len = 0 else: if not isinstance(self.payload, APCI): raise ConversionError( f"Invalid payload set for data TPDU: {type(self.payload)}" ) tpdu = self.payload.to_knx() tpdu[0] |= self.tpci.to_knx() npdu_len = self.payload.calculated_length() return ( self.flags.to_bytes(2, "big") + self.src_addr.to_knx() + self.dst_addr.to_knx() + npdu_len.to_bytes(1, "big") + tpdu ) @classmethod def from_knx(cls, raw: bytes) -> CEMILData: """Parse L_DATA_IND, CEMIMessageCode.L_DATA_REQ, CEMIMessageCode.L_DATA_CON.""" if len(raw) < 8: raise CouldNotParseCEMI( f"CEMI too small. Length: {len(raw)}; CEMI: {raw.hex()}" ) # Control field 1 and Control field 2 - first 2 octets flags = int.from_bytes(raw[0:2], "big") src_addr = IndividualAddress.from_knx(raw[2:4]) _dst_is_group_address = bool(flags & CEMIFlags.DESTINATION_GROUP_ADDRESS) dst_addr: GroupAddress | IndividualAddress = ( GroupAddress.from_knx(raw[4:6]) if _dst_is_group_address else IndividualAddress.from_knx(raw[4:6]) ) _npdu_len = raw[6] _tpdu = raw[7:] _apdu = bytes([_tpdu[0] & 0b11]) + _tpdu[1:] # clear TPCI bits if len(_apdu) != (_npdu_len + 1): # TCPI octet not included in NPDU length raise CouldNotParseCEMI( f"APDU LEN should be {_npdu_len} but is {len(_apdu) - 1} " f"from {src_addr} in CEMI: {raw.hex()}" ) # TPCI (transport layer control information) # - with control bit set -> 8 bit; no APDU # - no control bit set (data) -> First 6 bit # APCI (application layer control information) -> Last 10 bit of TPCI/APCI try: tpci = TPCI.resolve( raw_tpci=_tpdu[0], dst_is_group_address=_dst_is_group_address, dst_is_zero=not dst_addr.raw, ) except ConversionError as err: raise UnsupportedCEMIMessage( f"TPCI not supported: {_tpdu[0]:#10b} " f"from {src_addr} in CEMI: {raw.hex()}" ) from err if tpci.control: if _npdu_len: raise CouldNotParseCEMI( f"Invalid length for control TPDU {tpci}: {_npdu_len}" f" from {src_addr} in CEMI: {raw.hex()}" ) return cls( flags=flags, src_addr=src_addr, dst_addr=dst_addr, tpci=tpci, payload=None, ) try: payload = APCI.from_knx(_apdu) except ConversionError as err: raise UnsupportedCEMIMessage( f"APDU not supported from {src_addr} " f"from {src_addr} in CEMI: {raw.hex()}" ) from err return cls( flags=flags, src_addr=src_addr, dst_addr=dst_addr, tpci=tpci, payload=payload, ) def __repr__(self) -> str: """Return object as readable string.""" return ( "CEMILData(" f'src_addr="{self.src_addr.__repr__()}" ' f'dst_addr="{self.dst_addr.__repr__()}" ' f'flags="{self.flags:16b}" ' f'tpci="{self.tpci}" ' f'payload="{self.payload}")' ) class CEMIMPropInfo: """Representation of CEMI Device Management Property.""" LENGTH = 6 def __init__( self, *, object_type: ResourceObjectType, object_instance: int = 1, property_id: ResourcePropertyId | int, number_of_elements: int = 1, start_index: int = 1, ) -> None: """Initialize CEMIMProp object.""" self.object_type = object_type self.object_instance = object_instance self.property_id = ( property_id.value if isinstance(property_id, ResourcePropertyId) else property_id ) self.number_of_elements = number_of_elements self.start_index = start_index def calculated_length(self) -> int: """Get length of CEMI data.""" return CEMIMPropInfo.LENGTH @staticmethod def from_knx(raw: bytes) -> CEMIMPropInfo: """Parse/deserialize from KNX/IP raw data.""" if len(raw) != CEMIMPropInfo.LENGTH: raise CouldNotParseCEMI( f"Invalid CEMI length: {len(raw)}; CEMI: {raw.hex()}" ) try: object_type = ResourceObjectType(int.from_bytes(raw[0:2], "big")) except ValueError: raise UnsupportedCEMIMessage( f"CEMIMProp Object Type not supported: {raw[0:2].hex()} in CEMI: {raw.hex()}" ) from None return CEMIMPropInfo( object_type=object_type, object_instance=raw[2], property_id=raw[3], number_of_elements=(raw[4] >> 4), start_index=(int.from_bytes(raw[4:6], "big") % 0x1000), ) def to_knx(self) -> bytes: """Serialize to CEMI raw data.""" return ( self.object_type.value.to_bytes(2, "big") + self.object_instance.to_bytes(1, "big") + self.property_id.to_bytes(1, "big") + ((self.number_of_elements << 12) + self.start_index).to_bytes(2, "big") ) def __repr__(self) -> str: """Return object as readable string.""" return ( f'object_type="{self.object_type.value}" ' f'object_instance="{self.object_instance}" ' f'property_id="{self.property_id}" ' f'number_of_elements="{self.number_of_elements}" ' f'start_index="{self.start_index}" ' ) def __eq__(self, other: object) -> bool: """Equal operator.""" return self.__dict__ == other.__dict__ class CEMIMPropReadRequest(CEMIData): """Representation of CEMI Device Management Property Read Request.""" def __init__(self, *, property_info: CEMIMPropInfo) -> None: """Initialize CEMIMPropReadRequest object.""" self.property_info = property_info def calculated_length(self) -> int: """Get length of CEMI data.""" return self.property_info.calculated_length() def to_knx(self) -> bytes: """Serialize to CEMI raw data.""" return self.property_info.to_knx() @classmethod def from_knx(cls, raw: bytes) -> CEMIData: """Parse/deserialize from KNX/IP raw data.""" return cls(property_info=CEMIMPropInfo.from_knx(raw)) def __repr__(self) -> str: """Return object as readable string.""" return f"CEMIMPropReadRequest({self.property_info})" class CEMIMPropReadResponse(CEMIData): """Representation of CEMI Device Management Property Read Response.""" def __init__( self, *, property_info: CEMIMPropInfo, data: bytes, ) -> None: """Initialize CEMIMPropReadResponse object.""" self.property_info = property_info self.data = data @property def error_code(self) -> CEMIErrorCode | None: """Return an optional CEMI error code.""" if self.property_info.number_of_elements == 0: return CEMIErrorCode(self.data[0]) return None def calculated_length(self) -> int: """Get length of CEMI data.""" return CEMIMPropInfo.LENGTH + len(self.data) def to_knx(self) -> bytes: """Serialize to CEMI raw data.""" return self.property_info.to_knx() + self.data @classmethod def from_knx(cls, raw: bytes) -> CEMIData: """Parse/deserialize from KNX/IP raw data.""" if len(raw) <= CEMIMPropInfo.LENGTH: raise CouldNotParseCEMI( f"CEMI Property Read Response too small. Length: {len(raw)}; CEMI: {raw.hex()}" ) property_info = CEMIMPropInfo.from_knx(raw[0 : CEMIMPropInfo.LENGTH]) # Did we get an error? if property_info.number_of_elements == 0 and len(raw) != 7: raise CouldNotParseCEMI( f"Invalid CEMI error response length: {len(raw)}; CEMI: {raw.hex()}" ) return cls(property_info=property_info, data=raw[CEMIMPropInfo.LENGTH :]) def __repr__(self) -> str: """Return object as readable string.""" _data = ( f'data="{self.data.hex()}" ' if self.property_info.number_of_elements != 0 else "" ) return ( f"CEMIMPropReadResponse(" f"{self.property_info}" f'error_code="{self.error_code}" ' f"{_data})" ) class CEMIMPropWriteRequest(CEMIData): """Representation of CEMI Device Management Property Write Request.""" def __init__( self, *, property_info: CEMIMPropInfo, data: bytes, ) -> None: """Initialize CEMIMPropWriteRequest object.""" self.property_info = property_info self.data = data def calculated_length(self) -> int: """Get length of CEMI data.""" return CEMIMPropInfo.LENGTH + len(self.data) def to_knx(self) -> bytes: """Serialize to CEMI raw data.""" return self.property_info.to_knx() + self.data @classmethod def from_knx(cls, raw: bytes) -> CEMIData: """Parse/deserialize from KNX/IP raw data.""" if len(raw) <= CEMIMPropInfo.LENGTH: raise CouldNotParseCEMI( f"CEMI Property Write Request too small. Length: {len(raw)}; CEMI: {raw.hex()}" ) property_info = CEMIMPropInfo.from_knx(raw[0 : CEMIMPropInfo.LENGTH]) return cls(property_info=property_info, data=raw[CEMIMPropInfo.LENGTH :]) def __repr__(self) -> str: """Return object as readable string.""" return f'CEMIMPropWriteRequest({self.property_info}data="{self.data.hex()}" )' class CEMIMPropWriteResponse(CEMIData): """Representation of CEMI Device Management Property Write Response.""" def __init__( self, *, property_info: CEMIMPropInfo, error_code: CEMIErrorCode | None = None, ) -> None: """Initialize CEMIMPropWriteResponse object.""" self.property_info = property_info self._error_code = error_code def calculated_length(self) -> int: """Get length of CEMI data.""" return CEMIMPropInfo.LENGTH + (1 if self._error_code else 0) def to_knx(self) -> bytes: """Serialize to CEMI raw data.""" if self._error_code: return self.property_info.to_knx() + self._error_code.value.to_bytes( 1, "big" ) return self.property_info.to_knx() @property def error_code(self) -> CEMIErrorCode | None: """Return an optional CEMI error code.""" return self._error_code @classmethod def from_knx(cls, raw: bytes) -> CEMIData: """Parse/deserialize from KNX/IP raw data.""" if len(raw) < CEMIMPropInfo.LENGTH: raise CouldNotParseCEMI( f"CEMI Property Write Response too small. Length: {len(raw)}; CEMI: {raw.hex()}" ) property_info = CEMIMPropInfo.from_knx(raw[0 : CEMIMPropInfo.LENGTH]) # Did we get an error? if property_info.number_of_elements == 0: if len(raw) != 7: raise CouldNotParseCEMI( f"Invalid CEMI error response length: {len(raw)}; CEMI: {raw.hex()}" ) return cls( property_info=property_info, error_code=CEMIErrorCode(raw[CEMIMPropInfo.LENGTH]), ) if len(raw) != CEMIMPropInfo.LENGTH: raise CouldNotParseCEMI( f"Invalid CEMI response length: {len(raw)}; CEMI: {raw.hex()}" ) return cls(property_info=property_info) def __repr__(self) -> str: """Return object as readable string.""" return ( f"CEMIMPropWriteResponse(" f"{self.property_info}" f'error_code="{self.error_code}" )' ) class CEMIFrame: """Representation of a CEMI Frame.""" def __init__( self, *, code: CEMIMessageCode, info: CEMIInfo | None = None, data: CEMIData | None, ) -> None: """Initialize CEMIFrame object.""" self.code = code self.info = info or CEMIInfo() self.data = data @staticmethod def _has_info(code: CEMIMessageCode) -> bool: return code not in ( CEMIMessageCode.M_PROP_READ_REQ, CEMIMessageCode.M_PROP_READ_CON, CEMIMessageCode.M_PROP_WRITE_REQ, CEMIMessageCode.M_PROP_WRITE_CON, CEMIMessageCode.M_PROP_INFO_IND, CEMIMessageCode.M_FUNC_PROP_COMMAND_REQ, CEMIMessageCode.M_FUNC_PROP_STATE_READ_REQ, CEMIMessageCode.M_FUNC_PROP_COMMAND_CON, CEMIMessageCode.M_FUNC_PROP_STATE_READ_CON, CEMIMessageCode.M_RESET_REQ, CEMIMessageCode.M_RESET_IND, ) @staticmethod def _has_data(code: CEMIMessageCode) -> bool: return code not in ( CEMIMessageCode.M_RESET_REQ, CEMIMessageCode.M_RESET_IND, ) def calculated_length(self) -> int: """Get length of KNX/IP body.""" length = 1 if self._has_info(self.code): length += self.info.calculated_length() if self._has_data(self.code): if self.data is None: raise UnsupportedCEMIMessage( f"CEMI data required for code: {self.code}" ) length += self.data.calculated_length() return length @staticmethod def from_knx(raw: bytes) -> CEMIFrame: """Parse/deserialize from KNX/IP raw data.""" try: code = CEMIMessageCode(raw[0]) except ValueError: raise UnsupportedCEMIMessage( f"CEMIMessageCode not implemented: {raw[0]} in CEMI: {raw.hex()}" ) from None if code in ( CEMIMessageCode.L_DATA_IND, CEMIMessageCode.L_DATA_REQ, CEMIMessageCode.L_DATA_CON, ): info, remainder = CEMIInfo.from_knx(raw[1:]) return CEMIFrame( code=code, info=info, data=CEMILData.from_knx(remainder), ) if code == CEMIMessageCode.M_PROP_READ_REQ: return CEMIFrame(code=code, data=CEMIMPropReadRequest.from_knx(raw[1:])) if code == CEMIMessageCode.M_PROP_READ_CON: return CEMIFrame(code=code, data=CEMIMPropReadResponse.from_knx(raw[1:])) if code == CEMIMessageCode.M_PROP_WRITE_REQ: return CEMIFrame(code=code, data=CEMIMPropWriteRequest.from_knx(raw[1:])) if code == CEMIMessageCode.M_PROP_WRITE_CON: return CEMIFrame(code=code, data=CEMIMPropWriteResponse.from_knx(raw[1:])) raise UnsupportedCEMIMessage( f"Could not handle CEMIMessageCode: {code} / {raw[0]} in CEMI: {raw.hex()}" ) def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" raw = bytes((self.code.value,)) if self._has_info(self.code): raw += self.info.to_knx() if self._has_data(self.code): if self.data is None: raise UnsupportedCEMIMessage( f"CEMI data required for code: {self.code}" ) raw += self.data.to_knx() return raw def __repr__(self) -> str: """Return object as readable string.""" _info = f'info="{self.info.__repr__()}" ' if self._has_info(self.code) else "" _data = f'data="{self.data.__repr__()}" ' if self._has_data(self.code) else "" return f'' def __eq__(self, other: object) -> bool: """Equal operator.""" return self.__dict__ == other.__dict__ xknx-3.6.0/xknx/cemi/cemi_handler.py000066400000000000000000000130171475530762600174220ustar00rootroot00000000000000""" CEMI Frame handler. This class represents a CEMI Client vaguely according to KNX specification 3/6/3 §4.1.2. It is responsible for sending and receiving CEMI frames to/from a CEMI Server - this can be a remote server when using IP tunnelling or a local server when using IP routing. """ from __future__ import annotations import asyncio import logging from typing import TYPE_CHECKING from xknx.exceptions import ( CommunicationError, ConfirmationError, ConversionError, CouldNotParseCEMI, DataSecureError, UnsupportedCEMIMessage, ) from xknx.secure.data_secure import DataSecure from xknx.secure.keyring import Keyring from xknx.telegram import IndividualAddress, Telegram, TelegramDirection, tpci from xknx.util import asyncio_timeout from .cemi_frame import CEMIFrame, CEMILData from .const import CEMIMessageCode if TYPE_CHECKING: from xknx.xknx import XKNX logger = logging.getLogger("xknx.cemi") data_secure_logger = logging.getLogger("xknx.data_secure") # See 3/6/3 EMI_IMI §4.1.5 Data Link Layer messages REQUEST_TO_CONFIRMATION_TIMEOUT = 3 class CEMIHandler: """Class for handling CEMI frames from/to the TelegramQueue.""" def __init__(self, xknx: XKNX) -> None: """Initialize CEMIHandler class.""" self.xknx = xknx self.data_secure: DataSecure | None = None self._l_data_confirmation_event = asyncio.Event() def data_secure_init(self, keyring: Keyring | None) -> None: """Initialize DataSecure.""" if keyring is None: self.data_secure = None else: self.data_secure = DataSecure.init_from_keyring(keyring) async def send_telegram(self, telegram: Telegram) -> None: """Create a CEMIFrame from a Telegram and send it to the CEMI Server.""" cemi_data = CEMILData.init_from_telegram( telegram=telegram, src_addr=( self.xknx.current_address if telegram.source_address.raw == 0 else None ), ) cemi = CEMIFrame( code=CEMIMessageCode.L_DATA_REQ, data=cemi_data, ) logger.debug("Outgoing CEMI: %s", cemi) if self.data_secure is not None: cemi.data = self.data_secure.outgoing_cemi(cemi_data=cemi_data) self._l_data_confirmation_event.clear() try: await self.xknx.knxip_interface.send_cemi(cemi) except (ConversionError, CommunicationError) as ex: logger.warning("Could not send CEMI frame: %s for %s", ex, cemi) self.xknx.connection_manager.cemi_count_outgoing_error += 1 raise ex try: async with asyncio_timeout(REQUEST_TO_CONFIRMATION_TIMEOUT): await self._l_data_confirmation_event.wait() except asyncio.TimeoutError: self.xknx.connection_manager.cemi_count_outgoing_error += 1 raise ConfirmationError( f"L_DATA_CON Data Link Layer confirmation timed out for {cemi}" ) from None self.xknx.connection_manager.cemi_count_outgoing += 1 def handle_raw_cemi(self, raw_cemi: bytes) -> None: """Parse and handle incoming raw CEMI Frames.""" try: cemi = CEMIFrame.from_knx(raw_cemi) except CouldNotParseCEMI as cemi_parse_err: logger.warning("CEMI Frame failed to parse: %s", cemi_parse_err) self.xknx.connection_manager.cemi_count_incoming_error += 1 return except UnsupportedCEMIMessage as unsupported_cemi_err: logger.info("CEMI not supported: %s", unsupported_cemi_err) self.xknx.connection_manager.cemi_count_incoming_error += 1 return self.handle_cemi_frame(cemi) def handle_cemi_frame(self, cemi: CEMIFrame) -> None: """Handle incoming CEMI Frames.""" if not isinstance(cemi.data, CEMILData): logger.debug("Ignoring incoming non-link-layer CEMI: %s", cemi) return if cemi.code is CEMIMessageCode.L_DATA_CON: # L_DATA_CON confirmation frame signals ready to send next telegram self._l_data_confirmation_event.set() logger.debug("Incoming CEMI confirmation: %s", cemi) return if cemi.code is CEMIMessageCode.L_DATA_REQ: # L_DATA_REQ frames should only be outgoing. logger.warning("Received unexpected L_DATA_REQ frame: %s", cemi) self.xknx.connection_manager.cemi_count_incoming_error += 1 return logger.debug("Incoming CEMI: %s", cemi) if self.data_secure is not None: try: cemi.data = self.data_secure.received_cemi(cemi_data=cemi.data) except DataSecureError as err: data_secure_logger.log( err.log_level, "Could not decrypt CEMI frame: %s", err, ) return self.xknx.connection_manager.cemi_count_incoming += 1 telegram = cemi.data.telegram() telegram.direction = TelegramDirection.INCOMING self.telegram_received(telegram) def telegram_received(self, telegram: Telegram) -> None: """Forward Telegram to upper layer.""" if isinstance(telegram.tpci, tpci.TDataGroup): self.xknx.telegrams.put_nowait(telegram) return if ( isinstance(telegram.destination_address, IndividualAddress) and telegram.destination_address != self.xknx.current_address ): return self.xknx.management.process(telegram) xknx-3.6.0/xknx/cemi/const.py000066400000000000000000000056011475530762600161360ustar00rootroot00000000000000"""Constants for CEMI.""" from enum import Enum, IntEnum class CEMIMessageCode(Enum): """Enum class for CEMI Message Codes.""" # pylint disable=line-too-long # FROM NETWORK LAYER TO DATA LINK LAYER L_RAW_REQ = 0x10 L_DATA_REQ = 0x11 # Data Service. # Primitive used for transmitting a data frame L_POLL_DATA_REQ = 0x13 # Poll Data Service # FROM DATA LINK LAYER TO NETWORK LAYER L_POLL_DATA_CON = 0x25 # Poll Data Service L_DATA_IND = 0x29 # Data Service. # Primitive used for receiving a data frame L_BUSMON_IND = 0x2B # Bus Monitor Service L_RAW_IND = 0x2D L_DATA_CON = 0x2E # Data Service. # Primitive used for local confirmation # that a frame was sent # (does not indicate a successful receive though) L_RAW_CON = 0x2F # Management Configuration message types M_PROP_READ_REQ = 0xFC # Read Property Request M_PROP_READ_CON = 0xFB # Read Property Confirmation M_PROP_WRITE_REQ = 0xF6 # Write Property Request M_PROP_WRITE_CON = 0xF5 # Write Property Confirmation M_PROP_INFO_IND = 0xF7 # Property Indication M_FUNC_PROP_COMMAND_REQ = 0xF8 # Call Property Function M_FUNC_PROP_STATE_READ_REQ = 0xF9 # Read status of Property Function M_FUNC_PROP_COMMAND_CON = 0xFA # Property Function Confirmation M_FUNC_PROP_STATE_READ_CON = 0xFA # Read status of Property Function Confirmation M_RESET_REQ = 0xF1 # Reset Request M_RESET_IND = 0xF0 # Reset confirmation class CEMIFlags: """Enum class for CEMI Flags.""" # Bit 1/7 FRAME_TYPE_EXTENDED = 0x0000 FRAME_TYPE_STANDARD = 0x8000 # Bit 1/6 - Reserved # Bit 1/5 # Repeat in case of an error REPEAT = 0x0000 DO_NOT_REPEAT = 0x2000 # Bit 1/4 SYSTEM_BROADCAST = 0x0000 BROADCAST = 0x1000 # Bit 1/3+2 PRIORITY_SYSTEM = 0x0000 PRIORITY_NORMAL = 0x0400 PRIORITY_URGENT = 0x0800 PRIORITY_LOW = 0x0C00 # Bit 1/1 NO_ACK_REQUESTED = 0x0000 ACK_REQUESTED = 0x0200 # Bit 1/0 CONFIRM_NO_ERROR = 0x0000 CONFIRM_ERROR = 0x0100 # Bit 0/7 DESTINATION_INDIVIDUAL_ADDRESS = 0x0000 DESTINATION_GROUP_ADDRESS = 0x0080 # Bit 0/6+5+4 HOP_COUNT_NO = 0x0070 HOP_COUNT_1ST = 0x0060 # Bit 0/3+2+1+0 STANDARD_FRAME_FORMAT = 0x0000 EXTENDED_FRAME_FORMAT = 0x0001 class CEMIErrorCode(IntEnum): """Enum class for CEMI Error Codes.""" CEMI_ERROR_UNSPECIFIED = 0x00 CEMI_ERROR_OUT_OF_RANGE = 0x01 CEMI_ERROR_OUT_OF_MAX_RANGE = 0x02 CEMI_ERROR_OUT_OF_MIN_RANGE = 0x03 CEMI_ERROR_MEMORY = 0x04 CEMI_ERROR_READ_ONLY = 0x05 CEMI_ERROR_ILLEGAL_COMMAND = 0x06 CEMI_ERROR_VOID_DP = 0x07 CEMI_ERROR_TYPE_CONFLICT = 0x08 CEMI_ERROR_PROP_INDEX_RANGE = 0x09 CEMI_ERROR_TEMPORARILY_READ_ONLY = 0x0A def __repr__(self) -> str: """Return object as readable string.""" return str(self.value) xknx-3.6.0/xknx/core/000077500000000000000000000000001475530762600144475ustar00rootroot00000000000000xknx-3.6.0/xknx/core/__init__.py000066400000000000000000000006361475530762600165650ustar00rootroot00000000000000"""Module for the automations and business logic of XKNX.""" # ruff: noqa: F401 from .connection_manager import ConnectionManager from .connection_state import XknxConnectionState, XknxConnectionType from .group_address_dpt import GroupAddressDPT from .state_updater import StateUpdater from .task_registry import Task, TaskRegistry from .telegram_queue import TelegramQueue from .value_reader import ValueReader xknx-3.6.0/xknx/core/connection_manager.py000066400000000000000000000064531475530762600206620ustar00rootroot00000000000000"""Manages connection callbacks.""" from __future__ import annotations import asyncio from datetime import datetime from xknx.core.connection_state import XknxConnectionState, XknxConnectionType from xknx.typing import ConnectionChangeCallbackType class ConnectionManager: """Manages connection state changes XKNX.""" def __init__(self) -> None: """Initialize ConnectionState class.""" self._main_loop: asyncio.AbstractEventLoop | None = None self.connected = asyncio.Event() self._state = XknxConnectionState.DISCONNECTED self._connection_state_changed_cbs: list[ConnectionChangeCallbackType] = [] self.cemi_count_incoming: int = 0 self.cemi_count_incoming_error: int = 0 self.cemi_count_outgoing: int = 0 self.cemi_count_outgoing_error: int = 0 self.connected_since: datetime | None = None self.connection_type: XknxConnectionType = XknxConnectionType.NOT_CONNECTED async def register_loop(self) -> None: """Register main loop to enable thread-safe `connection_state_changed` calls.""" self._main_loop = asyncio.get_running_loop() def register_connection_state_changed_cb( self, connection_state_changed_cb: ConnectionChangeCallbackType ) -> None: """Register callback for connection state being updated.""" self._connection_state_changed_cbs.append(connection_state_changed_cb) def unregister_connection_state_changed_cb( self, connection_state_changed_cb: ConnectionChangeCallbackType ) -> None: """Unregister callback for connection state being updated.""" if connection_state_changed_cb in self._connection_state_changed_cbs: self._connection_state_changed_cbs.remove(connection_state_changed_cb) def connection_state_changed( self, state: XknxConnectionState, connection_type: XknxConnectionType = XknxConnectionType.NOT_CONNECTED, ) -> None: """Run registered callbacks in main loop. Set internal state flag.""" if self._main_loop: self._main_loop.call_soon_threadsafe( self._connection_state_changed, state, connection_type ) else: self._connection_state_changed(state, connection_type) def _connection_state_changed( self, state: XknxConnectionState, connection_type: XknxConnectionType ) -> None: """Run registered callbacks. Set internal state flag.""" if self._state == state: return self._state = state self.connection_type = connection_type if state == XknxConnectionState.CONNECTED: self.connected.set() self._reset_counters() else: self.connected.clear() self.connected_since = None for connection_state_change_cb in self._connection_state_changed_cbs: connection_state_change_cb(state) @property def state(self) -> XknxConnectionState: """Get current state.""" return self._state def _reset_counters(self) -> None: """Reset counters.""" self.cemi_count_incoming = 0 self.cemi_count_incoming_error = 0 self.cemi_count_outgoing = 0 self.cemi_count_outgoing_error = 0 self.connected_since = datetime.now().astimezone() xknx-3.6.0/xknx/core/connection_state.py000066400000000000000000000007611475530762600203640ustar00rootroot00000000000000"""Connection States from XKNX.""" from enum import Enum class XknxConnectionState(Enum): """Possible connection state values.""" CONNECTED = "CONNECTED" CONNECTING = "CONNECTING" DISCONNECTED = "DISCONNECTED" class XknxConnectionType(Enum): """Possible connection type values.""" NOT_CONNECTED = None ROUTING = "Routing" ROUTING_SECURE = "Routing IP Secure" TUNNEL_SECURE = "Tunnel IP Secure" TUNNEL_TCP = "Tunnel TCP" TUNNEL_UDP = "Tunnel UDP" xknx-3.6.0/xknx/core/group_address_dpt.py000066400000000000000000000067101475530762600205350ustar00rootroot00000000000000"""Group address data point type table.""" from __future__ import annotations from collections.abc import Mapping import logging from xknx.dpt.dpt import DPTBase from xknx.exceptions import ConversionError, CouldNotParseAddress, CouldNotParseTelegram from xknx.telegram import Telegram, TelegramDecodedData from xknx.telegram.address import ( DeviceAddressableType, DeviceGroupAddress, GroupAddress, InternalGroupAddress, parse_device_group_address, ) from xknx.telegram.apci import GroupValueResponse, GroupValueWrite from xknx.typing import DPTParsable _GA_DPT_LOGGER = logging.getLogger("xknx.ga_dpt") class GroupAddressDPT: """Class for mapping group addresses to data point types for eager decoding.""" __slots__ = ("_ga_dpts", "ga_decoding_error") def __init__(self) -> None: """Initialize GADataTypes class.""" # using dict[int | str] instead of dict[DeviceGroupAddress] is faster. self._ga_dpts: dict[int | str, type[DPTBase]] = {} self.ga_decoding_error: set[GroupAddress | InternalGroupAddress] = set() def set( self, ga_dpt: Mapping[DeviceAddressableType, DPTParsable], ) -> None: """Assign decoders to group addresses.""" unknown_dpts = set() for addr, dpt in ga_dpt.items(): try: address = parse_device_group_address(addr) except CouldNotParseAddress as err: _GA_DPT_LOGGER.warning("Invalid group address %s: %s", addr, err) continue if (transcoder := DPTBase.parse_transcoder(dpt)) is None: unknown_dpts.add(repr(dpt)) # prevent unhashable types (dict) continue self._ga_dpts[address.raw] = transcoder if unknown_dpts: _GA_DPT_LOGGER.debug("No transcoder found for DPTs: %s", unknown_dpts) def get(self, address: DeviceGroupAddress) -> type[DPTBase] | None: """Return transcoder for group address.""" return self._ga_dpts.get(address.raw) def clear(self) -> None: """Clear all group addresses.""" self._ga_dpts = {} def set_decoded_data(self, telegram: Telegram) -> None: """Update telegram data with decoded value.""" if telegram.decoded_data is not None: return if not isinstance(telegram.payload, GroupValueWrite | GroupValueResponse): return assert isinstance( # GroupValueWrite and GroupValueResponse can not have IndividualAddress telegram.destination_address, GroupAddress | InternalGroupAddress ) if (transcoder := self.get(telegram.destination_address)) is None: return try: value = transcoder.from_knx(telegram.payload.value) except (CouldNotParseTelegram, ConversionError) as err: if telegram.destination_address in self.ga_decoding_error: _logger_fn = _GA_DPT_LOGGER.debug else: _logger_fn = _GA_DPT_LOGGER.warning self.ga_decoding_error.add(telegram.destination_address) _logger_fn( "DPT decoding error. Telegram from %s to %s with payload %s can't be decoded by %s: %s", telegram.source_address, telegram.destination_address, telegram.payload.value, transcoder.dpt_name(), err, ) return telegram.decoded_data = TelegramDecodedData(transcoder, value) xknx-3.6.0/xknx/core/state_updater.py000066400000000000000000000234061475530762600176720ustar00rootroot00000000000000"""Module for keeping the value of a RemoteValue from KNX bus up to date.""" from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable from enum import Enum import logging from typing import TYPE_CHECKING, Any, NamedTuple from xknx.core import XknxConnectionState from xknx.remote_value import RemoteValue if TYPE_CHECKING: from xknx.xknx import XKNX logger = logging.getLogger("xknx.state_updater") DEFAULT_UPDATE_INTERVAL = 60 MAX_UPDATE_INTERVAL = 1440 class TrackerOptions(NamedTuple): """Options for the state tracker.""" tracker_type: StateTrackerType update_interval_min: int | float TrackerOptionType = bool | int | float | str | TrackerOptions class StateUpdater: """Class for keeping the states of RemoteValues up to date.""" def __init__( self, xknx: XKNX, default_tracker_option: TrackerOptionType, parallel_reads: int = 2, ) -> None: """Initialize StateUpdater class.""" self.xknx = xknx self.started = False self._workers: dict[int, _StateTracker] = {} self._semaphore = asyncio.Semaphore(value=parallel_reads) # used to determine if a RemoteValue shall register a tracker by default self.default_use_updater = bool(default_tracker_option) # set default before using it in `parse_tracker_options()` self._default_tracker_option = TrackerOptions( tracker_type=StateTrackerType.EXPIRE, update_interval_min=DEFAULT_UPDATE_INTERVAL, ) # Default options will be used if instantiated with `True` or `False`. self._default_tracker_option = self.parse_tracker_options( default_tracker_option, "default configuration" ) def parse_tracker_options( self, tracker_options: TrackerOptionType, tracker_name: str, ) -> TrackerOptions: """Parse tracker type and expiration time.""" def check_update_interval(update_interval: int | float) -> int | float: """Return valid update interval.""" if update_interval > MAX_UPDATE_INTERVAL: logger.warning( "StateUpdater interval of %s to long for %s. Using maximum of %s minutes (1 day)", tracker_options, tracker_name, MAX_UPDATE_INTERVAL, ) return MAX_UPDATE_INTERVAL if update_interval < 1: logger.warning( "StateUpdater interval of %s to short for %s. Using minimum of 1 minute", tracker_options, tracker_name, ) return 1 return update_interval if isinstance(tracker_options, TrackerOptions): return TrackerOptions( tracker_type=tracker_options.tracker_type, update_interval_min=check_update_interval( tracker_options.update_interval_min ), ) if isinstance(tracker_options, bool): # `True` would be overwritten by the check for `int` return self._default_tracker_option tracker_type = self._default_tracker_option.tracker_type update_interval: int | float = self._default_tracker_option.update_interval_min if isinstance(tracker_options, int | float): update_interval = check_update_interval(tracker_options) elif isinstance(tracker_options, str): _options = tracker_options.split() if _options[0].upper() == "INIT": tracker_type = StateTrackerType.INIT elif _options[0].upper() == "EXPIRE": tracker_type = StateTrackerType.EXPIRE elif _options[0].upper() == "EVERY": tracker_type = StateTrackerType.PERIODICALLY else: logger.warning( 'Could not parse StateUpdater tracker_options "%s" for %s. Using default %s %s minutes.', tracker_options, tracker_name, tracker_type, update_interval, ) return TrackerOptions(tracker_type, update_interval) try: if _options[1].isdigit(): update_interval = check_update_interval(int(_options[1])) except IndexError: pass # No time given (no _options[1]) return TrackerOptions(tracker_type, update_interval) def register_remote_value( self, remote_value: RemoteValue[Any], tracker_options: TrackerOptionType = True, ) -> None: """Register a RemoteValue to initialize its state and/or track for expiration.""" async def read_state_mutex() -> None: """Schedule to read the state from the KNX bus - one at a time.""" async with self._semaphore: # wait until there is nothing else to send to the bus await self.xknx.telegram_queue.outgoing_queue.join() logger.debug( "StateUpdater reading %s for %s - %s", remote_value.group_address_state, remote_value.device_name, remote_value.feature_name, ) # shield from cancellation so update_received() don't cancel the # ValueReader leaving the telegram_received_cb until next telegram await asyncio.shield(remote_value.read_state(wait_for_result=True)) tracker_options = self.parse_tracker_options(tracker_options, str(remote_value)) tracker = _StateTracker( read_state_awaitable=read_state_mutex, tracker_options=tracker_options, ) self._workers[id(remote_value)] = tracker logger.debug( "StateUpdater registered %s %s for %s", tracker_options.tracker_type, tracker_options.update_interval_min, remote_value, ) if self.started: tracker.start() def unregister_remote_value(self, remote_value: RemoteValue[Any]) -> None: """Unregister a RemoteValue from StateUpdater.""" self._workers.pop(id(remote_value)).stop() def update_received(self, remote_value: RemoteValue[Any]) -> None: """Reset the timer when a state update was received.""" if self.started and id(remote_value) in self._workers: self._workers[id(remote_value)].update_received() def _start(self) -> None: """Start internal StateUpdater. Initialize states.""" logger.debug("StateUpdater initializing values") self.started = True for worker in self._workers.values(): worker.start() def _stop(self) -> None: """Stop internal StateUpdater.""" logger.debug("StateUpdater stopping") self.started = False for worker in self._workers.values(): worker.stop() def start(self) -> None: """Start StateUpdater.""" self.xknx.connection_manager.register_connection_state_changed_cb( self.connection_state_change_callback ) if self.xknx.connection_manager.state == XknxConnectionState.CONNECTED: self._start() def stop(self) -> None: """Stop StateUpdater.""" self.xknx.connection_manager.unregister_connection_state_changed_cb( self.connection_state_change_callback ) self._stop() def connection_state_change_callback(self, state: XknxConnectionState) -> None: """Start and stop StateUpdater via connection state update.""" if state == XknxConnectionState.CONNECTED: if not self.started: self._start() elif self.started: self._stop() class StateTrackerType(Enum): """Enum indicating the StateUpdater Type.""" INIT = 1 EXPIRE = 2 PERIODICALLY = 3 class _StateTracker: """Keeps track of the age of the state from one RemoteValue.""" def __init__( self, read_state_awaitable: Callable[[], Awaitable[None]], tracker_options: TrackerOptions, ) -> None: """Initialize StateTracker class.""" self.tracker_type = tracker_options.tracker_type self.update_interval = tracker_options.update_interval_min * 60 self._read_state = read_state_awaitable self._task: asyncio.Task[None] | None = None def start(self) -> None: """Start StateTracker - read state on call.""" self.stop() self._task = asyncio.create_task(self._start_init()) async def _start_init(self) -> None: """Initialize state, start update loop if appropriate.""" await self._read_state() if self.tracker_type is not StateTrackerType.INIT: self.reset() def reset(self) -> None: """Start / Restart StateTracker timer - wait for value to expire.""" self.stop() self._task = asyncio.create_task(self._update_loop()) def stop(self) -> None: """Stop StateTracker.""" if self._task: self._task.cancel() self._task = None def update_received(self) -> None: """Reset the timer if a telegram was received for a "expire" typed StateUpdater.""" if self.tracker_type == StateTrackerType.EXPIRE: self.reset() async def _update_loop(self) -> None: """Wait for the update_interval to expire. Endless loop for updating states.""" # for StateUpdaterType.EXPIRE: # on successful read the while loop gets canceled when the callback calls update_received() # when no telegram was received it will try again endlessly while True: await asyncio.sleep(self.update_interval) await self._read_state() xknx-3.6.0/xknx/core/task_registry.py000066400000000000000000000106261475530762600177200ustar00rootroot00000000000000"""Manages global tasks.""" from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine, Generator import logging from typing import TYPE_CHECKING, Any from xknx.core import XknxConnectionState AsyncCallbackType = Callable[[], Coroutine[Any, Any, None]] if TYPE_CHECKING: from xknx import XKNX logger = logging.getLogger("xknx.log") class Task: """Handles a given task.""" def __init__( self, name: str, async_func: AsyncCallbackType, restart_after_reconnect: bool = False, ) -> None: """Initialize Task class.""" self.name = name self.async_func = async_func self.restart_after_reconnect = restart_after_reconnect self._task: asyncio.Task[None] | None = None def start(self) -> Task: """Start a task.""" self._task = asyncio.create_task(self.async_func(), name=self.name) return self def __await__(self) -> Generator[None, None, None]: """Wait for task to be finished.""" if self._task: yield from self._task def cancel(self) -> None: """Cancel a task.""" if self._task: self._task.cancel() self._task = None def done(self) -> bool: """Return if task is finished.""" return self._task is None or self._task.done() def connection_lost(self) -> None: """Cancel a task if connection was lost and the task should be cancelled if no connection is established.""" if self.restart_after_reconnect and self._task: logger.debug("Stopping task %s because of connection loss.", self.name) self.cancel() def reconnected(self) -> None: """Restart when reconnected to bus.""" if self.restart_after_reconnect and not self._task: logger.debug( "Restarting task %s as the connection to the bus was reestablished.", self.name, ) self.start() class TaskRegistry: """Manages async tasks in XKNX.""" def __init__(self, xknx: XKNX) -> None: """Initialize TaskRegistry class.""" self.xknx = xknx self.tasks: list[Task] = [] self._background_task: set[asyncio.Task[None]] = set() def register( self, name: str, async_func: AsyncCallbackType, track_task: bool = True, restart_after_reconnect: bool = False, ) -> Task: """Register new task.""" self.unregister(name) _task: Task = Task( name=name, async_func=async_func, restart_after_reconnect=restart_after_reconnect, ) if track_task: self.tasks.append(_task) return _task def unregister(self, name: str) -> None: """Unregister task.""" for task in self.tasks: if task.name == name: task.cancel() self.tasks.remove(task) def start(self) -> None: """Start task registry.""" self.xknx.connection_manager.register_connection_state_changed_cb( self.connection_state_changed_cb ) def stop(self) -> None: """Stop task registry and cancel all tasks.""" self.xknx.connection_manager.unregister_connection_state_changed_cb( self.connection_state_changed_cb ) for task in self.tasks: task.cancel() self.tasks = [] async def block_till_done(self) -> None: """Await all tracked tasks.""" await asyncio.gather(*self.tasks) def connection_state_changed_cb(self, state: XknxConnectionState) -> None: """Handle connection state changes.""" for task in self.tasks: if state == XknxConnectionState.CONNECTED: task.reconnected() else: task.connection_lost() def background(self, async_func: Coroutine[Any, Any, None]) -> None: """Run a task in the background. This task will not be tracked by the TaskRegistry.""" # Add task to the set. This creates a strong reference so it can't be garbage collected. task = asyncio.create_task(async_func) # To prevent keeping references to finished tasks forever, self._background_task.add(task) # make each task remove its own reference from the set after # completion: task.add_done_callback(self._background_task.discard) xknx-3.6.0/xknx/core/telegram_queue.py000066400000000000000000000230151475530762600200260ustar00rootroot00000000000000""" Module for queueing telegrams addressed to group addresses. When a device wants to send a telegram to the KNX bus, it has to queue it to the TelegramQueue within XKNX. The telegram will be forwarded to the local CEMIHandler and processed in xknx-Devices. You may register callbacks to be notified if a telegram was pushed to the queue. Telegrams addressed to IndividualAddresses are not processed by this queue. """ from __future__ import annotations import asyncio from collections.abc import Awaitable import logging from typing import TYPE_CHECKING from xknx.exceptions import CommunicationError, XKNXException from xknx.telegram import AddressFilter, Telegram, TelegramDirection from xknx.telegram.address import GroupAddress, InternalGroupAddress from xknx.typing import TelegramCallbackType if TYPE_CHECKING: from xknx.xknx import XKNX logger = logging.getLogger("xknx.log") telegram_logger = logging.getLogger("xknx.telegram") class TelegramQueue: """Class for telegram queue.""" class Callback: """Callback class for handling telegram received callbacks.""" def __init__( self, callback: TelegramCallbackType, address_filters: list[AddressFilter] | None = None, group_addresses: list[GroupAddress | InternalGroupAddress] | None = None, match_for_outgoing_telegrams: bool = False, ) -> None: """Initialize Callback class.""" self.callback = callback self._match_all = address_filters is None and group_addresses is None self._match_outgoing = match_for_outgoing_telegrams self.address_filters = [] if address_filters is None else address_filters self.group_addresses = [] if group_addresses is None else group_addresses def is_within_filter(self, telegram: Telegram) -> bool: """Test if callback is filtering for group address.""" if ( not self._match_outgoing and telegram.direction == TelegramDirection.OUTGOING ): return False if self._match_all: return True if isinstance( telegram.destination_address, GroupAddress | InternalGroupAddress ): for address_filter in self.address_filters: if address_filter.match(telegram.destination_address): return True for group_address in self.group_addresses: if telegram.destination_address == group_address: return True return False def __init__(self, xknx: XKNX) -> None: """Initialize TelegramQueue class.""" self.xknx = xknx self.telegram_received_cbs: list[TelegramQueue.Callback] = [] self.outgoing_queue: asyncio.Queue[Telegram | None] = asyncio.Queue() self._consumer_task: Awaitable[tuple[None, None]] | None = None self._rate_limiter: asyncio.Task[None] | None = None def register_telegram_received_cb( self, telegram_received_cb: TelegramCallbackType, address_filters: list[AddressFilter] | None = None, group_addresses: list[GroupAddress | InternalGroupAddress] | None = None, match_for_outgoing: bool = False, ) -> TelegramQueue.Callback: """Register callback for a telegram being received from KNX bus.""" callback = TelegramQueue.Callback( telegram_received_cb, address_filters=address_filters, group_addresses=group_addresses, match_for_outgoing_telegrams=match_for_outgoing, ) self.telegram_received_cbs.append(callback) return callback def unregister_telegram_received_cb( self, telegram_received_cb: TelegramQueue.Callback ) -> None: """Unregister callback for a telegram being received from KNX bus.""" self.telegram_received_cbs.remove(telegram_received_cb) async def start(self) -> None: """Start telegram queue.""" self._consumer_task = asyncio.gather( self._telegram_consumer(), self._outgoing_rate_limiter() ) async def stop(self) -> None: """Stop telegram queue.""" logger.debug("Stopping TelegramQueue") # If a None object is pushed to the queue, the queue stops self.xknx.telegrams.put_nowait(None) if self._consumer_task is not None: await self._consumer_task async def _telegram_consumer(self) -> None: """Endless loop for processing telegrams.""" while True: telegram = await self.xknx.telegrams.get() # Breaking up queue if None is pushed to the queue if telegram is None: self.outgoing_queue.put_nowait(None) await self.outgoing_queue.join() self.xknx.telegrams.task_done() break self.xknx.group_address_dpt.set_decoded_data(telegram) if telegram.direction == TelegramDirection.INCOMING: try: await self.process_telegram_incoming(telegram) except XKNXException: logger.exception( "Unexpected xknx error while processing incoming telegram %s", telegram, ) except Exception: # pylint: disable=broad-except # prevent the parser Task from stalling when unexpected errors occur logger.exception( "Unexpected error while processing incoming telegram %s", telegram, ) finally: self.xknx.telegrams.task_done() elif telegram.direction == TelegramDirection.OUTGOING: self.outgoing_queue.put_nowait(telegram) # self.xknx.telegrams.task_done() for outgoing is called in _outgoing_rate_limiter. async def _outgoing_rate_limiter(self) -> None: """Endless loop for processing outgoing telegrams.""" while True: telegram = await self.outgoing_queue.get() # Breaking up queue if None is pushed to the queue if telegram is None: self.outgoing_queue.task_done() if self._rate_limiter: self._rate_limiter.cancel() break # limit rate to knx bus - defaults to 20 per second if self.xknx.rate_limit and not isinstance( telegram.destination_address, InternalGroupAddress ): if self._rate_limiter is not None: await self._rate_limiter self._rate_limiter = asyncio.create_task( asyncio.sleep(1 / self.xknx.rate_limit) ) try: await self.process_telegram_outgoing(telegram) except CommunicationError as ex: if ex.should_log: logger.warning(ex) except XKNXException as ex: logger.error("Error while processing outgoing telegram %s", ex) except Exception: # pylint: disable=broad-except # prevent the sender Task from stalling when unexpected errors occur (eg. ValueError from creating KNXIPFrames) logger.exception( "Unexpected error while processing outgoing telegram %s", telegram ) finally: self.outgoing_queue.task_done() self.xknx.telegrams.task_done() async def _process_all_telegrams(self) -> None: """Process all telegrams being queued. Used in unit tests.""" while not self.xknx.telegrams.empty(): try: telegram = self.xknx.telegrams.get_nowait() if telegram is None: return if telegram.direction == TelegramDirection.INCOMING: await self.process_telegram_incoming(telegram) elif telegram.direction == TelegramDirection.OUTGOING: await self.process_telegram_outgoing(telegram) except XKNXException as ex: logger.error("Error while processing telegram %s", ex) finally: self.xknx.telegrams.task_done() async def process_telegram_outgoing(self, telegram: Telegram) -> None: """Process outgoing telegram.""" telegram_logger.debug(telegram) if not isinstance(telegram.destination_address, InternalGroupAddress): # raises CommunicationError when interface is not connected await self.xknx.cemi_handler.send_telegram(telegram) self.xknx.devices.process(telegram) self._run_telegram_received_cbs(telegram) async def process_telegram_incoming(self, telegram: Telegram) -> None: """Process incoming telegram.""" telegram_logger.debug(telegram) self._run_telegram_received_cbs(telegram) self.xknx.devices.process(telegram) def _run_telegram_received_cbs(self, telegram: Telegram) -> None: """Run registered callbacks. Don't propagate exceptions.""" for callback in self.telegram_received_cbs: if not callback.is_within_filter(telegram): continue try: callback.callback(telegram) except Exception: # pylint: disable=broad-except logger.exception( "Unexpected error while processing telegram_received_cb for %s", telegram, ) xknx-3.6.0/xknx/core/value_reader.py000066400000000000000000000057231475530762600174660ustar00rootroot00000000000000""" Module for reading the value of a specific KNX group address from KNX bus. The module will * ... send a group_read to the selected group address. * ... register a callback for receiving telegrams within telegram queue. * ... check if received telegrams have the correct group address. * ... store the received telegram for further processing. """ from __future__ import annotations import asyncio import logging from typing import TYPE_CHECKING from xknx.telegram import Telegram from xknx.telegram.address import GroupAddress, InternalGroupAddress from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite from xknx.util import asyncio_timeout if TYPE_CHECKING: from xknx.xknx import XKNX logger = logging.getLogger("xknx.log") class ValueReader: """Class for reading the value of a specific KNX group address from KNX bus.""" def __init__( self, xknx: XKNX, group_address: GroupAddress | InternalGroupAddress, timeout_in_seconds: float = 2.0, ) -> None: """Initialize ValueReader class.""" self.xknx = xknx self.group_address: GroupAddress | InternalGroupAddress = group_address self.response_received_event = asyncio.Event() self.timeout_in_seconds: float = timeout_in_seconds self.received_telegram: Telegram | None = None async def read(self) -> Telegram | None: """Send group read and wait for response.""" cb_obj = self.xknx.telegram_queue.register_telegram_received_cb( self.telegram_received, group_addresses=[self.group_address], match_for_outgoing=True, ) self.send_group_read() try: async with asyncio_timeout(self.timeout_in_seconds): await self.response_received_event.wait() except asyncio.TimeoutError: logger.warning( "Error: KNX bus did not respond in time (%s secs) to GroupValueRead request for: %s", self.timeout_in_seconds, self.group_address, ) else: return self.received_telegram finally: # cleanup to not leave callbacks (for asyncio.CancelledError) self.xknx.telegram_queue.unregister_telegram_received_cb(cb_obj) return None def send_group_read(self) -> None: """Send group read.""" telegram = Telegram( destination_address=self.group_address, payload=GroupValueRead(), source_address=self.xknx.current_address, ) self.xknx.telegrams.put_nowait(telegram) def telegram_received(self, telegram: Telegram) -> None: """Test if telegram has correct group address and trigger event.""" if telegram.destination_address == self.group_address and isinstance( telegram.payload, GroupValueResponse | GroupValueWrite ): self.received_telegram = telegram self.response_received_event.set() xknx-3.6.0/xknx/devices/000077500000000000000000000000001475530762600151415ustar00rootroot00000000000000xknx-3.6.0/xknx/devices/__init__.py000066400000000000000000000020341475530762600172510ustar00rootroot00000000000000"""Module for handling devices like Lights, Switches or Covers.""" from .binary_sensor import BinarySensor from .climate import Climate from .climate_mode import ClimateMode from .cover import Cover from .datetime import DateDevice, DateTimeDevice, TimeDevice from .device import Device from .devices import Devices from .expose_sensor import ExposeSensor from .fan import Fan from .light import Light from .notification import Notification from .numeric_value import NumericValue from .raw_value import RawValue from .scene import Scene from .sensor import Sensor from .switch import Switch from .travelcalculator import TravelCalculator, TravelStatus from .weather import Weather __all__ = [ "BinarySensor", "Climate", "ClimateMode", "Cover", "DateDevice", "DateTimeDevice", "Device", "Devices", "ExposeSensor", "Fan", "Light", "Notification", "NumericValue", "RawValue", "Scene", "Sensor", "Switch", "TimeDevice", "TravelCalculator", "TravelStatus", "Weather", ] xknx-3.6.0/xknx/devices/binary_sensor.py000066400000000000000000000150221475530762600203700ustar00rootroot00000000000000""" Module for managing a binary sensor. A binary sensor can be: * A switch in the wall (as in the thing you press to switch on the light) * A motion detector * A reed sensor for detecting of a window/door is opened or closed. """ from __future__ import annotations import asyncio from collections.abc import Iterator from functools import partial import time from typing import TYPE_CHECKING, cast from xknx.core import Task from xknx.remote_value import GroupAddressesType, RemoteValueSwitch from .device import Device, DeviceCallbackType if TYPE_CHECKING: from xknx.telegram import Telegram from xknx.xknx import XKNX class BinarySensor(Device): """Class for binary sensor.""" def __init__( self, xknx: XKNX, name: str, group_address_state: GroupAddressesType = None, invert: bool = False, sync_state: bool | int | float | str = True, ignore_internal_state: bool = False, reset_after: float | None = None, context_timeout: float | None = None, device_updated_cb: DeviceCallbackType[BinarySensor] | None = None, ) -> None: """Initialize BinarySensor class.""" super().__init__(xknx, name, device_updated_cb) self.ignore_internal_state = ignore_internal_state or bool(context_timeout) self.reset_after = reset_after self.state: bool | None = None self._context_timeout = context_timeout self._count_set_on = 0 self._count_set_off = 0 self._last_set: float | None = None self._reset_task: Task | None = None self._context_task: Task | None = None self.remote_value = RemoteValueSwitch( xknx, group_address_state=group_address_state, invert=invert, sync_state=sync_state, device_name=self.name, # after_update called internally after_update_cb=self._state_from_remote_value, ) def _iter_remote_values(self) -> Iterator[RemoteValueSwitch]: """Iterate the devices RemoteValue classes.""" yield self.remote_value def async_remove_tasks(self) -> None: """Remove async tasks of device.""" if self._context_task: self.xknx.task_registry.unregister(self._context_task.name) self._context_task = None if self._reset_task: self.xknx.task_registry.unregister(self._reset_task.name) self._reset_task = None @property def last_telegram(self) -> Telegram | None: """Return the last telegram received from the RemoteValue.""" return self.remote_value.telegram def _state_from_remote_value(self, state: bool) -> None: """Update the internal state from RemoteValue (Callback).""" self._set_internal_state(state) def _set_internal_state(self, state: bool) -> None: """Set the internal state of the device. If state was changed after_update hooks and connected Actions are executed.""" if state != self.state or self.ignore_internal_state: self.state = state if self.ignore_internal_state and self._context_timeout: self.bump_and_get_counter(state) self._context_task = self.xknx.task_registry.register( name=f"binary_sensor.context_{id(self)}", async_func=partial(self._counter_task, self._context_timeout), ).start() else: self.after_update() async def _counter_task(self, wait_seconds: float) -> None: """Trigger after 1 second to prevent double triggers.""" await asyncio.sleep(wait_seconds) self.after_update() self._count_set_on = 0 self._count_set_off = 0 self.after_update() @property def counter(self) -> int | None: """Return current counter for sensor.""" if self._context_timeout: return self._count_set_on if self.state else self._count_set_off return None def bump_and_get_counter(self, state: bool) -> int: """Bump counter and return the number of times a state was set to the same value within CONTEXT_TIMEOUT.""" def within_same_context() -> bool: """Check if state change was within same context (e.g. 'Button was pressed twice').""" if self._last_set is None: self._last_set = time.time() return False new_set_time = time.time() time_diff = new_set_time - self._last_set self._last_set = new_set_time return time_diff < cast(float, self._context_timeout) if within_same_context(): if state: self._count_set_on = self._count_set_on + 1 return self._count_set_on self._count_set_off = self._count_set_off + 1 return self._count_set_off if state: self._count_set_on = 1 self._count_set_off = 0 else: self._count_set_on = 0 self._count_set_off = 1 return 1 def process_group_write(self, telegram: Telegram) -> None: """Process incoming and outgoing GROUP WRITE telegram.""" if self.remote_value.process(telegram, always_callback=True): self._process_reset_after() def process_group_response(self, telegram: Telegram) -> None: """Process incoming GroupValueResponse telegrams.""" if self.remote_value.process(telegram, always_callback=False): self._process_reset_after() def _process_reset_after(self) -> None: """Create Task for resetting state if 'reset_after' is configured.""" if self.reset_after is not None and self.state: self._reset_task = self.xknx.task_registry.register( name=f"binary_sensor.reset_{id(self)}", async_func=partial(self._reset_state, self.reset_after), track_task=True, ).start() async def _reset_state(self, wait_seconds: float) -> None: await asyncio.sleep(wait_seconds) self._set_internal_state(False) def is_on(self) -> bool: """Return if binary sensor is 'on'.""" return bool(self.state) def is_off(self) -> bool: """Return if binary sensor is 'off'.""" return not self.state def __str__(self) -> str: """Return object as readable string.""" return ( f'" ) xknx-3.6.0/xknx/devices/climate.py000066400000000000000000000343601475530762600171370ustar00rootroot00000000000000""" Module for managing the climate within a room. * It reads/listens to a temperature address from KNX bus. * Manages and sends the desired setpoint to KNX bus. """ from __future__ import annotations from collections.abc import Iterator import logging from typing import TYPE_CHECKING, Any from xknx.devices.fan import FanSpeedMode from xknx.remote_value import ( GroupAddressesType, RemoteValue, RemoteValueDptValue1Ucount, RemoteValueNumeric, RemoteValueScaling, RemoteValueSetpointShift, RemoteValueSwitch, RemoteValueTemp, ) from xknx.remote_value.remote_value_setpoint_shift import SetpointShiftMode from .climate_mode import ClimateMode from .device import Device, DeviceCallbackType if TYPE_CHECKING: from xknx.telegram import Telegram from xknx.telegram.address import DeviceGroupAddress from xknx.xknx import XKNX logger = logging.getLogger("xknx.log") DEFAULT_SETPOINT_SHIFT_MAX = 6 DEFAULT_SETPOINT_SHIFT_MIN = -6 DEFAULT_TEMPERATURE_STEP = 0.1 class Climate(Device): """Class for managing the climate.""" def __init__( self, xknx: XKNX, name: str, group_address_temperature: GroupAddressesType = None, group_address_target_temperature: GroupAddressesType = None, group_address_target_temperature_state: GroupAddressesType = None, group_address_setpoint_shift: GroupAddressesType = None, group_address_setpoint_shift_state: GroupAddressesType = None, setpoint_shift_mode: SetpointShiftMode | None = None, setpoint_shift_max: float = DEFAULT_SETPOINT_SHIFT_MAX, setpoint_shift_min: float = DEFAULT_SETPOINT_SHIFT_MIN, temperature_step: float = DEFAULT_TEMPERATURE_STEP, group_address_on_off: GroupAddressesType = None, group_address_on_off_state: GroupAddressesType = None, on_off_invert: bool = False, group_address_active_state: GroupAddressesType = None, group_address_command_value_state: GroupAddressesType = None, sync_state: bool | int | float | str = True, min_temp: float | None = None, max_temp: float | None = None, mode: ClimateMode | None = None, device_updated_cb: DeviceCallbackType[Climate] | None = None, group_address_fan_speed: GroupAddressesType = None, group_address_fan_speed_state: GroupAddressesType = None, fan_speed_mode: FanSpeedMode = FanSpeedMode.PERCENT, group_address_humidity_state: GroupAddressesType = None, group_address_swing: GroupAddressesType = None, group_address_swing_state: GroupAddressesType = None, group_address_horizontal_swing: GroupAddressesType = None, group_address_horizontal_swing_state: GroupAddressesType = None, ) -> None: """Initialize Climate class.""" super().__init__(xknx, name, device_updated_cb) self.min_temp = min_temp self.max_temp = max_temp self.setpoint_shift_min = setpoint_shift_min self.setpoint_shift_max = setpoint_shift_max self.temperature_step = temperature_step self.temperature = RemoteValueTemp( xknx, group_address_state=group_address_temperature, sync_state=sync_state, device_name=self.name, feature_name="Current temperature", after_update_cb=self.after_update, ) self.target_temperature = RemoteValueTemp( xknx, group_address_target_temperature, group_address_target_temperature_state, sync_state=sync_state, device_name=self.name, feature_name="Target temperature", after_update_cb=self.after_update, ) self._setpoint_shift = RemoteValueSetpointShift( xknx, group_address_setpoint_shift, group_address_setpoint_shift_state, sync_state=sync_state, device_name=self.name, after_update_cb=self.after_update, setpoint_shift_mode=setpoint_shift_mode, setpoint_shift_step=self.temperature_step, ) self.supports_on_off = ( group_address_on_off is not None or group_address_on_off_state is not None ) self.on = RemoteValueSwitch( # pylint: disable=invalid-name xknx, group_address_on_off, group_address_on_off_state, sync_state=sync_state, device_name=self.name, after_update_cb=self.after_update, invert=on_off_invert, ) self.active = RemoteValueSwitch( xknx, group_address_state=group_address_active_state, sync_state=sync_state, device_name=self.name, feature_name="Active", after_update_cb=self.after_update, ) self.command_value = RemoteValueScaling( xknx, group_address_state=group_address_command_value_state, sync_state=sync_state, device_name=self.name, feature_name="Command value", after_update_cb=self.after_update, ) self.fan_speed: RemoteValueDptValue1Ucount | RemoteValueScaling self.fan_speed_mode = fan_speed_mode if self.fan_speed_mode == FanSpeedMode.STEP: self.fan_speed = RemoteValueDptValue1Ucount( xknx, group_address_fan_speed, group_address_fan_speed_state, sync_state=sync_state, device_name=self.name, feature_name="Fan Speed", after_update_cb=self.after_update, ) else: self.fan_speed = RemoteValueScaling( xknx, group_address_fan_speed, group_address_fan_speed_state, sync_state=sync_state, device_name=self.name, feature_name="Fan Speed", after_update_cb=self.after_update, range_from=0, range_to=100, ) self.swing = RemoteValueSwitch( xknx, group_address_swing, group_address_swing_state, sync_state=sync_state, device_name=self.name, after_update_cb=self.after_update, ) self.horizontal_swing = RemoteValueSwitch( xknx, group_address_horizontal_swing, group_address_horizontal_swing_state, sync_state=sync_state, device_name=self.name, after_update_cb=self.after_update, ) self.mode = mode self.humidity = RemoteValueNumeric( xknx, group_address_state=group_address_humidity_state, sync_state=sync_state, value_type="humidity", device_name=self.name, feature_name="Current humidity", after_update_cb=self.after_update, ) def _iter_remote_values(self) -> Iterator[RemoteValue[Any]]: """Iterate the devices RemoteValue classes.""" yield self.temperature yield self.target_temperature yield self._setpoint_shift yield self.on yield self.active yield self.command_value yield self.fan_speed yield self.swing yield self.horizontal_swing yield self.humidity def has_group_address(self, group_address: DeviceGroupAddress) -> bool: """Test if device has given group address.""" if self.mode is not None and self.mode.has_group_address(group_address): return True return super().has_group_address(group_address) @property def is_on(self) -> bool: """Return power status.""" # None will return False return bool(self.on.value) @property def is_active(self) -> bool | None: """Return if currently active. None if unknown.""" if self.active.value is not None: return self.active.value if self.command_value.value is not None: return bool(self.command_value.value) return None async def turn_on(self) -> None: """Set power status to on.""" self.on.on() async def turn_off(self) -> None: """Set power status to off.""" self.on.off() @property def initialized_for_setpoint_shift_calculations(self) -> bool: """Test if object is initialized for setpoint shift calculations.""" return ( self._setpoint_shift.initialized and self._setpoint_shift.value is not None and self.target_temperature.initialized and self.target_temperature.value is not None ) async def set_target_temperature(self, target_temperature: float) -> None: """Send new target temperature or setpoint_shift to KNX bus.""" if self.base_temperature is not None: # implies initialized_for_setpoint_shift_calculations temperature_delta = target_temperature - self.base_temperature await self.set_setpoint_shift(temperature_delta) else: validated_temp = self.validate_value( target_temperature, self.min_temp, self.max_temp ) self.target_temperature.set(validated_temp) async def set_fan_speed(self, speed: int) -> None: """Set the fan to a designated speed.""" self.fan_speed.set(speed) async def set_swing(self, swing: bool) -> None: """Set swing to the designated state.""" self.swing.set(swing) async def set_horizontal_swing(self, horizontal_swing: bool) -> None: """Set swing to the designated state.""" self.horizontal_swing.set(horizontal_swing) @property def base_temperature(self) -> float | None: """ Return the base temperature when setpoint_shift is initialized. Base temperature is the default temperature (setpoint-shift=0) for the active climate mode. As this value is usually not available via KNX, we have to derive this from the current target temperature and the current set point shift. """ # implies self.initialized_for_setpoint_shift_calculations in a mypy compatible way: if ( self.target_temperature.value is not None and self._setpoint_shift.value is not None ): return self.target_temperature.value - self._setpoint_shift.value return None @property def setpoint_shift(self) -> float | None: """Return current offset from base temperature in Kelvin.""" return self._setpoint_shift.value def validate_value( self, value: float, min_value: float | None, max_value: float | None ) -> float: """Check boundaries of temperature and return valid temperature value.""" if (min_value is not None) and (value < min_value): logger.warning("Min value exceeded at %s: %s", self.name, value) return min_value if (max_value is not None) and (value > max_value): logger.warning("Max value exceeded at %s: %s", self.name, value) return max_value return value async def set_setpoint_shift(self, offset: float) -> None: """Send new temperature offset to KNX bus.""" validated_offset = self.validate_value( offset, self.setpoint_shift_min, self.setpoint_shift_max ) base_temperature = self.base_temperature self._setpoint_shift.set(validated_offset) # broadcast new target temperature and set internally if self.target_temperature.writable and base_temperature is not None: self.target_temperature.set(base_temperature + validated_offset) @property def target_temperature_max(self) -> float | None: """Return the highest possible target temperature.""" if self.max_temp is not None: return self.max_temp if self.base_temperature is not None: # implies initialized_for_setpoint_shift_calculations return self.base_temperature + self.setpoint_shift_max return None @property def target_temperature_min(self) -> float | None: """Return the lowest possible target temperature.""" if self.min_temp is not None: return self.min_temp if self.base_temperature is not None: # implies initialized_for_setpoint_shift_calculations return self.base_temperature + self.setpoint_shift_min return None @property def current_fan_speed(self) -> int | None: """Return current speed of fan.""" return self.fan_speed.value @property def current_swing(self) -> bool | None: """Return current swing state.""" return self.swing.value @property def current_horizontal_swing(self) -> bool | None: """Return current horizontal swing state.""" return self.horizontal_swing.value def process_group_write(self, telegram: Telegram) -> None: """Process incoming and outgoing GROUP WRITE telegram.""" for remote_value in self._iter_remote_values(): remote_value.process(telegram) if self.mode is not None: self.mode.process_group_write(telegram) async def sync(self, wait_for_result: bool = False) -> None: """Read states of device from KNX bus.""" await super().sync(wait_for_result=wait_for_result) if self.mode is not None: await self.mode.sync(wait_for_result=wait_for_result) def __str__(self) -> str: """Return object as readable string.""" return ( f'" ) xknx-3.6.0/xknx/devices/climate_mode.py000066400000000000000000000307401475530762600201410ustar00rootroot00000000000000""" Module for managing operation and controller modes. Operation modes can be 'auto', 'comfort', 'standby', 'economy', 'protection' and use either a binary DPT or DPT 20.102. Controller modes use DPT 20.105. """ from __future__ import annotations from collections.abc import Iterator from typing import TYPE_CHECKING, Any from xknx.dpt.dpt_1 import HeatCool from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode, HVACStatus from xknx.exceptions import DeviceIllegalValue from xknx.remote_value import GroupAddressesType from xknx.remote_value.remote_value_climate_mode import ( RemoteValueBinaryHeatCool, RemoteValueBinaryOperationMode, RemoteValueClimateModeBase, RemoteValueControllerMode, RemoteValueHVACStatus, RemoteValueOperationMode, ) from .device import Device, DeviceCallbackType if TYPE_CHECKING: from xknx.telegram import Telegram from xknx.xknx import XKNX class ClimateMode(Device): """Class for managing the climate mode.""" def __init__( self, xknx: XKNX, name: str, group_address_operation_mode: GroupAddressesType = None, group_address_operation_mode_state: GroupAddressesType = None, group_address_operation_mode_protection: GroupAddressesType = None, group_address_operation_mode_economy: GroupAddressesType = None, group_address_operation_mode_comfort: GroupAddressesType = None, group_address_operation_mode_standby: GroupAddressesType = None, group_address_controller_status: GroupAddressesType = None, group_address_controller_status_state: GroupAddressesType = None, group_address_controller_mode: GroupAddressesType = None, group_address_controller_mode_state: GroupAddressesType = None, group_address_heat_cool: GroupAddressesType = None, group_address_heat_cool_state: GroupAddressesType = None, sync_state: bool | int | float | str = True, operation_modes: list[str | HVACOperationMode] | None = None, controller_modes: list[str | HVACControllerMode] | None = None, device_updated_cb: DeviceCallbackType[ClimateMode] | None = None, ) -> None: """Initialize ClimateMode class.""" super().__init__(xknx, name, device_updated_cb) self.remote_value_operation_mode = RemoteValueOperationMode( xknx, group_address=group_address_operation_mode, group_address_state=group_address_operation_mode_state, sync_state=sync_state, device_name=name, feature_name="Operation mode", after_update_cb=self._set_internal_operation_mode, ) self.remote_value_controller_mode = RemoteValueControllerMode( xknx, group_address=group_address_controller_mode, group_address_state=group_address_controller_mode_state, sync_state=sync_state, device_name=name, feature_name="Controller mode", after_update_cb=self._set_internal_controller_mode, ) self.remote_value_controller_status = RemoteValueHVACStatus( xknx, group_address=group_address_controller_status, group_address_state=group_address_controller_status_state, sync_state=sync_state, device_name=name, feature_name="Controller status", after_update_cb=self._set_internal_modes_from_status, ) self.remote_value_operation_mode_comfort = RemoteValueBinaryOperationMode( xknx, group_address=group_address_operation_mode_comfort, group_address_state=group_address_operation_mode_comfort, sync_state=sync_state, device_name=name, feature_name="Operation mode Comfort", operation_mode=HVACOperationMode.COMFORT, after_update_cb=self._set_internal_operation_mode, ) self.remote_value_operation_mode_standby = RemoteValueBinaryOperationMode( xknx, group_address=group_address_operation_mode_standby, group_address_state=group_address_operation_mode_standby, sync_state=sync_state, device_name=name, feature_name="Operation mode Standby", operation_mode=HVACOperationMode.STANDBY, after_update_cb=self._set_internal_operation_mode, ) self.remote_value_operation_mode_economy = RemoteValueBinaryOperationMode( xknx, group_address=group_address_operation_mode_economy, group_address_state=group_address_operation_mode_economy, sync_state=sync_state, device_name=name, feature_name="Operation mode Economy", operation_mode=HVACOperationMode.ECONOMY, after_update_cb=self._set_internal_operation_mode, ) self.remote_value_operation_mode_protection = RemoteValueBinaryOperationMode( xknx, group_address=group_address_operation_mode_protection, group_address_state=group_address_operation_mode_protection, sync_state=sync_state, device_name=name, feature_name="Operation mode Protection", operation_mode=HVACOperationMode.BUILDING_PROTECTION, after_update_cb=self._set_internal_operation_mode, ) self.remote_value_heat_cool = RemoteValueBinaryHeatCool( xknx, group_address=group_address_heat_cool, group_address_state=group_address_heat_cool_state, sync_state=sync_state, device_name=name, feature_name="Heat/Cool", controller_mode=HVACControllerMode.HEAT, after_update_cb=self._set_internal_controller_mode, ) self.operation_mode = HVACOperationMode.STANDBY self.controller_mode = HVACControllerMode.HEAT self._operation_modes = self.gather_operation_modes(only_writable=True) if operation_modes is not None: custom_operation_modes = [] for op_mode in operation_modes: if isinstance(op_mode, str): custom_operation_modes.append( HVACOperationMode[op_mode.replace(" ", "_").upper()] ) elif isinstance(op_mode, HVACOperationMode): custom_operation_modes.append(op_mode) self._operation_modes = [ mode for mode in custom_operation_modes if mode in self._operation_modes ] self._controller_modes = self.gather_controller_modes(only_writable=True) if controller_modes is not None: custom_controller_modes = [] for ct_mode in controller_modes: if isinstance(ct_mode, str): custom_controller_modes.append( HVACControllerMode[ct_mode.replace(" ", "_").upper()] ) elif isinstance(ct_mode, HVACControllerMode): custom_controller_modes.append(ct_mode) self._controller_modes = [ mode for mode in custom_controller_modes if mode in self._controller_modes ] self.supports_operation_mode = bool( self.gather_operation_modes(only_writable=False) ) self.supports_controller_mode = bool( self.gather_controller_modes(only_writable=False) ) def _iter_remote_values( self, ) -> Iterator[RemoteValueClimateModeBase[Any]]: """Iterate climate mode RemoteValue classes.""" yield self.remote_value_operation_mode yield self.remote_value_controller_mode yield self.remote_value_controller_status yield self.remote_value_heat_cool yield self.remote_value_operation_mode_comfort yield self.remote_value_operation_mode_economy yield self.remote_value_operation_mode_protection yield self.remote_value_operation_mode_standby def _set_internal_operation_mode( self, operation_mode: HVACOperationMode | None ) -> None: """Set internal value of operation mode. Call hooks if operation mode was changed.""" if operation_mode is not None and operation_mode != self.operation_mode: self.operation_mode = operation_mode self.after_update() def _set_internal_controller_mode( self, controller_mode: HVACControllerMode ) -> None: """Set internal value of controller mode. Call hooks if controller mode was changed.""" if controller_mode != self.controller_mode: self.controller_mode = controller_mode self.after_update() def _set_internal_modes_from_status(self, status: HVACStatus) -> None: """Set internal values from HVACStatus.""" updated = False if status.mode != self.operation_mode: self.operation_mode = status.mode updated = True contr_mode_heat_cool = ( HVACControllerMode.HEAT if status.heat_cool is HeatCool.HEAT else HVACControllerMode.COOL ) if contr_mode_heat_cool != self.controller_mode: self.controller_mode = contr_mode_heat_cool updated = True if updated: self.after_update() async def set_operation_mode(self, operation_mode: HVACOperationMode) -> None: """Set the operation mode of a thermostat. Send new operation_mode to BUS and update internal state.""" if ( not self.supports_operation_mode or operation_mode not in self._operation_modes ): raise DeviceIllegalValue( "operation (preset) mode not supported", str(operation_mode) ) for rv in self._iter_remote_values(): if rv.writable: rv.set_operation_mode(operation_mode) self._set_internal_operation_mode(operation_mode) async def set_controller_mode(self, controller_mode: HVACControllerMode) -> None: """Set the controller mode of a thermostat. Send new controller mode to the bus and update internal state.""" if ( not self.supports_controller_mode or controller_mode not in self._controller_modes ): raise DeviceIllegalValue( "controller (HVAC) mode not supported", str(controller_mode) ) for rv in self._iter_remote_values(): if rv.writable: rv.set_controller_mode(controller_mode) self._set_internal_controller_mode(controller_mode) @property def operation_modes(self) -> list[HVACOperationMode]: """Return all configured operation modes.""" return self._operation_modes @property def controller_modes(self) -> list[HVACControllerMode]: """Return all configured controller modes.""" return self._controller_modes def gather_operation_modes( self, only_writable: bool = True ) -> list[HVACOperationMode]: """Gather operation modes from RemoteValues.""" operation_modes: list[HVACOperationMode] = [] for rv in self._iter_remote_values(): if rv.initialized: if only_writable and not rv.writable: continue operation_modes.extend(rv.supported_operation_modes()) # remove duplicates return list(set(operation_modes)) def gather_controller_modes( self, only_writable: bool = True ) -> list[HVACControllerMode]: """Gather controller modes from RemoteValues.""" controller_modes: list[HVACControllerMode] = [] for rv in self._iter_remote_values(): if rv.initialized: if only_writable and not rv.writable: continue controller_modes.extend(rv.supported_controller_modes()) # remove duplicates return list(set(controller_modes)) def process_group_write(self, telegram: Telegram) -> None: """Process incoming and outgoing GROUP WRITE telegram.""" for rv in self._iter_remote_values(): rv.process(telegram) def __str__(self) -> str: """Return object as readable string.""" return ( f'" ) xknx-3.6.0/xknx/devices/cover.py000066400000000000000000000377321475530762600166450ustar00rootroot00000000000000""" Module for managing a cover via KNX. It provides functionality for * moving cover up/down or to a specific position * reading the current state from KNX bus. * Cover will also predict the current position. """ from __future__ import annotations import asyncio from collections.abc import Iterator import logging from typing import TYPE_CHECKING, Any, Final from xknx.core import Task from xknx.remote_value import ( GroupAddressesType, RemoteValue, RemoteValueScaling, RemoteValueStep, RemoteValueSwitch, RemoteValueUpDown, ) from .device import Device, DeviceCallbackType from .travelcalculator import TravelCalculator, TravelStatus if TYPE_CHECKING: from xknx.telegram import Telegram from xknx.xknx import XKNX logger = logging.getLogger("xknx.log") DEFAULT_TRAVEL_TIME: Final = 25 TRAVELING_CALLBACK_INTERVAL: Final = 1 class Cover(Device): """Class for managing a cover.""" def __init__( self, xknx: XKNX, name: str, group_address_long: GroupAddressesType = None, group_address_short: GroupAddressesType = None, group_address_stop: GroupAddressesType = None, group_address_position: GroupAddressesType = None, group_address_position_state: GroupAddressesType = None, group_address_angle: GroupAddressesType = None, group_address_angle_state: GroupAddressesType = None, group_address_locked_state: GroupAddressesType = None, sync_state: bool | int | float | str = True, travel_time_down: float = DEFAULT_TRAVEL_TIME, travel_time_up: float = DEFAULT_TRAVEL_TIME, invert_updown: bool = False, invert_position: bool = False, invert_angle: bool = False, device_updated_cb: DeviceCallbackType[Cover] | None = None, ) -> None: """Initialize Cover class.""" super().__init__(xknx, name, device_updated_cb) # self.after_update for position changes is called after updating the # travelcalculator (in process_group_write and set_*) - angle changes # are updated from RemoteValue objects self.updown = RemoteValueUpDown( xknx, group_address_long, device_name=self.name, after_update_cb=None, invert=invert_updown, ) self.step = RemoteValueStep( xknx, group_address_short, device_name=self.name, after_update_cb=self.after_update, invert=invert_updown, ) self.stop_ = RemoteValueSwitch( xknx, group_address=group_address_stop, sync_state=False, device_name=self.name, after_update_cb=None, ) position_range_from = 100 if invert_position else 0 position_range_to = 0 if invert_position else 100 self.position_current = RemoteValueScaling( xknx, group_address_state=group_address_position_state, sync_state=sync_state, device_name=self.name, feature_name="Position", after_update_cb=self._current_position_from_rv, range_from=position_range_from, range_to=position_range_to, ) self.position_target = RemoteValueScaling( xknx, group_address=group_address_position, device_name=self.name, feature_name="Target position", after_update_cb=self._target_position_from_rv, range_from=position_range_from, range_to=position_range_to, ) angle_range_from = 100 if invert_angle else 0 angle_range_to = 0 if invert_angle else 100 self.angle = RemoteValueScaling( xknx, group_address_angle, group_address_angle_state, sync_state=sync_state, device_name=self.name, feature_name="Tilt angle", after_update_cb=self.after_update, range_from=angle_range_from, range_to=angle_range_to, ) self.locked = RemoteValueSwitch( xknx, group_address_state=group_address_locked_state, sync_state=sync_state, device_name=self.name, feature_name="Locked", after_update_cb=self.after_update, ) self.travel_time_down = travel_time_down self.travel_time_up = travel_time_up self.travelcalculator = TravelCalculator(travel_time_down, travel_time_up) self._auto_stop_task: Task | None = None self._periodic_update_task: Task | None = None self._travel_direction_tilt: TravelStatus | None = None def _iter_remote_values(self) -> Iterator[RemoteValue[Any]]: """Iterate the devices RemoteValue classes.""" yield self.updown yield self.step yield self.stop_ yield self.position_current yield self.position_target yield self.angle yield self.locked def async_remove_tasks(self) -> None: """Remove async tasks of device.""" if self._auto_stop_task is not None: self.xknx.task_registry.unregister(self._auto_stop_task.name) self._auto_stop_task = None if self._periodic_update_task is not None: self.xknx.task_registry.unregister(self._periodic_update_task.name) self._periodic_update_task = None async def set_down(self) -> None: """Move cover down.""" if self.updown.writable: self.updown.down() self._travel_direction_tilt = None self._start_position_update( target_position=self.travelcalculator.position_closed ) elif self.position_target.writable: self.position_target.set(self.travelcalculator.position_closed) async def set_up(self) -> None: """Move cover up.""" if self.updown.writable: self.updown.up() self._travel_direction_tilt = None self._start_position_update( target_position=self.travelcalculator.position_open ) elif self.position_target.writable: self.position_target.set(self.travelcalculator.position_open) async def set_short_down(self) -> None: """Move cover short down.""" self.step.increase() async def set_short_up(self) -> None: """Move cover short up.""" self.step.decrease() async def stop(self) -> None: """Stop cover.""" if self.stop_.writable: self.stop_.on() elif self.step.writable: if TravelStatus.DIRECTION_UP in ( self.travelcalculator.travel_direction, self._travel_direction_tilt, ): self.step.decrease() elif TravelStatus.DIRECTION_DOWN in ( self.travelcalculator.travel_direction, self._travel_direction_tilt, ): self.step.increase() else: logger.warning("Stop not supported for device %s", self.get_name()) return self._travel_direction_tilt = None self._stop_position_update() async def set_position(self, position: int) -> None: """Move cover to a desginated position.""" if self.position_target.writable: self.position_target.set(position) return # No direct positioning group address defined # fully open or close is always possible even if current position is not known current_position = self.travelcalculator.current_position() if current_position is None: if position == self.travelcalculator.position_open: self.updown.up() elif position == self.travelcalculator.position_closed: self.updown.down() else: logger.warning( "Current position unknown. Initialize cover by moving to end position." ) return self._start_position_update(target_position=position) return if position < current_position: self.updown.up() elif position > current_position: self.updown.down() self._start_position_update(target_position=position) # If device does not support auto_positioning, # we have to stop the device when position is reached, # unless device was traveling to fully open # or fully closed state. if ( self.supports_stop and self.travelcalculator.position_open < position < self.travelcalculator.position_closed ): stop_in_seconds = self.travelcalculator.calculate_travel_time( from_position=current_position, to_position=position ) async def auto_stopper() -> None: await asyncio.sleep(stop_in_seconds) # stop() calls stop_position_update() which cancels this task asyncio.shield(self.stop()) self._auto_stop_task = self.xknx.task_registry.register( name=f"cover.auto_stopper_{id(self)}", async_func=auto_stopper, ).start() def _start_position_update(self, target_position: int) -> None: """Start the travel calculator and run device callbacks.""" self.travelcalculator.start_travel(target_position) self.after_update() if self.travelcalculator.is_traveling(): self._start_auto_updater() def _start_auto_updater(self) -> None: """Start calling callback periodically while traveling.""" async def periodic_updater() -> None: """Run callback periodically while traveling.""" while self.travelcalculator.is_traveling(): await asyncio.sleep(TRAVELING_CALLBACK_INTERVAL) if self.travelcalculator.is_traveling(): # else _stop_position_update will call after_update a second time self.after_update() self._stop_position_update() # restarts when already running self._periodic_update_task = self.xknx.task_registry.register( name=f"cover.periodic_update_{id(self)}", async_func=periodic_updater, ).start() def _stop_position_update(self) -> None: """Stop the travel calculator and periodic device callbacks.""" if not self.travelcalculator.position_reached(): self.travelcalculator.stop() if self._periodic_update_task: self._periodic_update_task.cancel() self._periodic_update_task = None if self._auto_stop_task: self._auto_stop_task.cancel() self._auto_stop_task = None self.after_update() def _target_position_from_rv(self, new_target_postion: int) -> None: """Update the target position from RemoteValue (Callback).""" self._start_position_update(target_position=new_target_postion) def _current_position_from_rv(self, new_position: int) -> None: """Update the current position from RemoteValue (Callback).""" position_before_update = self.travelcalculator.current_position() if self.is_traveling(): self.travelcalculator.update_position(new_position) else: self.travelcalculator.set_position(new_position) if position_before_update != self.travelcalculator.current_position(): if position_before_update is not None: # None on first move self._start_auto_updater() # to restart the periodic updater self.after_update() async def set_angle(self, angle: int) -> None: """Move cover to designated angle.""" if not self.supports_angle: logger.warning("Angle not supported for device %s", self.get_name()) return current_angle = self.current_angle() self._travel_direction_tilt = ( TravelStatus.DIRECTION_DOWN if current_angle is not None and angle >= current_angle else TravelStatus.DIRECTION_UP ) self.angle.set(angle) async def sync(self, wait_for_result: bool = False) -> None: """Read states of device from KNX bus.""" await self.position_current.read_state(wait_for_result=wait_for_result) await self.angle.read_state(wait_for_result=wait_for_result) def process_group_write(self, telegram: Telegram) -> None: """Process incoming and outgoing GROUP WRITE telegram.""" # call after_update to account for travelcalculator changes if self.updown.process(telegram): if ( not self.is_opening() and self.updown.value == RemoteValueUpDown.Direction.UP ): self._start_position_update( target_position=self.travelcalculator.position_open ) elif ( not self.is_closing() and self.updown.value == RemoteValueUpDown.Direction.DOWN ): self._start_position_update( target_position=self.travelcalculator.position_closed ) # stop from bus if self.stop_.process(telegram) or self.step.process(telegram): if self.is_traveling(): self._stop_position_update() self.position_current.process(telegram, always_callback=True) self.position_target.process(telegram, always_callback=True) self.angle.process(telegram) self.locked.process(telegram) def current_position(self) -> int | None: """Return current position of cover.""" return self.travelcalculator.current_position() def current_angle(self) -> int | None: """Return current tilt angle of cover.""" return self.angle.value def is_locked(self) -> bool | None: """Return if the cover is currently locked for manual movement.""" return self.locked.value def is_traveling(self) -> bool: """Return if cover is traveling at the moment.""" return self.travelcalculator.is_traveling() def position_reached(self) -> bool: """Return if cover has reached its final position.""" return self.travelcalculator.position_reached() def is_open(self) -> bool: """Return if cover is open.""" return self.travelcalculator.is_open() def is_closed(self) -> bool: """Return if cover is closed.""" return self.travelcalculator.is_closed() def is_opening(self) -> bool: """Return if the cover is opening or not.""" return self.travelcalculator.is_opening() def is_closing(self) -> bool: """Return if the cover is closing or not.""" return self.travelcalculator.is_closing() @property def supports_stop(self) -> bool: """Return if cover supports manual stopping.""" return self.stop_.writable or self.step.writable @property def supports_locked(self) -> bool: """Return if cover supports locking.""" return self.locked.initialized @property def supports_position(self) -> bool: """Return if cover supports direct positioning.""" return self.position_target.initialized @property def supports_angle(self) -> bool: """Return if cover supports tilt angle.""" return self.angle.initialized def __str__(self) -> str: """Return object as readable string.""" return ( f'" ) xknx-3.6.0/xknx/devices/datetime.py000066400000000000000000000204771475530762600173210ustar00rootroot00000000000000"""Module for broadcasting date/time to the KNX bus, optionally broadcasting localtime periodically.""" from __future__ import annotations from abc import abstractmethod import asyncio from collections.abc import Iterator import datetime from functools import partial import logging import time from typing import TYPE_CHECKING, Any, Generic, TypeVar from xknx.core import Task from xknx.dpt.dpt_10 import KNXDay, KNXTime from xknx.dpt.dpt_11 import KNXDate from xknx.dpt.dpt_19 import KNXDateTime, KNXDayOfWeek from xknx.remote_value import ( GroupAddressesType, RemoteValue, RemoteValueDate, RemoteValueDateTime, RemoteValueTime, ) from xknx.typing import Self from .device import Device, DeviceCallbackType if TYPE_CHECKING: from xknx.telegram import Telegram from xknx.xknx import XKNX logger = logging.getLogger("xknx.log") BROADCAST_MINUTES = 60 _RemoteValueTimeT = TypeVar( "_RemoteValueTimeT", RemoteValueTime, RemoteValueDate, RemoteValueDateTime ) class _DateTimeBase(Device, Generic[_RemoteValueTimeT]): """Base class for virtual date/time device.""" _remote_value_cls: type[_RemoteValueTimeT] # set in subclass remote_value: _RemoteValueTimeT def __init__( self, xknx: XKNX, name: str, localtime: bool | datetime.tzinfo = True, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, respond_to_read: bool = False, sync_state: bool | int | float | str = True, device_updated_cb: DeviceCallbackType[Self] | None = None, ) -> None: """Initialize DateTime class.""" super().__init__(xknx, name, device_updated_cb) self.localtime = bool(localtime) self._localtime_zone = ( localtime if isinstance(localtime, datetime.tzinfo) else None ) if localtime and group_address_state is not None: logger.warning( "State address invalid in %s device when using `localtime=True`. Ignoring `group_address_state=%s` argument.", self.__class__.__name__, group_address_state, ) # state address invalid for localtime - therefore sync_state doesn't apply for localtime group_address_state = None self.respond_to_read = respond_to_read self.remote_value = self._remote_value_cls( xknx, group_address=group_address, group_address_state=group_address_state, sync_state=sync_state, device_name=name, after_update_cb=self.after_update, ) self._broadcast_task: Task | None = None def _iter_remote_values(self) -> Iterator[RemoteValue[Any]]: """Iterate the devices RemoteValue classes.""" yield self.remote_value @property @abstractmethod def value(self) -> datetime.datetime | datetime.date | datetime.time | None: """Return the current date/time value.""" def async_start_tasks(self) -> None: """Create an asyncio.Task for broadcasting local time periodically if `localtime` is set.""" if not self.localtime: return None async def broadcast_loop(self: Self, minutes: int) -> None: """Endless loop for broadcasting local time.""" while True: self.broadcast_localtime() await asyncio.sleep(minutes * 60) self._broadcast_task = self.xknx.task_registry.register( name=f"datetime.broadcast_{id(self)}", async_func=partial(broadcast_loop, self, BROADCAST_MINUTES), restart_after_reconnect=True, ).start() def async_remove_tasks(self) -> None: """Stop background tasks of device.""" if self._broadcast_task is not None: self.xknx.task_registry.unregister(self._broadcast_task.name) self._broadcast_task = None @abstractmethod def broadcast_localtime(self, response: bool = False) -> None: """Broadcast the local time to KNX bus.""" # self.remote_value.set(now, response=response) @abstractmethod async def set(self, value: Any) -> None: """Set time and send to KNX bus.""" # self.remote_value.set(value) def process_group_write(self, telegram: Telegram) -> None: """Process incoming and outgoing GROUP WRITE telegram.""" self.remote_value.process(telegram) def process_group_read(self, telegram: Telegram) -> None: """Process incoming GROUP READ telegram.""" if self.localtime: self.broadcast_localtime(True) elif ( self.respond_to_read and telegram.destination_address == self.remote_value.group_address ): self.remote_value.respond() async def sync(self, wait_for_result: bool = False) -> None: """Read state of device from KNX bus. Used here to broadcast time to KNX bus.""" if self.localtime: self.broadcast_localtime(response=False) else: await super().sync(wait_for_result) def __str__(self) -> str: """Return object as readable string.""" return ( f'<{self.__class__.__name__} name="{self.name}" ' f"remote_value={self.remote_value.group_addr_str()} />" ) class TimeDevice(_DateTimeBase[RemoteValueTime]): """Class for virtual time device.""" _remote_value_cls = RemoteValueTime @property def value(self) -> datetime.time | None: """Return the current time value.""" return self.remote_value.value.as_time() if self.remote_value.value else None async def set(self, value: KNXTime | datetime.time) -> None: """Set time and send to KNX bus.""" if isinstance(value, datetime.time): value = KNXTime.from_time(value) self.remote_value.set(value) def broadcast_localtime(self, response: bool = False) -> None: """Broadcast the local time to KNX bus.""" now = datetime.datetime.now(self._localtime_zone) knx_time = KNXTime.from_time(now.time()) knx_time.day = KNXDay(now.weekday() + 1) self.remote_value.set(knx_time, response=response) class DateDevice(_DateTimeBase[RemoteValueDate]): """Class for virtual date device.""" _remote_value_cls = RemoteValueDate @property def value(self) -> datetime.date | None: """Return the current time value.""" return self.remote_value.value.as_date() if self.remote_value.value else None async def set(self, value: KNXDate | datetime.date) -> None: """Set date and send to KNX bus.""" if isinstance(value, datetime.date): value = KNXDate.from_date(value) self.remote_value.set(value) def broadcast_localtime(self, response: bool = False) -> None: """Broadcast the local date to KNX bus.""" now = datetime.datetime.now(self._localtime_zone) self.remote_value.set(KNXDate.from_date(now.date()), response=response) class DateTimeDevice(_DateTimeBase[RemoteValueDateTime]): """Class for virtual date/time device.""" _remote_value_cls = RemoteValueDateTime timezone: datetime.tzinfo | None = None @property def value(self) -> datetime.datetime | None: """Return the current time value.""" return ( self.remote_value.value.as_datetime() if self.remote_value.value else None ) async def set(self, value: KNXDateTime | datetime.datetime) -> None: """Set date/time and send to KNX bus.""" if isinstance(value, datetime.datetime): value = KNXDateTime.from_datetime(value) self.remote_value.set(value) def broadcast_localtime(self, response: bool = False) -> None: """Broadcast the local date/time to KNX bus.""" now = datetime.datetime.now(self._localtime_zone) knx_datetime = KNXDateTime.from_datetime(now) knx_datetime.day_of_week = KNXDayOfWeek(now.weekday() + 1) if self._localtime_zone is not None: dst = self._localtime_zone.dst(now) knx_datetime.dst = dst > datetime.timedelta(0) if dst is not None else False else: time_now = time.localtime() knx_datetime.dst = time_now.tm_isdst > 0 knx_datetime.external_sync = True knx_datetime.source_reliable = True self.remote_value.set(knx_datetime, response=response) xknx-3.6.0/xknx/devices/device.py000066400000000000000000000114241475530762600167540ustar00rootroot00000000000000""" Device is the base class for all implemented devices (e.g. Lights/Switches/Sensors). It provides basis functionality for reading the state from the KNX bus. """ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Iterator import logging from typing import TYPE_CHECKING, Any from xknx.remote_value import RemoteValue from xknx.telegram import Telegram from xknx.telegram.address import DeviceGroupAddress from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite from xknx.typing import DeviceCallbackType, Self if TYPE_CHECKING: from xknx.xknx import XKNX logger = logging.getLogger("xknx.log") class Device(ABC): """Base class for devices.""" def __init__( self, xknx: XKNX, name: str, device_updated_cb: DeviceCallbackType[Self] | None = None, ) -> None: """Initialize Device class.""" self.xknx = xknx self.name = name self.device_updated_cbs: list[DeviceCallbackType[Self]] = [] if device_updated_cb is not None: self.register_device_updated_cb(device_updated_cb) def register_state_updater(self) -> None: """Register device addresses for StateUpdater.""" for remote_value in self._iter_remote_values(): remote_value.register_state_updater() def unregister_state_updater(self) -> None: """Unregister device addresses from StateUpdater.""" for remote_value in self._iter_remote_values(): remote_value.unregister_state_updater() def async_start_tasks(self) -> None: """Start async background tasks of device.""" return def async_remove_tasks(self) -> None: """Remove all tasks of device.""" return @abstractmethod def _iter_remote_values(self) -> Iterator[RemoteValue[Any]]: """Iterate the devices RemoteValue classes.""" # yield self.remote_value # yield from () yield from () def register_device_updated_cb( self, device_updated_cb: DeviceCallbackType[Self] ) -> None: """Register device updated callback.""" self.device_updated_cbs.append(device_updated_cb) def unregister_device_updated_cb( self, device_updated_cb: DeviceCallbackType[Self] ) -> None: """Unregister device updated callback.""" if device_updated_cb in self.device_updated_cbs: self.device_updated_cbs.remove(device_updated_cb) def after_update( self: Self, *args: Any, # a single argument may be passed if used as a RemoteValue callback ) -> None: """Execute callbacks after internal state has been changed.""" for device_callback in self.device_updated_cbs: try: device_callback(self) except Exception: # pylint: disable=broad-except logger.exception( "Unexpected error while processing device_updated_cb for %s", self, ) async def sync(self, wait_for_result: bool = False) -> None: """Read states of device from KNX bus.""" for remote_value in self._iter_remote_values(): await remote_value.read_state(wait_for_result=wait_for_result) def process(self, telegram: Telegram) -> None: """Process incoming telegram.""" if isinstance(telegram.payload, GroupValueWrite): self.process_group_write(telegram) elif isinstance(telegram.payload, GroupValueResponse): self.process_group_response(telegram) elif isinstance(telegram.payload, GroupValueRead): self.process_group_read(telegram) def process_group_read(self, telegram: Telegram) -> None: """Process incoming GroupValueRead telegrams.""" # The default is, that devices don't answer to group reads return def process_group_response(self, telegram: Telegram) -> None: """Process incoming GroupValueResponse telegrams.""" # Per default mapped to group write. self.process_group_write(telegram) def process_group_write(self, telegram: Telegram) -> None: """Process incoming GroupValueWrite telegrams.""" # The default is, that devices don't process group writes return def get_name(self) -> str: """Return name of device.""" return self.name def has_group_address(self, group_address: DeviceGroupAddress) -> bool: """Test if device has given group address.""" return any( remote_value.has_group_address(group_address) for remote_value in self._iter_remote_values() ) def __eq__(self, other: object) -> bool: """Compare for quality.""" return self.__dict__ == other.__dict__ xknx-3.6.0/xknx/devices/devices.py000066400000000000000000000075501475530762600171440ustar00rootroot00000000000000""" Module for handling a vector/array of devices. More or less an array with devices. Adds some search functionality to find devices. """ from __future__ import annotations import asyncio from collections.abc import Iterator from xknx.telegram import Telegram from xknx.telegram.address import DeviceGroupAddress, GroupAddress, InternalGroupAddress from xknx.typing import DeviceCallbackType from .device import Device class Devices: """Class for handling a vector/array of devices.""" def __init__(self, started: asyncio.Event) -> None: """Initialize Devices class.""" self.started = started # xknx.started self.__devices: list[Device] = [] self.device_updated_cbs: list[DeviceCallbackType[Device]] = [] def async_start_device_tasks(self) -> None: """Start all devices tasks.""" for device in self.__devices: device.async_start_tasks() def async_remove_device_tasks(self) -> None: """Remove all devices tasks.""" for device in self.__devices: device.async_remove_tasks() def register_device_updated_cb( self, device_updated_cb: DeviceCallbackType[Device] ) -> None: """Register callback for devices being updated.""" self.device_updated_cbs.append(device_updated_cb) def unregister_device_updated_cb( self, device_updated_cb: DeviceCallbackType[Device] ) -> None: """Unregister callback for devices being updated.""" self.device_updated_cbs.remove(device_updated_cb) def __iter__(self) -> Iterator[Device]: """Iterate registered devices.""" yield from self.__devices def devices_by_group_address( self, group_address: DeviceGroupAddress ) -> Iterator[Device]: """Return device(s) by group address.""" for device in self.__devices: if device.has_group_address(group_address): yield device def __getitem__(self, key: str | int) -> Device: """Return device by name or by index.""" for device in self.__devices: if device.name == key: return device if isinstance(key, int): return self.__devices[key] raise KeyError def __len__(self) -> int: """Return number of devices within vector.""" return len(self.__devices) def __contains__(self, key: str) -> bool: """Return if devices with name 'key' is within devices.""" return any(device.name == key for device in self.__devices) def async_add(self, device: Device) -> None: """Add device to active XKNX devices.""" device.register_device_updated_cb(self.device_updated) self.__devices.append(device) device.register_state_updater() if self.started.is_set(): # start if device was added after async_start_device_tasks() / xknx.start() device.async_start_tasks() def async_remove(self, device: Device) -> None: """Remove device from XKNX devices.""" device.async_remove_tasks() device.unregister_state_updater() device.unregister_device_updated_cb(self.device_updated) self.__devices.remove(device) def device_updated(self, device: Device) -> None: """Call all registered device updated callbacks of device.""" for device_updated_cb in self.device_updated_cbs: device_updated_cb(device) def process(self, telegram: Telegram) -> None: """Process telegram.""" if isinstance( telegram.destination_address, GroupAddress | InternalGroupAddress ): for device in self.devices_by_group_address(telegram.destination_address): device.process(telegram) async def sync(self) -> None: """Read state of devices from KNX bus.""" await asyncio.gather(*[device.sync() for device in self.__devices]) xknx-3.6.0/xknx/devices/expose_sensor.py000066400000000000000000000114221475530762600204070ustar00rootroot00000000000000""" Module for exposing a (virtual) sensor to KNX bus. It provides functionality for * push local state changes to KNX bus * KNX devices may read local values via GROUP READ. (A typical example for using this class is the outside temperature read from e.g. an internet serviceand exposed to the KNX bus. KNX devices may show this value within their display.) """ from __future__ import annotations import asyncio from collections.abc import Iterator from typing import TYPE_CHECKING, Any from xknx.core import Task from xknx.dpt import DPTBase from xknx.remote_value import ( GroupAddressesType, RemoteValue, RemoteValueSensor, RemoteValueSwitch, ) from xknx.typing import DPTParsable from .device import Device, DeviceCallbackType if TYPE_CHECKING: from xknx.telegram import Telegram from xknx.xknx import XKNX class ExposeSensor(Device): """Class for managing a sensor.""" def __init__( self, xknx: XKNX, name: str, group_address: GroupAddressesType = None, respond_to_read: bool = True, value_type: DPTParsable | type[DPTBase] | None = None, cooldown: float = 0, device_updated_cb: DeviceCallbackType[ExposeSensor] | None = None, ) -> None: """Initialize Sensor class.""" super().__init__(xknx, name, device_updated_cb) self.cooldown = cooldown self.respond_to_read = respond_to_read self.sensor_value: RemoteValueSensor | RemoteValueSwitch if value_type == "binary": self.sensor_value = RemoteValueSwitch( xknx, group_address=group_address, sync_state=False, device_name=self.name, after_update_cb=self.expose_after_update, ) else: self.sensor_value = RemoteValueSensor( xknx, group_address=group_address, sync_state=False, device_name=self.name, after_update_cb=self.expose_after_update, value_type=value_type, ) self._cooldown_latest_value: Any | None = None self._cooldown_task: Task | None = None self._cooldown_task_name = f"expose_sensor.cooldown_{id(self)}" def expose_after_update(self, value: int | float | str | bool) -> None: """Call after state was updated.""" self._cooldown_latest_value = value super().after_update() def _iter_remote_values(self) -> Iterator[RemoteValue[Any]]: """Iterate the devices RemoteValue classes.""" yield self.sensor_value def async_remove_tasks(self) -> None: """Remove async tasks of device.""" if self._cooldown_task is not None: self.xknx.task_registry.unregister(self._cooldown_task.name) self._cooldown_task = None def process_group_write(self, telegram: Telegram) -> None: """Process incoming and outgoing GROUP WRITE telegram.""" self.sensor_value.process(telegram) def process_group_read(self, telegram: Telegram) -> None: """Process incoming GROUP READ telegram.""" if not self.respond_to_read: return if self._cooldown_latest_value is not None: self.sensor_value.set(self._cooldown_latest_value, response=True) return self.sensor_value.respond() async def set(self, value: Any) -> None: """Set new value.""" if self.cooldown: self._cooldown_latest_value = value if self._cooldown_task is not None and not self._cooldown_task.done(): return self._cooldown_task = self.xknx.task_registry.register( name=self._cooldown_task_name, async_func=self._cooldown_wait, ).start() self.sensor_value.set(value) async def _cooldown_wait(self) -> None: """Send value after cooldown if it differs from last processed value.""" while True: await asyncio.sleep(self.cooldown) if self.sensor_value.value == self._cooldown_latest_value: break self.sensor_value.set(self._cooldown_latest_value) # type: ignore[arg-type] def unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" return self.sensor_value.unit_of_measurement def resolve_state(self) -> Any: """Return the current state of the sensor as a human readable string.""" return self.sensor_value.value def __str__(self) -> str: """Return object as readable string.""" return ( f'' ) xknx-3.6.0/xknx/devices/fan.py000066400000000000000000000132101475530762600162540ustar00rootroot00000000000000""" Module for managing a fan via KNX. It provides functionality for * setting fan to specific speed / step * reading the current speed from KNX bus. """ from __future__ import annotations from collections.abc import Iterator from enum import Enum import logging from typing import TYPE_CHECKING, Any from xknx.remote_value import ( GroupAddressesType, RemoteValue, RemoteValueDptValue1Ucount, RemoteValueScaling, RemoteValueSwitch, ) from .device import Device, DeviceCallbackType if TYPE_CHECKING: from xknx.telegram import Telegram from xknx.xknx import XKNX DEFAULT_TURN_ON_SPEED = 50 logger = logging.getLogger("xknx.log") class FanSpeedMode(Enum): """Enum for setting the fan speed mode.""" PERCENT = 1 STEP = 2 class Fan(Device): """Class for managing a fan.""" def __init__( self, xknx: XKNX, name: str, group_address_speed: GroupAddressesType = None, group_address_speed_state: GroupAddressesType = None, group_address_oscillation: GroupAddressesType = None, group_address_oscillation_state: GroupAddressesType = None, group_address_switch: GroupAddressesType = None, group_address_switch_state: GroupAddressesType = None, sync_state: bool | int | float | str = True, device_updated_cb: DeviceCallbackType[Fan] | None = None, max_step: int | None = None, ) -> None: """Initialize fan class.""" super().__init__(xknx, name, device_updated_cb) self.speed: RemoteValueDptValue1Ucount | RemoteValueScaling self.mode = FanSpeedMode.STEP if max_step else FanSpeedMode.PERCENT self.max_step = max_step # If there is a dedicated switch GA, it controls the on/off behavior of the fan. # Otherwise the speed GA of the fan implicitly controls the on/off behavior instead. # `self.switch.initialized`` can be used to check which setup is used. self.switch = RemoteValueSwitch( xknx, group_address_switch, group_address_switch_state, sync_state=sync_state, device_name=self.name, feature_name="Switch", after_update_cb=self.after_update, ) if self.mode == FanSpeedMode.STEP: self.speed = RemoteValueDptValue1Ucount( xknx, group_address_speed, group_address_speed_state, sync_state=sync_state, device_name=self.name, feature_name="Speed", after_update_cb=self.after_update, ) else: self.speed = RemoteValueScaling( xknx, group_address_speed, group_address_speed_state, sync_state=sync_state, device_name=self.name, feature_name="Speed", after_update_cb=self.after_update, range_from=0, range_to=100, ) self.oscillation = RemoteValueSwitch( xknx, group_address_oscillation, group_address_oscillation_state, sync_state=sync_state, device_name=self.name, feature_name="Oscillation", after_update_cb=self.after_update, ) def _iter_remote_values(self) -> Iterator[RemoteValue[Any]]: """Iterate the devices RemoteValue classes.""" yield from (self.switch, self.speed, self.oscillation) @property def supports_oscillation(self) -> bool: """Return if fan supports oscillation.""" return self.oscillation.initialized @property def is_on(self) -> bool: """Return the current fan state of the device.""" if self.switch.initialized: return bool(self.switch.value) return bool(self.current_speed) async def turn_on(self, speed: int | None = None) -> None: """Turn on fan.""" if self.switch.initialized: self.switch.on() # For a switch GA fan, we only use an explicitly provided speed, but not # arbitrarily set a default speed here, compared to the speed GA based fans below. if speed is not None: await self.set_speed(speed) else: await self.set_speed(speed or DEFAULT_TURN_ON_SPEED) async def turn_off(self) -> None: """Turn off fan.""" if self.switch.initialized: self.switch.off() else: await self.set_speed(0) async def set_speed(self, speed: int) -> None: """Set the fan to a designated speed.""" self.speed.set(speed) async def set_oscillation(self, oscillation: bool) -> None: """Set the fan oscillation mode on or off.""" self.oscillation.set(oscillation) def process_group_write(self, telegram: Telegram) -> None: """Process incoming and outgoing GROUP WRITE telegram.""" self.switch.process(telegram) self.speed.process(telegram) self.oscillation.process(telegram) @property def current_speed(self) -> int | None: """Return current speed of fan.""" return self.speed.value @property def current_oscillation(self) -> bool | None: """Return true if the fan is oscillating.""" return self.oscillation.value def __str__(self) -> str: """Return object as readable string.""" str_oscillation = ( f" oscillation={self.oscillation.group_addr_str()}" if self.supports_oscillation else "" ) return f'' xknx-3.6.0/xknx/devices/light.py000066400000000000000000000624051475530762600166310ustar00rootroot00000000000000""" Module for managing a light via KNX. It provides functionality for * switching light 'on' and 'off'. * setting the brightness. * setting the color. * setting the relative color temperature (tunable white). * setting the absolute color temperature. * reading the current state from KNX bus. """ from __future__ import annotations import asyncio from collections.abc import Iterator from enum import Enum from itertools import chain import logging from typing import TYPE_CHECKING, Any, cast from xknx.dpt import RGBColor, RGBWColor, XYYColor from xknx.remote_value import ( GroupAddressesType, RemoteValue, RemoteValueColorRGB, RemoteValueColorRGBW, RemoteValueColorXYY, RemoteValueNumeric, RemoteValueScaling, RemoteValueSwitch, ) from xknx.remote_value.remote_value import RVCallbackType from .device import Device, DeviceCallbackType if TYPE_CHECKING: from xknx.telegram import Telegram from xknx.xknx import XKNX logger = logging.getLogger("xknx.log") class ColorTemperatureType(Enum): """DPT used for absolute color temperature.""" UINT_2_BYTE = "color_temperature" # DPTColorTemperature 7.600 FLOAT_2_BYTE = "2byte_float" # DPT2ByteFloat generic 9 class _SwitchAndBrightness: def __init__( self, xknx: XKNX, name: str, feature_name: str, group_address_switch: GroupAddressesType = None, group_address_switch_state: GroupAddressesType = None, group_address_brightness: GroupAddressesType = None, group_address_brightness_state: GroupAddressesType = None, sync_state: bool | int | float | str = True, after_update_cb: RVCallbackType[bool | int] | None = None, ) -> None: self.switch = RemoteValueSwitch( xknx, group_address_switch, group_address_switch_state, sync_state=sync_state, device_name=name, feature_name=f"{feature_name}_state", after_update_cb=after_update_cb, ) self.brightness = RemoteValueScaling( xknx, group_address_brightness, group_address_brightness_state, sync_state=sync_state, device_name=name, feature_name=f"{feature_name}_brightness", after_update_cb=after_update_cb, range_from=0, range_to=255, ) @property def is_on(self) -> bool | None: """Return if light is on.""" if self.switch.initialized: return self.switch.value if self.brightness.initialized and self.brightness.value is not None: return bool(self.brightness.value) return None def set_on(self) -> None: """Switch light on.""" if self.switch.writable: self.switch.on() return if self.brightness.writable: self.brightness.set(self.brightness.range_to) def set_off(self) -> None: """Switch light off.""" if self.switch.writable: self.switch.off() return if self.brightness.writable: self.brightness.set(0) def __eq__(self, other: object) -> bool: """Compare for equality.""" return self.__dict__ == other.__dict__ class Light(Device): """Class for managing a light.""" DEBOUNCE_TIMEOUT = 0.2 DEFAULT_MIN_KELVIN = 2700 # 370 mireds DEFAULT_MAX_KELVIN = 6000 # 166 mireds def __init__( self, xknx: XKNX, name: str, group_address_switch: GroupAddressesType = None, group_address_switch_state: GroupAddressesType = None, group_address_brightness: GroupAddressesType = None, group_address_brightness_state: GroupAddressesType = None, group_address_color: GroupAddressesType = None, group_address_color_state: GroupAddressesType = None, group_address_rgbw: GroupAddressesType = None, group_address_rgbw_state: GroupAddressesType = None, group_address_hue: GroupAddressesType = None, group_address_hue_state: GroupAddressesType = None, group_address_saturation: GroupAddressesType = None, group_address_saturation_state: GroupAddressesType = None, group_address_xyy_color: GroupAddressesType = None, group_address_xyy_color_state: GroupAddressesType = None, group_address_tunable_white: GroupAddressesType = None, group_address_tunable_white_state: GroupAddressesType = None, group_address_color_temperature: GroupAddressesType = None, group_address_color_temperature_state: GroupAddressesType = None, group_address_switch_red: GroupAddressesType = None, group_address_switch_red_state: GroupAddressesType = None, group_address_brightness_red: GroupAddressesType = None, group_address_brightness_red_state: GroupAddressesType = None, group_address_switch_green: GroupAddressesType = None, group_address_switch_green_state: GroupAddressesType = None, group_address_brightness_green: GroupAddressesType = None, group_address_brightness_green_state: GroupAddressesType = None, group_address_switch_blue: GroupAddressesType = None, group_address_switch_blue_state: GroupAddressesType = None, group_address_brightness_blue: GroupAddressesType = None, group_address_brightness_blue_state: GroupAddressesType = None, group_address_switch_white: GroupAddressesType = None, group_address_switch_white_state: GroupAddressesType = None, group_address_brightness_white: GroupAddressesType = None, group_address_brightness_white_state: GroupAddressesType = None, color_temperature_type: ColorTemperatureType = ColorTemperatureType.UINT_2_BYTE, sync_state: bool | int | float | str = True, min_kelvin: int | None = None, max_kelvin: int | None = None, device_updated_cb: DeviceCallbackType[Light] | None = None, ) -> None: """Initialize Light class.""" super().__init__(xknx, name, device_updated_cb) self.switch = RemoteValueSwitch( xknx, group_address_switch, group_address_switch_state, sync_state=sync_state, device_name=self.name, feature_name="State", after_update_cb=self.after_update, ) self.brightness = RemoteValueScaling( xknx, group_address_brightness, group_address_brightness_state, sync_state=sync_state, device_name=self.name, feature_name="Brightness", after_update_cb=self.after_update, range_from=0, range_to=255, ) self.color = RemoteValueColorRGB( xknx, group_address_color, group_address_color_state, sync_state=sync_state, device_name=self.name, feature_name="Color RGB", after_update_cb=self.after_update, ) self.rgbw = RemoteValueColorRGBW( xknx, group_address_rgbw, group_address_rgbw_state, sync_state=sync_state, device_name=self.name, feature_name="Color RGBW", after_update_cb=self.after_update, ) self.hue = RemoteValueNumeric( xknx, group_address_hue, group_address_hue_state, sync_state=sync_state, value_type="angle", device_name=self.name, feature_name="Hue", after_update_cb=self.after_update, ) self.saturation = RemoteValueNumeric( xknx, group_address_saturation, group_address_saturation_state, sync_state=sync_state, value_type="percent", device_name=self.name, feature_name="Saturation", after_update_cb=self.after_update, ) self._xyy_color_valid: XYYColor | None = None self.xyy_color = RemoteValueColorXYY( xknx, group_address_xyy_color, group_address_xyy_color_state, sync_state=sync_state, device_name=self.name, feature_name="Color XYY", after_update_cb=self._xyy_color_from_rv, ) self.tunable_white = RemoteValueScaling( xknx, group_address_tunable_white, group_address_tunable_white_state, sync_state=sync_state, device_name=self.name, feature_name="Tunable white", after_update_cb=self.after_update, range_from=0, range_to=255, ) self.color_temperature = RemoteValueNumeric( xknx, group_address_color_temperature, group_address_color_temperature_state, sync_state=sync_state, value_type=color_temperature_type.value, device_name=self.name, feature_name="Color temperature", after_update_cb=self.after_update, ) self.red = _SwitchAndBrightness( xknx, self.name, "red", group_address_switch_red, group_address_switch_red_state, group_address_brightness_red, group_address_brightness_red_state, sync_state=sync_state, after_update_cb=self._individual_color_callback_debounce, ) self.green = _SwitchAndBrightness( xknx, self.name, "green", group_address_switch_green, group_address_switch_green_state, group_address_brightness_green, group_address_brightness_green_state, sync_state=sync_state, after_update_cb=self._individual_color_callback_debounce, ) self.blue = _SwitchAndBrightness( xknx, self.name, "blue", group_address_switch_blue, group_address_switch_blue_state, group_address_brightness_blue, group_address_brightness_blue_state, sync_state=sync_state, after_update_cb=self._individual_color_callback_debounce, ) self.white = _SwitchAndBrightness( xknx, self.name, "white", group_address_switch_white, group_address_switch_white_state, group_address_brightness_white, group_address_brightness_white_state, sync_state=sync_state, after_update_cb=self._individual_color_callback_debounce, ) self.min_kelvin = min_kelvin self.max_kelvin = max_kelvin self._individual_color_debounce_task_name = ( f"{id(self)}_individual_color_debounce" ) self._individual_color_debounce_telegram_counter: int self._reset_individual_color_debounce_telegrams() def _iter_remote_values(self) -> Iterator[RemoteValue[Any]]: """Iterate the devices RemoteValue classes.""" return chain( self._iter_instant_remote_values(), self._iter_debounce_remote_values(), ) def _iter_instant_remote_values(self) -> Iterator[RemoteValue[Any]]: """Iterate the devices RemoteValue classes calling after_update_cb immediately.""" yield self.switch yield self.brightness yield self.color yield self.rgbw yield self.hue yield self.saturation yield self.xyy_color yield self.tunable_white yield self.color_temperature def _iter_debounce_remote_values(self) -> Iterator[RemoteValue[Any]]: """Iterate the devices RemoteValue classes debouncing after_update_cb.""" for color in self._iter_individual_colors(): yield color.switch yield color.brightness def _iter_individual_colors(self) -> Iterator[_SwitchAndBrightness]: """Iterate the devices individual colors.""" yield from (self.red, self.green, self.blue, self.white) def _reset_individual_color_debounce_telegrams(self) -> None: """Reset individual color debounce telegram counter.""" self._individual_color_debounce_telegram_counter = sum( ( self.red.switch.initialized or self.red.brightness.initialized, self.green.switch.initialized or self.green.brightness.initialized, self.blue.switch.initialized or self.blue.brightness.initialized, self.white.switch.initialized or self.white.brightness.initialized, ) ) def _individual_color_callback_debounce(self, *args: Any) -> None: """Run callback after all individual colors were updated or timeout passed.""" async def debouncer() -> None: await asyncio.sleep(Light.DEBOUNCE_TIMEOUT) self._reset_individual_color_debounce_telegrams() self.after_update() self._individual_color_debounce_telegram_counter -= 1 if self._individual_color_debounce_telegram_counter > 0: # task registry cancels existing task self.xknx.task_registry.register( name=self._individual_color_debounce_task_name, async_func=debouncer, ).start() return self.xknx.task_registry.unregister(self._individual_color_debounce_task_name) self._reset_individual_color_debounce_telegrams() self.after_update() @property def supports_brightness(self) -> bool: """Return if light supports brightness.""" return self.brightness.initialized @property def supports_color(self) -> bool: """Return if light supports color.""" return self.color.initialized or all( c.brightness.initialized for c in (self.red, self.green, self.blue) ) @property def supports_rgbw(self) -> bool: """Return if light supports RGBW.""" return self.rgbw.initialized or all( c.brightness.initialized for c in self._iter_individual_colors() ) @property def supports_hs_color(self) -> bool: """Return if light supports HS-color.""" return self.hue.initialized and self.saturation.initialized @property def supports_xyy_color(self) -> bool: """Return if light supports xyY-color.""" return self.xyy_color.initialized @property def supports_tunable_white(self) -> bool: """Return if light supports tunable white / relative color temperature.""" return self.tunable_white.initialized @property def supports_color_temperature(self) -> bool: """Return if light supports absolute color temperature.""" return self.color_temperature.initialized @property def state(self) -> bool | None: """Return the current switch state of the device.""" if self.switch.value is not None: return self.switch.value if any(c.is_on is not None for c in self._iter_individual_colors()): return any(c.is_on for c in self._iter_individual_colors()) return None async def set_on(self) -> None: """Switch light on.""" if self.switch.writable: self.switch.on() return for color in self._iter_individual_colors(): color.set_on() async def set_off(self) -> None: """Switch light off.""" if self.switch.writable: self.switch.off() return for color in self._iter_individual_colors(): color.set_off() @property def current_brightness(self) -> int | None: """Return current brightness of light between 0..255.""" return self.brightness.value async def set_brightness(self, brightness: int) -> None: """Set brightness of light.""" if not self.supports_brightness: logger.warning("Dimming not supported for device %s", self.get_name()) return self.brightness.set(brightness) @property def current_color(self) -> tuple[tuple[int, int, int] | None, int | None]: """ Return current color of light. If the device supports RGBW, get the current RGB+White values instead. """ if self.supports_rgbw and self.rgbw.initialized: if not self.rgbw.value: return None, None if ( self.rgbw.value.red is not None and self.rgbw.value.green is not None and self.rgbw.value.blue is not None ): return ( (self.rgbw.value.red, self.rgbw.value.green, self.rgbw.value.blue), self.rgbw.value.white, ) return None, self.rgbw.value.white if self.color.initialized: if self.color.value is None: return None, None return ( self.color.value.red, self.color.value.green, self.color.value.blue, ), None # individual RGB addresses - white will return None when it is not initialized colors = ( self.red.brightness.value, self.green.brightness.value, self.blue.brightness.value, ) if None in colors: return None, self.white.brightness.value return cast(tuple[int, int, int], colors), self.white.brightness.value async def set_color( self, color: tuple[int, int, int], white: int | None = None ) -> None: """ Set color of a light device. If also the white value is given and the device supports RGBW, set all four values. """ if white is not None: if self.supports_rgbw: if self.rgbw.initialized: self.rgbw.set(RGBWColor(*color, white)) return if all( c.brightness.initialized for c in self._iter_individual_colors() ): self.red.brightness.set(color[0]) self.green.brightness.set(color[1]) self.blue.brightness.set(color[2]) self.white.brightness.set(white) return logger.warning("RGBW not supported for device %s", self.get_name()) else: if self.supports_color: if self.color.initialized: self.color.set(RGBColor(*color)) return if all( c.brightness.initialized for c in (self.red, self.green, self.blue) ): self.red.brightness.set(color[0]) self.green.brightness.set(color[1]) self.blue.brightness.set(color[2]) return logger.warning("Colors not supported for device %s", self.get_name()) @property def current_hs_color(self) -> tuple[float, float] | None: """ Return current HS-color of the light. Hue is scaled 0-360 (265 possible values from KNX) Sat is scaled 0-100 """ if (hue := self.hue.value) is not None and ( (saturation := self.saturation.value) is not None ): return (hue, saturation) return None async def set_hs_color(self, hs_color: tuple[float, float]) -> None: """Set HS-color of the light.""" if not self.supports_hs_color: logger.warning("HS-color not supported for device %s", self.get_name()) return value_sent = False if (hue := hs_color[0]) != self.hue.value: self.hue.set(hue) value_sent = True if (saturation := hs_color[1]) != self.saturation.value: self.saturation.set(saturation) value_sent = True if not value_sent: # at least one value shall be sent to enable turn-on by hs_color self.hue.set(hue) self.saturation.set(saturation) def _xyy_color_from_rv(self, xyy_color: XYYColor) -> None: """Update the current xyY-color from RemoteValue (Callback).""" if self._xyy_color_valid is not None: self._xyy_color_valid = self._xyy_color_valid | xyy_color else: self._xyy_color_valid = xyy_color self.after_update() @property def current_xyy_color(self) -> XYYColor | None: """Return current xyY-color of the light.""" return self._xyy_color_valid async def set_xyy_color(self, xyy: XYYColor) -> None: """Set xyY-color of the light.""" if not self.supports_xyy_color: logger.warning("XYY-color not supported for device %s", self.get_name()) return self.xyy_color.set(xyy) @property def current_tunable_white(self) -> int | None: """Return current relative color temperature of light.""" return self.tunable_white.value async def set_tunable_white(self, tunable_white: int) -> None: """Set relative color temperature of light.""" if not self.supports_tunable_white: logger.warning("Tunable white not supported for device %s", self.get_name()) return self.tunable_white.set(tunable_white) @property def current_color_temperature(self) -> int | float | None: """Return current absolute color temperature of light.""" return self.color_temperature.value async def set_color_temperature(self, color_temperature: int) -> None: """Set absolute color temperature of light.""" if not self.supports_color_temperature: logger.warning( "Absolute Color Temperature not supported for device %s", self.get_name(), ) return self.color_temperature.set(color_temperature) def process_group_write(self, telegram: Telegram) -> None: """Process incoming and outgoing GROUP WRITE telegram.""" for remote_value in self._iter_instant_remote_values(): remote_value.process(telegram) for remote_value in self._iter_debounce_remote_values(): remote_value.process(telegram, always_callback=True) def __str__(self) -> str: """Return object as readable string.""" str_brightness = ( f" brightness={self.brightness.group_addr_str()}" if self.supports_brightness else "" ) str_color = ( f" color={self.color.group_addr_str()}" if self.supports_color else "" ) str_rgbw = f" rgbw={self.rgbw.group_addr_str()}" if self.supports_rgbw else "" str_hue = ( f" brightness={self.hue.group_addr_str()}" if self.hue.initialized else "" ) str_saturation = ( f" brightness={self.saturation.group_addr_str()}" if self.saturation.initialized else "" ) str_xyy_color = ( f" xyy_color={self.xyy_color.group_addr_str()}" if self.supports_xyy_color else "" ) str_tunable_white = ( f" tunable_white={self.tunable_white.group_addr_str()}" if self.supports_tunable_white else "" ) str_color_temperature = ( f" color_temperature={self.color_temperature.group_addr_str()}" if self.supports_color_temperature else "" ) str_red_state = ( f" red_state={self.red.switch.group_addr_str()}" if self.red.switch.initialized else "" ) str_red_brightness = ( f" red_brightness={self.red.brightness.group_addr_str()}" if self.red.brightness.initialized else "" ) str_green_state = ( f" green_state={self.green.switch.group_addr_str()}" if self.green.switch.initialized else "" ) str_green_brightness = ( f" green_brightness={self.green.brightness.group_addr_str()}" if self.green.brightness.initialized else "" ) str_blue_state = ( f" blue_state={self.blue.switch.group_addr_str()}" if self.blue.switch.initialized else "" ) str_blue_brightness = ( f" blue_brightness={self.blue.brightness.group_addr_str()}" if self.blue.brightness.initialized else "" ) str_white_state = ( f" white_state={self.white.switch.group_addr_str()}" if self.white.switch.initialized else "" ) str_white_brightness = ( f" white_brightness={self.white.brightness.group_addr_str()}" if self.white.brightness.initialized else "" ) return ( f'" ) xknx-3.6.0/xknx/devices/notification.py000066400000000000000000000047311475530762600202060ustar00rootroot00000000000000"""Module for managing a text via KNX.""" from __future__ import annotations from collections.abc import Iterator from typing import TYPE_CHECKING from xknx.dpt import DPTString from xknx.remote_value import GroupAddressesType, RemoteValueString from xknx.typing import DPTParsable from .device import Device, DeviceCallbackType if TYPE_CHECKING: from xknx.telegram import Telegram from xknx.xknx import XKNX class Notification(Device): """Class for managing a notification.""" def __init__( self, xknx: XKNX, name: str, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, respond_to_read: bool = False, sync_state: bool | int | float | str = True, value_type: DPTParsable | type[DPTString] | None = None, device_updated_cb: DeviceCallbackType[Notification] | None = None, ) -> None: """Initialize notification class.""" super().__init__(xknx, name, device_updated_cb) self.respond_to_read = respond_to_read self.remote_value = RemoteValueString( xknx, group_address=group_address, group_address_state=group_address_state, sync_state=sync_state, value_type=value_type, device_name=name, feature_name="Text", after_update_cb=self.after_update, ) def _iter_remote_values(self) -> Iterator[RemoteValueString]: """Iterate the devices RemoteValue classes.""" yield self.remote_value @property def message(self) -> str | None: """Return the current message.""" return self.remote_value.value async def set(self, message: str) -> None: """Set message.""" cropped_message = message[:14] self.remote_value.set(cropped_message) def process_group_write(self, telegram: Telegram) -> None: """Process incoming and outgoing GROUP WRITE telegram.""" self.remote_value.process(telegram) def process_group_read(self, telegram: Telegram) -> None: """Process incoming GroupValueResponse telegrams.""" if ( self.respond_to_read and telegram.destination_address == self.remote_value.group_address ): self.remote_value.respond() def __str__(self) -> str: """Return object as readable string.""" return f'' xknx-3.6.0/xknx/devices/numeric_value.py000066400000000000000000000065771475530762600203700ustar00rootroot00000000000000""" Module for managing a generic numeric value via KNX. It provides functionality for * reading the current state from KNX bus or providing its state to the bus. * send local state changes to KNX bus. * watching for state updates from KNX bus. """ from __future__ import annotations from collections.abc import Iterator from typing import TYPE_CHECKING from xknx.dpt import DPTNumeric from xknx.remote_value import GroupAddressesType, RemoteValueNumeric from xknx.typing import DPTParsable from .device import Device, DeviceCallbackType if TYPE_CHECKING: from xknx.telegram import Telegram from xknx.xknx import XKNX class NumericValue(Device): """Class for managing a numeric value.""" def __init__( self, xknx: XKNX, name: str, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, respond_to_read: bool = False, sync_state: bool | int | float | str = True, value_type: DPTParsable | type[DPTNumeric] | None = None, always_callback: bool = False, device_updated_cb: DeviceCallbackType[NumericValue] | None = None, ) -> None: """Initialize Sensor class.""" super().__init__(xknx, name, device_updated_cb) self.always_callback = always_callback self.respond_to_read = respond_to_read self.sensor_value = RemoteValueNumeric( xknx, group_address=group_address, group_address_state=group_address_state, sync_state=sync_state, value_type=value_type, device_name=self.name, after_update_cb=self.after_update, ) def _iter_remote_values(self) -> Iterator[RemoteValueNumeric]: """Iterate the devices RemoteValue classes.""" yield self.sensor_value @property def last_telegram(self) -> Telegram | None: """Return the last telegram received from the RemoteValue.""" return self.sensor_value.telegram def process_group_write(self, telegram: Telegram) -> None: """Process incoming and outgoing GROUP WRITE telegram.""" self.sensor_value.process(telegram, always_callback=self.always_callback) def process_group_read(self, telegram: Telegram) -> None: """Process incoming GroupValueResponse telegrams.""" if ( self.respond_to_read and telegram.destination_address == self.sensor_value.group_address ): self.sensor_value.respond() async def set(self, value: float | int) -> None: """Set new value.""" self.sensor_value.set(value) def unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" return self.sensor_value.unit_of_measurement def ha_device_class(self) -> str | None: """Return the home assistant device class as string.""" return self.sensor_value.ha_device_class def resolve_state(self) -> float | int | None: """Return the current state of the sensor as a human readable string.""" return self.sensor_value.value def __str__(self) -> str: """Return object as readable string.""" return ( f'' ) xknx-3.6.0/xknx/devices/raw_value.py000066400000000000000000000055701475530762600175070ustar00rootroot00000000000000""" Module for managing a raw value via KNX. It provides functionality for * reading the current value from KNX bus or providing a value to the bus. * send local state changes to KNX bus. * watching for state updates from KNX bus. """ from __future__ import annotations from collections.abc import Iterator from typing import TYPE_CHECKING from xknx.remote_value import GroupAddressesType, RemoteValueRaw from .device import Device, DeviceCallbackType if TYPE_CHECKING: from xknx.telegram import Telegram from xknx.xknx import XKNX class RawValue(Device): """Class for managing a raw value.""" def __init__( self, xknx: XKNX, name: str, payload_length: int, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, respond_to_read: bool = False, sync_state: bool | int | float | str = True, always_callback: bool = False, device_updated_cb: DeviceCallbackType[RawValue] | None = None, ) -> None: """Initialize Sensor class.""" super().__init__(xknx, name, device_updated_cb) self.always_callback = always_callback self.respond_to_read = respond_to_read self.remote_value = RemoteValueRaw( xknx, payload_length=payload_length, group_address=group_address, group_address_state=group_address_state, sync_state=sync_state, device_name=self.name, after_update_cb=self.after_update, ) def _iter_remote_values(self) -> Iterator[RemoteValueRaw]: """Iterate the devices RemoteValue classes.""" yield self.remote_value @property def last_telegram(self) -> Telegram | None: """Return the last telegram received from the RemoteValue.""" return self.remote_value.telegram def process_group_write(self, telegram: Telegram) -> None: """Process incoming and outgoing GROUP WRITE telegram.""" self.remote_value.process(telegram, always_callback=self.always_callback) def process_group_read(self, telegram: Telegram) -> None: """Process incoming GroupValueResponse telegrams.""" if ( self.respond_to_read and telegram.destination_address == self.remote_value.group_address ): self.remote_value.respond() async def set(self, value: int) -> None: """Set new value.""" self.remote_value.set(value) def resolve_state(self) -> int | None: """Return the current state of the sensor as an unsigned integer.""" return self.remote_value.value def __str__(self) -> str: """Return object as readable string.""" return ( f'" ) xknx-3.6.0/xknx/devices/scene.py000066400000000000000000000031711475530762600166120ustar00rootroot00000000000000"""Module for managing a KNX scene.""" from __future__ import annotations from collections.abc import Iterator import logging from typing import TYPE_CHECKING from xknx.remote_value import GroupAddressesType, RemoteValueSceneNumber from .device import Device, DeviceCallbackType if TYPE_CHECKING: from xknx.xknx import XKNX logger = logging.getLogger("xknx.log") class Scene(Device): """Class for managing a scene.""" def __init__( self, xknx: XKNX, name: str, group_address: GroupAddressesType = None, scene_number: int = 1, device_updated_cb: DeviceCallbackType[Scene] | None = None, ) -> None: """Initialize Sceneclass.""" super().__init__(xknx, name, device_updated_cb) # TODO: state_updater: disable for scene number per default? self.scene_value = RemoteValueSceneNumber( xknx, group_address=group_address, device_name=self.name, feature_name="Scene number", after_update_cb=self.after_update, ) self.scene_number = int(scene_number) def _iter_remote_values(self) -> Iterator[RemoteValueSceneNumber]: """Iterate the devices RemoteValue classes.""" yield self.scene_value async def run(self) -> None: """Activate scene.""" self.scene_value.set(self.scene_number) def __str__(self) -> str: """Return object as readable string.""" return ( f'' ) xknx-3.6.0/xknx/devices/sensor.py000066400000000000000000000055621475530762600170340ustar00rootroot00000000000000""" Module for managing a sensor via KNX. It provides functionality for * reading the current state from KNX bus. * watching for state updates from KNX bus. """ from __future__ import annotations from collections.abc import Iterator from typing import TYPE_CHECKING, Any from xknx.dpt import DPTBase from xknx.remote_value import ( GroupAddressesType, RemoteValue, RemoteValueSensor, ) from xknx.typing import DPTParsable from .device import Device, DeviceCallbackType if TYPE_CHECKING: from xknx.telegram import Telegram from xknx.xknx import XKNX class Sensor(Device): """Class for managing a sensor.""" def __init__( self, xknx: XKNX, name: str, group_address_state: GroupAddressesType = None, sync_state: bool | int | float | str = True, always_callback: bool = False, value_type: DPTParsable | type[DPTBase] | None = None, device_updated_cb: DeviceCallbackType[Sensor] | None = None, ) -> None: """Initialize Sensor class.""" super().__init__(xknx, name, device_updated_cb) self.sensor_value = RemoteValueSensor( xknx, group_address_state=group_address_state, sync_state=sync_state, value_type=value_type, device_name=self.name, after_update_cb=self.after_update, ) self.always_callback = always_callback def _iter_remote_values(self) -> Iterator[RemoteValue[Any]]: """Iterate the devices RemoteValue classes.""" yield self.sensor_value @property def last_telegram(self) -> Telegram | None: """Return the last telegram received from the RemoteValue.""" return self.sensor_value.telegram def process_group_write(self, telegram: Telegram) -> None: """Process incoming and outgoing GROUP WRITE telegram.""" self.sensor_value.process(telegram, always_callback=self.always_callback) def process_group_response(self, telegram: Telegram) -> None: """Process incoming GroupValueResponse telegrams.""" self.sensor_value.process(telegram) def unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" return self.sensor_value.unit_of_measurement def ha_device_class(self) -> str | None: """Return the home assistant device class as string.""" return self.sensor_value.ha_device_class def resolve_state(self) -> Any | None: """Return the current state of the sensor as a human readable string.""" return self.sensor_value.value def __str__(self) -> str: """Return object as readable string.""" return ( f'' ) xknx-3.6.0/xknx/devices/switch.py000066400000000000000000000064311475530762600170200ustar00rootroot00000000000000""" Module for managing a switch via KNX. It provides functionality for * switching 'on' and 'off'. * reading the current state from KNX bus. """ from __future__ import annotations import asyncio from collections.abc import Iterator from functools import partial import logging from typing import TYPE_CHECKING from xknx.core import Task from xknx.remote_value import GroupAddressesType, RemoteValueSwitch from .device import Device, DeviceCallbackType if TYPE_CHECKING: from xknx.telegram import Telegram from xknx.xknx import XKNX logger = logging.getLogger("xknx.log") class Switch(Device): """Class for managing a switch.""" def __init__( self, xknx: XKNX, name: str, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, respond_to_read: bool = False, sync_state: bool | int | float | str = True, invert: bool = False, reset_after: float | None = None, device_updated_cb: DeviceCallbackType[Switch] | None = None, ) -> None: """Initialize Switch class.""" super().__init__(xknx, name, device_updated_cb) self.reset_after = reset_after self._reset_task: Task | None = None self.respond_to_read = respond_to_read self.switch = RemoteValueSwitch( xknx, group_address, group_address_state, sync_state=sync_state, invert=invert, device_name=self.name, after_update_cb=self.after_update, ) def _iter_remote_values(self) -> Iterator[RemoteValueSwitch]: """Iterate the devices RemoteValue classes.""" yield self.switch def async_remove_tasks(self) -> None: """Remove async tasks of device.""" if self._reset_task is not None: self._reset_task.cancel() self._reset_task = None @property def state(self) -> bool | None: """Return the current switch state of the device.""" return self.switch.value async def set_on(self) -> None: """Switch on switch.""" self.switch.on() async def set_off(self) -> None: """Switch off switch.""" self.switch.off() def process_group_write(self, telegram: Telegram) -> None: """Process incoming and outgoing GROUP WRITE telegram.""" if self.switch.process(telegram): if self.reset_after is not None and self.switch.value: self._reset_task = self.xknx.task_registry.register( name=f"switch.reset_{id(self)}", async_func=partial(self._reset_state, self.reset_after), track_task=True, ).start() def process_group_read(self, telegram: Telegram) -> None: """Process incoming GroupValueResponse telegrams.""" if ( self.respond_to_read and telegram.destination_address == self.switch.group_address ): self.switch.respond() async def _reset_state(self, wait_seconds: float) -> None: await asyncio.sleep(wait_seconds) await self.set_off() def __str__(self) -> str: """Return object as readable string.""" return f'' xknx-3.6.0/xknx/devices/travelcalculator.py000066400000000000000000000142301475530762600210620ustar00rootroot00000000000000""" Module TravelCalculator provides functionality for predicting the current position of a Cover. E.g.: * Given a Cover that takes 100 seconds to travel from top to bottom. * Starting from position 90, directed to position 60 at time 0. * At time 10 TravelCalculator will return position 80 (final position not reached). * At time 20 TravelCalculator will return position 70 (final position not reached). * At time 30 TravelCalculator will return position 60 (final position reached). """ from __future__ import annotations from enum import Enum import time class TravelStatus(Enum): """Enum class for travel status.""" DIRECTION_UP = 1 DIRECTION_DOWN = 2 STOPPED = 3 class TravelCalculator: """Class for calculating the current position of a cover.""" __slots__ = ( "_last_known_position", "_last_known_position_timestamp", "_position_confirmed", "_travel_to_position", "position_closed", "position_open", "travel_direction", "travel_time_down", "travel_time_up", ) def __init__(self, travel_time_down: float, travel_time_up: float) -> None: """Initialize TravelCalculator class.""" self.travel_direction = TravelStatus.STOPPED self.travel_time_down = travel_time_down self.travel_time_up = travel_time_up self._last_known_position: int | None = None self._last_known_position_timestamp: float = 0.0 self._position_confirmed: bool = False self._travel_to_position: int | None = None # 100 is closed, 0 is fully open self.position_closed: int = 100 self.position_open: int = 0 def set_position(self, position: int) -> None: """Set position and target of cover.""" self._travel_to_position = position self.update_position(position) def update_position(self, position: int) -> None: """Update known position of cover.""" self._last_known_position = position self._last_known_position_timestamp = time.time() if position == self._travel_to_position: self._position_confirmed = True def stop(self) -> None: """Stop traveling.""" stop_position = self.current_position() if stop_position is None: return self._last_known_position = stop_position self._travel_to_position = stop_position self._position_confirmed = False self.travel_direction = TravelStatus.STOPPED def start_travel(self, _travel_to_position: int) -> None: """Start traveling to position.""" if self._last_known_position is None: self.set_position(_travel_to_position) return self.stop() self._last_known_position_timestamp = time.time() self._travel_to_position = _travel_to_position self._position_confirmed = False self.travel_direction = ( TravelStatus.DIRECTION_DOWN if _travel_to_position > self._last_known_position else TravelStatus.DIRECTION_UP ) def start_travel_up(self) -> None: """Start traveling up.""" self.start_travel(self.position_open) def start_travel_down(self) -> None: """Start traveling down.""" self.start_travel(self.position_closed) def current_position(self) -> int | None: """Return current (calculated or known) position.""" if not self._position_confirmed: return self._calculate_position() return self._last_known_position def is_traveling(self) -> bool: """Return if cover is traveling.""" return self.current_position() != self._travel_to_position def is_opening(self) -> bool: """Return if the cover is opening.""" return ( self.is_traveling() and self.travel_direction == TravelStatus.DIRECTION_UP ) def is_closing(self) -> bool: """Return if the cover is closing.""" return ( self.is_traveling() and self.travel_direction == TravelStatus.DIRECTION_DOWN ) def position_reached(self) -> bool: """Return if cover has reached designated position.""" return self.current_position() == self._travel_to_position def is_open(self) -> bool: """Return if cover is (fully) open.""" return self.current_position() == self.position_open def is_closed(self) -> bool: """Return if cover is (fully) closed.""" return self.current_position() == self.position_closed def _calculate_position(self) -> int | None: """Return calculated position.""" if self._travel_to_position is None or self._last_known_position is None: return self._last_known_position relative_position = self._travel_to_position - self._last_known_position def position_reached_or_exceeded(relative_position: int) -> bool: """Return if designated position was reached.""" return ( relative_position <= 0 and self.travel_direction == TravelStatus.DIRECTION_DOWN ) or ( relative_position >= 0 and self.travel_direction == TravelStatus.DIRECTION_UP ) if position_reached_or_exceeded(relative_position): return self._travel_to_position remaining_travel_time = self.calculate_travel_time( from_position=self._last_known_position, to_position=self._travel_to_position, ) if time.time() > self._last_known_position_timestamp + remaining_travel_time: return self._travel_to_position progress = ( time.time() - self._last_known_position_timestamp ) / remaining_travel_time return int(self._last_known_position + relative_position * progress) def calculate_travel_time(self, from_position: int, to_position: int) -> float: """Calculate time to travel from one position to another.""" travel_range = to_position - from_position travel_time_full = ( self.travel_time_down if travel_range > 0 else self.travel_time_up ) return travel_time_full * abs(travel_range) / self.position_closed xknx-3.6.0/xknx/devices/weather.py000066400000000000000000000316701475530762600171610ustar00rootroot00000000000000""" Module for managing a weather station via KNX. It provides functionality for * reading current outside temperature * reading current brightness in 3 directions (DPT 9.004) * reading current alarms (DPTBinary) * reading current wind speed in m/s (DPT 9.005) * reading current wind bearing in degrees (DPT 5.003) * reading current air pressure (DPT 9.006) * reading current humidity (DPT 9.007) """ from __future__ import annotations from collections.abc import Callable, Iterator from datetime import date, datetime from enum import Enum from typing import TYPE_CHECKING, Any from xknx.dpt import DPTPressure, DPTPressure2Byte from xknx.remote_value import ( GroupAddressesType, RemoteValue, RemoteValueByLength, RemoteValueNumeric, RemoteValueSwitch, ) from .device import Device, DeviceCallbackType if TYPE_CHECKING: from xknx.telegram import Telegram from xknx.xknx import XKNX class WeatherCondition(Enum): """Home assistant weather conditions (partially).""" CLEAR_NIGHT = "clear-night" CLOUDY = "cloudy" LIGHTNING = "lightning" LIGHTNING_RAINY = "lightning-rainy" PARTLY_CLOUDY = "partly-cloudy" POURING = "pouring" RAINY = "rainy" SNOWY = "snowy" SNOWY_RAINY = "snowy-rainy" SUNNY = "sunny" WINDY = "windy" WINDY_VARIANT = "windy_variant" EXCEPTIONAL = "exceptional" class Season(Enum): """Seasonal mapping for illuminance.""" WINTER = 0 SUMMER = 1 YEAR = 2000 # dummy leap year to allow input X-02-29 (leap day) # Map year to winter and summer in order to be able to have seasonal illuminance checks # Sun during summer is stronger than in winter SEASONS = [ (Season.WINTER, (date(YEAR, 1, 1), date(YEAR, 4, 20))), (Season.SUMMER, (date(YEAR, 4, 21), date(YEAR, 10, 1))), (Season.WINTER, (date(YEAR, 10, 2), date(YEAR, 12, 31))), ] # Define current condition ILLUMINANCE_MAPPING: tuple[ tuple[Season, Callable[[float], bool], WeatherCondition], ... ] = ( (Season.SUMMER, lambda lx: 2000 <= lx <= 20000, WeatherCondition.CLOUDY), (Season.SUMMER, lambda lx: lx > 20000, WeatherCondition.SUNNY), (Season.WINTER, lambda lx: 999 <= lx <= 4500, WeatherCondition.CLOUDY), (Season.WINTER, lambda lx: lx > 4500, WeatherCondition.SUNNY), ) class Weather(Device): """Class for managing a weather device.""" def __init__( self, xknx: XKNX, name: str, group_address_temperature: GroupAddressesType = None, group_address_brightness_south: GroupAddressesType = None, group_address_brightness_north: GroupAddressesType = None, group_address_brightness_west: GroupAddressesType = None, group_address_brightness_east: GroupAddressesType = None, group_address_wind_speed: GroupAddressesType = None, group_address_wind_bearing: GroupAddressesType = None, group_address_rain_alarm: GroupAddressesType = None, group_address_frost_alarm: GroupAddressesType = None, group_address_wind_alarm: GroupAddressesType = None, group_address_day_night: GroupAddressesType = None, group_address_air_pressure: GroupAddressesType = None, group_address_humidity: GroupAddressesType = None, sync_state: bool | int | float | str = True, device_updated_cb: DeviceCallbackType[Weather] | None = None, ) -> None: """Initialize Weather class.""" super().__init__(xknx, name, device_updated_cb) self._temperature = RemoteValueNumeric( xknx, group_address_state=group_address_temperature, sync_state=sync_state, value_type="temperature", device_name=self.name, feature_name="Temperature", after_update_cb=self.after_update, ) self._brightness_south = RemoteValueNumeric( xknx, group_address_state=group_address_brightness_south, sync_state=sync_state, value_type="illuminance", device_name=self.name, feature_name="Brightness south", after_update_cb=self.after_update, ) self._brightness_north = RemoteValueNumeric( xknx, group_address_state=group_address_brightness_north, sync_state=sync_state, value_type="illuminance", device_name=self.name, feature_name="Brightness north", after_update_cb=self.after_update, ) self._brightness_west = RemoteValueNumeric( xknx, group_address_state=group_address_brightness_west, sync_state=sync_state, value_type="illuminance", device_name=self.name, feature_name="Brightness west", after_update_cb=self.after_update, ) self._brightness_east = RemoteValueNumeric( xknx, group_address_state=group_address_brightness_east, sync_state=sync_state, value_type="illuminance", device_name=self.name, feature_name="Brightness east", after_update_cb=self.after_update, ) self._wind_speed = RemoteValueNumeric( xknx, group_address_state=group_address_wind_speed, sync_state=sync_state, value_type="wind_speed_ms", device_name=self.name, feature_name="Wind speed", after_update_cb=self.after_update, ) self._wind_bearing = RemoteValueNumeric( xknx, group_address_state=group_address_wind_bearing, sync_state=sync_state, value_type="angle", device_name=self.name, feature_name="Wind bearing", after_update_cb=self.after_update, ) self._rain_alarm = RemoteValueSwitch( xknx, group_address_state=group_address_rain_alarm, sync_state=sync_state, device_name=self.name, feature_name="Rain alarm", after_update_cb=self.after_update, ) self._frost_alarm = RemoteValueSwitch( xknx, group_address_state=group_address_frost_alarm, sync_state=sync_state, device_name=self.name, feature_name="Frost alarm", after_update_cb=self.after_update, ) self._wind_alarm = RemoteValueSwitch( xknx, group_address_state=group_address_wind_alarm, sync_state=sync_state, device_name=self.name, feature_name="Wind alarm", after_update_cb=self.after_update, ) self._day_night = RemoteValueSwitch( xknx, group_address_state=group_address_day_night, sync_state=sync_state, device_name=self.name, feature_name="Day/Night", after_update_cb=self.after_update, ) self._air_pressure = RemoteValueByLength( xknx, dpt_classes=(DPTPressure, DPTPressure2Byte), group_address_state=group_address_air_pressure, sync_state=sync_state, device_name=self.name, feature_name="Air pressure", after_update_cb=self.after_update, ) self._humidity = RemoteValueNumeric( xknx, group_address_state=group_address_humidity, sync_state=sync_state, value_type="humidity", device_name=self.name, feature_name="Humidity", after_update_cb=self.after_update, ) def _iter_remote_values(self) -> Iterator[RemoteValue[Any]]: """Iterate the devices remote values.""" yield self._temperature yield self._brightness_south yield self._brightness_north yield self._brightness_east yield self._brightness_west yield self._wind_speed yield self._wind_bearing yield self._rain_alarm yield self._wind_alarm yield self._frost_alarm yield self._day_night yield self._air_pressure yield self._humidity def process_group_write(self, telegram: Telegram) -> None: """Process incoming and outgoing GROUP WRITE telegram.""" for remote_value in self._iter_remote_values(): remote_value.process(telegram) @property def temperature(self) -> float | None: """Return current temperature.""" return self._temperature.value @property def brightness_south(self) -> float: """Return brightness south.""" if self._brightness_south.value is not None: return self._brightness_south.value return 0.0 @property def brightness_north(self) -> float: """Return brightness north.""" if self._brightness_north.value is not None: return self._brightness_north.value return 0.0 @property def brightness_east(self) -> float: """Return brightness east.""" if self._brightness_east.value is not None: return self._brightness_east.value return 0.0 @property def brightness_west(self) -> float: """Return brightness west.""" if self._brightness_west.value is not None: return self._brightness_west.value return 0.0 @property def wind_speed(self) -> float | None: """Return wind speed in m/s.""" return self._wind_speed.value @property def wind_bearing(self) -> int | None: """Return wind bearing in °.""" return self._wind_bearing.value # type: ignore @property def rain_alarm(self) -> bool | None: """Return True if rain alarm False if not.""" return self._rain_alarm.value @property def wind_alarm(self) -> bool | None: """Return True if wind alarm False if not.""" return self._wind_alarm.value @property def frost_alarm(self) -> bool | None: """Return True if frost alarm False if not.""" return self._frost_alarm.value @property def day_night(self) -> bool | None: """Return day or night.""" return self._day_night.value @property def air_pressure(self) -> float | None: """Return pressure in Pa.""" return self._air_pressure.value @property def humidity(self) -> float | None: """Return humidity in %.""" return self._humidity.value @property def max_brightness(self) -> float: """Return highest illuminance from all sensors.""" return max( self.brightness_west, self.brightness_south, self.brightness_north, self.brightness_east, ) def ha_current_state(self, current_date: date | None = None) -> WeatherCondition: """Return the current state for home assistant.""" def _get_season(now: date) -> Season: """Return winter or summer.""" if isinstance(now, datetime): now = now.date() now = now.replace(year=YEAR) return next( season for season, (start, end) in SEASONS if start <= now <= end ) if self.wind_alarm and self.rain_alarm: return WeatherCondition.LIGHTNING_RAINY if self.frost_alarm and self.rain_alarm: return WeatherCondition.SNOWY_RAINY if self.rain_alarm: return WeatherCondition.RAINY if self.wind_alarm: return WeatherCondition.WINDY _current_date = current_date if current_date is not None else date.today() current_season: Season = _get_season(_current_date) _season: Season function: Callable[[float], bool] result: WeatherCondition for _season, function, result in ILLUMINANCE_MAPPING: if _season == current_season and function(self.max_brightness): return result if self.day_night is False: return WeatherCondition.CLEAR_NIGHT return WeatherCondition.EXCEPTIONAL def __str__(self) -> str: """Return object as readable string.""" return ( f'" ) xknx-3.6.0/xknx/dpt/000077500000000000000000000000001475530762600143065ustar00rootroot00000000000000xknx-3.6.0/xknx/dpt/__init__.py000066400000000000000000000115711475530762600164240ustar00rootroot00000000000000""" Module for encoding and decoding KNX datatypes. * KNX Values like Int, Float, String, Time * Derived KNX Values like Scaling, Temperature """ # ruff: noqa: F401 from .dpt import DPTBase, DPTComplex, DPTComplexData, DPTEnum, DPTEnumData, DPTNumeric from .dpt_1 import ( DPTAck, DPTAlarm, DPTBinaryValue, DPTBool, DPTConsumerProducer, DPTDayNight, DPTDimSendStyle, DPTEnable, DPTEnergyDirection, DPTHeatCool, DPTInputSource, DPTInvert, DPTLogicalFunction, DPTOccupancy, DPTOpenClose, DPTRamp, DPTReset, DPTSceneAB, DPTShutterBlindsMode, DPTStart, DPTState, DPTStep, DPTSwitch, DPTTrigger, DPTUpDown, DPTWindowDoor, ) from .dpt_3 import DPTControlBlinds, DPTControlDimming from .dpt_5 import ( DPTAngle, DPTDecimalFactor, DPTPercentU8, DPTScaling, DPTTariff, DPTValue1ByteUnsigned, DPTValue1Ucount, ) from .dpt_6 import DPTPercentV8, DPTSignedRelativeValue, DPTValue1Count from .dpt_7 import ( DPT2ByteUnsigned, DPT2Ucount, DPTBrightness, DPTColorTemperature, DPTLengthMm, DPTPropDataType, DPTTimePeriod10Msec, DPTTimePeriod100Msec, DPTTimePeriodHrs, DPTTimePeriodMin, DPTTimePeriodMsec, DPTTimePeriodSec, DPTUElCurrentmA, ) from .dpt_8 import ( DPT2ByteSigned, DPTDeltaTime10Msec, DPTDeltaTime100Msec, DPTDeltaTimeHrs, DPTDeltaTimeMin, DPTDeltaTimeMsec, DPTDeltaTimeSec, DPTLengthM, DPTPercentV16, DPTRotationAngle, DPTValue2Count, ) from .dpt_9 import ( DPT2ByteFloat, DPTAbsoluteHumidity, DPTAirFlow, DPTConcentrationUGM3, DPTCurrent, DPTEnthalpy, DPTHumidity, DPTKelvinPerPercent, DPTLux, DPTPartsPerMillion, DPTPower2Byte, DPTPowerDensity, DPTPressure2Byte, DPTRainAmount, DPTTemperature, DPTTemperatureA, DPTTemperatureDifference2Byte, DPTTemperatureF, DPTTime1, DPTTime2, DPTVoltage, DPTVolumeFlow, DPTWsp, DPTWspKmh, ) from .dpt_10 import DPTTime from .dpt_11 import DPTDate from .dpt_12 import ( DPT4ByteUnsigned, DPTLongTimePeriodHrs, DPTLongTimePeriodMin, DPTLongTimePeriodSec, DPTValue4Ucount, DPTVolumeLiquidLitre, DPTVolumeM3, ) from .dpt_13 import ( DPT4ByteSigned, DPTActiveEnergy, DPTActiveEnergykWh, DPTActiveEnergyMWh, DPTApparantEnergy, DPTApparantEnergykVAh, DPTFlowRateM3H, DPTLongDeltaTimeSec, DPTReactiveEnergy, DPTReactiveEnergykVARh, DPTValue4Count, ) from .dpt_14 import ( DPT4ByteFloat, DPTAbsoluteTemperature, DPTAcceleration, DPTAccelerationAngular, DPTActivationEnergy, DPTActivity, DPTAmplitude, DPTAngleDeg, DPTAngleRad, DPTAngularFrequency, DPTAngularMomentum, DPTAngularVelocity, DPTApparentPower, DPTArea, DPTCapacitance, DPTChargeDensitySurface, DPTChargeDensityVolume, DPTCommonTemperature, DPTCompressibility, DPTConductance, DPTDensity, DPTElectricalConductivity, DPTElectricCharge, DPTElectricCurrent, DPTElectricCurrentDensity, DPTElectricDipoleMoment, DPTElectricDisplacement, DPTElectricFieldStrength, DPTElectricFlux, DPTElectricFluxDensity, DPTElectricPolarization, DPTElectricPotential, DPTElectricPotentialDifference, DPTElectromagneticMoment, DPTElectromotiveForce, DPTEnergy, DPTForce, DPTFrequency, DPTHeatCapacity, DPTHeatFlowRate, DPTHeatQuantity, DPTImpedance, DPTLength, DPTLightQuantity, DPTLuminance, DPTLuminousFlux, DPTLuminousIntensity, DPTMagneticFieldStrength, DPTMagneticFlux, DPTMagneticFluxDensity, DPTMagneticMoment, DPTMagneticPolarization, DPTMagnetization, DPTMagnetomotiveForce, DPTMass, DPTMassFlux, DPTMol, DPTMomentum, DPTPhaseAngleDeg, DPTPhaseAngleRad, DPTPower, DPTPowerFactor, DPTPressure, DPTReactance, DPTResistance, DPTResistivity, DPTSelfInductance, DPTSolidAngle, DPTSoundIntensity, DPTSpeed, DPTStress, DPTSurfaceTension, DPTTemperatureDifference, DPTThermalCapacity, DPTThermalConductivity, DPTThermoelectricPower, DPTTimeSeconds, DPTTorque, DPTVolume, DPTVolumeFlux, DPTWeight, DPTWork, ) from .dpt_16 import DPTLatin1, DPTString from .dpt_17 import DPTSceneNumber from .dpt_18 import DPTSceneControl, SceneControl from .dpt_19 import DPTDateTime from .dpt_20 import DPTHVACContrMode, DPTHVACMode, DPTHVACStatus from .dpt_29 import ( DPT8ByteSigned, DPTActiveEnergy8Byte, DPTApparantEnergy8Byte, DPTReactiveEnergy8Byte, ) from .dpt_232 import DPTColorRGB, RGBColor from .dpt_235 import DPTTariffActiveEnergy, TariffActiveEnergy from .dpt_242 import DPTColorXYY, XYYColor from .dpt_251 import DPTColorRGBW, RGBWColor from .payload import DPTArray, DPTBinary xknx-3.6.0/xknx/dpt/dpt.py000066400000000000000000000360141475530762600154530ustar00rootroot00000000000000"""Implementation of Basic KNX datatypes.""" from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Iterator, Mapping from dataclasses import dataclass from enum import Enum from inspect import isabstract import struct from typing import Any, Generic, TypeVar, cast, final from xknx.exceptions import ConversionError, CouldNotParseTelegram from xknx.typing import DPTParsable, Self from .payload import DPTArray, DPTBinary class DPTBase(ABC): """ Base class for KNX data point type transcoder. KNX communicates using Group-addresses, and every Group Object represents a data point of some type. To have a standardized interpretation of the data there are a number of Data Point types (DPT). The DPT's is written like: xx.yyy, for example 14.056 for a 4-octet float, with Power info in Watts. The Major number (xx) describes the data type (format and encoding) - while the the minor (YYY) number describes the measurement with value range and unit. More DTP's are added as new needs come up, but this a list of some of the commonly used ones: 1.yyy boolean, like switching, on/off, open/close, move up/down, step 2.yyy 2 x boolean, e.g. switching + priority control 3.yyy boolean + 3-bit unsigned value, e.g. dimming up/down 4.yyy character (8-bit) 5.yyy 8-bit unsigned value, like dim value (0..100%), blinds position (0..100%) 6.yyy 8-bit signed (2's complement), e.g. +/- % 7.yyy 2-byte unsigned value, i.e. pulse counter 8.yyy 2-byte signed (2's complement), e.g. +/- % 9.yyy 2-byte float, e.g. temperature 10.yyy time (3 bytes) 11.yyy date (3 bytes) 12.yyy 4-byte unsigned value, i.e. pulse counter 13.yyy 4-byte signed (2's complement), i.e. flow, energy 14.yyy 4-byte float, IEEE 754, i.e. Electrical measurements: current, power 15.yyy access control 16.yyy string -> 14 characters (14 x 8-bit) 17.yyy scene number 18.yyy scene control 19.yyy date / time 20.yyy 8-bit enumeration, e.g. HVAC mode ('auto', 'comfort', 'standby', 'economy', 'protection') 28.yyy UTF-8 29.yyy V64, 64-bit signed value 232.yyy RGB [0,0,0]...[255,255,255] """ payload_type: type[DPTArray | DPTBinary] payload_length: int = cast(int, None) # DPTArray: byte length; DPTBinary bit length dpt_main_number: int | None = None dpt_sub_number: int | None = None value_type: str | None = None unit: str | None = None ha_device_class: str | None = None @classmethod def dpt_number_str(cls) -> str: """Return DPT number string representation.""" if cls.dpt_sub_number is not None: return f"{cls.dpt_main_number}.{cls.dpt_sub_number:03d}" return f"{cls.dpt_main_number or ''}" @classmethod def dpt_name(cls) -> str: """Return string representation of class name and DPT number.""" if cls.dpt_main_number is not None: return f"{cls.__name__} ({cls.dpt_number_str()})" return f"{cls.__name__} (abstract)" # concrete classes have dpt_main_number @classmethod @abstractmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> Any: """ Parse/deserialize from KNX/IP payload data. Raise `CouldNotParseTelegram` for wrong payload or `ConversionError` for unparsable value. """ # raw = cls.validate_payload(payload) @classmethod def validate_payload(cls, payload: DPTArray | DPTBinary) -> tuple[int, ...]: """ Test if payload has the correct length and type for given DPT. Return tuple of raw values. Raise CouldNotParseTelegram if payload type or length is invalid for DPT. """ if cls.payload_type is DPTArray and isinstance(payload, DPTArray): if cls.payload_length == len(payload.value): return payload.value raise CouldNotParseTelegram( f"Invalid payload length for {cls.dpt_name()}", payload=payload, expected_length=cls.payload_length, ) if cls.payload_type is DPTBinary and isinstance(payload, DPTBinary): if payload.value >= 2**cls.payload_length: # >= 0 is already checked by DPTBinary raise CouldNotParseTelegram( f"Invalid payload bitlength for {cls.dpt_name()}", payload=payload, expected_length=cls.payload_length, ) # wrap in tuple for consistent return signature return (payload.value,) raise CouldNotParseTelegram( f"Invalid payload type for {cls.dpt_name()}", payload=payload, expected_type=cls.payload_type.__name__, ) @classmethod @abstractmethod def to_knx(cls, value: Any) -> DPTArray | DPTBinary: """ Serialize to KNX/IP raw data. Raise `ConversionError` for unparsable value. """ @classmethod def __recursive_subclasses__(cls: type[Self]) -> Iterator[type[Self]]: """Yield all subclasses and their subclasses.""" for subclass in cls.__subclasses__(): if not isabstract(subclass): yield subclass yield from subclass.__recursive_subclasses__() @classmethod def dpt_class_tree(cls: type[Self]) -> Iterator[type[Self]]: """Yield class, all subclasses and their subclasses that are not abstract.""" if not isabstract(cls): yield cls yield from cls.__recursive_subclasses__() @classmethod def has_distinct_dpt_numbers(cls) -> bool: """Return True if dpt numbers are defined (not inherited).""" return "dpt_main_number" in cls.__dict__ and "dpt_sub_number" in cls.__dict__ @classmethod def has_distinct_value_type(cls) -> bool: """Return True if value_type is defined (not inherited).""" return "value_type" in cls.__dict__ @classmethod def transcoder_by_dpt( cls: type[Self], dpt_main: int, dpt_sub: int | None = None ) -> type[Self] | None: """Return Class reference of DPTBase subclass with matching DPT number.""" for dpt in cls.dpt_class_tree(): if dpt.has_distinct_dpt_numbers(): if dpt_main == dpt.dpt_main_number and dpt_sub == dpt.dpt_sub_number: return dpt return None @classmethod def transcoder_by_value_type(cls: type[Self], value_type: str) -> type[Self] | None: """Return Class reference of DPTBase subclass with matching value_type.""" for dpt in cls.dpt_class_tree(): if dpt.has_distinct_value_type(): if value_type == dpt.value_type: return dpt return None @classmethod def parse_transcoder(cls: type[Self], value_type: DPTParsable) -> type[Self] | None: """ Return Class reference of DPTBase subclass from value_type or DPT number. `value_type` accepts - Integer: DPT main number - String: value_type or "." separated dpt main and sub numbers (eg. "9.001") - Mapping: "main" and "sub" keys with DPT main and sub numbers (in accordance to xknxproject data) """ if isinstance(value_type, int): return cls.transcoder_by_dpt(value_type) if isinstance(value_type, str): string_type = value_type.strip() transcoder = cls.transcoder_by_value_type(string_type) if transcoder is None: # Try to parse the value_type if it is a string but not found by cls.transcoder_by_value_type() # for backwards compatibility (eg. "DPT-5") and strings representing numbers (eg. "7", "9.001") string_type = string_type.upper().strip(" DPT-") if string_type.isdigit(): transcoder = cls.transcoder_by_dpt(int(string_type)) else: try: main, sub = map(int, string_type.split(".")) transcoder = cls.transcoder_by_dpt(dpt_main=main, dpt_sub=sub) except (ValueError, IndexError): pass return transcoder if isinstance(value_type, Mapping): try: main = int(value_type["main"]) if (_sub := value_type.get("sub")) is not None: _sub = int(_sub) else: _sub = None except (KeyError, TypeError, ValueError): return None return cls.transcoder_by_dpt(dpt_main=main, dpt_sub=_sub) @classmethod def get_dpt(cls: type[Self], value_type: DPTParsable | type[DPTBase]) -> type[Self]: """ Return DPT class from value. Raises ValueError if value_type can't be parsed to DPT class. """ if isinstance(value_type, type): if issubclass(value_type, cls) and not isabstract(value_type): return value_type else: if transcoder := cls.parse_transcoder(value_type): return transcoder raise ValueError( f"Invalid value type for base class {cls.__name__}: {value_type}" ) class DPTNumeric(DPTBase): """Base class for KNX data point types decoding numeric values.""" payload_type = DPTArray value_min: int | float value_max: int | float resolution: int | float @classmethod @abstractmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> int | float: """Parse/deserialize from KNX/IP payload data.""" @classmethod @abstractmethod def to_knx(cls, value: int | float) -> DPTArray: """Serialize to KNX/IP raw data.""" class DPTStructIntMixin: """ Mixin for DPT classes using struct to convert values. Base class shall be DPTNumeric. Resolution shall always be 1. """ value_min: int | float value_max: int | float # https://docs.python.org/3/library/struct.html#format-characters _struct_format: str @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> int: """Parse/deserialize from KNX/IP raw data.""" raw = cls.validate_payload(payload) # type: ignore[attr-defined] try: return struct.unpack(cls._struct_format, bytes(raw))[0] # type: ignore[no-any-return] except struct.error as err: raise ConversionError(f"Could not parse {cls.dpt_name()}", raw=raw) from err # type: ignore[attr-defined] @classmethod def to_knx(cls, value: int | float) -> DPTArray: """Serialize to KNX/IP raw data.""" try: knx_value = int(value) if not (cls.value_min <= knx_value <= cls.value_max): raise ValueError return DPTArray(struct.pack(cls._struct_format, knx_value)) except (ValueError, struct.error) as err: raise ConversionError( f"Could not serialize {cls.dpt_name()}", # type: ignore[attr-defined] value=value, ) from err class DPTEnumData(Enum): """ Base class for KNX data point types decoding Enum values. Member values should be integers representing the raw KNX value. """ @classmethod def parse(cls, value: Self | str | int) -> Self: """Parse from Enum instance, name or value. Raises ValueError if parsing fails.""" if isinstance(value, cls): return value if isinstance(value, str): try: # snake_cased name may be used as translation key or serializable value return cls[value.upper()] except KeyError: pass # raise ValueError below if isinstance(value, int): try: return cls(value) except ValueError: pass # raise ValueError below raise ValueError(f"Could not parse {cls.__name__} from {value}") EnumDataT = TypeVar("EnumDataT", bound=DPTEnumData) class DPTEnum(DPTBase, Generic[EnumDataT]): """Base class for KNX data point types decoding Enum values.""" payload_type: type[DPTArray | DPTBinary] = DPTArray payload_length = 1 data_type: type[EnumDataT] @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> EnumDataT: """Parse/deserialize from KNX/IP raw data.""" raw = cls.validate_payload(payload) try: return cls.data_type(raw[0]) # type: ignore[no-any-return] except ValueError: raise ConversionError( f"Payload not supported for {cls.dpt_name()}", raw=raw ) from None @classmethod def to_knx(cls, value: EnumDataT | str | int) -> DPTArray | DPTBinary: """Serialize to KNX/IP raw data.""" try: return cls._to_knx(cls.data_type.parse(value)) except ValueError as err: raise ConversionError( f"Value not supported for {cls.data_type.__name__} in {cls.dpt_name()}", value=value, valid_values=cls.get_valid_values(), ) from err @classmethod @abstractmethod def _to_knx(cls, value: EnumDataT) -> DPTArray | DPTBinary: """Return the raw KNX value for an Enum member.""" # At least one abstract method is needed for our parse_transcoder lookup to # ignore the DPTEnum base class and only find concrete base classes. # `return DPTArray(value.value)` can be used typesafely if the Enum values are integers. @classmethod def get_valid_values(cls) -> list[EnumDataT]: """Return list of valid values.""" return list(cls.data_type) # type: ignore[call-overload, no-any-return] @dataclass(slots=True) class DPTComplexData(ABC): """Base class for KNX data point types decoding complex values.""" @classmethod @abstractmethod def from_dict(cls, data: Mapping[str, Any]) -> Self: """Init from a dictionary.""" @abstractmethod def as_dict(self) -> dict[str, Any]: """Create a JSON serializable dictionary.""" _ComplexDataT = TypeVar("_ComplexDataT", bound=DPTComplexData) class DPTComplex(DPTBase, Generic[_ComplexDataT]): """Base class for KNX data point types decoding complex values.""" data_type: type[_ComplexDataT] @classmethod @abstractmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> _ComplexDataT: """Parse/deserialize from KNX/IP payload data.""" @final @classmethod def to_knx(cls, value: _ComplexDataT | Mapping[str, Any]) -> DPTArray | DPTBinary: """Serialize to KNX/IP raw data.""" try: if isinstance(value, cls.data_type): return cls._to_knx(value) return cls._to_knx(cls.data_type.from_dict(value)) except (ValueError, TypeError, AttributeError, ConversionError) as err: raise ConversionError( f"Could not serialize {cls.dpt_name()}: {err}", value=value ) from err @classmethod @abstractmethod def _to_knx(cls, value: _ComplexDataT) -> DPTArray | DPTBinary: """Serialize to KNX/IP raw data.""" xknx-3.6.0/xknx/dpt/dpt_1.py000066400000000000000000000305461475530762600156770ustar00rootroot00000000000000"""Implementation of KNX 1-Bit KNX values.""" from __future__ import annotations from .dpt import DPTEnum, DPTEnumData, EnumDataT from .payload import DPTBinary class DPT1BitEnum(DPTEnum[EnumDataT]): """Base class for KNX 1-Bit values encoded as Enums.""" payload_type = DPTBinary payload_length = 1 dpt_main_number = 1 class Switch(DPTEnumData): """Enum for switching control.""" OFF = False ON = True class DPTSwitch(DPT1BitEnum[Switch]): """Abstraction for KNX 1-Bit switch value.""" dpt_main_number = 1 dpt_sub_number = 1 value_type = "switch" data_type = Switch @classmethod def _to_knx(cls, value: Switch) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class Bool(DPTEnumData): """Enum for switching control.""" FALSE = False TRUE = True class DPTBool(DPT1BitEnum[Bool]): """Abstraction for KNX 1-Bit bool value.""" dpt_main_number = 1 dpt_sub_number = 2 value_type = "bool" data_type = Bool @classmethod def _to_knx(cls, value: Bool) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class Enable(DPTEnumData): """Enum for enable control.""" DISABLE = False ENABLE = True class DPTEnable(DPT1BitEnum[Enable]): """Abstraction for KNX 1-Bit enable value.""" dpt_main_number = 1 dpt_sub_number = 3 value_type = "enable" data_type = Enable @classmethod def _to_knx(cls, value: Enable) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class Ramp(DPTEnumData): """Enum for ramp control.""" NO_RAMP = False RAMP = True class DPTRamp(DPT1BitEnum[Ramp]): """Abstraction for KNX 1-Bit ramp value.""" dpt_main_number = 1 dpt_sub_number = 4 value_type = "ramp" data_type = Ramp @classmethod def _to_knx(cls, value: Ramp) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class Alarm(DPTEnumData): """Enum for alarm control.""" NO_ALARM = False ALARM = True class DPTAlarm(DPT1BitEnum[Alarm]): """Abstraction for KNX 1-Bit alarm value.""" dpt_main_number = 1 dpt_sub_number = 5 value_type = "alarm" data_type = Alarm @classmethod def _to_knx(cls, value: Alarm) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class BinaryValue(DPTEnumData): """Enum for binary value control.""" LOW = False HIGH = True class DPTBinaryValue(DPT1BitEnum[BinaryValue]): """Abstraction for KNX 1-Bit binary_value value.""" dpt_main_number = 1 dpt_sub_number = 6 value_type = "binary_value" data_type = BinaryValue @classmethod def _to_knx(cls, value: BinaryValue) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class Step(DPTEnumData): """Enum for dimming control.""" DECREASE = False INCREASE = True class DPTStep(DPT1BitEnum[Step]): """Abstraction for KNX 1-Bit step value.""" dpt_main_number = 1 dpt_sub_number = 7 value_type = "step" data_type = Step @classmethod def _to_knx(cls, value: Step) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class UpDown(DPTEnumData): """Enum for up/down.""" UP = False DOWN = True class DPTUpDown(DPT1BitEnum[UpDown]): """Abstraction for KNX 1-Bit up/down value.""" dpt_main_number = 1 dpt_sub_number = 8 value_type = "up_down" data_type = UpDown @classmethod def _to_knx(cls, value: UpDown) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class OpenClose(DPTEnumData): """Enum for dimming control.""" OPEN = False CLOSE = True class DPTOpenClose(DPT1BitEnum[OpenClose]): """Abstraction for KNX 1-Bit open_close value.""" dpt_main_number = 1 dpt_sub_number = 9 value_type = "open_close" data_type = OpenClose @classmethod def _to_knx(cls, value: OpenClose) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class Start(DPTEnumData): """Enum for dimming control.""" STOP = False START = True class DPTStart(DPT1BitEnum[Start]): """Abstraction for KNX 1-Bit start value.""" dpt_main_number = 1 dpt_sub_number = 10 value_type = "start" data_type = Start @classmethod def _to_knx(cls, value: Start) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class State(DPTEnumData): """Enum for dimming control.""" INACTIVE = False ACTIVE = True class DPTState(DPT1BitEnum[State]): """Abstraction for KNX 1-Bit state value.""" dpt_main_number = 1 dpt_sub_number = 11 value_type = "state" data_type = State @classmethod def _to_knx(cls, value: State) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class Invert(DPTEnumData): """Enum for dimming control.""" NOT_INVERTED = False INVERTED = True class DPTInvert(DPT1BitEnum[Invert]): """Abstraction for KNX 1-Bit invert value.""" dpt_main_number = 1 dpt_sub_number = 12 value_type = "invert" data_type = Invert @classmethod def _to_knx(cls, value: Invert) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class DimSendStyle(DPTEnumData): """Enum for dimming control.""" START_STOP = False CYCLICALLY = True class DPTDimSendStyle(DPT1BitEnum[DimSendStyle]): """Abstraction for KNX 1-Bit dim_send_style value.""" dpt_main_number = 1 dpt_sub_number = 13 value_type = "dim_send_style" data_type = DimSendStyle @classmethod def _to_knx(cls, value: DimSendStyle) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class InputSource(DPTEnumData): """Enum for dimming control.""" FIXED = False CALCULATED = True class DPTInputSource(DPT1BitEnum[InputSource]): """Abstraction for KNX 1-Bit input_source value.""" dpt_main_number = 1 dpt_sub_number = 14 value_type = "input_source" data_type = InputSource @classmethod def _to_knx(cls, value: InputSource) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class Reset(DPTEnumData): """Enum for dimming control.""" NO_ACTION = False RESET = True class DPTReset(DPT1BitEnum[Reset]): """Abstraction for KNX 1-Bit reset value.""" dpt_main_number = 1 dpt_sub_number = 15 value_type = "reset" data_type = Reset @classmethod def _to_knx(cls, value: Reset) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class Ack(DPTEnumData): """Enum for dimming control.""" NO_ACTION = False ACKNOWLEDGE = True class DPTAck(DPT1BitEnum[Ack]): """Abstraction for KNX 1-Bit ack value.""" dpt_main_number = 1 dpt_sub_number = 16 value_type = "ack" data_type = Ack @classmethod def _to_knx(cls, value: Ack) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class Trigger(DPTEnumData): """Enum for dimming control.""" TRIGGER_0 = False TRIGGER = True class DPTTrigger(DPT1BitEnum[Trigger]): """Abstraction for KNX 1-Bit trigger value.""" dpt_main_number = 1 dpt_sub_number = 17 value_type = "trigger" data_type = Trigger @classmethod def _to_knx(cls, value: Trigger) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class Occupancy(DPTEnumData): """Enum for dimming control.""" NOT_OCCUPIED = False OCCUPIED = True class DPTOccupancy(DPT1BitEnum[Occupancy]): """Abstraction for KNX 1-Bit occupancy value.""" dpt_main_number = 1 dpt_sub_number = 18 value_type = "occupancy" data_type = Occupancy @classmethod def _to_knx(cls, value: Occupancy) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class WindowDoor(DPTEnumData): """Enum for dimming control.""" CLOSED = False OPEN = True class DPTWindowDoor(DPT1BitEnum[WindowDoor]): """Abstraction for KNX 1-Bit window_door value.""" dpt_main_number = 1 dpt_sub_number = 19 value_type = "window_door" data_type = WindowDoor @classmethod def _to_knx(cls, value: WindowDoor) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class LogicalFunction(DPTEnumData): """Enum for dimming control.""" OR = False AND = True class DPTLogicalFunction(DPT1BitEnum[LogicalFunction]): """Abstraction for KNX 1-Bit logical_function value.""" dpt_main_number = 1 dpt_sub_number = 21 value_type = "logical_function" data_type = LogicalFunction @classmethod def _to_knx(cls, value: LogicalFunction) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class SceneAB(DPTEnumData): """Enum for dimming control.""" SCENE_A = False SCENE_B = True class DPTSceneAB(DPT1BitEnum[SceneAB]): """Abstraction for KNX 1-Bit scene_ab value.""" dpt_main_number = 1 dpt_sub_number = 22 value_type = "scene_ab" data_type = SceneAB @classmethod def _to_knx(cls, value: SceneAB) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class ShutterBlindsMode(DPTEnumData): """Enum for dimming control.""" UP_DOWN_MODE = False STEP_STOP_MODE = True class DPTShutterBlindsMode(DPT1BitEnum[ShutterBlindsMode]): """Abstraction for KNX 1-Bit shutter_blinds_mode value.""" dpt_main_number = 1 dpt_sub_number = 23 value_type = "shutter_blinds_mode" data_type = ShutterBlindsMode @classmethod def _to_knx(cls, value: ShutterBlindsMode) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class DayNight(DPTEnumData): """Enum for dimming control.""" DAY = False NIGHT = True class DPTDayNight(DPT1BitEnum[DayNight]): """Abstraction for KNX 1-Bit day_night value.""" dpt_main_number = 1 dpt_sub_number = 24 value_type = "day_night" data_type = DayNight @classmethod def _to_knx(cls, value: DayNight) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class HeatCool(DPTEnumData): """Enum for heat/cool.""" COOL = False HEAT = True class DPTHeatCool(DPT1BitEnum[HeatCool]): """Abstraction for KNX 1-Bit heat/cool value.""" dpt_main_number = 1 dpt_sub_number = 100 value_type = "heat_cool" data_type = HeatCool @classmethod def _to_knx(cls, value: HeatCool) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class ConsumerProducer(DPTEnumData): """Enum for dimming control.""" CONSUMER = False PRODUCER = True class DPTConsumerProducer(DPT1BitEnum[ConsumerProducer]): """Abstraction for KNX 1-Bit consumer_producer value.""" dpt_main_number = 1 dpt_sub_number = 1200 value_type = "consumer_producer" data_type = ConsumerProducer @classmethod def _to_knx(cls, value: ConsumerProducer) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) class EnergyDirection(DPTEnumData): """Enum for dimming control.""" POSITIVE = False NEGATIVE = True class DPTEnergyDirection(DPT1BitEnum[EnergyDirection]): """Abstraction for KNX 1-Bit energy_direction value.""" dpt_main_number = 1 dpt_sub_number = 1201 value_type = "energy_direction" data_type = EnergyDirection @classmethod def _to_knx(cls, value: EnergyDirection) -> DPTBinary: """Return the raw KNX value for an Enum member.""" return DPTBinary(value.value) xknx-3.6.0/xknx/dpt/dpt_10.py000066400000000000000000000072721475530762600157570ustar00rootroot00000000000000"""Implementation of Basic KNX Time.""" from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass import datetime from typing import Any from xknx.exceptions import ConversionError from .dpt import DPTComplex, DPTComplexData, DPTEnumData from .payload import DPTArray, DPTBinary class KNXDay(DPTEnumData): """Enum for the different KNX days.""" NO_DAY = 0 MONDAY = 1 TUESDAY = 2 WEDNESDAY = 3 THURSDAY = 4 FRIDAY = 5 SATURDAY = 6 SUNDAY = 7 @dataclass(slots=True) class KNXTime(DPTComplexData): """Class for KNX Time.""" hour: int minutes: int seconds: int day: KNXDay = KNXDay.NO_DAY @classmethod def from_dict(cls, data: Mapping[str, Any]) -> KNXTime: """Init from a dictionary.""" try: hour = int(data["hour"]) minutes = int(data["minutes"]) seconds = int(data["seconds"]) day = KNXDay.parse(data.get("day", KNXDay.NO_DAY)) except (KeyError, TypeError, ValueError) as err: raise ValueError(f"Invalid value for KNXTime: {err}") from err return cls(hour=hour, minutes=minutes, seconds=seconds, day=day) def as_dict(self) -> dict[str, int | str]: """Create a JSON serializable dictionary.""" return { "hour": self.hour, "minutes": self.minutes, "seconds": self.seconds, "day": self.day.name.lower(), } def as_time(self) -> datetime.time: """Return time object. Ignoring day field.""" return datetime.time(self.hour, self.minutes, self.seconds) @classmethod def from_time(cls, time: datetime.time) -> KNXTime: """Return KNXTime object from time object. Day field is set to NO_DAY.""" return cls(time.hour, time.minute, time.second) class DPTTime(DPTComplex[KNXTime]): """ Abstraction for KNX 3 Octet Time. DPT 10.001 """ data_type = KNXTime payload_type = DPTArray payload_length = 3 dpt_main_number = 10 dpt_sub_number = 1 value_type = "time" @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> KNXTime: """Parse/deserialize from KNX/IP raw data.""" raw = cls.validate_payload(payload) weekday = (raw[0] & 0xE0) >> 5 # can not be out of range - 3 bits 0..7 hours = raw[0] & 0x1F minutes = raw[1] & 0x3F seconds = raw[2] & 0x3F try: DPTTime._test_range(hours, minutes, seconds) except ValueError as err: raise ConversionError(f"Could not parse {cls.dpt_name()}: {err}") from err return KNXTime( hour=hours, minutes=minutes, seconds=seconds, day=KNXDay(weekday) ) @classmethod def _to_knx(cls, value: KNXTime) -> DPTArray: """Serialize to KNX/IP raw data.""" try: DPTTime._test_range(value.hour, value.minutes, value.seconds) except ValueError as err: raise ConversionError( f"Could not serialize {cls.dpt_name()}: {err}" ) from err return DPTArray( ( value.day.value << 5 | value.hour, value.minutes, value.seconds, ) ) @staticmethod def _test_range(hour: int, minutes: int, seconds: int) -> None: """Test if values are in the correct value range.""" if not 0 <= hour <= 23: raise ValueError(f"Hour out of range 0..23: {hour}") if not 0 <= minutes <= 59: raise ValueError(f"Minutes out of range 0..59: {minutes}") if not 0 <= seconds <= 59: raise ValueError(f"Seconds out of range 0..59: {seconds}") xknx-3.6.0/xknx/dpt/dpt_11.py000066400000000000000000000062711475530762600157560ustar00rootroot00000000000000"""Implementation of the KNX date data point.""" from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass import datetime from typing import Any from xknx.exceptions import ConversionError from .dpt import DPTComplex, DPTComplexData from .payload import DPTArray, DPTBinary @dataclass(slots=True) class KNXDate(DPTComplexData): """Class for KNX Date.""" year: int month: int day: int @classmethod def from_dict(cls, data: Mapping[str, Any]) -> KNXDate: """Init from a dictionary.""" try: year = int(data["year"]) month = int(data["month"]) day = int(data["day"]) except (KeyError, TypeError, ValueError) as err: raise ValueError(f"Invalid value for KNXDate: {err}") from err return cls(year=year, month=month, day=day) def as_dict(self) -> dict[str, int]: """Return object as dictionary.""" return { "year": self.year, "month": self.month, "day": self.day, } def as_date(self) -> datetime.date: """Return datetime object.""" return datetime.date(self.year, self.month, self.day) @classmethod def from_date(cls, date: datetime.date) -> KNXDate: """Return KNXDate object from a datetime.date object.""" return cls(date.year, date.month, date.day) class DPTDate(DPTComplex[KNXDate]): """Abstraction for KNX 3 octet date (DPT 11.001).""" data_type = KNXDate payload_type = DPTArray payload_length = 3 dpt_main_number = 11 dpt_sub_number = 1 value_type = "date" @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> KNXDate: """Parse/deserialize from KNX/IP raw data.""" raw = cls.validate_payload(payload) day = raw[0] & 0x1F month = raw[1] & 0x0F year = raw[2] & 0x7F if not DPTDate._test_range(day, month, year): raise ConversionError("Could not parse DPTDate", raw=raw) if year >= 90: year += 1900 else: year += 2000 return KNXDate(year, month, day) @classmethod def _to_knx(cls, value: KNXDate) -> DPTArray: """Serialize to KNX/IP raw data.""" def _knx_year(year: int) -> int: if 2000 <= year < 2090: return year - 2000 if 1990 <= year < 2000: return year - 1900 raise ConversionError( f"Could not serialize {cls.dpt_name()}. Year out of range 1990..2089", year=year, ) knx_year = _knx_year(value.year) if not DPTDate._test_range(value.day, value.month, knx_year): raise ConversionError( f"Could not serialize {cls.dpt_name()}. Value out of range", value=value ) return DPTArray( ( value.day, value.month, knx_year, ) ) @staticmethod def _test_range(day: int, month: int, knx_year: int) -> bool: """Test if the values are in the correct range.""" return 1 <= day <= 31 and 1 <= month <= 12 and 0 <= knx_year <= 99 xknx-3.6.0/xknx/dpt/dpt_12.py000066400000000000000000000033451475530762600157560ustar00rootroot00000000000000"""Implementation of Basic KNX 4-Byte unsigned integer values.""" from __future__ import annotations from .dpt import DPTNumeric, DPTStructIntMixin class DPT4ByteUnsigned(DPTStructIntMixin, DPTNumeric): """ Abstraction for KNX 4 Byte "32-bit unsigned". DPT 12.*** """ dpt_main_number = 12 dpt_sub_number: int | None = None value_type = "4byte_unsigned" payload_length = 4 value_min = 0 value_max = 4294967295 resolution = 1 _struct_format = ">I" class DPTValue4Ucount(DPT4ByteUnsigned): """DPT 12.001 DPT_Value_4_Ucount.""" dpt_main_number = 12 dpt_sub_number = 1 value_type = "pulse_4_ucount" unit = "counter pulses" class DPTLongTimePeriodSec(DPT4ByteUnsigned): """DPT 12.100 DPT_LongTimePeriod_Sec (seconds).""" dpt_main_number = 12 dpt_sub_number = 100 value_type = "long_time_period_sec" unit = "s" class DPTLongTimePeriodMin(DPT4ByteUnsigned): """DPT 12.101 DPT_LongTimePeriod_Min (minutes).""" dpt_main_number = 12 dpt_sub_number = 101 value_type = "long_time_period_min" unit = "min" class DPTLongTimePeriodHrs(DPT4ByteUnsigned): """DPT 12.102 DPT_LongTimePeriod_Hrs (hours).""" dpt_main_number = 12 dpt_sub_number = 102 value_type = "long_time_period_hrs" unit = "h" class DPTVolumeLiquidLitre(DPT4ByteUnsigned): """DPT 12.1200 DPT_VolumeLiquid_Litre (water/gas total consumption).""" dpt_main_number = 12 dpt_sub_number = 1200 value_type = "volume_liquid_litre" unit = "L" class DPTVolumeM3(DPT4ByteUnsigned): """DPT 12.1201 DPT_Volume_m3 (water/gas total consumption volume).""" dpt_main_number = 12 dpt_sub_number = 1201 value_type = "volume_m3" unit = "m³" xknx-3.6.0/xknx/dpt/dpt_13.py000066400000000000000000000047371475530762600157650ustar00rootroot00000000000000"""Implementation of Basic KNX 4-Byte signed (2's complement) values.""" from __future__ import annotations from .dpt import DPTNumeric, DPTStructIntMixin class DPT4ByteSigned(DPTStructIntMixin, DPTNumeric): """ Abstraction for KNX 4 Byte "32-bit signed". DPT 13.*** """ dpt_main_number = 13 dpt_sub_number: int | None = None value_type = "4byte_signed" payload_length = 4 value_min = -2147483648 value_max = 2147483647 resolution = 1 _struct_format = ">i" class DPTValue4Count(DPT4ByteSigned): """DPT 13.001 DPT_Value_4_Count (pulse).""" dpt_main_number = 13 dpt_sub_number = 1 value_type = "pulse_4byte" unit = "counter pulses" class DPTFlowRateM3H(DPT4ByteSigned): """DPT 13.002 DPT_FlowRate_m3/h (m³/h).""" dpt_main_number = 13 dpt_sub_number = 2 value_type = "flow_rate_m3h" unit = "m³/h" class DPTActiveEnergy(DPT4ByteSigned): """DPT 13.010 DPT_ActiveEnergy (Wh).""" dpt_main_number = 13 dpt_sub_number = 10 value_type = "active_energy" unit = "Wh" ha_device_class = "energy" class DPTApparantEnergy(DPT4ByteSigned): """DPT 13.011 DPT_ActiveEnergy (VAh).""" dpt_main_number = 13 dpt_sub_number = 11 value_type = "apparant_energy" unit = "VAh" class DPTReactiveEnergy(DPT4ByteSigned): """DPT 13.012 DPT_ActiveEnergy (VARh).""" dpt_main_number = 13 dpt_sub_number = 12 value_type = "reactive_energy" unit = "VARh" class DPTActiveEnergykWh(DPT4ByteSigned): """DPT 13.013 DPT_ActiveEnergy_kWh (kWh).""" dpt_main_number = 13 dpt_sub_number = 13 value_type = "active_energy_kwh" unit = "kWh" ha_device_class = "energy" class DPTApparantEnergykVAh(DPT4ByteSigned): """DPT 13.014 DPT_ActiveEnergy_kVAh (kVAh).""" dpt_main_number = 13 dpt_sub_number = 14 value_type = "apparant_energy_kvah" unit = "kVAh" class DPTReactiveEnergykVARh(DPT4ByteSigned): """DPT 13.015 DPT_ActiveEnergy (kVARh).""" dpt_main_number = 13 dpt_sub_number = 15 value_type = "reactive_energy_kvarh" unit = "kVARh" class DPTActiveEnergyMWh(DPT4ByteSigned): """DPT 13.016 DPT_ActiveEnergy_MWh (MWh).""" dpt_main_number = 13 dpt_sub_number = 16 value_type = "active_energy_mwh" unit = "MWh" class DPTLongDeltaTimeSec(DPT4ByteSigned): """DPT 13.100 DPT_LongDeltaTimeSec (s).""" dpt_main_number = 13 dpt_sub_number = 100 value_type = "long_delta_timesec" unit = "s" xknx-3.6.0/xknx/dpt/dpt_14.py000066400000000000000000000424771475530762600157710ustar00rootroot00000000000000""" Implementation of KNX 4 byte Float-values. They correspond to the the following KDN DPT 14 class. """ from __future__ import annotations from math import ceil, log10 import struct from typing import cast from xknx.exceptions import ConversionError from .dpt import DPTNumeric from .payload import DPTArray, DPTBinary class DPT4ByteFloat(DPTNumeric): """ Abstraction for KNX 4 Octet Floating Point Numbers, with a maximum usable range as specified in IEEE 754. The largest positive finite float literal is 3.40282347e+38f. The smallest positive finite non-zero literal of type float is 1.40239846e-45f. The negative minimum finite float literal is -3.40282347e+38f. No value range are defined for DPTs 14.000-079. DPT 14.*** """ dpt_main_number = 14 dpt_sub_number: int | None = None value_type = "4byte_float" payload_length = 4 value_min = float("-inf") value_max = float("inf") resolution = 0.0000001 @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> float: """Parse/deserialize from KNX/IP raw data (big endian).""" raw = cls.validate_payload(payload) try: raw_float = cast(float, struct.unpack(">f", bytes(raw))[0]) except struct.error as err: raise ConversionError(f"Could not parse {cls.dpt_name()}", raw=raw) from err try: # round to 7 digit precision independent of exponent - same value as ETS 5.7 group monitor return round(raw_float, 7 - ceil(log10(abs(raw_float)))) except (ValueError, OverflowError): # account for 0 and special values # ValueError: log10(0.0); ceil(float('nan')) # OverflowError: ceil(float('inf')) return raw_float @classmethod def to_knx(cls, value: float) -> DPTArray: """Serialize to KNX/IP raw data.""" try: knx_value = float(value) return DPTArray(struct.pack(">f", knx_value)) except (ValueError, struct.error) as err: raise ConversionError( f"Could not serialize {cls.dpt_name()}", value=value ) from err class DPTAcceleration(DPT4ByteFloat): """DPT 14.000 DPT_Value_Acceleration (ms-2).""" dpt_main_number = 14 dpt_sub_number = 0 value_type = "acceleration" unit = "m/s²" class DPTAccelerationAngular(DPT4ByteFloat): """DPT 14.001 DPT_Value_Acceleration_Angular (rad s-2).""" dpt_main_number = 14 dpt_sub_number = 1 value_type = "acceleration_angular" unit = "rad/s²" class DPTActivationEnergy(DPT4ByteFloat): """DPT 14.002 DPT_Value_Activation_Energy (J mol-1).""" dpt_main_number = 14 dpt_sub_number = 2 value_type = "activation_energy" unit = "J/mol" class DPTActivity(DPT4ByteFloat): """DPT 14.003 DPT_Value_Activity (s-1).""" dpt_main_number = 14 dpt_sub_number = 3 value_type = "activity" unit = "s⁻¹" class DPTMol(DPT4ByteFloat): """DPT 14.004 DPT_Value_Mol (mol).""" dpt_main_number = 14 dpt_sub_number = 4 value_type = "mol" unit = "mol" class DPTAmplitude(DPT4ByteFloat): """DPT 14.005 DPT_Value_Amplitude.""" dpt_main_number = 14 dpt_sub_number = 5 value_type = "amplitude" class DPTAngleRad(DPT4ByteFloat): """DPT 14.006 DPT_Value_AngleRad (rad).""" dpt_main_number = 14 dpt_sub_number = 6 value_type = "angle_rad" unit = "rad" class DPTAngleDeg(DPT4ByteFloat): """DPT 14.007 DPT_Value_AngleDeg ((degree)).""" dpt_main_number = 14 dpt_sub_number = 7 value_type = "angle_deg" unit = "°" class DPTAngularMomentum(DPT4ByteFloat): """DPT 14.008 DPT_Value_Angular_Momentum (J s).""" dpt_main_number = 14 dpt_sub_number = 8 value_type = "angular_momentum" unit = "J s" class DPTAngularVelocity(DPT4ByteFloat): """DPT 14.009 DPT_Value_Angular_Velocity.""" dpt_main_number = 14 dpt_sub_number = 9 value_type = "angular_velocity" unit = "rad/s" class DPTArea(DPT4ByteFloat): """DPT 14.010 DPT_Value_Area.""" dpt_main_number = 14 dpt_sub_number = 10 value_type = "area" unit = "m²" class DPTCapacitance(DPT4ByteFloat): """DPT 14.011 DPT_Value_Capacitance.""" dpt_main_number = 14 dpt_sub_number = 11 value_type = "capacitance" unit = "F" class DPTChargeDensitySurface(DPT4ByteFloat): """DPT 14.012 DPT_Value_Charge_DensitySurface.""" dpt_main_number = 14 dpt_sub_number = 12 value_type = "charge_density_surface" unit = "C/m²" class DPTChargeDensityVolume(DPT4ByteFloat): """DPT 14.013 DPT_Value_Charge_DensityVolume.""" dpt_main_number = 14 dpt_sub_number = 13 value_type = "charge_density_volume" unit = "C/m³" class DPTCompressibility(DPT4ByteFloat): """DPT 14.014 DPT_Value_Compressibility.""" dpt_main_number = 14 dpt_sub_number = 14 value_type = "compressibility" unit = "m²/N" class DPTConductance(DPT4ByteFloat): """DPT 14.015 DPT_Value_Conductance.""" dpt_main_number = 14 dpt_sub_number = 15 value_type = "conductance" unit = "S" class DPTElectricalConductivity(DPT4ByteFloat): """DPT 14.016 DPT_Value_Electrical_Conductivity.""" dpt_main_number = 14 dpt_sub_number = 16 value_type = "electrical_conductivity" unit = "S/m" class DPTDensity(DPT4ByteFloat): """DPT 14.017 DPT_Value_Density.""" dpt_main_number = 14 dpt_sub_number = 17 value_type = "density" unit = "kg/m³" class DPTElectricCharge(DPT4ByteFloat): """DPT 14.018 DPT_Value_Electric_Charge.""" dpt_main_number = 14 dpt_sub_number = 18 value_type = "electric_charge" unit = "C" class DPTElectricCurrent(DPT4ByteFloat): """DPT 14.019 DPT_Value_Electric_Current.""" dpt_main_number = 14 dpt_sub_number = 19 value_type = "electric_current" unit = "A" ha_device_class = "current" class DPTElectricCurrentDensity(DPT4ByteFloat): """DPT 14.020 DPT_Value_Electric_CurrentDensity.""" dpt_main_number = 14 dpt_sub_number = 20 value_type = "electric_current_density" unit = "A/m²" class DPTElectricDipoleMoment(DPT4ByteFloat): """DPT 14.021 DPT_Value_Electric_DipoleMoment.""" dpt_main_number = 14 dpt_sub_number = 21 value_type = "electric_dipole_moment" unit = "C m" class DPTElectricDisplacement(DPT4ByteFloat): """DPT 14.022 DPT_Value_Electric_Displacement.""" dpt_main_number = 14 dpt_sub_number = 22 value_type = "electric_displacement" unit = "C/m²" class DPTElectricFieldStrength(DPT4ByteFloat): """DPT 14.023 DPT_Value_Electric_FieldStrength.""" dpt_main_number = 14 dpt_sub_number = 23 value_type = "electric_field_strength" unit = "V/m" class DPTElectricFlux(DPT4ByteFloat): """DPT 14.024 DPT_Value_Electric_Flux.""" dpt_main_number = 14 dpt_sub_number = 24 value_type = "electric_flux" unit = "c" class DPTElectricFluxDensity(DPT4ByteFloat): """DPT 14.025 DPT_Value_Electric_FluxDensity.""" dpt_main_number = 14 dpt_sub_number = 25 value_type = "electric_flux_density" unit = "C/m²" class DPTElectricPolarization(DPT4ByteFloat): """DPT 14.026 DPT_Value_Electric_Polarization.""" dpt_main_number = 14 dpt_sub_number = 26 value_type = "electric_polarization" unit = "C/m²" class DPTElectricPotential(DPT4ByteFloat): """DPT 14.027 DPT_Value_Electric_Potential.""" dpt_main_number = 14 dpt_sub_number = 27 value_type = "electric_potential" unit = "V" class DPTElectricPotentialDifference(DPT4ByteFloat): """DPT 14.028 DPT_Value_Electric_PotentialDifference.""" dpt_main_number = 14 dpt_sub_number = 28 value_type = "electric_potential_difference" unit = "V" class DPTElectromagneticMoment(DPT4ByteFloat): """DPT 14.029 DPT_Value_ElectromagneticMoment.""" dpt_main_number = 14 dpt_sub_number = 29 value_type = "electromagnetic_moment" unit = "A m²" class DPTElectromotiveForce(DPT4ByteFloat): """DPT 14.030 DPT_Value_Electromotive_Force.""" dpt_main_number = 14 dpt_sub_number = 30 value_type = "electromotive_force" unit = "V" class DPTEnergy(DPT4ByteFloat): """DPT 14.031 DPT_Value_Energy.""" dpt_main_number = 14 dpt_sub_number = 31 value_type = "energy" unit = "J" class DPTForce(DPT4ByteFloat): """DPT 14.032 DPT_Value_Force.""" dpt_main_number = 14 dpt_sub_number = 32 value_type = "force" unit = "N" class DPTFrequency(DPT4ByteFloat): """DPT 14.033 DPT_Value_Frequency.""" dpt_main_number = 14 dpt_sub_number = 33 value_type = "frequency" unit = "Hz" ha_device_class = "frequency" class DPTAngularFrequency(DPT4ByteFloat): """DPT 14.034 DPT_Value_Angular_Frequency.""" dpt_main_number = 14 dpt_sub_number = 34 value_type = "angular_frequency" unit = "rad/s" class DPTHeatCapacity(DPT4ByteFloat): """DPT 14.035 DPT_Value_Heat_Capacity.""" dpt_main_number = 14 dpt_sub_number = 35 value_type = "heatcapacity" unit = "J/K" class DPTHeatFlowRate(DPT4ByteFloat): """DPT 14.036 DPT_Value_Heat_Flow_Rate.""" dpt_main_number = 14 dpt_sub_number = 36 value_type = "heatflowrate" unit = "W" class DPTHeatQuantity(DPT4ByteFloat): """DPT 14.037 DPT_Value_Heat_Quantity.""" dpt_main_number = 14 dpt_sub_number = 37 value_type = "heat_quantity" unit = "J" class DPTImpedance(DPT4ByteFloat): """DPT 14.038 DPT_Value_Impedance.""" dpt_main_number = 14 dpt_sub_number = 38 value_type = "impedance" unit = "Ω" class DPTLength(DPT4ByteFloat): """DPT 14.039 DPT_Value_Length.""" dpt_main_number = 14 dpt_sub_number = 39 value_type = "length" unit = "m" ha_device_class = "distance" class DPTLightQuantity(DPT4ByteFloat): """DPT 14.040 DPT_Value_Light_Quantity.""" dpt_main_number = 14 dpt_sub_number = 40 value_type = "light_quantity" unit = "lm s" class DPTLuminance(DPT4ByteFloat): """DPT 14.041 DPT_Value_Luminance.""" dpt_main_number = 14 dpt_sub_number = 41 value_type = "luminance" unit = "cd/m²" class DPTLuminousFlux(DPT4ByteFloat): """DPT 14.042 DPT_Value_Heat_Flow_Rate.""" dpt_main_number = 14 dpt_sub_number = 42 value_type = "luminous_flux" unit = "lm" class DPTLuminousIntensity(DPT4ByteFloat): """DPT 14.043 DPT_Value_Luminous_Intensity.""" dpt_main_number = 14 dpt_sub_number = 43 value_type = "luminous_intensity" unit = "cd" class DPTMagneticFieldStrength(DPT4ByteFloat): """DPT 14.044 DPT_Value_Magnetic_FieldStrength.""" dpt_main_number = 14 dpt_sub_number = 44 value_type = "magnetic_field_strength" unit = "A/m" class DPTMagneticFlux(DPT4ByteFloat): """DPT 14.045 DPT_Value_Magnetic_Flux.""" dpt_main_number = 14 dpt_sub_number = 45 value_type = "magnetic_flux" unit = "Wb" class DPTMagneticFluxDensity(DPT4ByteFloat): """DPT 14.046 DPT_Value_Magnetic_FluxDensity.""" dpt_main_number = 14 dpt_sub_number = 46 value_type = "magnetic_flux_density" unit = "T" class DPTMagneticMoment(DPT4ByteFloat): """DPT 14.047 DPT_Value_Magnetic_Moment.""" dpt_main_number = 14 dpt_sub_number = 47 value_type = "magnetic_moment" unit = "A m²" class DPTMagneticPolarization(DPT4ByteFloat): """DPT 14.048 DPT_Value_Magnetic_Polarization.""" dpt_main_number = 14 dpt_sub_number = 48 value_type = "magnetic_polarization" unit = "T" class DPTMagnetization(DPT4ByteFloat): """DPT 14.049 DPT_Value_Magnetization.""" dpt_main_number = 14 dpt_sub_number = 49 value_type = "magnetization" unit = "A/m" class DPTMagnetomotiveForce(DPT4ByteFloat): """DPT 14.050 DPT_Value_MagnetomotiveForce.""" dpt_main_number = 14 dpt_sub_number = 50 value_type = "magnetomotive_force" unit = "A" class DPTMass(DPT4ByteFloat): """DPT 14.051 DPT_Value_Mass.""" dpt_main_number = 14 dpt_sub_number = 51 value_type = "mass" unit = "kg" ha_device_class = "weight" class DPTMassFlux(DPT4ByteFloat): """DPT 14.052 DPT_Value_MassFlux.""" dpt_main_number = 14 dpt_sub_number = 52 value_type = "mass_flux" unit = "kg/s" class DPTMomentum(DPT4ByteFloat): """DPT 14.053 DPT_Value_Momentum.""" dpt_main_number = 14 dpt_sub_number = 53 value_type = "momentum" unit = "N/s" class DPTPhaseAngleRad(DPT4ByteFloat): """DPT 14.054 DPT_Value_Phase_Angle, Radiant.""" dpt_main_number = 14 dpt_sub_number = 54 value_type = "phaseanglerad" unit = "rad" class DPTPhaseAngleDeg(DPT4ByteFloat): """DPT 14.055 DPT_Value_Phase_Angle, Degree.""" dpt_main_number = 14 dpt_sub_number = 55 value_type = "phaseangledeg" unit = "°" class DPTPower(DPT4ByteFloat): """DPT 14.056 DPT_Value_Power.""" dpt_main_number = 14 dpt_sub_number = 56 value_type = "power" unit = "W" ha_device_class = "power" class DPTPowerFactor(DPT4ByteFloat): """DPT 14.057 DPT_Value_Power.""" dpt_main_number = 14 dpt_sub_number = 57 value_type = "powerfactor" ha_device_class = "power_factor" class DPTPressure(DPT4ByteFloat): """DPT 14.058 DPT_Value_Pressure.""" dpt_main_number = 14 dpt_sub_number = 58 value_type = "pressure" unit = "Pa" ha_device_class = "pressure" class DPTReactance(DPT4ByteFloat): """DPT 14.059 DPT_Value_Reactance.""" dpt_main_number = 14 dpt_sub_number = 59 value_type = "reactance" unit = "Ω" class DPTResistance(DPT4ByteFloat): """DPT 14.060 DPT_Value_Resistance.""" dpt_main_number = 14 dpt_sub_number = 60 value_type = "resistance" unit = "Ω" class DPTResistivity(DPT4ByteFloat): """DPT 14.061 DPT_Value_Resistivity.""" dpt_main_number = 14 dpt_sub_number = 61 value_type = "resistivity" unit = "Ωm" class DPTSelfInductance(DPT4ByteFloat): """DPT 14.062 DPT_Value_SelfInductance.""" dpt_main_number = 14 dpt_sub_number = 62 value_type = "self_inductance" unit = "H" class DPTSolidAngle(DPT4ByteFloat): """DPT 14.063 DPT_Value_SolidAngle.""" dpt_main_number = 14 dpt_sub_number = 63 value_type = "solid_angle" unit = "sr" class DPTSoundIntensity(DPT4ByteFloat): """DPT 14.064 DPT_Value_Sound_Intensity.""" dpt_main_number = 14 dpt_sub_number = 64 value_type = "sound_intensity" unit = "W/m²" class DPTSpeed(DPT4ByteFloat): """DPT 14.065 DPT_Value_Speed.""" dpt_main_number = 14 dpt_sub_number = 65 value_type = "speed" unit = "m/s" ha_device_class = "speed" class DPTStress(DPT4ByteFloat): """DPT 14.066 DPT_Value_Stress.""" dpt_main_number = 14 dpt_sub_number = 66 value_type = "stress" unit = "Pa" class DPTSurfaceTension(DPT4ByteFloat): """DPT 14.067 DPT_Value_Surface_Tension.""" dpt_main_number = 14 dpt_sub_number = 67 value_type = "surface_tension" unit = "N/m" class DPTCommonTemperature(DPT4ByteFloat): """DPT 14.068 DPT_Value_Common_Temperature.""" dpt_main_number = 14 dpt_sub_number = 68 value_type = "common_temperature" unit = "°C" class DPTAbsoluteTemperature(DPT4ByteFloat): """DPT 14.069 DPT_Value_Absolute_Temperature.""" dpt_main_number = 14 dpt_sub_number = 69 value_type = "absolute_temperature" unit = "K" class DPTTemperatureDifference(DPT4ByteFloat): """DPT 14.070 DPT_Value_TemperatureDifference.""" dpt_main_number = 14 dpt_sub_number = 70 value_type = "temperature_difference" unit = "K" class DPTThermalCapacity(DPT4ByteFloat): """DPT 14.071 DPT_Value_Thermal_Capacity.""" dpt_main_number = 14 dpt_sub_number = 71 value_type = "thermal_capacity" unit = "J/K" class DPTThermalConductivity(DPT4ByteFloat): """DPT 14.072 DPT_Value_Thermal_Conductivity.""" dpt_main_number = 14 dpt_sub_number = 72 value_type = "thermal_conductivity" unit = "W/mK" class DPTThermoelectricPower(DPT4ByteFloat): """DPT 14.073 DPT_Value_ThermoelectricPower.""" dpt_main_number = 14 dpt_sub_number = 73 value_type = "thermoelectric_power" unit = "V/K" class DPTTimeSeconds(DPT4ByteFloat): """DPT 14.074 DPT_Value_Time.""" dpt_main_number = 14 dpt_sub_number = 74 value_type = "time_seconds" unit = "s" class DPTTorque(DPT4ByteFloat): """DPT 14.075 DPT_Value_Torque.""" dpt_main_number = 14 dpt_sub_number = 75 value_type = "torque" unit = "Nm" class DPTVolume(DPT4ByteFloat): """DPT 14.076 DPT_Value_Volume.""" dpt_main_number = 14 dpt_sub_number = 76 value_type = "volume" unit = "m³" class DPTVolumeFlux(DPT4ByteFloat): """DPT 14.077 DPT_Value_Volume_Flux.""" dpt_main_number = 14 dpt_sub_number = 77 value_type = "volume_flux" unit = "m³/s" class DPTWeight(DPT4ByteFloat): """DPT 14.078 DPT_Value_Weight.""" dpt_main_number = 14 dpt_sub_number = 78 value_type = "weight" unit = "N" class DPTWork(DPT4ByteFloat): """DPT 14.079 DPT_Value_Work.""" dpt_main_number = 14 dpt_sub_number = 79 value_type = "work" unit = "J" class DPTApparentPower(DPT4ByteFloat): """DPT 14.080 DPT_Value_Apparent_Power.""" dpt_main_number = 14 dpt_sub_number = 80 value_type = "apparent_power" unit = "VA" ha_device_class = "apparent_power" xknx-3.6.0/xknx/dpt/dpt_16.py000066400000000000000000000034621475530762600157620ustar00rootroot00000000000000"""Implementation of 3.17 Datapoint Types String.""" from __future__ import annotations from xknx.exceptions import ConversionError from .dpt import DPTBase from .payload import DPTArray, DPTBinary class DPTString(DPTBase): """ Abstraction for KNX 14 Octet ASCII string. DPT 16.000 """ payload_type = DPTArray payload_length = 14 dpt_main_number = 16 dpt_sub_number = 0 value_type = "string" unit = None _encoding = "ascii" @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> str: """Parse/deserialize from KNX/IP raw data.""" raw = cls.validate_payload(payload) return bytes(byte for byte in raw if byte != 0x00).decode( cls._encoding, errors="replace" ) @classmethod def to_knx(cls, value: str) -> DPTArray: """Serialize to KNX/IP raw data.""" try: knx_value = str(value) if not cls._test_boundaries(knx_value): raise ValueError("Value out of range") except ValueError as err: raise ConversionError( f"Could not serialize {cls.dpt_name()}", value=value ) from err # replace invalid characters with question marks raw_bytes = knx_value.encode(cls._encoding, errors="replace") padding = bytes(cls.payload_length - len(raw_bytes)) return DPTArray(raw_bytes + padding) @classmethod def _test_boundaries(cls, value: str) -> bool: """Test if value is within defined range for this object.""" return len(value) <= cls.payload_length class DPTLatin1(DPTString): """ Abstraction for KNX 14 Octet Latin-1 (ISO 8859-1) string. DPT 16.001 """ dpt_main_number = 16 dpt_sub_number = 1 value_type = "latin_1" _encoding = "latin_1" xknx-3.6.0/xknx/dpt/dpt_17.py000066400000000000000000000024401475530762600157560ustar00rootroot00000000000000"""Implementation of KNX DPT 17 Scene.""" from __future__ import annotations from xknx.exceptions import ConversionError from .dpt_5 import DPTValue1ByteUnsigned from .payload import DPTArray, DPTBinary class DPTSceneNumber(DPTValue1ByteUnsigned): """ Abstraction for KNX 1 Octet Scene Number. DPT 17.001 """ dpt_main_number = 17 dpt_sub_number = 1 value_type = "scene_number" value_min = 1 value_max = 64 @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> int: """Parse/deserialize from KNX/IP raw data.""" value = cls.validate_payload(payload)[0] + 1 if not cls._test_boundaries(value): raise ConversionError( f"Could not parse {cls.dpt_name()}", value=value, payload=payload ) return value @classmethod def to_knx(cls, value: int | float) -> DPTArray: """Serialize to KNX/IP raw data.""" try: knx_value = int(value) - 1 if not cls._test_boundaries(knx_value + 1): raise ValueError("Value out of range") return DPTArray(knx_value) except ValueError as err: raise ConversionError( f"Could not serialize {cls.dpt_name()}", value=value ) from err xknx-3.6.0/xknx/dpt/dpt_18.py000066400000000000000000000040721475530762600157620ustar00rootroot00000000000000"""Implementation of KNX DPT 18 Scene control.""" from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass from typing import Any from .dpt import DPTComplex, DPTComplexData from .dpt_17 import DPTSceneNumber from .payload import DPTArray, DPTBinary @dataclass(slots=True) class SceneControl(DPTComplexData): """Class for scene control.""" scene_number: int learn: bool = False @classmethod def from_dict(cls, data: Mapping[str, Any]) -> SceneControl: """Init from a dictionary.""" try: scene_number = int(data["scene_number"]) learn = data.get("learn", False) except (KeyError, TypeError, ValueError) as err: raise ValueError(f"Invalid value for SceneControl: {err}") from err if learn not in (True, False): raise ValueError(f"Invalid value for SceneControl value `learn`: {learn}") return cls(scene_number=scene_number, learn=bool(learn)) def as_dict(self) -> dict[str, int | str]: """Create a JSON serializable dictionary.""" return { "scene_number": self.scene_number, "learn": self.learn, } class DPTSceneControl(DPTComplex[SceneControl]): """ Abstraction for KNX 1 Octet Scene Control. DPT 18.001 """ data_type = SceneControl payload_type = DPTArray payload_length = 1 dpt_main_number = 18 dpt_sub_number = 1 value_type = "scene_control" @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> SceneControl: """Parse/deserialize from KNX/IP raw data.""" raw = cls.validate_payload(payload)[0] return SceneControl( learn=bool(raw & 0x80), scene_number=(raw & 0x3F) + 1, ) @classmethod def _to_knx(cls, value: SceneControl) -> DPTArray: """Serialize to KNX/IP raw data.""" scene_payload = DPTSceneNumber.to_knx(value.scene_number) if value.learn: return DPTArray(scene_payload.value[0] | 0x80) return scene_payload xknx-3.6.0/xknx/dpt/dpt_19.py000066400000000000000000000172511475530762600157660ustar00rootroot00000000000000"""Implementation of the KNX datetime data point.""" from __future__ import annotations from collections.abc import Mapping from dataclasses import asdict, dataclass import datetime from typing import Any from xknx.exceptions import ConversionError from .dpt import DPTComplex, DPTComplexData, DPTEnumData from .payload import DPTArray, DPTBinary class KNXDayOfWeek(DPTEnumData): """Enum for the different KNX days.""" ANY_DAY = 0 MONDAY = 1 TUESDAY = 2 WEDNESDAY = 3 THURSDAY = 4 FRIDAY = 5 SATURDAY = 6 SUNDAY = 7 @dataclass(slots=True) class KNXDateTime(DPTComplexData): """ Class for KNX DateTime. `year`, `day_of_week` and `working_day` may be None if invalid. `month` and `day` have to be both either int or None (invalid). `hour`, `minutes` and `seconds` all have to be either int or None (invalid). """ year: int | None = None month: int | None = None day: int | None = None hour: int | None = None minutes: int | None = None seconds: int | None = None day_of_week: KNXDayOfWeek | None = None fault: bool = False working_day: bool | None = None dst: bool = False external_sync: bool = False source_reliable: bool = False @classmethod def from_dict(cls, data: Mapping[str, Any]) -> KNXDateTime: """Init from a dictionary.""" _data = {**data} if "day_of_week" in data: _data["day_of_week"] = KNXDayOfWeek.parse(data["day_of_week"]) try: return cls(**_data) except TypeError as err: raise ValueError(f"Invalid value for KNXDateTime: {err}") from err def as_dict(self) -> dict[str, int | str]: """Create a JSON serializable dictionary.""" _data = asdict(self) if self.day_of_week is not None: _data["day_of_week"] = self.day_of_week.name.lower() return _data def as_datetime(self) -> datetime.datetime: """Return datetime object.""" try: return datetime.datetime( self.year, # type: ignore[arg-type] self.month, # type: ignore[arg-type] self.day, # type: ignore[arg-type] self.hour, # type: ignore[arg-type] self.minutes, # type: ignore[arg-type] self.seconds, # type: ignore[arg-type] ) except (TypeError, ValueError) as err: raise ValueError( f"KNXDateTime not convertible to datetime object: {err}" ) from err @classmethod def from_datetime(cls, dt: datetime.datetime) -> KNXDateTime: """Return KNXDateTime object from a datetime.datetime object.""" return cls( year=dt.year, month=dt.month, day=dt.day, hour=dt.hour, minutes=dt.minute, seconds=dt.second, ) class DPTDateTime(DPTComplex[KNXDateTime]): """Abstraction for KNX 8 octet datetime (DPT 19.001).""" data_type = KNXDateTime payload_type = DPTArray payload_length = 8 dpt_main_number = 19 dpt_sub_number = 1 value_type = "datetime" @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> KNXDateTime: """Parse/deserialize from KNX/IP raw data.""" raw = cls.validate_payload(payload) year = raw[0] + 1900 month = raw[1] & 0b00001111 day = raw[2] & 0b00011111 weekday = (raw[3] & 0b11100000) >> 5 hour = raw[3] & 0b00011111 minutes = raw[4] & 0b00111111 seconds = raw[5] & 0b00111111 fault = bool(raw[6] & 0b10000000) _working_day = bool(raw[6] & 0b01000000) _working_day_invalid = bool(raw[6] & 0b00100000) _year_invalid = bool(raw[6] & 0b00010000) _date_invalid = bool(raw[6] & 0b00001000) # month, day _weekday_invalid = bool(raw[6] & 0b00000100) _time_invalid = bool(raw[6] & 0b00000010) # hour, minutes, seconds dst = bool(raw[6] & 0b00000001) external_sync = bool(raw[7] & 0b10000000) source_reliable = bool(raw[7] & 0b01000000) knx_date_time = KNXDateTime( year=year if not _year_invalid else None, month=month if not _date_invalid else None, day=day if not _date_invalid else None, day_of_week=KNXDayOfWeek(weekday) if not _weekday_invalid else None, hour=hour if not _time_invalid else None, minutes=minutes if not _time_invalid else None, seconds=seconds if not _time_invalid else None, fault=fault, working_day=_working_day if not _working_day_invalid else None, dst=dst, external_sync=external_sync, source_reliable=source_reliable, ) try: cls._test_range(knx_date_time) except ValueError as err: raise ConversionError(f"Could not parse {cls.dpt_name()}: {err}") from err return knx_date_time @classmethod def _to_knx(cls, value: KNXDateTime) -> DPTArray: """Serialize to KNX/IP raw data.""" try: cls._test_range(value) except ValueError as err: raise ConversionError( f"Could not serialize {cls.dpt_name()}: {err}" ) from err knx_year = (value.year - 1900) & 0xFF if value.year is not None else 0 month_day_invalid = None in (value.month, value.day) month = value.month if value.month is not None else 0 day = value.day if value.day is not None else 0 day_of_week = value.day_of_week.value if value.day_of_week is not None else 0 time_invalid = None in (value.hour, value.minutes, value.seconds) hour = value.hour if value.hour is not None else 0 minutes = value.minutes if value.minutes is not None else 0 seconds = value.seconds if value.seconds is not None else 0 return DPTArray( ( knx_year, month, day, day_of_week << 5 | hour, minutes, seconds, ( value.fault << 7 | bool(value.working_day) << 6 | (value.working_day is None) << 5 | (value.year is None) << 4 | month_day_invalid << 3 | (value.day_of_week is None) << 2 | time_invalid << 1 | value.dst ), (value.external_sync << 7 | value.source_reliable << 6), ) ) @staticmethod def _test_range(value: KNXDateTime) -> None: """Test if date is in valid range.""" if value.year is not None and not 1900 <= value.year <= 2155: raise ValueError(f"Year out of range 1900..2155: {value.year}") if value.month is not None and not 1 <= value.month <= 12: raise ValueError(f"Month out of range 1..12: {value.month}") if value.day is not None and not 1 <= value.day <= 31: raise ValueError(f"Day out of range 1..31: {value.day}") if value.hour is not None and not 0 <= value.hour <= 24: raise ValueError(f"Hour out of range 0..24: {value.hour}") if value.minutes is not None and not 0 <= value.minutes <= 59: raise ValueError(f"Minutes out of range 0..59: {value.minutes}") if value.seconds is not None and not 0 <= value.seconds <= 59: raise ValueError(f"Seconds out of range 0..59: {value.seconds}") if value.hour == 24 and (value.minutes != 0 or value.seconds != 0): raise ValueError( "Invalid time. When hour is 24, minutes and seconds have to be set to zero." ) xknx-3.6.0/xknx/dpt/dpt_20.py000066400000000000000000000126551475530762600157610ustar00rootroot00000000000000"""Implementation of different KNX DPT HVAC Operation modes.""" from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass from typing import Any from .dpt import DPTComplex, DPTComplexData, DPTEnum, DPTEnumData from .dpt_1 import HeatCool from .payload import DPTArray, DPTBinary class HVACOperationMode(DPTEnumData): """Enum for the different KNX HVAC operation modes.""" AUTO = 0 COMFORT = 1 STANDBY = 2 ECONOMY = 3 BUILDING_PROTECTION = 4 class DPTHVACMode(DPTEnum[HVACOperationMode]): """ Abstraction for KNX HVAC mode. DPT 20.102 """ dpt_main_number = 20 dpt_sub_number = 102 value_type = "hvac_mode" data_type = HVACOperationMode @classmethod def _to_knx(cls, value: HVACOperationMode) -> DPTArray: """Return the raw KNX value for an Enum member.""" return DPTArray(value.value) class HVACControllerMode(DPTEnumData): """Enum for the different KNX HVAC controller modes.""" AUTO = 0 HEAT = 1 MORNING_WARMUP = 2 COOL = 3 NIGHT_PURGE = 4 PRECOOL = 5 OFF = 6 TEST = 7 EMERGENCY_HEAT = 8 FAN_ONLY = 9 FREE_COOL = 10 ICE = 11 MAXIMUM_HEATING_MODE = 12 ECONOMIC_HEAT_COOL_MODE = 13 DEHUMIDIFICATION = 14 CALIBRATION_MODE = 15 EMERGENCY_COOL_MODE = 16 EMERGENCY_STEAM_MODE = 17 NODEM = 20 class DPTHVACContrMode(DPTEnum[HVACControllerMode]): """ Abstraction for KNX HVAC controller mode. DPT 20.105 """ dpt_main_number = 20 dpt_sub_number = 105 value_type = "hvac_controller_mode" data_type = HVACControllerMode @classmethod def _to_knx(cls, value: HVACControllerMode) -> DPTArray: """Return the raw KNX value for an Enum member.""" return DPTArray(value.value) @dataclass(slots=True) class HVACStatus(DPTComplexData): """Class for HVACStatus.""" mode: HVACOperationMode dew_point: bool heat_cool: HeatCool inactive: bool frost_alarm: bool @classmethod def from_dict(cls, data: Mapping[str, Any]) -> HVACStatus: """Init from a dictionary.""" try: _mode = data["mode"] _heat_cool = data["heat_cool"] try: mode = HVACOperationMode[_mode.upper()] heat_cool = HeatCool[_heat_cool.upper()] except AttributeError as err: raise ValueError( f"Invalid type for HVAC mode or heat_cool: {err}" ) from err dew_point = data["dew_point"] inactive = data["inactive"] frost_alarm = data["frost_alarm"] except KeyError as err: raise ValueError(f"Missing required key: {err}") from err if ( not isinstance(dew_point, bool) or not isinstance(inactive, bool) or not isinstance(frost_alarm, bool) ): raise ValueError( f"Invalid value for HVACStatus boolean fields: {dew_point=}, {inactive=}, {frost_alarm=}" ) return cls( mode=mode, dew_point=dew_point, heat_cool=heat_cool, inactive=inactive, frost_alarm=frost_alarm, ) def as_dict(self) -> dict[str, str | bool]: """Create a JSON serializable dictionary.""" return { "mode": self.mode.name.lower(), "dew_point": self.dew_point, "heat_cool": self.heat_cool.name.lower(), "inactive": self.inactive, "frost_alarm": self.frost_alarm, } class DPTHVACStatus(DPTComplex[HVACStatus]): """ Abstraction for KNX HVACStatus. Non-standardised DP type (in accordance with KNX AN097 “Eberle Status Byte”). """ data_type = HVACStatus payload_type = DPTArray payload_length = 1 dpt_main_number = 20 dpt_sub_number = 60102 value_type = "hvac_status" @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> HVACStatus: """Parse/deserialize from KNX/IP raw data.""" raw = cls.validate_payload(payload)[0] mode: HVACOperationMode if raw & 0x1: mode = HVACOperationMode.COMFORT elif raw & 0x2: mode = HVACOperationMode.STANDBY elif raw & 0x4: mode = HVACOperationMode.ECONOMY elif raw & 0x8: mode = HVACOperationMode.BUILDING_PROTECTION else: mode = HVACOperationMode.AUTO # not sure if this is possible / valid return HVACStatus( mode=mode, dew_point=bool(raw & 0x10), heat_cool=HeatCool.HEAT if raw & 0x20 else HeatCool.COOL, inactive=bool(raw & 0x40), frost_alarm=bool(raw & 0x80), ) @classmethod def _to_knx(cls, value: HVACStatus) -> DPTArray: """Serialize to KNX/IP raw data.""" raw = 0 if value.mode is HVACOperationMode.COMFORT: raw |= 0x1 elif value.mode is HVACOperationMode.STANDBY: raw |= 0x2 elif value.mode is HVACOperationMode.ECONOMY: raw |= 0x4 elif value.mode is HVACOperationMode.BUILDING_PROTECTION: raw |= 0x8 if value.dew_point: raw |= 0x10 if value.heat_cool is HeatCool.HEAT: raw |= 0x20 if value.inactive: raw |= 0x40 if value.frost_alarm: raw |= 0x80 return DPTArray(raw) xknx-3.6.0/xknx/dpt/dpt_232.py000066400000000000000000000041421475530762600160360ustar00rootroot00000000000000"""Implementation of KNX RGB color data point type.""" from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass from typing import Any from .dpt import DPTComplex, DPTComplexData from .payload import DPTArray, DPTBinary @dataclass(slots=True) class RGBColor(DPTComplexData): """ Representation of RGB color. `red`: int 0..255 `green`: int 0..255 `blue`: int 0..255 """ red: int green: int blue: int @classmethod def from_dict(cls, data: Mapping[str, Any]) -> RGBColor: """Init from a dictionary.""" try: red = int(data["red"]) green = int(data["green"]) blue = int(data["blue"]) except KeyError as err: raise ValueError(f"Missing required key: {err}") from err except (ValueError, TypeError) as err: raise ValueError(f"Invalid value for RGB color: {err}") from err return cls(red=red, green=green, blue=blue) def as_dict(self) -> dict[str, int]: """Create a JSON serializable dictionary.""" return { "red": self.red, "green": self.green, "blue": self.blue, } class DPTColorRGB(DPTComplex[RGBColor]): """Abstraction for KNX 3 octet color RGB (DPT 232.600).""" data_type = RGBColor payload_type = DPTArray payload_length = 3 dpt_main_number = 232 dpt_sub_number = 600 value_type = "color_rgb" @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> RGBColor: """Parse/deserialize from KNX/IP raw data.""" raw = cls.validate_payload(payload) return RGBColor( red=raw[0], green=raw[1], blue=raw[2], ) @classmethod def _to_knx(cls, value: RGBColor) -> DPTArray: """Serialize to KNX/IP raw data.""" colors: tuple[int, int, int] = (value.red, value.green, value.blue) for color in colors: if not 0 <= color <= 255: raise ValueError(f"Color value out of range 0..255: {value}") return DPTArray(colors) xknx-3.6.0/xknx/dpt/dpt_235.py000066400000000000000000000056771475530762600160570ustar00rootroot00000000000000"""Implementation of the KNX 235 data point type.""" from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass from .dpt import DPTComplex, DPTComplexData from .dpt_5 import DPTTariff from .dpt_13 import DPTActiveEnergy from .payload import DPTArray, DPTBinary @dataclass(slots=True) class TariffActiveEnergy(DPTComplexData): """ Representation of Tariff and ActiveEnergy. `energy`: int 4byte signed; None if invalid `tariff`: int 0..254; None if invalid """ energy: int | None = None tariff: int | None = None @classmethod def from_dict(cls, data: Mapping[str, int]) -> TariffActiveEnergy: """Init from a dictionary.""" try: energy = data.get("energy") tariff = data.get("tariff") except AttributeError as err: raise ValueError(f"Invalid value for TariffActiveEnergy: {err}") from err if energy is not None: try: energy = int(energy) except ValueError as err: raise ValueError(f"Invalid value for energy: {err}") from err if tariff is not None: try: tariff = int(tariff) except ValueError as err: raise ValueError(f"Invalid value for tariff: {err}") from err return cls(energy=energy, tariff=tariff) def as_dict(self) -> dict[str, int | None]: """Create a JSON serializable dictionary.""" return { "energy": self.energy, "tariff": self.tariff, } class DPTTariffActiveEnergy(DPTComplex[TariffActiveEnergy]): """Abstraction for KNX 6 octet Tariff and ActiveEnergy (DPT 235.001).""" data_type = TariffActiveEnergy payload_type = DPTArray payload_length = 6 dpt_main_number = 235 dpt_sub_number = 1 value_type = "tariff_active_energy" @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> TariffActiveEnergy: """Parse/deserialize from KNX/IP raw data.""" raw = cls.validate_payload(payload) energy_valid = raw[5] >> 1 & 0b1 tariff_valid = raw[5] & 0b1 return TariffActiveEnergy( energy=( DPTActiveEnergy.from_knx(DPTArray(raw[:4])) if energy_valid else None ), tariff=DPTTariff.from_knx(DPTArray([raw[4]])) if tariff_valid else None, ) @classmethod def _to_knx(cls, value: TariffActiveEnergy) -> DPTArray: """Serialize to KNX/IP raw data.""" energy = ( DPTActiveEnergy.to_knx(value.energy).value if value.energy is not None else [0, 0, 0, 0] ) tariff = ( DPTTariff.to_knx(value.tariff).value if value.tariff is not None else [0] ) return DPTArray( ( *energy, *tariff, (value.energy is not None) << 1 | (value.tariff is not None), ) ) xknx-3.6.0/xknx/dpt/dpt_242.py000066400000000000000000000102321475530762600160340ustar00rootroot00000000000000"""Implementation of KNX XYY color data point type.""" from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass from typing import Any from .dpt import DPTComplex, DPTComplexData from .payload import DPTArray, DPTBinary @dataclass(slots=True) class XYYColor(DPTComplexData): """ Representation of XY color with brightness. `color`: tuple(x-axis, y-axis) each 0..1; None if invalid. `brightness`: int 0..255; None if invalid. """ color: tuple[float, float] | None = None brightness: int | None = None @classmethod def from_dict(cls, data: Mapping[str, Any]) -> XYYColor: """Init from a dictionary.""" color = None brightness = data.get("brightness") x_axis = data.get("x_axis") y_axis = data.get("y_axis") if x_axis is not None and y_axis is not None: try: color = (float(x_axis), float(y_axis)) except ValueError as err: raise ValueError(f"Invalid value for color axis: {err}") from err elif x_axis is not None or y_axis is not None: raise ValueError("Both x_axis and y_axis must be provided") if brightness is not None: try: brightness = int(brightness) except ValueError as err: raise ValueError(f"Invalid value for brightness: {err}") from err return cls(color=color, brightness=brightness) def as_dict(self) -> dict[str, int | float | None]: """Create a JSON serializable dictionary.""" return { "x_axis": self.color[0] if self.color is not None else None, "y_axis": self.color[1] if self.color is not None else None, "brightness": self.brightness, } def __or__(self, other: XYYColor) -> XYYColor: """Merge two XYYColor objects using only valid values.""" return XYYColor( color=other.color if other.color is not None else self.color, brightness=other.brightness if other.brightness is not None else self.brightness, ) class DPTColorXYY(DPTComplex[XYYColor]): """Abstraction for KNX 6 octet color xyY (DPT 242.600).""" data_type = XYYColor payload_type = DPTArray payload_length = 6 dpt_main_number = 242 dpt_sub_number = 600 value_type = "color_xyy" @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> XYYColor: """Parse/deserialize from KNX/IP raw data.""" raw = cls.validate_payload(payload) x_axis_int = raw[0] << 8 | raw[1] y_axis_int = raw[2] << 8 | raw[3] brightness = raw[4] color_valid = raw[5] >> 1 & 0b1 brightness_valid = raw[5] & 0b1 return XYYColor( color=( # round to 5 digits for better readability but still preserving precision round(x_axis_int / 0xFFFF, 5), round(y_axis_int / 0xFFFF, 5), ) if color_valid else None, brightness=brightness if brightness_valid else None, ) @classmethod def _to_knx(cls, value: XYYColor) -> DPTArray: """Serialize to KNX/IP raw data.""" x_axis, y_axis, brightness = 0, 0, 0 color_valid = False brightness_valid = False if value.color is not None: for axis in value.color: if not 0 <= axis <= 1: raise ValueError( f"Color axis value out of range 0..1: {value.color}" ) x_axis, y_axis = (round(axis * 0xFFFF) for axis in value.color) color_valid = True if value.brightness is not None: brightness = value.brightness if not 0 <= brightness <= 255: raise ValueError(f"Brightness out of range 0..255: {brightness}") brightness_valid = True return DPTArray( ( x_axis >> 8, x_axis & 0xFF, y_axis >> 8, y_axis & 0xFF, brightness, color_valid << 1 | brightness_valid, ) ) xknx-3.6.0/xknx/dpt/dpt_251.py000066400000000000000000000072111475530762600160370ustar00rootroot00000000000000"""Implementation of KNX RGBW color data point type.""" from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass from .dpt import DPTComplex, DPTComplexData from .payload import DPTArray, DPTBinary @dataclass(slots=True) class RGBWColor(DPTComplexData): """ Representation of RGBW color. `red`: int 0..255; None if invalid `green`: int 0..255; None if invalid `blue`: int 0..255; None if invalid `white`: int 0..255; None if invalid """ red: int | None = None green: int | None = None blue: int | None = None white: int | None = None @classmethod def from_dict(cls, data: Mapping[str, int]) -> RGBWColor: """Init from a dictionary.""" result = {} for color in ("red", "green", "blue", "white"): try: value = data.get(color) result[color] = int(value) if value is not None else None except AttributeError as err: raise ValueError(f"Invalid value for color {color}: {err}") from err except ValueError as err: raise ValueError(f"Invalid value for color {color}: {err}") from err return cls(**result) def as_dict(self) -> dict[str, int | None]: """Create a JSON serializable dictionary.""" return { "red": self.red, "green": self.green, "blue": self.blue, "white": self.white, } def __or__(self, other: RGBWColor) -> RGBWColor: """Merge two RGBWColor objects using only valid values.""" return RGBWColor( red=other.red if other.red is not None else self.red, green=other.green if other.green is not None else self.green, blue=other.blue if other.blue is not None else self.blue, white=other.white if other.white is not None else self.white, ) class DPTColorRGBW(DPTComplex[RGBWColor]): """Abstraction for KNX 6 octet color RGBW (DPT 251.600).""" data_type = RGBWColor payload_type = DPTArray payload_length = 6 dpt_main_number = 251 dpt_sub_number = 600 value_type = "color_rgbw" @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> RGBWColor: """Parse/deserialize from KNX/IP raw data.""" raw = cls.validate_payload(payload) red_valid = raw[5] >> 3 & 0b1 green_valid = raw[5] >> 2 & 0b1 blue_valid = raw[5] >> 1 & 0b1 white_valid = raw[5] & 0b1 return RGBWColor( red=raw[0] if red_valid else None, green=raw[1] if green_valid else None, blue=raw[2] if blue_valid else None, white=raw[3] if white_valid else None, ) @classmethod def _to_knx(cls, value: RGBWColor) -> DPTArray: """Serialize to KNX/IP raw data.""" for color in (value.red, value.green, value.blue, value.white): if color is None: continue if not 0 <= color <= 255: raise ValueError(f"Color value out of range 0..255: {value}") return DPTArray( ( value.red if value.red is not None else 0x00, value.green if value.green is not None else 0x00, value.blue if value.blue is not None else 0x00, value.white if value.white is not None else 0x00, 0x00, # reserved ( (value.red is not None) << 3 | (value.green is not None) << 2 | (value.blue is not None) << 1 | (value.white is not None) ), ) ) xknx-3.6.0/xknx/dpt/dpt_29.py000066400000000000000000000022431475530762600157620ustar00rootroot00000000000000"""Implementation of Basic KNX 8-Byte signed (2's complement) values.""" from __future__ import annotations from .dpt import DPTNumeric, DPTStructIntMixin class DPT8ByteSigned(DPTStructIntMixin, DPTNumeric): """ Abstraction for KNX 8 Byte "64-bit signed". DPT 29.*** """ dpt_main_number = 29 dpt_sub_number: int | None = None payload_length = 8 value_type = "8byte_signed" value_min = -9_223_372_036_854_775_808 value_max = 9_223_372_036_854_775_807 resolution = 1 _struct_format = ">q" class DPTActiveEnergy8Byte(DPT8ByteSigned): """DPT 29.010 DPT_Active_Energy_V64.""" dpt_main_number = 29 dpt_sub_number = 10 value_type = "active_energy_8byte" unit = "Wh" ha_device_class = "energy" class DPTApparantEnergy8Byte(DPT8ByteSigned): """DPT 29.011 DPT_Apparant_Energy_V64 (VAh).""" dpt_main_number = 29 dpt_sub_number = 11 value_type = "apparant_energy_8byte" unit = "VAh" class DPTReactiveEnergy8Byte(DPT8ByteSigned): """DPT 29.012 DPT_Reactive_Energy_V64 (VARh).""" dpt_main_number = 29 dpt_sub_number = 12 value_type = "reactive_energy_8byte" unit = "VARh" xknx-3.6.0/xknx/dpt/dpt_3.py000066400000000000000000000077211475530762600157000ustar00rootroot00000000000000"""Implementation of KNX DPT 3 4-bit control.""" from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass from typing import Any from xknx.exceptions import ConversionError from .dpt import DPTComplex, DPTComplexData from .dpt_1 import Step, UpDown from .payload import DPTArray, DPTBinary @dataclass(slots=True) class ControlDimming(DPTComplexData): """ Class for dimming control. Range is subdivided into `2**(step_code-1)`; `step_code=0` indicates break. """ control: Step step_code: int # 1..7 higher is more intervals -> slower; 0 stop @classmethod def from_dict(cls, data: Mapping[str, Any]) -> ControlDimming: """Init from a dictionary.""" try: control = Step.parse(data["control"]) step_code = int(data["step_code"]) except (KeyError, TypeError, ValueError) as err: raise ValueError(f"Invalid value for ControlDimming: {err}") from err return cls(control=control, step_code=step_code) def as_dict(self) -> dict[str, int | str]: """Create a JSON serializable dictionary.""" return { "control": self.control.name.lower(), "step_code": self.step_code, } class DPTControlDimming(DPTComplex[ControlDimming]): """ Abstraction for KNX 4-Bit dimming control. DPT 3.007 """ data_type = ControlDimming payload_type = DPTBinary payload_length = 4 dpt_main_number = 3 dpt_sub_number = 7 value_type = "control_dimming" @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> ControlDimming: """Parse/deserialize from KNX/IP payload data.""" raw = cls.validate_payload(payload)[0] return ControlDimming( control=Step((raw & 0b1000) >> 3), step_code=raw & 0b0111, ) @classmethod def _to_knx(cls, value: ControlDimming) -> DPTBinary: """Serialize to KNX/IP raw data.""" if not 0 <= value.step_code <= 7: raise ConversionError("Invalid value for step_code: must be 0..7") return DPTBinary(value.control.value << 3 | value.step_code) @dataclass(slots=True) class ControlBlinds(DPTComplexData): """ Class for blinds control. Range is subdivided into `2**(step_code-1)`; `step_code=0` indicates break. """ control: UpDown step_code: int # 1..7 higher is more intervals -> slower; 0 stop @classmethod def from_dict(cls, data: Mapping[str, Any]) -> ControlBlinds: """Init from a dictionary.""" try: control = UpDown.parse(data["control"]) step_code = int(data["step_code"]) except (KeyError, TypeError, ValueError) as err: raise ValueError(f"Invalid value for ControlBlinds: {err}") from err return cls(control=control, step_code=step_code) def as_dict(self) -> dict[str, int | str]: """Create a JSON serializable dictionary.""" return { "control": self.control.name.lower(), "step_code": self.step_code, } class DPTControlBlinds(DPTComplex[ControlBlinds]): """ Abstraction for KNX 4-Bit dimming control. DPT 3.008 """ data_type = ControlBlinds payload_type = DPTBinary payload_length = 4 dpt_main_number = 3 dpt_sub_number = 8 value_type = "control_blinds" @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> ControlBlinds: """Parse/deserialize from KNX/IP payload data.""" raw = cls.validate_payload(payload)[0] return ControlBlinds( control=UpDown((raw & 0b1000) >> 3), step_code=raw & 0b0111, ) @classmethod def _to_knx(cls, value: ControlBlinds) -> DPTBinary: """Serialize to KNX/IP raw data.""" if not 0 <= value.step_code <= 7: raise ConversionError("Invalid value for step_code: must be 0..7") return DPTBinary(value.control.value << 3 | value.step_code) xknx-3.6.0/xknx/dpt/dpt_5.py000066400000000000000000000102441475530762600156740ustar00rootroot00000000000000"""Implementation of Basic KNX DPT_1_Ucount Values.""" from __future__ import annotations from xknx.exceptions import ConversionError from .dpt import DPTNumeric from .payload import DPTArray, DPTBinary class DPTValue1ByteUnsigned(DPTNumeric): """ Abstraction for KNX 1 Octet. DPT 5.*** """ dpt_main_number = 5 dpt_sub_number: int | None = None value_type = "1byte_unsigned" payload_length = 1 value_min = 0 value_max = 255 resolution = 1 @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> int: """Parse/deserialize from KNX/IP raw data.""" value = cls.validate_payload(payload)[0] if not cls._test_boundaries(value): raise ConversionError( f"Could not parse {cls.dpt_name()}", value=value, payload=payload ) return value @classmethod def to_knx(cls, value: int | float) -> DPTArray: """Serialize to KNX/IP raw data.""" try: knx_value = int(value) if not cls._test_boundaries(knx_value): raise ValueError(f"Value out of range {cls.value_min}..{cls.value_max}") return DPTArray(knx_value) except ValueError as err: raise ConversionError( f"Could not serialize {cls.dpt_name()}", value=value ) from err @classmethod def _test_boundaries(cls, value: int) -> bool: """Test if value is within defined range for this object.""" return cls.value_min <= value <= cls.value_max class DPTScaling(DPTNumeric): """ Abstraction for KNX 1 Octet Percent. DPT 5.001 """ dpt_main_number = 5 dpt_sub_number = 1 value_type = "percent" unit = "%" payload_length = 1 value_min = 0 value_max = 100 resolution = 1 @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> int: """Parse/deserialize from KNX/IP raw data.""" knx_value = cls.validate_payload(payload)[0] delta = cls.value_max - cls.value_min value = round((knx_value / 255) * delta) + cls.value_min if not cls._test_boundaries(value): raise ConversionError( f"Could not parse {cls.dpt_name()}", value=value, payload=payload ) return value @classmethod def to_knx(cls, value: float) -> DPTArray: """Serialize to KNX/IP raw data.""" try: percent_value = float(value) if not cls._test_boundaries(percent_value): raise ValueError("Value out of range") delta = cls.value_max - cls.value_min knx_value = round((percent_value - cls.value_min) / delta * 255) return DPTArray(knx_value) except ValueError as err: raise ConversionError( f"Could not serialize {cls.dpt_name()}", value=value ) from err @classmethod def _test_boundaries(cls, value: float) -> bool: """Test if value is within defined range for this object.""" return cls.value_min <= value <= cls.value_max class DPTAngle(DPTScaling): """ Abstraction for KNX 1 Octet Angle. DPT 5.003 """ dpt_main_number = 5 dpt_sub_number = 3 value_type = "angle" unit = "°" value_min = 0 value_max = 360 resolution = 1 class DPTPercentU8(DPTValue1ByteUnsigned): """ Abstraction for KNX 1 Octet Percent. DPT 5.004 """ dpt_main_number = 5 dpt_sub_number = 4 value_type = "percentU8" unit = "%" resolution = 1 class DPTDecimalFactor(DPTValue1ByteUnsigned): """ Abstraction for KNX 1 Octet Percent. DPT 5.005 """ dpt_main_number = 5 dpt_sub_number = 5 value_type = "decimal_factor" class DPTTariff(DPTValue1ByteUnsigned): """ Abstraction for KNX 1 Octet tariff information. DPT 5.006 """ dpt_main_number = 5 dpt_sub_number = 6 value_type = "tariff" value_max = 254 class DPTValue1Ucount(DPTValue1ByteUnsigned): """ Abstraction for KNX 1 Octet counter pulses. DPT 5.010 """ dpt_main_number = 5 dpt_sub_number = 10 value_type = "pulse" unit = "counter pulses" xknx-3.6.0/xknx/dpt/dpt_6.py000066400000000000000000000036721475530762600157040ustar00rootroot00000000000000"""Implementation of Basic KNX 1-Byte signed integer values.""" from __future__ import annotations from xknx.exceptions import ConversionError from .dpt import DPTNumeric from .payload import DPTArray, DPTBinary class DPTSignedRelativeValue(DPTNumeric): """ Abstraction for KNX 1 Byte "1-octet Signed Relative Value". DPT 6.*** """ dpt_main_number = 6 dpt_sub_number: int | None = None value_type = "1byte_signed" payload_length = 1 value_min = -128 value_max = 127 resolution = 1 @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> int: """Parse/deserialize from KNX/IP raw data.""" raw = cls.validate_payload(payload) if raw[0] > cls.value_max: return raw[0] - 0x100 return raw[0] @classmethod def to_knx(cls, value: int | float) -> DPTArray: """Serialize to KNX/IP raw data.""" try: knx_value = int(value) if not cls._test_boundaries(knx_value): raise ValueError("Value out of range") if knx_value < 0: knx_value += 0x100 return DPTArray(knx_value & 0xFF) except ValueError as err: raise ConversionError( f"Could not serialize {cls.dpt_name()}", value=value ) from err @classmethod def _test_boundaries(cls, value: int) -> bool: """Test if value is within defined range for this object.""" return cls.value_min <= value <= cls.value_max class DPTPercentV8(DPTSignedRelativeValue): """ Abstraction for KNX DPT_Percent_V8. DPT 6.001 """ dpt_main_number = 6 dpt_sub_number = 1 value_type = "percentV8" unit = "%" class DPTValue1Count(DPTSignedRelativeValue): """ Abstraction for KNX DPT_Value_1_Count. DPT 6.010 """ dpt_main_number = 6 dpt_sub_number = 10 value_type = "counter_pulses" unit = "counter pulses" xknx-3.6.0/xknx/dpt/dpt_7.py000066400000000000000000000074721475530762600157070ustar00rootroot00000000000000"""Implementation of Basic KNX 2-Byte/octet values.""" from __future__ import annotations from xknx.exceptions import ConversionError from .dpt import DPTNumeric from .payload import DPTArray, DPTBinary class DPT2ByteUnsigned(DPTNumeric): """ Abstraction for KNX 2 Byte "2-octet unsigned value". Contains smaller counters, timers etc. DPT 7.*** """ dpt_main_number = 7 dpt_sub_number: int | None = None value_type = "2byte_unsigned" payload_length = 2 value_min = 0 value_max = 65535 resolution = 1 @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> int: """Parse/deserialize from KNX/IP raw data.""" raw = cls.validate_payload(payload) return ((raw[0] * 256) + raw[1]) * cls.resolution @classmethod def to_knx(cls, value: int | float) -> DPTArray: """Serialize to KNX/IP raw data.""" try: knx_value = int(value) // cls.resolution if not cls._test_boundaries(knx_value): raise ValueError("Value out of range") return DPTArray((knx_value >> 8, knx_value & 0xFF)) except ValueError as err: raise ConversionError( f"Could not serialize {cls.dpt_name()}", value=value ) from err @classmethod def _test_boundaries(cls, value: int) -> bool: """Test if value is within defined range for this object.""" return cls.value_min <= value <= cls.value_max class DPT2Ucount(DPT2ByteUnsigned): """DPT 7.001 DPT_Value_2_Ucount.""" dpt_main_number = 7 dpt_sub_number = 1 value_type = "pulse_2byte" unit = "pulses" class DPTTimePeriodMsec(DPT2ByteUnsigned): """DPT 7.002 DPT_TimePeriodMsec (ms).""" dpt_main_number = 7 dpt_sub_number = 2 value_type = "time_period_msec" unit = "ms" class DPTTimePeriod10Msec(DPT2ByteUnsigned): """DPT 7.003 DPT_TimePeriod10Msec (ms).""" dpt_main_number = 7 dpt_sub_number = 3 value_type = "time_period_10msec" resolution = 10 unit = "ms" class DPTTimePeriod100Msec(DPT2ByteUnsigned): """DPT 7.004 DPT_TimePeriod100Msec (ms).""" dpt_main_number = 7 dpt_sub_number = 4 value_type = "time_period_100msec" resolution = 100 unit = "ms" class DPTTimePeriodSec(DPT2ByteUnsigned): """DPT 7.005 DPT_TimePeriodSec (s).""" dpt_main_number = 7 dpt_sub_number = 5 value_type = "time_period_sec" unit = "s" class DPTTimePeriodMin(DPT2ByteUnsigned): """DPT 7.006 DPT_TimePeriodMin (min).""" dpt_main_number = 7 dpt_sub_number = 6 value_type = "time_period_min" unit = "min" class DPTTimePeriodHrs(DPT2ByteUnsigned): """DPT 7.007 DPT_TimePeriodHrs (h).""" dpt_main_number = 7 dpt_sub_number = 7 value_type = "time_period_hrs" unit = "h" class DPTPropDataType(DPT2ByteUnsigned): """DPT 7.010 DPT_PropDataType.""" dpt_main_number = 7 dpt_sub_number = 10 value_type = "prop_data_type" class DPTLengthMm(DPT2ByteUnsigned): """DPT 7.011 Abstraction for KNX 2 Byte DPT_Length_mm (mm).""" dpt_main_number = 7 dpt_sub_number = 11 value_type = "length_mm" unit = "mm" ha_device_class = "distance" class DPTUElCurrentmA(DPT2ByteUnsigned): """DPT 7.012 Abstraction for KNX 2 Byte DPTUElCurrentmA.""" dpt_main_number = 7 dpt_sub_number = 12 value_type = "current" unit = "mA" ha_device_class = "current" class DPTBrightness(DPT2ByteUnsigned): """DPT 7.013 DPT_Brightness (lux).""" dpt_main_number = 7 dpt_sub_number = 13 value_type = "brightness" unit = "lx" ha_device_class = "illuminance" class DPTColorTemperature(DPT2ByteUnsigned): """DPT 7.600 DPT_Color_Temperature (K).""" dpt_main_number = 7 dpt_sub_number = 600 value_type = "color_temperature" unit = "K" xknx-3.6.0/xknx/dpt/dpt_8.py000066400000000000000000000071601475530762600157020ustar00rootroot00000000000000""" Implementation of Basic KNX 2-Byte Signed Values. They correspond the following KNX DPTs: 8.*** 2-byte/octet signed (2's complement), i.e. percentV16, delta time """ from __future__ import annotations import struct from xknx.exceptions import ConversionError from .dpt import DPTNumeric from .payload import DPTArray, DPTBinary class DPT2ByteSigned(DPTNumeric): """ Abstraction for KNX 2 Byte signed values. DPT 8.*** """ dpt_main_number = 8 dpt_sub_number: int | None = None value_type = "2byte_signed" payload_length = 2 value_min = -32768 value_max = 32767 resolution: int | float = 1 # not using DPTStructIntMixin because return type of from_knx can be float when resolution is < 1 _struct_format = ">h" @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> int | float: """Parse/deserialize from KNX/IP raw data.""" raw = cls.validate_payload(payload) try: return struct.unpack(cls._struct_format, bytes(raw))[0] * cls.resolution # type: ignore[no-any-return] except struct.error as err: raise ConversionError(f"Could not parse {cls.dpt_name()}", raw=raw) from err @classmethod def to_knx(cls, value: int | float) -> DPTArray: """Serialize to KNX/IP raw data.""" try: knx_value = int(float(value) / cls.resolution) if not (cls.value_min <= knx_value <= cls.value_max): raise ValueError("Value out of range") return DPTArray(struct.pack(cls._struct_format, knx_value)) except (ValueError, struct.error) as err: raise ConversionError( f"Could not serialize {cls.dpt_name()}", value=value ) from err class DPTValue2Count(DPT2ByteSigned): """DPT 8.001 DPT_Value_2_Count (pulses).""" dpt_main_number = 8 dpt_sub_number = 1 value_type = "pulse_2byte_signed" unit = "pulses" class DPTDeltaTimeMsec(DPT2ByteSigned): """DPT 8.002 DPT_DeltaTimeMsec (ms).""" dpt_main_number = 8 dpt_sub_number = 2 value_type = "delta_time_ms" unit = "ms" class DPTDeltaTime10Msec(DPT2ByteSigned): """DPT 8.003 DPT_DeltaTime10Msec (ms).""" dpt_main_number = 8 dpt_sub_number = 3 value_type = "delta_time_10ms" resolution = 10 unit = "ms" class DPTDeltaTime100Msec(DPT2ByteSigned): """DPT 8.004 DPT_DeltaTime100Msec (ms).""" dpt_main_number = 8 dpt_sub_number = 4 value_type = "delta_time_100ms" resolution = 100 unit = "ms" class DPTDeltaTimeSec(DPT2ByteSigned): """DPT 8.005 DPT_DeltaTimeSec (s).""" dpt_main_number = 8 dpt_sub_number = 5 value_type = "delta_time_sec" unit = "s" class DPTDeltaTimeMin(DPT2ByteSigned): """DPT 8.006 DPT_DeltaTimeMin (min).""" dpt_main_number = 8 dpt_sub_number = 6 value_type = "delta_time_min" unit = "min" class DPTDeltaTimeHrs(DPT2ByteSigned): """DPT 8.007 DPT_DeltaTimeHrs (h).""" dpt_main_number = 8 dpt_sub_number = 7 value_type = "delta_time_hrs" unit = "h" class DPTPercentV16(DPT2ByteSigned): """DPT 8.010 DPT_Percent_V16 (%).""" dpt_main_number = 8 dpt_sub_number = 10 value_type = "percentV16" resolution = 0.01 unit = "%" class DPTRotationAngle(DPT2ByteSigned): """DPT 8.011 DPT_Rotation_Angle (°).""" dpt_main_number = 8 dpt_sub_number = 11 value_type = "rotation_angle" unit = "°" class DPTLengthM(DPT2ByteSigned): """DPT 8.012 DPT_Length_m.""" dpt_main_number = 8 dpt_sub_number = 12 value_type = "length_m" unit = "m" ha_device_class = "distance" xknx-3.6.0/xknx/dpt/dpt_9.py000066400000000000000000000166701475530762600157110ustar00rootroot00000000000000""" Implementation of KNX 2 byte Float-values. They correspond to the the following KDN DPT 9 class. """ from __future__ import annotations from xknx.exceptions import ConversionError from .dpt import DPTNumeric from .payload import DPTArray, DPTBinary class DPT2ByteFloat(DPTNumeric): """ Abstraction for KNX 2 Octet Floating Point Numbers. DPT 9.*** """ dpt_main_number = 9 dpt_sub_number: int | None = None value_type = "2byte_float" payload_length = 2 value_min = -671088.64 value_max = 670760.96 resolution = 0.01 @classmethod def from_knx(cls, payload: DPTArray | DPTBinary) -> float: """Parse/deserialize from KNX/IP raw data.""" raw = cls.validate_payload(payload) data = (raw[0] * 256) + raw[1] exponent = (data >> 11) & 0x0F significand = data & 0x7FF sign = data >> 15 if sign == 1: significand = significand - 2048 value = float(significand << exponent) / 100 if not cls._test_boundaries(value): raise ConversionError(f"Could not parse {cls.dpt_name()}", value=value) return value @classmethod def to_knx(cls, value: float) -> DPTArray: """Serialize to KNX/IP raw data.""" try: value = float(value) if not cls._test_boundaries(value): raise ValueError("Value out of range") knx_value = value * 100 if round(knx_value) == 0: # ETS converts values near 0 like this # -0.0051 -> 0x87FF # -0.005 -> 0x8000 ... we don't want this since its equivalent to -20.48 # 0.005 -> 0x0000 # 0.0051 -> 0x0001 return DPTArray((0x00, 0x00)) exponent = 0 while not -2048 <= knx_value <= 2047: exponent += 1 knx_value /= 2 mantisse = int(round(knx_value)) & 0x7FF msb = exponent << 3 | mantisse >> 8 if knx_value < 0: msb |= 0x80 return DPTArray((msb, mantisse & 0xFF)) except ValueError as err: raise ConversionError( f"Could not serialize {cls.dpt_name()}", value=value ) from err @classmethod def _test_boundaries(cls, value: float) -> bool: """Test if value is within defined range for this object.""" return cls.value_min <= value <= cls.value_max class DPTTemperature(DPT2ByteFloat): """DPT 9.001 DPT_Value_Temp.""" dpt_main_number = 9 dpt_sub_number = 1 value_type = "temperature" unit = "°C" ha_device_class = "temperature" value_min = -273 value_max = 670760 class DPTTemperatureDifference2Byte(DPT2ByteFloat): """DPT 9.002 DPT_Value_Tempd.""" dpt_main_number = 9 dpt_sub_number = 2 value_type = "temperature_difference_2byte" unit = "K" ha_device_class = "temperature" value_min = -670760 value_max = 670760 class DPTTemperatureA(DPT2ByteFloat): """DPT 9.003 DPT_Value_Tempa.""" dpt_main_number = 9 dpt_sub_number = 3 value_type = "temperature_a" unit = "K/h" value_min = -670760 value_max = 670760 class DPTLux(DPT2ByteFloat): """DPT 9.004 DPT_Value_Lux.""" dpt_main_number = 9 dpt_sub_number = 4 value_type = "illuminance" unit = "lx" ha_device_class = "illuminance" value_min = 0 value_max = 670760 class DPTWsp(DPT2ByteFloat): """DPT 9.005 DPT_Value_Ws Speed (m/s).""" dpt_main_number = 9 dpt_sub_number = 5 value_type = "wind_speed_ms" unit = "m/s" ha_device_class = "wind_speed" value_min = 0 value_max = 670760 class DPTPressure2Byte(DPT2ByteFloat): """DPT 9.006 DPT_Value_Pres (Pa).""" dpt_main_number = 9 dpt_sub_number = 6 value_type = "pressure_2byte" unit = "Pa" ha_device_class = "pressure" value_min = 0 value_max = 670760 class DPTHumidity(DPT2ByteFloat): """DPT 9.007 DPT_Value_Humidity.""" dpt_main_number = 9 dpt_sub_number = 7 value_type = "humidity" unit = "%" ha_device_class = "humidity" value_min = 0 value_max = 670760 class DPTPartsPerMillion(DPT2ByteFloat): """DPT 9.008 DPT_Value_parts/million.""" dpt_main_number = 9 dpt_sub_number = 8 value_type = "ppm" unit = "ppm" class DPTAirFlow(DPT2ByteFloat): """DPT 9.009 DPT_Value_AirFlow.""" dpt_main_number = 9 dpt_sub_number = 9 value_type = "air_flow" unit = "m³/h" class DPTTime1(DPT2ByteFloat): """DPT 9.010 DPT_Value_Time1 (s).""" dpt_main_number = 9 dpt_sub_number = 10 value_type = "time_1" unit = "s" value_min = -670760 value_max = 670760 class DPTTime2(DPT2ByteFloat): """DPT 9.011 DPT_Value_Time2 (ms).""" dpt_main_number = 9 dpt_sub_number = 11 value_type = "time_2" unit = "ms" value_min = -670760 value_max = 670760 class DPTVoltage(DPT2ByteFloat): """DPT 9.020 DPT_Value_Voltage.""" dpt_main_number = 9 dpt_sub_number = 20 value_type = "voltage" unit = "mV" ha_device_class = "voltage" class DPTCurrent(DPT2ByteFloat): """DPT 9.021 DPT_Value_Curr (mA).""" dpt_main_number = 9 dpt_sub_number = 21 value_type = "curr" unit = "mA" ha_device_class = "current" class DPTPowerDensity(DPT2ByteFloat): """DPT 9.022 DPT_PowerDensity (W/m²).""" dpt_main_number = 9 dpt_sub_number = 22 value_type = "power_density" unit = "W/m²" class DPTKelvinPerPercent(DPT2ByteFloat): """DPT 9.023 DPT_KelvinPerPercent (K/%).""" dpt_main_number = 9 dpt_sub_number = 23 value_type = "kelvin_per_percent" unit = "K/%" class DPTPower2Byte(DPT2ByteFloat): """DPT 9.024 DPT_Power (kW).""" dpt_main_number = 9 dpt_sub_number = 24 value_type = "power_2byte" unit = "kW" ha_device_class = "power" class DPTVolumeFlow(DPT2ByteFloat): """DPT 9.025 DPT_Value_Volume_Flow (L/h).""" dpt_main_number = 9 dpt_sub_number = 25 value_type = "volume_flow" unit = "L/h" class DPTRainAmount(DPT2ByteFloat): """DPT 9.026 DPT_Rain_Amount (L/m²).""" dpt_main_number = 9 dpt_sub_number = 26 value_type = "rain_amount" unit = "L/m²" value_min = -671088.64 value_max = 670760.96 class DPTTemperatureF(DPT2ByteFloat): """DPT 9.027 DPT_Value_Temp_F.""" dpt_main_number = 9 dpt_sub_number = 27 value_type = "temperature_f" unit = "°F" ha_device_class = "temperature" value_min = -459.6 value_max = 670760 class DPTWspKmh(DPT2ByteFloat): """DPT 9.028 DPT_Value_Wsp_kmh Speed (km/h).""" dpt_main_number = 9 dpt_sub_number = 28 value_type = "wind_speed_kmh" unit = "km/h" ha_device_class = "wind_speed" value_min = 0 value_max = 670760 class DPTAbsoluteHumidity(DPT2ByteFloat): """DPT 9.029 DPT_Value_Absolute_Humidity.""" dpt_main_number = 9 dpt_sub_number = 29 value_type = "absolute_humidity" unit = "g/m³" value_min = 0 class DPTConcentrationUGM3(DPT2ByteFloat): """DPT 9.030 DPT_Value_Concentration_μgm3.""" dpt_main_number = 9 dpt_sub_number = 30 value_type = "concentration_ugm3" unit = "μg/m³" value_min = 0 class DPTEnthalpy(DPT2ByteFloat): """DPT 9.? 2-byte float value (with unit).""" dpt_main_number = 9 # this is here for backwards compatibility dpt_sub_number = 60000 value_type = "enthalpy" unit = "H" xknx-3.6.0/xknx/dpt/payload.py000066400000000000000000000041671475530762600163210ustar00rootroot00000000000000"""Implementation of KNX raw payload abstractions.""" from __future__ import annotations from xknx.exceptions import ConversionError class DPTBinary: """The DPTBinary is a base class for all datatypes encoded directly into the last 6 bit of the APCI/data octet.""" __slots__ = ("value",) APCI_BITMASK = 0x3F # APCI uses first 2 bits def __init__(self, value: int | tuple[int]) -> None: """Initialize DPTBinary class.""" if isinstance(value, tuple): value = value[0] if not isinstance(value, int): raise TypeError() if not 0 <= value <= DPTBinary.APCI_BITMASK: raise ConversionError("Could not init DPTBinary", value=str(value)) self.value = value def __eq__(self, other: object) -> bool: """Equal operator.""" return isinstance(other, DPTBinary) and self.value == other.value def __repr__(self) -> str: """Return object representation.""" return f"DPTBinary({hex(self.value)})" def __str__(self) -> str: """Return object as readable string.""" return f'' class DPTArray: """The DPTArray is a base class for all datatypes appended to the KNX telegram.""" __slots__ = ("value",) def __init__(self, value: int | bytes | tuple[int, ...] | list[int]) -> None: """Initialize DPTArray class.""" self.value: tuple[int, ...] if isinstance(value, int): self.value = (value,) elif isinstance(value, list | bytes): self.value = tuple(value) elif isinstance(value, tuple): self.value = value else: raise TypeError() def __eq__(self, other: object) -> bool: """Equal operator.""" return isinstance(other, DPTArray) and self.value == other.value def __repr__(self) -> str: """Return object representation.""" return f"DPTArray(({', '.join(hex(b) for b in self.value)}))" def __str__(self) -> str: """Return object as readable string.""" return f'' xknx-3.6.0/xknx/exceptions/000077500000000000000000000000001475530762600157005ustar00rootroot00000000000000xknx-3.6.0/xknx/exceptions/__init__.py000066400000000000000000000021131475530762600200060ustar00rootroot00000000000000"""Module for XKNX Exception handling.""" from .exception import ( CommunicationError, ConfirmationError, ConversionError, CouldNotParseAddress, CouldNotParseCEMI, CouldNotParseKNXIP, CouldNotParseTelegram, DataSecureError, DeviceIllegalValue, IncompleteKNXIPFrame, InvalidSecureConfiguration, IPSecureError, KNXSecureValidationError, ManagementConnectionError, ManagementConnectionRefused, ManagementConnectionTimeout, TunnellingAckError, UnsupportedCEMIMessage, XKNXException, ) __all__ = [ "CommunicationError", "ConfirmationError", "ConversionError", "CouldNotParseAddress", "CouldNotParseCEMI", "CouldNotParseKNXIP", "CouldNotParseTelegram", "DataSecureError", "DeviceIllegalValue", "IPSecureError", "IncompleteKNXIPFrame", "InvalidSecureConfiguration", "KNXSecureValidationError", "ManagementConnectionError", "ManagementConnectionRefused", "ManagementConnectionTimeout", "TunnellingAckError", "UnsupportedCEMIMessage", "XKNXException", ] xknx-3.6.0/xknx/exceptions/exception.py000066400000000000000000000141451475530762600202550ustar00rootroot00000000000000"""Module for XKXN Exceptions.""" from __future__ import annotations import logging from typing import Any class XKNXException(Exception): """Default XKNX Exception.""" def __eq__(self, other: object | None) -> bool: """Equal operator.""" return repr(self) == repr(other) def __hash__(self) -> int: """Hash function.""" return hash(str(self)) def __repr__(self) -> str: """Representation of object.""" return str(self) class CommunicationError(XKNXException): """Unable to communicate with KNX bus.""" def __init__(self, message: str, should_log: bool = True) -> None: """Instantiate exception.""" super().__init__(message) self.should_log = should_log class ConfirmationError(CommunicationError): """No confirmation received from KNX server for sent Telegram.""" class TunnellingAckError(CommunicationError): """No ACK or error status received from UDP KNX server for sent Telegram.""" class IPSecureError(CommunicationError): """Error in IP Secure communication.""" class CouldNotParseTelegram(XKNXException): """Could not parse telegram error.""" def __init__(self, description: str, **kwargs: Any) -> None: """Initialize CouldNotParseTelegram class.""" super().__init__() self.description = description self.parameter = kwargs def _format_parameter(self) -> str: return " ".join( [f'{key}="{value}"' for (key, value) in sorted(self.parameter.items())] ) def __str__(self) -> str: """Return object as readable string.""" return ( "' ) class CouldNotParseKNXIP(XKNXException): """Exception class for wrong KNXIP data.""" def __init__(self, description: str = "") -> None: """Initialize CouldNotParseKNXIP class.""" super().__init__() self.description = description def __str__(self) -> str: """Return object as readable string.""" return f'' class KNXSecureValidationError(CouldNotParseKNXIP): """Exception class for invalid KNX Secure data.""" def __str__(self) -> str: """Return object as readable string.""" return f'' class IncompleteKNXIPFrame(CouldNotParseKNXIP): """ Exception class for incomplete KNXIP data. Used for TCP connections to indicate to buffer the data until the complete frame is received. UDP connections should just handle CouldNotParseKNXIP. """ def __str__(self) -> str: """Return object as readable string.""" return f'' class CouldNotParseCEMI(XKNXException): """Exception class for wrong CEMI data.""" def __init__(self, description: str = "") -> None: """Initialize CouldNotParseCEMI class.""" super().__init__() self.description = description def __str__(self) -> str: """Return object as readable string.""" return f'' class UnsupportedCEMIMessage(XKNXException): """Exception class for unsupported CEMI Messages.""" def __init__(self, description: str = "") -> None: """Initialize UnsupportedCEMIMessage class.""" super().__init__() self.description = description def __str__(self) -> str: """Return object as readable string.""" return f'' class ConversionError(XKNXException): """Exception class for error while converting one type to another.""" def __init__(self, description: str, **kwargs: Any) -> None: """Initialize ConversionError class.""" super().__init__() self.description = description self.parameter = kwargs def _format_parameter(self) -> str: return " ".join( [f'{key}="{value}"' for (key, value) in sorted(self.parameter.items())] ) def __str__(self) -> str: """Return object as readable string.""" return f'' class CouldNotParseAddress(XKNXException): """Exception class for wrong address format.""" def __init__(self, address: Any = None, message: str = "") -> None: """Initialize CouldNotParseAddress class.""" super().__init__() self.address = address self.message = message def __str__(self) -> str: """Return object as readable string.""" _msg = f'message="{self.message}" ' if self.message else "" return f'' class DeviceIllegalValue(XKNXException): """Exception class for setting a value of a device with an illegal value.""" def __init__(self, value: Any, description: str) -> None: """Initialize DeviceIllegalValue class.""" super().__init__() self.value = value self.description = description def __str__(self) -> str: """Return object as readable string.""" return f'' class DataSecureError(XKNXException): """Exception class for KNX Data Secure handling.""" def __init__(self, message: str, log_level: int = logging.WARNING) -> None: """Instantiate exception.""" super().__init__(message) self.log_level = log_level class InvalidSecureConfiguration(XKNXException): """Exception class used when the secure configuration is invalid.""" class ManagementConnectionError(XKNXException): """Exception class used when a management connection fails.""" class ManagementConnectionRefused(ManagementConnectionError): """Exception class used when a management connection request is refused.""" class ManagementConnectionTimeout(ManagementConnectionError): """Exception class used when a management connection timed out.""" xknx-3.6.0/xknx/io/000077500000000000000000000000001475530762600141265ustar00rootroot00000000000000xknx-3.6.0/xknx/io/__init__.py000066400000000000000000000020561475530762600162420ustar00rootroot00000000000000""" Package containing all objects managing Tunneling and Routing Connections.. - KNXIPInterface is the overall managing class. - GatewayScanner searches for available KNX/IP devices in the local network. - Routing uses UDP/Multicast to communicate with KNX/IP device. - Tunnel uses UDP packets and builds a static tunnel with KNX/IP device. """ from .connection import ConnectionConfig, ConnectionType, SecureConfig from .const import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from .gateway_scanner import GatewayDescriptor, GatewayScanFilter, GatewayScanner from .knxip_interface import KNXIPInterface, knx_interface_factory from .routing import Routing from .self_description import DescriptionQuery from .tunnel import TCPTunnel, UDPTunnel __all__ = [ "DEFAULT_MCAST_GRP", "DEFAULT_MCAST_PORT", "ConnectionConfig", "ConnectionType", "DescriptionQuery", "GatewayDescriptor", "GatewayScanFilter", "GatewayScanner", "KNXIPInterface", "Routing", "SecureConfig", "TCPTunnel", "UDPTunnel", "knx_interface_factory", ] xknx-3.6.0/xknx/io/connection.py000066400000000000000000000124531475530762600166440ustar00rootroot00000000000000"""Manages a connection to the KNX bus.""" from __future__ import annotations from enum import Enum, auto import os from typing import Any from xknx.telegram.address import IndividualAddress, IndividualAddressableType from .const import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from .gateway_scanner import GatewayScanFilter class ConnectionType(Enum): """Enum class for different types of KNX/IP Connections.""" AUTOMATIC = auto() ROUTING = auto() ROUTING_SECURE = auto() TUNNELING = auto() TUNNELING_TCP = auto() TUNNELING_TCP_SECURE = auto() class ConnectionConfig: """ Connection configuration. Handles: * type of connection: * AUTOMATIC for using GatewayScanner for searching and finding KNX/IP devices in the network. * ROUTING use KNX/IP multicast routing. * TUNNELING connect to a specific KNX/IP tunneling device via UDP. * TUNNELING_TCP connect to a specific KNX/IP tunneling v2 device via TCP. * individual address: * AUTOMATIC use a specific tunnel endpoint from a given knxkeys file * ROUTING the individual address used as source address for routing * TCP TUNNELING request a specific tunnel endpoint * SECURE TUNNELING use a specific tunnel endpoint from the knxkeys file * local_ip: Local ip or interface name though which xknx should connect. * gateway_ip: IP or hostname of KNX/IP tunneling device. * gateway_port: Port of KNX/IP tunneling device. * route_back: For UDP TUNNELING connection. The KNXnet/IP Server shall use the IP address and port in the received IP package as the target IP address or port number for the response to the KNXnet/IP Client. * multicast_group: Multicast group for KNXnet/IP routing. * multicast_port: Multicast port for KNXnet/IP routing. * auto_reconnect: Auto reconnect to KNX/IP tunneling device if connection cannot be established. * auto_reconnect_wait: Wait n seconds before trying to reconnect to KNX/IP tunneling device. * scan_filter: For AUTOMATIC connection, limit scan with the given filter * threaded: Run connection logic in separate thread to avoid concurrency issues in HA * secure_config: KNX Secure config to use """ def __init__( self, *, connection_type: ConnectionType = ConnectionType.AUTOMATIC, individual_address: IndividualAddressableType | None = None, local_ip: str | None = None, local_port: int = 0, gateway_ip: str | None = None, gateway_port: int = DEFAULT_MCAST_PORT, route_back: bool = False, multicast_group: str = DEFAULT_MCAST_GRP, multicast_port: int = DEFAULT_MCAST_PORT, auto_reconnect: bool = True, auto_reconnect_wait: int = 3, scan_filter: GatewayScanFilter | None = None, threaded: bool = False, secure_config: SecureConfig | None = None, ) -> None: """Initialize ConnectionConfig class.""" self.connection_type = connection_type self.individual_address = ( IndividualAddress(individual_address) if individual_address else None ) self.local_ip = local_ip self.local_port = local_port self.gateway_ip = gateway_ip self.gateway_port = gateway_port self.route_back = route_back self.multicast_group = multicast_group self.multicast_port = multicast_port self.auto_reconnect = auto_reconnect self.auto_reconnect_wait = auto_reconnect_wait self.scan_filter = scan_filter or GatewayScanFilter() self.threaded = threaded self.secure_config = secure_config def __eq__(self, other: object) -> bool: """Equality for ConnectionConfig class (used in unit tests).""" return self.__dict__ == other.__dict__ class SecureConfig: """ Secure configuration. Handles: * backbone_key: Key used for KNX Secure Routing in hex representation. * latency_ms: Latency in milliseconds for KNX Secure Routing. * user_id: The user id to use when initializing the secure tunnel. * device_authentication_password: the authentication password to use when connecting to the tunnel. * user_password: the user password for knx secure. * knxkeys_file_path: Full path to the knxkeys file including the file name. * knxkeys_password: Password to decrypt the knxkeys file. """ def __init__( self, *, backbone_key: str | None = None, latency_ms: int | None = None, user_id: int | None = None, device_authentication_password: str | None = None, user_password: str | None = None, knxkeys_file_path: str | os.PathLike[Any] | None = None, knxkeys_password: str | None = None, ) -> None: """Initialize SecureConfig class.""" self.backbone_key = bytes.fromhex(backbone_key) if backbone_key else None self.latency_ms = latency_ms self.user_id = user_id self.device_authentication_password = device_authentication_password self.user_password = user_password self.knxkeys_file_path = knxkeys_file_path self.knxkeys_password = knxkeys_password def __eq__(self, other: object) -> bool: """Equality for SecureConfig class (used in unit tests).""" return self.__dict__ == other.__dict__ xknx-3.6.0/xknx/io/const.py000066400000000000000000000013141475530762600156250ustar00rootroot00000000000000"""KNX Constants used within io.""" from typing import Final from xknx.telegram.address import IndividualAddress DEFAULT_INDIVIDUAL_ADDRESS: Final = IndividualAddress("15.15.250") DEFAULT_MCAST_GRP: Final = "224.0.23.12" DEFAULT_MCAST_PORT: Final = 3671 CONNECTION_ALIVE_TIME: Final = 120 CONNECTIONSTATE_REQUEST_TIMEOUT: Final = 10 HEARTBEAT_RATE: Final = CONNECTION_ALIVE_TIME - (CONNECTIONSTATE_REQUEST_TIMEOUT * 5) # Maximum time an authenticated secure session may remain unused (without # any communication over this session) until the session will be dropped. SESSION_TIMEOUT: Final = 60 SESSION_KEEPALIVE_RATE: Final = SESSION_TIMEOUT - 10 XKNX_SERIAL_NUMBER: Final = bytes.fromhex("00 00 78 6b 6e 78") xknx-3.6.0/xknx/io/gateway_scanner.py000066400000000000000000000310721475530762600176550ustar00rootroot00000000000000""" GatewayScanner is an abstraction for searching for KNX/IP devices on the local network. It walks through all network interfaces and sends UDP multicast SearchRequest and SearchRequestExtended frames. """ from __future__ import annotations import asyncio from collections.abc import AsyncGenerator from functools import partial import logging from typing import TYPE_CHECKING from xknx.exceptions import XKNXException from xknx.io import util from xknx.knxip import ( HPAI, SRP, DIBServiceFamily, DIBTypeCode, KNXIPFrame, KNXIPServiceType, SearchRequest, SearchRequestExtended, SearchResponse, SearchResponseExtended, ) from xknx.knxip.dib import ( DIB, DIBDeviceInformation, DIBSecuredServiceFamilies, DIBSuppSVCFamilies, DIBTunnelingInfo, TunnelingSlotStatus, ) from xknx.telegram import IndividualAddress from xknx.util import asyncio_timeout from .transport import UDPTransport if TYPE_CHECKING: from xknx.xknx import XKNX logger = logging.getLogger("xknx.log") class GatewayDescriptor: """Used to return information about the discovered gateways.""" def __init__( self, ip_addr: str, port: int, local_ip: str = "", local_interface: str = "", name: str = "UNKNOWN", supports_routing: bool = False, supports_tunnelling: bool = False, supports_tunnelling_tcp: bool = False, supports_secure: bool = False, individual_address: IndividualAddress | None = None, ) -> None: """Initialize GatewayDescriptor class.""" self.name = name self.ip_addr = ip_addr self.port = port self.individual_address = individual_address self.local_interface = local_interface self.local_ip = local_ip self.supports_routing = supports_routing self.supports_tunnelling = supports_tunnelling self.supports_tunnelling_tcp = supports_tunnelling_tcp self.supports_secure = supports_secure self.core_version: int = 0 self.routing_requires_secure: bool | None = None self.tunnelling_requires_secure: bool | None = None self.tunnelling_slots: dict[IndividualAddress, TunnelingSlotStatus] = {} def parse_dibs(self, dibs: list[DIB]) -> None: """Parse DIBs for gateway information.""" for dib in dibs: if isinstance(dib, DIBDeviceInformation): self.name = dib.name self.individual_address = dib.individual_address continue if isinstance(dib, DIBSuppSVCFamilies): self.core_version = dib.version(DIBServiceFamily.CORE) or 0 self.supports_routing = dib.supports(DIBServiceFamily.ROUTING) if _tunnelling_version := dib.version(DIBServiceFamily.TUNNELING): self.supports_tunnelling = True self.supports_tunnelling_tcp = _tunnelling_version >= 2 self.supports_secure = dib.supports( DIBServiceFamily.SECURITY, version=1 ) continue if isinstance(dib, DIBSecuredServiceFamilies): self.tunnelling_requires_secure = dib.supports( DIBServiceFamily.TUNNELING ) self.routing_requires_secure = dib.supports(DIBServiceFamily.ROUTING) continue if isinstance(dib, DIBTunnelingInfo): self.tunnelling_slots = dib.slots continue def __repr__(self) -> str: """Return object as representation string.""" return ( "GatewayDescriptor(\n" f" name={self.name},\n" f" ip_addr={self.ip_addr},\n" f" port={self.port},\n" f" individual_address={self.individual_address}\n" f" local_interface={self.local_interface},\n" f" local_ip={self.local_ip},\n" f" core_version={self.core_version},\n" f" supports_routing={self.supports_routing},\n" f" supports_tunnelling={self.supports_tunnelling},\n" f" supports_tunnelling_tcp={self.supports_tunnelling_tcp},\n" f" supports_secure={self.supports_secure},\n" f" routing_requires_secure={self.routing_requires_secure}\n" f" tunnelling_requires_secure={self.tunnelling_requires_secure}\n" f" tunnelling_slots={self.tunnelling_slots}\n" ")" ) def __str__(self) -> str: """Return object as readable string.""" return f"{self.individual_address} - {self.name} @ {self.ip_addr}:{self.port}" class GatewayScanFilter: """ Filter to limit gateway scan results. If `name` doesn't match the gateway name, the gateway will be ignored. Connection methods are treated as OR if `True` is set for multiple methods. Non-secure methods don't match if secure is required. """ def __init__( self, name: str | None = None, tunnelling: bool | None = True, tunnelling_tcp: bool | None = True, routing: bool | None = True, secure_tunnelling: bool | None = True, secure_routing: bool | None = True, ) -> None: """Initialize GatewayScanFilter class.""" self.name = name self.tunnelling = tunnelling self.tunnelling_tcp = tunnelling_tcp self.routing = routing self.secure_tunnelling = secure_tunnelling self.secure_routing = secure_routing def match(self, gateway: GatewayDescriptor) -> bool: """Check whether the device is a gateway and given GatewayDescriptor matches the filter.""" if self.name is not None and self.name != gateway.name: return False return ( bool( self.tunnelling and gateway.supports_tunnelling and not gateway.tunnelling_requires_secure ) or bool( self.tunnelling_tcp and gateway.supports_tunnelling_tcp and not gateway.tunnelling_requires_secure ) or bool( self.routing and gateway.supports_routing and not gateway.routing_requires_secure ) or bool( self.secure_tunnelling and gateway.supports_tunnelling_tcp and gateway.tunnelling_requires_secure ) or bool( self.secure_routing and gateway.supports_routing and gateway.routing_requires_secure ) ) def __eq__(self, other: object) -> bool: """Equality for GatewayScanFilter class.""" return self.__dict__ == other.__dict__ class GatewayScanner: """Class for searching KNX/IP devices.""" def __init__( self, xknx: XKNX, local_ip: str | None = None, timeout_in_seconds: float = 3.0, stop_on_found: int | None = None, scan_filter: GatewayScanFilter | None = None, ) -> None: """Initialize GatewayScanner class.""" self.xknx = xknx self.local_ip = local_ip self.timeout_in_seconds = timeout_in_seconds self.stop_on_found = stop_on_found self.scan_filter = scan_filter or GatewayScanFilter() self.found_gateways: dict[HPAI, GatewayDescriptor] = {} self._response_received_event = asyncio.Event() async def scan(self) -> list[GatewayDescriptor]: """Scan and return a list of GatewayDescriptors on success.""" await self._scan() return list(self.found_gateways.values()) async def async_scan(self) -> AsyncGenerator[GatewayDescriptor, None]: """Search and yield found gateways.""" queue: asyncio.Queue[GatewayDescriptor | None] = asyncio.Queue() scan_task = asyncio.create_task(self._scan(queue=queue)) try: while True: gateway = await queue.get() if gateway is None: return yield gateway finally: # cleanup after GeneratorExit or XKNXExceptions if not scan_task.done(): scan_task.cancel() await scan_task # to bubble up exceptions async def _scan( self, queue: asyncio.Queue[GatewayDescriptor | None] | None = None ) -> None: """Scan for gateways.""" _local_ip = self.local_ip or await util.get_default_local_ip( remote_ip=self.xknx.multicast_group ) if _local_ip is None: if queue is not None: queue.put_nowait(None) raise XKNXException("No usable network interface found.") local_ip = await util.validate_ip(_local_ip) interface_name = util.get_local_interface_name(local_ip=local_ip) logger.debug("Searching on %s / %s", interface_name, local_ip) udp_transport = UDPTransport( local_addr=(local_ip, 0), remote_addr=(self.xknx.multicast_group, self.xknx.multicast_port), ) udp_transport.register_callback( partial(self._response_rec_callback, interface=interface_name, queue=queue), [ KNXIPServiceType.SEARCH_RESPONSE, KNXIPServiceType.SEARCH_RESPONSE_EXTENDED, ], ) try: await self._send_search_requests(udp_transport=udp_transport) async with asyncio_timeout(self.timeout_in_seconds): await self._response_received_event.wait() except asyncio.TimeoutError: pass except asyncio.CancelledError: pass finally: udp_transport.stop() if queue is not None: queue.put_nowait(None) @staticmethod async def _send_search_requests(udp_transport: UDPTransport) -> None: """Send search requests on a specific interface.""" await udp_transport.connect() discovery_endpoint = HPAI(*udp_transport.getsockname()) # send SearchRequestExtended requesting needed DIBs search_request_extended = SearchRequestExtended( discovery_endpoint=discovery_endpoint, srps=[ SRP.request_device_description( [ DIBTypeCode.DEVICE_INFO, DIBTypeCode.SUPP_SVC_FAMILIES, DIBTypeCode.SECURED_SERVICE_FAMILIES, DIBTypeCode.TUNNELING_INFO, ] ) ], ) udp_transport.send(KNXIPFrame.init_from_body(search_request_extended)) # send SearchRequest for Core-V1 devices search_request = SearchRequest(discovery_endpoint=discovery_endpoint) udp_transport.send(KNXIPFrame.init_from_body(search_request)) def _response_rec_callback( self, knx_ip_frame: KNXIPFrame, source: HPAI, udp_transport: UDPTransport, interface: str = "", queue: asyncio.Queue[GatewayDescriptor | None] | None = None, ) -> None: """Verify and handle knxipframe. Callback from internal udp_transport.""" if not isinstance(knx_ip_frame.body, SearchResponse | SearchResponseExtended): logger.warning("Could not understand knxipframe") return # skip non-extended SearchResponse for Core-V2 devices if knx_ip_frame.header.service_type_ident == KNXIPServiceType.SEARCH_RESPONSE: if svc_families_dib := next( ( dib for dib in knx_ip_frame.body.dibs if isinstance(dib, DIBSuppSVCFamilies) ), None, ): if svc_families_dib.supports(DIBServiceFamily.CORE, version=2): logger.debug("Skipping SearchResponse for Core-V2 device") return gateway = GatewayDescriptor( ip_addr=knx_ip_frame.body.control_endpoint.ip_addr, port=knx_ip_frame.body.control_endpoint.port, local_ip=udp_transport.local_addr[0], local_interface=interface, ) gateway.parse_dibs(knx_ip_frame.body.dibs) logger.debug("Found KNX/IP device at %s: %s", source, repr(gateway)) if self.scan_filter.match(gateway): self.found_gateways[knx_ip_frame.body.control_endpoint] = gateway if queue is not None: queue.put_nowait(gateway) if self.stop_on_found and len(self.found_gateways) >= self.stop_on_found: self._response_received_event.set() xknx-3.6.0/xknx/io/interface.py000066400000000000000000000015061475530762600164420ustar00rootroot00000000000000""" Abstract base for a specific KNX/IP connection (Tunneling or Routing). * It handles connection and disconnections * It starts and stops a udp transport * It packs Telegrams into KNX Frames and passes them to a udp transport """ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Callable from xknx.cemi import CEMIFrame CEMIBytesCallbackType = Callable[[bytes], None] class Interface(ABC): """Abstract base class for KNX/IP connections.""" @abstractmethod async def connect(self) -> bool: """Connect to KNX bus. Returns True on success.""" @abstractmethod async def disconnect(self) -> None: """Disconnect from KNX bus.""" @abstractmethod async def send_cemi(self, cemi: CEMIFrame) -> None: """Send CEMIFrame to KNX bus.""" xknx-3.6.0/xknx/io/ip_secure.py000066400000000000000000000750341475530762600164670ustar00rootroot00000000000000"""IPSecure is an abstraction for handling a KNXnet/IP Secure layer.""" from __future__ import annotations from abc import ABC, abstractmethod import asyncio from collections.abc import Callable import logging import random from typing import Final from cryptography.hazmat.primitives.asymmetric.x25519 import ( X25519PrivateKey, X25519PublicKey, ) from xknx.exceptions import ( CommunicationError, CouldNotParseKNXIP, IPSecureError, KNXSecureValidationError, ) from xknx.knxip import ( HPAI, KNXIPFrame, KNXIPServiceType, RoutingIndication, SecureWrapper, SessionResponse, SessionStatus, TimerNotify, ) from xknx.knxip.knxip_enum import SecureSessionStatusCode from xknx.secure.security_primitives import ( calculate_message_authentication_code_cbc, decrypt_ctr, derive_device_authentication_password, derive_user_password, encrypt_data_ctr, generate_ecdh_key_pair, ) from xknx.secure.util import bytes_xor, sha256_hash from xknx.util import asyncio_timeout from .const import SESSION_KEEPALIVE_RATE, XKNX_SERIAL_NUMBER from .request_response import Authenticate, Session from .transport import KNXIPTransport, TCPTransport, UDPTransport logger = logging.getLogger("xknx.log") knx_logger = logging.getLogger("xknx.knx") ip_secure_logger = logging.getLogger("xknx.ip_secure") COUNTER_0_HANDSHAKE = ( # used in SessionResponse and SessionAuthenticate b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\x00" ) MESSAGE_TAG_TUNNELLING = bytes.fromhex("00 00") # use 0x00 0x00 for tunneling class _IPSecureTransportLayer(ABC): """Abstract for Secure transport layer.""" session_id: int _key: bytes @abstractmethod def get_sequence_information(self) -> bytes: """Return byte representation of sequence information.""" @abstractmethod def get_message_tag(self) -> bytes: """Return byte representation of message tag.""" def decrypt_frame(self, encrypted_frame: KNXIPFrame) -> KNXIPFrame: """Unwrap and verify KNX/IP frame from SecureWrapper.""" # TODO: get raw data from KNXIPFrame class directly instead of recalculating it with to_knx() # TODO: refactor so assert isn't needed (maybe subclass SecureWrapper from KNXIPFrame instead of being an attribute) assert isinstance(encrypted_frame.body, SecureWrapper) if encrypted_frame.body.secure_session_id != self.session_id: raise KNXSecureValidationError("Invalid secure session id") session_id_bytes = encrypted_frame.body.secure_session_id.to_bytes(2, "big") wrapper_header = encrypted_frame.header.to_knx() dec_frame, mac_tr = decrypt_ctr( key=self._key, counter_0=( encrypted_frame.body.sequence_information + encrypted_frame.body.serial_number + encrypted_frame.body.message_tag + b"\xff\x00" ), mac=encrypted_frame.body.message_authentication_code, payload=encrypted_frame.body.encrypted_data, ) mac_cbc = calculate_message_authentication_code_cbc( key=self._key, additional_data=wrapper_header + session_id_bytes, payload=dec_frame, block_0=( encrypted_frame.body.sequence_information + encrypted_frame.body.serial_number + encrypted_frame.body.message_tag + len(dec_frame).to_bytes(2, "big") ), ) if mac_cbc != mac_tr: raise KNXSecureValidationError( "Verification of message authentication code failed" ) knxipframe, _ = KNXIPFrame.from_knx(dec_frame) return knxipframe def encrypt_frame(self, plain_frame: KNXIPFrame) -> KNXIPFrame: """Wrap KNX/IP frame in SecureWrapper.""" try: sequence_information = self.get_sequence_information() except OverflowError as err: raise IPSecureError( "KNX IP Secure sequence counter overflow." "\nCongratulations! You've managed to overflow a counter designed to last about 9000 years! " "Before celebrating, please check for any malfunctioning devices or suspicious activity " "in your installation. Once you've ensured everything's safe, reset the secure session " "to restore normal operation." ) from err message_tag = self.get_message_tag() plain_payload = plain_frame.to_knx() # P payload_length = len(plain_payload) # Q # 6 KNXnet/IP header, 2 session_id, 6 sequence_number, 6 serial_number, 2 message_tag, 16 MAC = 38 total_length = 38 + payload_length # TODO: get header data and total_length from SecureWrapper class wrapper_header = bytes.fromhex("06 10 09 50") + total_length.to_bytes(2, "big") mac_cbc = calculate_message_authentication_code_cbc( key=self._key, additional_data=wrapper_header + self.session_id.to_bytes(2, "big"), payload=plain_payload, block_0=( sequence_information + XKNX_SERIAL_NUMBER + message_tag + payload_length.to_bytes(2, "big") ), ) encrypted_data, mac = encrypt_data_ctr( key=self._key, counter_0=( sequence_information + XKNX_SERIAL_NUMBER + message_tag + b"\xff\x00" ), mac_cbc=mac_cbc, payload=plain_payload, ) return KNXIPFrame.init_from_body( SecureWrapper( secure_session_id=self.session_id, sequence_information=sequence_information, serial_number=XKNX_SERIAL_NUMBER, message_tag=message_tag, encrypted_data=encrypted_data, message_authentication_code=mac, ) ) class SecureSession(TCPTransport, _IPSecureTransportLayer): """Class for handling a KNXnet/IP Secure tunnelling session.""" def __init__( self, remote_addr: tuple[str, int], user_id: int, user_password: str, device_authentication_password: str | None = None, connection_lost_cb: Callable[[], None] | None = None, ) -> None: """Initialize SecureSession class.""" super().__init__( remote_addr=remote_addr, connection_lost_cb=connection_lost_cb, ) self._device_authentication_code: bytes | None = ( derive_device_authentication_password(device_authentication_password) if device_authentication_password else None ) self.user_id = user_id self._user_password = derive_user_password(user_password) self._private_key: X25519PrivateKey self.public_key: bytes self._peer_public_key: X25519PublicKey self._key: bytes # Session Key self.session_id: int self._sequence_number: int = 0 self._sequence_number_received: int = -1 self.initialized: bool = False self._keepalive_task: asyncio.Task[None] | None = None self._session_status_handler: KNXIPTransport.Callback | None = None async def connect(self) -> None: """Connect transport.""" await super().connect() self._private_key, self.public_key = generate_ecdh_key_pair() self._sequence_number = 0 self._sequence_number_received = -1 request_session = Session( transport=self, ecdh_client_public_key=self.public_key, ) await request_session.start() if request_session.response is None: raise CommunicationError( "Secure session could not be established. No response received." ) # SessionAuthenticate and everything else after now shall be wrapped in SecureWrapper authenticate_mac = self.handshake(request_session.response) self.initialized = True request_authentication = Authenticate( transport=self, user_id=self.user_id, message_authentication_code=authenticate_mac, ) await request_authentication.start() if request_authentication.response is None: raise CommunicationError( "Secure session could not be established. No response received." ) if ( # TODO: look for status in request/response and use `success` instead of response ? request_authentication.response.status != SecureSessionStatusCode.STATUS_AUTHENTICATION_SUCCESS ): raise IPSecureError( f"Secure session authentication failed: {request_authentication.response.status}" ) self._session_status_handler = self.register_callback( self._handle_session_status, [KNXIPServiceType.SESSION_STATUS] ) def handshake(self, session_response: SessionResponse) -> bytes: """ Handshake with device. Returns a SessionAuthenticate KNX/IP body. """ self._peer_public_key = X25519PublicKey.from_public_bytes( session_response.ecdh_server_public_key ) self.session_id = session_response.secure_session_id # verify SessionResponse MAC # TODO: get header data from actual KNX/IP frame response_header_data = bytes.fromhex("06 10 09 52 00 38") pub_keys_xor = bytes_xor( self.public_key, session_response.ecdh_server_public_key, ) if self._device_authentication_code: response_mac_cbc = calculate_message_authentication_code_cbc( key=self._device_authentication_code, additional_data=response_header_data + self.session_id.to_bytes(2, "big") + pub_keys_xor, # knx_ip_header + secure_session_id + bytes_xor(client_pub_key, server_pub_key) ) _, mac_tr = decrypt_ctr( key=self._device_authentication_code, counter_0=COUNTER_0_HANDSHAKE, mac=session_response.message_authentication_code, ) if mac_tr != response_mac_cbc: raise IPSecureError("SessionResponse MAC verification failed.") # calculate session key ecdh_shared_secret = self._private_key.exchange(self._peer_public_key) self._key = sha256_hash(ecdh_shared_secret)[:16] # generate SessionAuthenticate MAC authenticate_header_data = bytes.fromhex("06 10 09 53 00 18") authenticate_mac_cbc = calculate_message_authentication_code_cbc( key=self._user_password, additional_data=authenticate_header_data + bytes(1) # reserved + self.user_id.to_bytes(1, "big") + pub_keys_xor, block_0=bytes(16), ) _, authenticate_mac = encrypt_data_ctr( key=self._user_password, counter_0=COUNTER_0_HANDSHAKE, mac_cbc=authenticate_mac_cbc, ) return authenticate_mac def stop(self) -> None: """Stop session and transport.""" if self._session_status_handler: self.unregister_callback(self._session_status_handler) self._session_status_handler = None if self.transport and self.initialized: self.send( KNXIPFrame.init_from_body( SessionStatus(status=SecureSessionStatusCode.STATUS_CLOSE) ) ) self.stop_keepalive_task() self.initialized = False super().stop() def handle_knxipframe(self, knxipframe: KNXIPFrame, source: HPAI) -> None: """Handle KNXIP Frame and call all callbacks matching the service type ident.""" # TODO: disallow unencrypted frames with exceptions for discovery etc. eg. DescriptionResponse if isinstance(knxipframe.body, SecureWrapper): if not self.initialized: raise CouldNotParseKNXIP( "Received SecureWrapper while Secure session not initialized" ) new_sequence_number = int.from_bytes( knxipframe.body.sequence_information, "big" ) if not new_sequence_number > self._sequence_number_received: ip_secure_logger.warning( "Discarding SecureWrapper with invalid sequence number: %s", knxipframe, ) return try: knxipframe = self.decrypt_frame(knxipframe) except KNXSecureValidationError as err: ip_secure_logger.warning("Could not decrypt KNXIPFrame: %s", err) # Frame shall be discarded return except CouldNotParseKNXIP as couldnotparseknxip: knx_logger.debug( "Unsupported encrypted KNXIPFrame: %s", couldnotparseknxip.description, ) return self._sequence_number_received = new_sequence_number knx_logger.debug("Decrypted frame: %s", knxipframe) super().handle_knxipframe(knxipframe, source) async def _session_keepalive(self) -> None: """Keep session alive.""" await asyncio.sleep(SESSION_KEEPALIVE_RATE) self.send( KNXIPFrame.init_from_body( SessionStatus(status=SecureSessionStatusCode.STATUS_KEEPALIVE) ) ) def send(self, knxipframe: KNXIPFrame, addr: tuple[str, int] | None = None) -> None: """Send KNXIPFrame to socket. `addr` is ignored on TCP.""" if self.initialized: knx_logger.debug("Encrypting frame: %s", knxipframe) knxipframe = self.encrypt_frame(plain_frame=knxipframe) # keepalive timer is started with first and reset with every other # SecureWrapper frame (including wrapped keepalive frames themselves) self.start_keepalive_task() # TODO: disallow sending unencrypted frames over non-initialized session with # exceptions for discovery and SessionRequest super().send(knxipframe, addr) def get_sequence_information(self) -> bytes: """Increment sequence number. Return byte representation of current sequence number.""" next_sn = self._sequence_number.to_bytes(6, "big") self._sequence_number += 1 return next_sn def get_message_tag(self) -> bytes: """Return byte representation of current message tag.""" return MESSAGE_TAG_TUNNELLING def start_keepalive_task(self) -> None: """Start or restart session keepalive task.""" if self._keepalive_task: self._keepalive_task.cancel() self._keepalive_task = asyncio.create_task(self._session_keepalive()) def stop_keepalive_task(self) -> None: """Stop keepalive task.""" if self._keepalive_task: self._keepalive_task.cancel() self._keepalive_task = None def _handle_session_status( self, knxipframe: KNXIPFrame, source: HPAI, transport: KNXIPTransport ) -> None: """Handle session status.""" assert isinstance(knxipframe.body, SessionStatus) if knxipframe.body.status in ( SecureSessionStatusCode.STATUS_CLOSE, SecureSessionStatusCode.STATUS_TIMEOUT, SecureSessionStatusCode.STATUS_UNAUTHENTICATED, ): logger.info("Secure session closed by server: %s.", knxipframe.body.status) if self.transport: # closing transport will call `asyncio.Protocol.connection_lost` # and its callback from SecureTunnel self.transport.close() class SecureGroup(UDPTransport, _IPSecureTransportLayer): """Class for secure KNXnet/IP multicast communication.""" session_id = 0 # Routing uses fixed session id 0 def __init__( self, local_addr: tuple[str, int], remote_addr: tuple[str, int], backbone_key: bytes, latency_ms: int = 1000, ) -> None: """Initialize SecureGroup class.""" super().__init__( local_addr=local_addr, remote_addr=remote_addr, multicast=True, ) self._key = backbone_key self.secure_timer = SecureSequenceTimer( backbone_key=backbone_key, latency_ms=latency_ms, transport_send=super().send, ) async def connect(self) -> None: """Connect transport.""" await super().connect() await self.secure_timer.synchronize() def stop(self) -> None: """Stop tasks and transport.""" self.secure_timer.stop() super().stop() def handle_knxipframe(self, knxipframe: KNXIPFrame, source: HPAI) -> None: """Handle KNXIP Frame and call all callbacks matching the service type ident.""" if isinstance(knxipframe.body, RoutingIndication): ip_secure_logger.info( "Discarding received unencrypted RoutingIndication: %s", knxipframe, ) return if isinstance(knxipframe.body, TimerNotify): self.secure_timer.handle_timer_notify(knxipframe.body) return if isinstance(knxipframe.body, SecureWrapper): if not self.secure_timer.timer_authenticated: ip_secure_logger.debug( "Discarding received SecureWrapper before timer synchronisazion finished: %s", knxipframe, ) return secure_wrapper = knxipframe.body try: knxipframe = self.decrypt_frame(knxipframe) except KNXSecureValidationError as err: ip_secure_logger.warning("Could not decrypt KNXIPFrame: %s", err) # Frame shall be discarded return except CouldNotParseKNXIP as couldnotparseknxip: knx_logger.debug( "Unsupported encrypted KNXIPFrame: %s", couldnotparseknxip.description, ) return if not self.secure_timer.validate_secure_wrapper(secure_wrapper): ip_secure_logger.warning( "Discarding SecureWrapper with invalid timer value: %s", secure_wrapper, ) return knx_logger.debug("Decrypted frame: %s", knxipframe) # handle decrypted frames and plain frames (e.g. SearchRequest for discovery) super().handle_knxipframe(knxipframe, source) def send(self, knxipframe: KNXIPFrame, addr: tuple[str, int] | None = None) -> None: """Send KNXIPFrame to socket. `addr` is ignored on TCP.""" knx_logger.debug("Encrypting frame: %s", knxipframe) knxipframe = self.encrypt_frame(plain_frame=knxipframe) # TODO: disallow sending unencrypted frames over non-initialized session with # exceptions for discovery and SessionRequest super().send(knxipframe, addr) def get_sequence_information(self) -> bytes: """Return byte representation of current timer value.""" return self.secure_timer.get_for_outgoing_secure_wrapper().to_bytes(6, "big") def get_message_tag(self) -> bytes: """Return byte representation of current message tag.""" return random.randbytes(2) class SecureSequenceTimer: """ Class for holding and synchronizing the timer for secure sequence information. According to AN159 v06 KNXnet-IP Secure AS §2.2.2.3 Timer synchronizing """ TIMER_NOTIFY_HEADER = bytes.fromhex("06 10 09 55 00 24") min_delay_time_keeper_periodic_notify: Final = 10 # pylint: disable=invalid-name min_delay_time_keeper_update_notify: Final = 0.1 # pylint: disable=invalid-name sync_latency_fraction: int = 10 # 10% def __init__( self, backbone_key: bytes, latency_ms: int, transport_send: Callable[[KNXIPFrame, tuple[str, int] | None], None], ) -> None: """Initialize SecureSequenceTimer class.""" self._backbone_key = backbone_key self._clock_difference: int = 0 self._expected_notify_handler: ( tuple[bytes, asyncio.Future[int]] # message_tag, synchronization future | None ) = None self._loop = asyncio.get_running_loop() self._notify_timer_handle: asyncio.TimerHandle | None = None self._transport_send = transport_send self.sched_update: bool = False self.timekeeper: bool = False self.timer_authenticated: bool = False self.latency_tolerance_ms = latency_ms self.sync_latency_tolerance_ms: int = round( latency_ms / 100 * self.sync_latency_fraction ) _sync_latency_tolerance_seconds = self.sync_latency_tolerance_ms / 1000 self.max_delay_time_keeper_periodic_notify = ( self.min_delay_time_keeper_periodic_notify + _sync_latency_tolerance_seconds * 3 ) self.min_delay_time_follower_periodic_notify = ( self.max_delay_time_keeper_periodic_notify + _sync_latency_tolerance_seconds * 1 ) self.max_delay_time_follower_periodic_notify = ( self.min_delay_time_follower_periodic_notify + _sync_latency_tolerance_seconds * 10 ) self.max_delay_time_keeper_update_notify = ( self.min_delay_time_keeper_update_notify + _sync_latency_tolerance_seconds * 1 ) self.min_delay_time_follower_update_notify = ( self.max_delay_time_keeper_update_notify + _sync_latency_tolerance_seconds * 1 ) self.max_delay_time_follower_update_notify = ( self.min_delay_time_follower_update_notify + _sync_latency_tolerance_seconds * 10 ) def stop(self) -> None: """Cancel notify timer.""" if self._notify_timer_handle: self._notify_timer_handle.cancel() self._notify_timer_handle = None if self._expected_notify_handler: self._expected_notify_handler[1].cancel() def _monotonic_ms(self) -> int: """Return current monotonic time in milliseconds.""" return int(self._loop.time() * 1000.0) def current_timer_value(self) -> int: """Return current timer value in milliseconds.""" return self._monotonic_ms() + self._clock_difference def update(self, new_value: int) -> None: """Update timer value.""" self._clock_difference = new_value - self._monotonic_ms() def reschedule(self, update: tuple[bytes, bytes] | None = None) -> None: """ Reschedule notify timer. If `update` is set, the timer is rescheduled for an update notify, else for a periodic notify. `update` is expected to be a tuple of (message_tag, serial_number). """ if self._notify_timer_handle is not None: self._notify_timer_handle.cancel() self.sched_update = bool(update) min_delay: float max_delay: float if self.sched_update: if self.timekeeper: min_delay = self.min_delay_time_keeper_update_notify max_delay = self.max_delay_time_keeper_update_notify else: min_delay = self.min_delay_time_follower_update_notify max_delay = self.max_delay_time_follower_update_notify elif self.timekeeper: min_delay = self.min_delay_time_keeper_periodic_notify max_delay = self.max_delay_time_keeper_periodic_notify else: min_delay = self.min_delay_time_follower_periodic_notify max_delay = self.max_delay_time_follower_periodic_notify self._notify_timer_handle = self._loop.call_later( random.uniform(min_delay, max_delay), self._notify_timer_expired, update ) def _notify_timer_expired(self, update: tuple[bytes, bytes] | None) -> None: """Notify timer expired.""" if update: self.send_timer_notify(message_tag=update[0], serial_number=update[1]) else: self.send_timer_notify() if not self.timekeeper: self.timekeeper = True ip_secure_logger.debug("Becoming timekeeper") self.reschedule() def send_timer_notify( self, message_tag: bytes | None = None, serial_number: bytes = XKNX_SERIAL_NUMBER, ) -> None: """Send a TimerNotify frame.""" timer_value = self.current_timer_value() timer_bytes = timer_value.to_bytes(6, "big") _message_tag = message_tag or random.randbytes(2) b_0 = timer_bytes + serial_number + _message_tag + b"\x00\x00" # TODO: get header data and total_length from TimerNotify class # or handle mac calculation in TimerNotify class directly mac_cbc = calculate_message_authentication_code_cbc( key=self._backbone_key, additional_data=self.TIMER_NOTIFY_HEADER, block_0=b_0, ) c_0 = timer_bytes + serial_number + _message_tag + b"\xff\x00" _, mac = encrypt_data_ctr( key=self._backbone_key, counter_0=c_0, mac_cbc=mac_cbc, ) self._transport_send( KNXIPFrame.init_from_body( TimerNotify( timer_value=timer_value, serial_number=serial_number, message_tag=_message_tag, message_authentication_code=mac, ) ), None, ) def verify_timer_notify_mac(self, timer_notify: TimerNotify) -> None: """Verify MAC of timer notify.""" timer_bytes = timer_notify.timer_value.to_bytes(6, "big") b_0 = ( timer_bytes + timer_notify.serial_number + timer_notify.message_tag + b"\x00\x00" ) c_0 = ( timer_bytes + timer_notify.serial_number + timer_notify.message_tag + b"\xff\x00" ) _, mac_tr = decrypt_ctr( key=self._backbone_key, counter_0=c_0, mac=timer_notify.message_authentication_code, ) mac_cbc = calculate_message_authentication_code_cbc( key=self._backbone_key, additional_data=self.TIMER_NOTIFY_HEADER, block_0=b_0, ) if mac_cbc != mac_tr: raise KNXSecureValidationError("MAC verification failed") async def synchronize(self) -> None: """Synchronize timer with remote time keeper.""" message_tag = random.randbytes(2) waiter_fut: asyncio.Future[int] = self._loop.create_future() self._expected_notify_handler = message_tag, waiter_fut self.send_timer_notify(message_tag=message_tag) try: async with asyncio_timeout( # 3.3 seconds at latency_ms=1000, sync_latency_fraction=10% self.max_delay_time_follower_update_notify + 2 * self.latency_tolerance_ms / 1000 ): timer_value = await waiter_fut self.update(new_value=timer_value) except asyncio.TimeoutError: # use highest received timer value of TimerNotify or SecureWrapper frames ip_secure_logger.warning( "Timer synchronization not answered. Becoming time keeper." ) self.timekeeper = True except asyncio.CancelledError: return finally: self._expected_notify_handler = None self.timer_authenticated = True self.reschedule() def validate_secure_wrapper(self, secure_wrapper: SecureWrapper) -> bool: """Validate a SecureWrapper frames and handle timer update schedule.""" local_timer_value = self.current_timer_value() received_timer_value = int.from_bytes( secure_wrapper.sequence_information, "big" ) # §2.2.2.3.2.5 Events: E5 - E8 if received_timer_value > local_timer_value: self._clock_difference += received_timer_value - local_timer_value if not self.sched_update: self.reschedule() return True if received_timer_value > local_timer_value - self.sync_latency_tolerance_ms: if not self.sched_update: self.reschedule() return True if received_timer_value > local_timer_value - self.latency_tolerance_ms: return True if not self.sched_update: self.reschedule( update=(secure_wrapper.message_tag, secure_wrapper.serial_number) ) return False def handle_timer_notify(self, timer_notify: TimerNotify) -> None: """Handle received TimerNotify frame.""" local_timer_value = self.current_timer_value() try: self.verify_timer_notify_mac(timer_notify) except KNXSecureValidationError: ip_secure_logger.warning( "Discarding TimerNotify with invalid MAC: %s", timer_notify ) return received_timer_value = timer_notify.timer_value # §2.2.2.3.2.5 Events: E11 if ( self._expected_notify_handler is not None and timer_notify.serial_number == XKNX_SERIAL_NUMBER and timer_notify.message_tag == self._expected_notify_handler[0] ): fut = self._expected_notify_handler[1] fut.set_result(received_timer_value) return # §2.2.2.3.2.5 Events: E1 - E4 if received_timer_value > local_timer_value: self._clock_difference += received_timer_value - local_timer_value if self.timekeeper: ip_secure_logger.debug("Becoming time follower") self.timekeeper = False self.reschedule() return if received_timer_value > local_timer_value - self.sync_latency_tolerance_ms: if self.timekeeper: ip_secure_logger.debug("Becoming time follower") self.timekeeper = False self.reschedule() return if received_timer_value > local_timer_value - self.latency_tolerance_ms: return if not self.sched_update: self.reschedule( update=(timer_notify.message_tag, timer_notify.serial_number) ) def get_for_outgoing_secure_wrapper(self) -> int: """Return current timer value and handle timer update schedule.""" # §2.2.2.3.2.5 Events: E9 if not self.sched_update: self.reschedule() return self.current_timer_value() xknx-3.6.0/xknx/io/knxip_interface.py000066400000000000000000000547611475530762600176660ustar00rootroot00000000000000""" KNXIPInterface manages KNX/IP Tunneling or Routing connections. * It searches for available devices and connects with the corresponding connect method. * It passes KNX telegrams from the network and * provides callbacks after having received a telegram from the network. """ from __future__ import annotations import asyncio from collections.abc import Awaitable import logging import threading from typing import TYPE_CHECKING, TypeVar from xknx.cemi import CEMIFrame from xknx.exceptions import ( CommunicationError, InvalidSecureConfiguration, XKNXException, ) from xknx.io import util from xknx.secure.keyring import InterfaceType, Keyring, XMLInterface, load_keyring from xknx.telegram import IndividualAddress from .connection import ConnectionConfig, ConnectionType from .const import DEFAULT_INDIVIDUAL_ADDRESS from .gateway_scanner import GatewayDescriptor, GatewayScanner from .routing import Routing, SecureRouting from .self_description import request_description from .tunnel import SecureTunnel, TCPTunnel, UDPTunnel, _Tunnel if TYPE_CHECKING: import concurrent from xknx.xknx import XKNX from .interface import Interface logger = logging.getLogger("xknx.log") T = TypeVar("T") # pylint: disable=invalid-name def knx_interface_factory( xknx: XKNX, connection_config: ConnectionConfig, ) -> KNXIPInterface: """Create KNX/IP interface from config.""" if connection_config.threaded: return KNXIPInterfaceThreaded(xknx=xknx, connection_config=connection_config) return KNXIPInterface(xknx=xknx, connection_config=connection_config) class KNXIPInterface: """Class for managing KNX/IP Tunneling or Routing connections.""" def __init__( self, xknx: XKNX, connection_config: ConnectionConfig | None = None, ) -> None: """Initialize KNXIPInterface class.""" self.xknx = xknx self.connection_config = connection_config or ConnectionConfig() self._gateway_info: GatewayDescriptor | None = None self._interface: Interface | None = None async def start(self) -> None: """Start KNX/IP interface. Raise `CommunicationError` if connection fails.""" await self._start() async def _start(self) -> None: """Start interface. Connecting KNX/IP device with the selected method.""" if gateway_ip := self.connection_config.gateway_ip: gateway_ip = await util.validate_ip(gateway_ip, address_name="Gateway IP") if local_ip := self.connection_config.local_ip: local_ip = await util.validate_ip(local_ip, address_name="Local IP") keyring: Keyring | None = None if secure_config := self.connection_config.secure_config: if ( secure_config.knxkeys_file_path is not None and secure_config.knxkeys_password is not None ): keyring = await load_keyring( secure_config.knxkeys_file_path, secure_config.knxkeys_password, ) self.xknx.cemi_handler.data_secure_init(keyring=keyring) if self.connection_config.connection_type == ConnectionType.ROUTING: await self._start_routing(local_ip=local_ip) elif self.connection_config.connection_type == ConnectionType.ROUTING_SECURE: await self._start_secure_routing(local_ip=local_ip, keyring=keyring) elif ( self.connection_config.connection_type == ConnectionType.TUNNELING and gateway_ip is not None ): await self._start_tunnelling_udp( gateway_ip=gateway_ip, gateway_port=self.connection_config.gateway_port, local_ip=local_ip, ) elif ( self.connection_config.connection_type == ConnectionType.TUNNELING_TCP and gateway_ip is not None ): await self._start_tunnelling_tcp( gateway_ip=gateway_ip, gateway_port=self.connection_config.gateway_port, ) elif ( self.connection_config.connection_type == ConnectionType.TUNNELING_TCP_SECURE and gateway_ip is not None ): await self._start_secure_tunnelling_tcp( gateway_ip=gateway_ip, gateway_port=self.connection_config.gateway_port, keyring=keyring, ) else: await self._start_automatic(local_ip=local_ip, keyring=keyring) async def _start_automatic( self, local_ip: str | None, keyring: Keyring | None, ) -> None: """Start GatewayScanner and connect to the found device.""" keyring_host_filter: set[IndividualAddress] = set() if keyring: if required_addr := self.connection_config.individual_address: _host_ia = keyring.get_tunnel_host_by_interface( tunnelling_slot=required_addr ) if _host_ia is None: raise InvalidSecureConfiguration( f"No host for required address {required_addr} found in keyring file." ) keyring_host_filter.add(_host_ia) else: keyring_host_filter.update( interface.host for interface in keyring.interfaces if interface.host is not None and interface.type is InterfaceType.TUNNELING ) async for gateway in GatewayScanner( self.xknx, local_ip=local_ip, scan_filter=self.connection_config.scan_filter, ).async_scan(): if ( keyring_host_filter and gateway.individual_address not in keyring_host_filter ): logger.debug("Skipping %s. No match in keyring file", gateway) continue try: if gateway.supports_tunnelling_tcp: if gateway.tunnelling_requires_secure: await self._start_secure_tunnelling_tcp( gateway_ip=gateway.ip_addr, gateway_port=gateway.port, gateway_descriptor=gateway, keyring=keyring, ) else: await self._start_tunnelling_tcp( gateway_ip=gateway.ip_addr, gateway_port=gateway.port, ) elif ( gateway.supports_tunnelling and not gateway.tunnelling_requires_secure ): await self._start_tunnelling_udp( gateway_ip=gateway.ip_addr, gateway_port=gateway.port, local_ip=local_ip, ) elif gateway.supports_routing and not gateway.routing_requires_secure: await self._start_routing(local_ip=local_ip) except CommunicationError as ex: logger.debug("Skipping %s. Could not connect: %s", gateway, ex) continue except InvalidSecureConfiguration as ex: logger.debug( "Skipping %s. Invalid secure configuration: %s", gateway, ex ) continue else: self._gateway_info = gateway break else: raise CommunicationError( f"No usable KNX/IP device found{' in keyring file' if keyring_host_filter else ''}." ) async def _start_tunnelling_tcp( self, gateway_ip: str, gateway_port: int, ) -> None: """Start KNX/IP TCP tunnel.""" tunnel_address = self.connection_config.individual_address logger.debug( "Starting tunnel to %s:%s over TCP%s", gateway_ip, gateway_port, f" requesting individual address {tunnel_address}" if tunnel_address else "", ) self._interface = TCPTunnel( self.xknx, gateway_ip=gateway_ip, gateway_port=gateway_port, individual_address=tunnel_address, cemi_received_callback=self.cemi_received, auto_reconnect=self.connection_config.auto_reconnect, auto_reconnect_wait=self.connection_config.auto_reconnect_wait, ) await self._interface.connect() async def _start_secure_tunnelling_tcp( self, gateway_ip: str, gateway_port: int, gateway_descriptor: GatewayDescriptor | None = None, keyring: Keyring | None = None, ) -> None: """Start KNX/IP Secure TCP tunnel.""" if (secure_config := self.connection_config.secure_config) is None: raise InvalidSecureConfiguration("SecureConfig missing in ConnectionConfig") if ( secure_config.user_id is not None and secure_config.user_password is not None ): user_id = secure_config.user_id user_password = secure_config.user_password device_authentication_password = ( secure_config.device_authentication_password ) elif keyring is not None: _gateway = gateway_descriptor or await request_description( gateway_ip=gateway_ip, gateway_port=gateway_port ) xml_interface = self._get_tunnel_interface_from_keyring( keyring=keyring, gateway_descriptor=_gateway, individual_address=self.connection_config.individual_address, config_user_id=secure_config.user_id, ) if ( xml_interface.user_id is None or xml_interface.decrypted_password is None ): raise InvalidSecureConfiguration( f"No user_id or password found for tunnel {xml_interface.individual_address}" ) user_id = xml_interface.user_id user_password = xml_interface.decrypted_password device_authentication_password = xml_interface.decrypted_authentication else: raise InvalidSecureConfiguration( "No `user_id` or `knxkeys_file_path` and password found in secure configuration" ) logger.debug( "Starting secure tunnel to %s:%s over TCP", gateway_ip, gateway_port, ) self._interface = SecureTunnel( self.xknx, gateway_ip=gateway_ip, gateway_port=gateway_port, auto_reconnect=self.connection_config.auto_reconnect, auto_reconnect_wait=self.connection_config.auto_reconnect_wait, user_id=user_id, user_password=user_password, device_authentication_password=device_authentication_password, cemi_received_callback=self.cemi_received, ) await self._interface.connect() @staticmethod def _get_tunnel_interface_from_keyring( keyring: Keyring, gateway_descriptor: GatewayDescriptor, individual_address: IndividualAddress | None = None, config_user_id: int | None = None, ) -> XMLInterface: """ Get tunnel interface from keyring. Precedence: configured individual address > configured user id > first free tunnel interface """ if individual_address: if xml_interface := keyring.get_tunnel_interface_by_individual_address( individual_address ): return xml_interface raise InvalidSecureConfiguration( f"Interface with individual address {individual_address} not found in keyfile" ) if not (host_ia := gateway_descriptor.individual_address): raise InvalidSecureConfiguration( "Gateway does not provide individual address" ) if config_user_id: if xml_interface := keyring.get_tunnel_interface_by_host_and_user_id( host=host_ia, user_id=config_user_id ): return xml_interface raise InvalidSecureConfiguration( f"Interface of host {host_ia} with user_id {config_user_id} not found in keyfile" ) _free_slots = [ tunnel_ia for tunnel_ia, slot in gateway_descriptor.tunnelling_slots.items() if slot.usable and slot.free ] if not _free_slots: raise InvalidSecureConfiguration( f"No free tunnelling slots found on gateway {host_ia}" ) for _tunnel_ia in _free_slots: if xml_interface := keyring.get_tunnel_interface_by_individual_address( tunnelling_slot=_tunnel_ia ): return xml_interface raise InvalidSecureConfiguration( "No credentials for any free tunnelling slot found in keyfile" ) async def _start_tunnelling_udp( self, gateway_ip: str, gateway_port: int, local_ip: str | None, ) -> None: """Start KNX/IP UDP tunnel.""" local_port = self.connection_config.local_port route_back = self.connection_config.route_back local_ip = local_ip or util.find_local_ip(gateway_ip=gateway_ip) if local_ip is None: local_ip = await util.get_default_local_ip(gateway_ip) if local_ip is None: raise XKNXException("No network interface found.") route_back = True logger.debug("Falling back to default interface and enabling route back.") logger.debug( "Starting tunnel from %s:%s to %s:%s", local_ip, local_port, gateway_ip, gateway_port, ) self._interface = UDPTunnel( self.xknx, gateway_ip=gateway_ip, gateway_port=gateway_port, local_ip=local_ip, local_port=local_port, route_back=route_back, cemi_received_callback=self.cemi_received, auto_reconnect=self.connection_config.auto_reconnect, auto_reconnect_wait=self.connection_config.auto_reconnect_wait, ) await self._interface.connect() async def _start_routing(self, local_ip: str | None) -> None: """Start KNX/IP Routing.""" multicast_group = self.connection_config.multicast_group multicast_port = self.connection_config.multicast_port local_ip = local_ip or await util.get_default_local_ip() if local_ip is None: raise XKNXException("No network interface found.") individual_address = ( self.connection_config.individual_address or DEFAULT_INDIVIDUAL_ADDRESS ) logger.debug( "Starting Routing from %s as %s via %s:%s", local_ip, individual_address, multicast_group, multicast_port, ) self._interface = Routing( self.xknx, individual_address=individual_address, cemi_received_callback=self.cemi_received, local_ip=local_ip, multicast_group=multicast_group, multicast_port=multicast_port, ) await self._interface.connect() async def _start_secure_routing( self, local_ip: str | None, keyring: Keyring | None = None, ) -> None: """Start KNX/IP Routing.""" if self.connection_config.secure_config is None: raise InvalidSecureConfiguration("SecureConfig missing in ConnectionConfig") backbone_key = self.connection_config.secure_config.backbone_key latency_ms = self.connection_config.secure_config.latency_ms multicast_group = self.connection_config.multicast_group multicast_port = self.connection_config.multicast_port if keyring is not None and keyring.backbone is not None: # default to manually configured values backbone_key = backbone_key or keyring.backbone.decrypted_key latency_ms = latency_ms or keyring.backbone.latency if keyring.backbone.multicast_address: multicast_group = keyring.backbone.multicast_address if not backbone_key: raise InvalidSecureConfiguration( "No backbone key found in secure configuration" ) local_ip = local_ip or await util.get_default_local_ip() if local_ip is None: raise XKNXException("No network interface found.") individual_address = ( self.connection_config.individual_address or DEFAULT_INDIVIDUAL_ADDRESS ) logger.debug( "Starting Secure Routing from %s as %s via %s:%s", local_ip, individual_address, multicast_group, multicast_port, ) self._interface = SecureRouting( self.xknx, individual_address=individual_address, cemi_received_callback=self.cemi_received, local_ip=local_ip, backbone_key=backbone_key, latency_ms=latency_ms, multicast_group=multicast_group, multicast_port=multicast_port, ) await self._interface.connect() async def stop(self) -> None: """Stop connected interfae (either Tunneling or Routing).""" if self._interface is not None: await self._interface.disconnect() self._interface = None def cemi_received(self, raw_cemi: bytes) -> None: """Pass raw CEMIFrame data to CEMIHandler. Callback for having received CEMIFrames.""" self.xknx.cemi_handler.handle_raw_cemi(raw_cemi) async def send_cemi(self, cemi: CEMIFrame) -> None: """Send CEMIFrame to connected device (either Tunneling or Routing).""" # to ease converting L_Data.req CEMI frames to L_Data.ind and local confirmation # in routing we pass `CEMIFrame` from the CEMIHandler here, instead of raw bytes if self._interface is None: raise CommunicationError("KNX/IP interface not connected") return await self._interface.send_cemi(cemi) async def gateway_info(self) -> GatewayDescriptor | None: """Get gateway descriptor from interface.""" if self._gateway_info is not None: return self._gateway_info if isinstance(self._interface, _Tunnel): return await self._interface.request_description() return None class KNXIPInterfaceThreaded(KNXIPInterface): """Class for managing KNX/IP Tunneling or Routing connections.""" def __init__( self, xknx: XKNX, connection_config: ConnectionConfig | None = None, ) -> None: """Initialize KNXIPInterface class.""" super().__init__(xknx, connection_config) self._main_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() self._connection_thread: threading.Thread | None = None self._thread_loop: asyncio.AbstractEventLoop | None = None def _init_connection_thread(self) -> None: """Start KNX/IP interface in its own thread.""" loop_loaded = threading.Event() self._connection_thread = threading.Thread( target=self._init_connection_loop, args=[loop_loaded], name="KNX Interface", daemon=True, ) self._connection_thread.start() loop_loaded.wait() # wait for the thread to initialize its loop def _init_connection_loop(self, loop_loaded: threading.Event) -> None: """Start KNX/IP interface in its own thread.""" self._thread_loop = asyncio.new_event_loop() asyncio.set_event_loop(self._thread_loop) loop_loaded.set() self._thread_loop.run_forever() async def _await_from_connection_thread(self, coro: Awaitable[T]) -> T: """Await coroutine in different thread.""" if self._thread_loop is None: raise CommunicationError("KNX connection thread not initialized.") fut = asyncio.run_coroutine_threadsafe(coro, self._thread_loop) finished = threading.Event() def fut_finished_cb(_: concurrent.futures.Future[T]) -> None: """Fire threading.Event when the future is finished.""" finished.set() fut.add_done_callback(fut_finished_cb) # wait on that event in an executor, yielding control to _main_loop await self._main_loop.run_in_executor(None, finished.wait) return fut.result() async def start(self) -> None: """Start KNX/IP interface.""" if self._connection_thread is not None or self._thread_loop is not None: raise CommunicationError("KNX threaded interface already initialized.") await self._main_loop.run_in_executor(None, self._init_connection_thread) try: return await self._await_from_connection_thread(self._start()) except CommunicationError: await self.stop() raise async def stop(self) -> None: """Stop connected interface (either Tunneling or Routing).""" if self._interface is not None: await self._await_from_connection_thread(self._interface.disconnect()) self._interface = None if self._thread_loop is not None: self._thread_loop.call_soon_threadsafe(self._thread_loop.stop) self._thread_loop = None if self._connection_thread is not None: self._connection_thread.join() self._connection_thread = None def cemi_received(self, raw_cemi: bytes) -> None: """Pass CEMIFrame to CEMIHandler. Callback for having received CEMIFrames.""" self._main_loop.call_soon_threadsafe(super().cemi_received, raw_cemi) async def send_cemi(self, cemi: CEMIFrame) -> None: """Send CEMIFrame to connected device (either Tunneling or Routing).""" if self._interface is None: raise CommunicationError("KNX/IP interface not connected") return await self._await_from_connection_thread(self._interface.send_cemi(cemi)) async def gateway_info(self) -> GatewayDescriptor | None: """Get gateway descriptor from interface.""" if self._gateway_info is not None: return self._gateway_info if isinstance(self._interface, _Tunnel): return await self._await_from_connection_thread( self._interface.request_description() ) return None xknx-3.6.0/xknx/io/request_response/000077500000000000000000000000001475530762600175345ustar00rootroot00000000000000xknx-3.6.0/xknx/io/request_response/__init__.py000066400000000000000000000007141475530762600216470ustar00rootroot00000000000000"""Package containing all objects for sending requests and waiting for the corresponding response of specific KNX/IP frames.""" # ruff: noqa: F401 from .authenticate import Authenticate from .connect import Connect from .connectionstate import ConnectionState from .device_configuration import DeviceConfiguration from .disconnect import Disconnect from .request_response import RequestResponse from .session import Session from .tunnelling import Tunnelling xknx-3.6.0/xknx/io/request_response/authenticate.py000066400000000000000000000026401475530762600225660ustar00rootroot00000000000000"""Abstraction to send SessionAuthenticate and wait for SessionStatus.""" from __future__ import annotations from typing import TYPE_CHECKING from xknx.knxip import KNXIPFrame, SessionAuthenticate, SessionStatus from .request_response import RequestResponse if TYPE_CHECKING: from xknx.io.transport import KNXIPTransport class Authenticate(RequestResponse): """Class to send a SessionAuthenticate and wait for SessionStatus.""" def __init__( self, transport: KNXIPTransport, user_id: int, message_authentication_code: bytes, ) -> None: """Initialize Session class.""" super().__init__(transport, SessionStatus, timeout_in_seconds=10) self.user_id = user_id self.message_authentication_code = message_authentication_code self.response: SessionStatus | None = None def create_knxipframe(self) -> KNXIPFrame: """Create KNX/IP Frame object to be sent to device.""" return KNXIPFrame.init_from_body( SessionAuthenticate( user_id=self.user_id, message_authentication_code=self.message_authentication_code, ) ) def on_success_hook(self, knxipframe: KNXIPFrame) -> None: """Set communication channel and identifier after having received a valid answer.""" assert isinstance(knxipframe.body, SessionStatus) self.response = knxipframe.body xknx-3.6.0/xknx/io/request_response/connect.py000066400000000000000000000035071475530762600215440ustar00rootroot00000000000000"""Abstraction to send ConnectRequest and wait for ConnectResponse.""" from __future__ import annotations from typing import TYPE_CHECKING from xknx.knxip import ( HPAI, ConnectRequest, ConnectRequestInformation, ConnectResponse, ConnectResponseData, KNXIPFrame, ) from .request_response import RequestResponse if TYPE_CHECKING: from xknx.io.transport import KNXIPTransport class Connect(RequestResponse): """ Class to send a ConnectRequest and wait for ConnectResponse. Setting a `individual_address` is only supported for Tunnelling v2 connections. """ def __init__( self, transport: KNXIPTransport, local_hpai: HPAI, cri: ConnectRequestInformation | None = None, ) -> None: """Initialize Connect class.""" super().__init__(transport, ConnectResponse) self.communication_channel = 0 self.data_endpoint = HPAI() self.local_hpai = local_hpai self.cri = cri or ConnectRequestInformation() self.crd = ConnectResponseData() def create_knxipframe(self) -> KNXIPFrame: """Create KNX/IP Frame object to be sent to device.""" # use the same HPAI for control_endpoint and data_endpoint connect_request = ConnectRequest( control_endpoint=self.local_hpai, data_endpoint=self.local_hpai, cri=self.cri, ) return KNXIPFrame.init_from_body(connect_request) def on_success_hook(self, knxipframe: KNXIPFrame) -> None: """Set communication channel and identifier after having received a valid answer.""" assert isinstance(knxipframe.body, ConnectResponse) self.communication_channel = knxipframe.body.communication_channel self.data_endpoint = knxipframe.body.data_endpoint self.crd = knxipframe.body.crd xknx-3.6.0/xknx/io/request_response/connectionstate.py000066400000000000000000000025221475530762600233070ustar00rootroot00000000000000"""Abstraction to send ConnectonStateRequest and wait for ConnectionStateResponse.""" from __future__ import annotations from typing import TYPE_CHECKING from xknx.io.const import CONNECTIONSTATE_REQUEST_TIMEOUT from xknx.knxip import HPAI, ConnectionStateRequest, ConnectionStateResponse, KNXIPFrame from .request_response import RequestResponse if TYPE_CHECKING: from xknx.io.transport import KNXIPTransport class ConnectionState(RequestResponse): """Class to send ConnectonStateRequest and wait for ConnectionStateResponse.""" def __init__( self, transport: KNXIPTransport, communication_channel_id: int, local_hpai: HPAI, ) -> None: """Initialize ConnectionState class.""" super().__init__( transport, ConnectionStateResponse, timeout_in_seconds=CONNECTIONSTATE_REQUEST_TIMEOUT, ) self.communication_channel_id = communication_channel_id self.local_hpai = local_hpai def create_knxipframe(self) -> KNXIPFrame: """Create KNX/IP Frame object to be sent to device.""" connectionstate_request = ConnectionStateRequest( communication_channel_id=self.communication_channel_id, control_endpoint=self.local_hpai, ) return KNXIPFrame.init_from_body(connectionstate_request) xknx-3.6.0/xknx/io/request_response/device_configuration.py000066400000000000000000000025051475530762600242760ustar00rootroot00000000000000"""Abstraction to send a DeviceConfigurationRequest and wait for DeviceConfigurationAck.""" from __future__ import annotations from typing import TYPE_CHECKING from xknx.knxip import DeviceConfigurationAck, DeviceConfigurationRequest, KNXIPFrame from .request_response import RequestResponse if TYPE_CHECKING: from xknx.io.transport import UDPTransport class DeviceConfiguration(RequestResponse): """Class to send DeviceConfigurationRequest and wait for DeviceConfigurationAck (UDP only).""" transport: UDPTransport def __init__( self, transport: UDPTransport, data_endpoint: tuple[str, int] | None, device_configuration_request: DeviceConfigurationRequest, ) -> None: """Initialize DeviceConfiguration class.""" self.data_endpoint_addr = data_endpoint self.device_configuration_request = device_configuration_request super().__init__(transport, DeviceConfigurationAck) async def send_request(self) -> None: """Build knxipframe (within derived class) and send via UDP.""" self.transport.send(self.create_knxipframe(), addr=self.data_endpoint_addr) def create_knxipframe(self) -> KNXIPFrame: """Create KNX/IP Frame object to be sent to device.""" return KNXIPFrame.init_from_body(self.device_configuration_request) xknx-3.6.0/xknx/io/request_response/disconnect.py000066400000000000000000000021771475530762600222460ustar00rootroot00000000000000"""Abstraction to send DisconnectRequest and wait for DisconnectResponse.""" from __future__ import annotations from typing import TYPE_CHECKING from xknx.knxip import HPAI, DisconnectRequest, DisconnectResponse, KNXIPFrame from .request_response import RequestResponse if TYPE_CHECKING: from xknx.io.transport import KNXIPTransport class Disconnect(RequestResponse): """Class to send a DisconnectRequest and wait for a DisconnectResponse.""" def __init__( self, transport: KNXIPTransport, communication_channel_id: int, local_hpai: HPAI, ) -> None: """Initialize Disconnect class.""" super().__init__(transport, DisconnectResponse) self.communication_channel_id = communication_channel_id self.local_hpai = local_hpai def create_knxipframe(self) -> KNXIPFrame: """Create KNX/IP Frame object to be sent to device.""" disconnect_request = DisconnectRequest( communication_channel_id=self.communication_channel_id, control_endpoint=self.local_hpai, ) return KNXIPFrame.init_from_body(disconnect_request) xknx-3.6.0/xknx/io/request_response/request_response.py000066400000000000000000000071111475530762600235140ustar00rootroot00000000000000""" Base class for sending a specific type of KNX/IP Packet to a KNX/IP device and wait for the corresponding answer. Will report if the corresponding answer was not received. """ from __future__ import annotations import asyncio import logging from xknx.exceptions import CommunicationError from xknx.io.transport import KNXIPTransport from xknx.knxip import HPAI, ErrorCode, KNXIPBody, KNXIPBodyResponse, KNXIPFrame from xknx.util import asyncio_timeout logger = logging.getLogger("xknx.log") class RequestResponse: """Class for sending a specific type of KNX/IP Packet to a KNX/IP device and wait for the corresponding answer.""" def __init__( self, transport: KNXIPTransport, awaited_response_class: type[KNXIPBody], timeout_in_seconds: float = 1.0, ) -> None: """Initialize RequstResponse class.""" self.transport = transport self.awaited_response_class: type[KNXIPBody] = awaited_response_class self.response_received_event = asyncio.Event() self.success = False self.timeout_in_seconds = timeout_in_seconds self.response_status_code: ErrorCode | None = None def create_knxipframe(self) -> KNXIPFrame: """Create KNX/IP Frame object to be sent to device.""" raise NotImplementedError("create_knxipframe has to be implemented") async def start(self) -> None: """Start. Send request and wait for an answer.""" callb = self.transport.register_callback( self.response_rec_callback, [self.awaited_response_class.SERVICE_TYPE] ) try: await self.send_request() async with asyncio_timeout(self.timeout_in_seconds): await self.response_received_event.wait() except asyncio.TimeoutError: logger.debug( "Error: KNX bus did not respond in time (%s secs) to request of type '%s'", self.timeout_in_seconds, self.__class__.__name__, ) except CommunicationError as err: logger.warning( "Sending request of type '%s' failed: %s", self.__class__.__name__, err ) finally: # cleanup to not leave callbacks (for asyncio.CancelledError) self.transport.unregister_callback(callb) async def send_request(self) -> None: """Build knxipframe (within derived class) and send via transport.""" self.transport.send(self.create_knxipframe()) def response_rec_callback( self, knxipframe: KNXIPFrame, source: HPAI, _: KNXIPTransport ) -> None: """Verify and handle knxipframe. Callback from internal transport.""" if not isinstance(knxipframe.body, self.awaited_response_class): logger.warning("Could not understand knxipframe") return self.response_received_event.set() if isinstance(knxipframe.body, KNXIPBodyResponse): self.response_status_code = knxipframe.body.status_code if knxipframe.body.status_code != ErrorCode.E_NO_ERROR: logger.debug( "Error: KNX bus responded to request of type '%s' with error in '%s': %s", self.__class__.__name__, self.awaited_response_class.__name__, knxipframe.body.status_code, ) return self.success = True self.on_success_hook(knxipframe) def on_success_hook(self, knxipframe: KNXIPFrame) -> None: """Do something after having received a valid answer. May be overwritten in derived class.""" xknx-3.6.0/xknx/io/request_response/session.py000066400000000000000000000027741475530762600216030ustar00rootroot00000000000000"""Abstraction to send SessionRequest and wait for SessionResponse.""" from __future__ import annotations from typing import TYPE_CHECKING from xknx.knxip import KNXIPFrame, SessionRequest, SessionResponse from .request_response import RequestResponse if TYPE_CHECKING: from xknx.io.transport import KNXIPTransport class Session(RequestResponse): """Class to send a SessionRequest and wait for SessionResponse.""" def __init__( self, transport: KNXIPTransport, ecdh_client_public_key: bytes, ) -> None: """Initialize Session class.""" # TODO: increase timeout to timeoutAuthentication: 10sec ? super().__init__(transport, SessionResponse) self.ecdh_client_public_key = ecdh_client_public_key # TODO: make RequestResponse generic for response class # maybe replace self.success with self.response None check # remove on_success_hook in favour of using knxipframe.body directly self.response: SessionResponse | None = None def create_knxipframe(self) -> KNXIPFrame: """Create KNX/IP Frame object to be sent to device.""" return KNXIPFrame.init_from_body( SessionRequest(ecdh_client_public_key=self.ecdh_client_public_key) ) def on_success_hook(self, knxipframe: KNXIPFrame) -> None: """Set communication channel and identifier after having received a valid answer.""" assert isinstance(knxipframe.body, SessionResponse) self.response = knxipframe.body xknx-3.6.0/xknx/io/request_response/tunnelling.py000066400000000000000000000023041475530762600222640ustar00rootroot00000000000000"""Abstraction to send a TunnelingRequest and wait for TunnelingResponse.""" from __future__ import annotations from typing import TYPE_CHECKING from xknx.knxip import KNXIPFrame, TunnellingAck, TunnellingRequest from .request_response import RequestResponse if TYPE_CHECKING: from xknx.io.transport import UDPTransport class Tunnelling(RequestResponse): """Class to send TunnelingRequest and wait for TunnelingACK (UDP only).""" transport: UDPTransport def __init__( self, transport: UDPTransport, data_endpoint: tuple[str, int] | None, tunnelling_request: TunnellingRequest, ) -> None: """Initialize Tunnelling class.""" self.data_endpoint_addr = data_endpoint self.tunnelling_request = tunnelling_request super().__init__(transport, TunnellingAck) async def send_request(self) -> None: """Build knxipframe (within derived class) and send via UDP.""" self.transport.send(self.create_knxipframe(), addr=self.data_endpoint_addr) def create_knxipframe(self) -> KNXIPFrame: """Create KNX/IP Frame object to be sent to device.""" return KNXIPFrame.init_from_body(self.tunnelling_request) xknx-3.6.0/xknx/io/routing.py000066400000000000000000000246341475530762600162000ustar00rootroot00000000000000""" Abstraction for handling KNXnet/IP routing. Routing uses UDP Multicast to send and receive KNXnet/IP messages. """ from __future__ import annotations import asyncio from collections.abc import AsyncIterator from contextlib import asynccontextmanager import logging import random from typing import TYPE_CHECKING, Final from xknx.cemi import CEMIFrame, CEMIMessageCode from xknx.core import XknxConnectionState, XknxConnectionType from xknx.exceptions import CommunicationError from xknx.knxip import ( HPAI, KNXIPFrame, KNXIPServiceType, RoutingBusy, RoutingIndication, RoutingLostMessage, ) from xknx.telegram import IndividualAddress from .const import DEFAULT_INDIVIDUAL_ADDRESS, DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from .interface import CEMIBytesCallbackType, Interface from .ip_secure import SecureGroup from .transport import KNXIPTransport, UDPTransport if TYPE_CHECKING: from xknx.xknx import XKNX logger = logging.getLogger("xknx.log") BUSY_DECREMENT_TIME: Final = 0.005 # 5 ms BUSY_INCREMENT_COOLDOWN: Final = 0.01 # 10 ms BUSY_RANDOM_TIME_FACTOR: Final = 0.05 # 50 ms BUSY_SLOWDURATION_TIME_FACTOR: Final = 0.1 # 100 ms ROUTING_INDICATION_WAIT_TIME: Final = 0.02 # 20 ms DEFAULT_LATENCY_TOLERANCE_MS: Final = 1000 class _RoutingFlowControl: """ Class for handling KNXnet/IP routing flow control. See KNX Specifications 3.8.5 Routing §2.3.5 Flow control handling """ def __init__(self) -> None: self._last_busy_frame_time: float = 0.0 self._last_sent_routing_indication_time: float = 0.0 self._loop = asyncio.get_running_loop() self._ready = asyncio.Event() self._ready.set() self._received_busy_frames: int = 0 self._timer_task: asyncio.Task[None] | None = None self._wait_start_time: float | None = None self._wait_time_ms: int = 0 def cancel(self) -> None: """Cancel internal tasks.""" if self._timer_task: self._timer_task.cancel() @asynccontextmanager async def throttle(self) -> AsyncIterator[None]: """Context manager to wait for ready state and throttle outgoing frames.""" # limit RoutingIndication transmission rate according to # KNX Specifications 3.2.6 Communication Medium KNX IP §2.1 # simplified version - pause 20 ms after transmit a RoutingIndication elapsed = self._loop.time() - self._last_sent_routing_indication_time if elapsed < ROUTING_INDICATION_WAIT_TIME: await asyncio.sleep(ROUTING_INDICATION_WAIT_TIME - elapsed) await self._ready.wait() yield self._last_sent_routing_indication_time = self._loop.time() def handle_routing_busy(self, routing_busy: RoutingBusy) -> None: """Handle incoming RoutingBusy.""" self._ready.clear() now = self._loop.time() previous_busy_frame_time = self._last_busy_frame_time self._last_busy_frame_time = now if self._wait_start_time is None: logger.info( "RoutingBusy received: %s", routing_busy, ) else: # only apply if we have already received a RoutingBusy frame and are still pausing if (now - previous_busy_frame_time) > BUSY_INCREMENT_COOLDOWN: self._received_busy_frames += 1 logger.debug( "RoutingBusy received: %s - %s ms since previous - number %s in moving time window", routing_busy, round((now - previous_busy_frame_time) * 1000), self._received_busy_frames, ) # discard frame if wait time is lower than remaining time remaining_ms = (now - self._wait_start_time) * 1000 if remaining_ms >= routing_busy.wait_time: return self._wait_time_ms = routing_busy.wait_time self._wait_start_time = now if self._timer_task: self._timer_task.cancel() self._timer_task = asyncio.create_task(self._resume_sending()) async def _resume_sending(self) -> None: """Reset ready flag after wait_time_ms and fade out slowduration.""" random_wait_extension = ( random.random() * self._received_busy_frames * BUSY_RANDOM_TIME_FACTOR ) slowduration = self._received_busy_frames * BUSY_SLOWDURATION_TIME_FACTOR await asyncio.sleep(self._wait_time_ms / 1000 + random_wait_extension) self._ready.set() self._wait_start_time = None await asyncio.sleep(slowduration) while self._received_busy_frames > 0: await asyncio.sleep(BUSY_DECREMENT_TIME) self._received_busy_frames -= 1 class Routing(Interface): """Class for handling KNXnet/IP multicast communication.""" connection_type = XknxConnectionType.ROUTING transport: UDPTransport def __init__( self, xknx: XKNX, individual_address: IndividualAddress | None, cemi_received_callback: CEMIBytesCallbackType, local_ip: str, multicast_group: str = DEFAULT_MCAST_GRP, multicast_port: int = DEFAULT_MCAST_PORT, ) -> None: """Initialize Routing class.""" self.xknx = xknx self.individual_address = individual_address or DEFAULT_INDIVIDUAL_ADDRESS self.cemi_received_callback = cemi_received_callback self.local_ip = local_ip self.multicast_group = multicast_group self.multicast_port = multicast_port self._init_transport() self.transport.register_callback( self._handle_frame, [ KNXIPServiceType.ROUTING_INDICATION, KNXIPServiceType.ROUTING_BUSY, KNXIPServiceType.ROUTING_LOST_MESSAGE, ], ) self._flow_control = _RoutingFlowControl() def _init_transport(self) -> None: """Initialize transport.""" self.transport = UDPTransport( local_addr=(self.local_ip, 0), remote_addr=(self.multicast_group, self.multicast_port), multicast=True, ) #################### # # CONNECT DISCONNECT # #################### async def connect(self) -> bool: """Start routing.""" self.xknx.current_address = self.individual_address self.xknx.connection_manager.connection_state_changed( XknxConnectionState.CONNECTING, self.connection_type ) try: await self.transport.connect() except OSError as ex: logger.debug( "Could not establish connection to KNXnet/IP network. %s: %s", type(ex).__name__, ex, ) self.xknx.connection_manager.connection_state_changed( XknxConnectionState.DISCONNECTED ) # close udp transport to prevent open file descriptors self.transport.stop() raise CommunicationError("Routing could not be started") from ex self.xknx.connection_manager.connection_state_changed( XknxConnectionState.CONNECTED, self.connection_type ) return True async def disconnect(self) -> None: """Stop routing.""" self.transport.stop() self.xknx.connection_manager.connection_state_changed( XknxConnectionState.DISCONNECTED ) self._flow_control.cancel() ################## # # OUTGOING FRAMES # ################## async def send_cemi(self, cemi: CEMIFrame) -> None: """Send CEMIFrame to the network.""" # send L_DATA_IND to network, create L_DATA_CON locally for routing cemi.code = CEMIMessageCode.L_DATA_IND routing_indication = RoutingIndication(raw_cemi=cemi.to_knx()) async with self._flow_control.throttle(): self._send_knxipframe(KNXIPFrame.init_from_body(routing_indication)) cemi.code = CEMIMessageCode.L_DATA_CON self.cemi_received_callback(cemi.to_knx()) def _send_knxipframe(self, knxipframe: KNXIPFrame) -> None: """Send KNXIPFrame to connected routing device.""" self.transport.send(knxipframe) ################## # # INCOMING FRAMES # ################## def _handle_frame( self, knxipframe: KNXIPFrame, source: HPAI, _: KNXIPTransport ) -> None: """Handle incoming KNXIPFrames. Callback from internal transport.""" if isinstance(knxipframe.body, RoutingIndication): self._handle_routing_indication(knxipframe.body) elif isinstance(knxipframe.body, RoutingBusy): self._flow_control.handle_routing_busy(knxipframe.body) elif isinstance(knxipframe.body, RoutingLostMessage): logger.warning( "RoutingLostMessage received from %s - %s lost messages.", source.ip_addr, knxipframe.body.lost_messages, ) else: logger.warning("Service not implemented: %s", knxipframe) def _handle_routing_indication(self, routing_indication: RoutingIndication) -> None: """Handle incoming RoutingIndication.""" self.cemi_received_callback(routing_indication.raw_cemi) class SecureRouting(Routing): """Class for handling KNXnet/IP secure multicast communication.""" connection_type = XknxConnectionType.ROUTING_SECURE transport: SecureGroup def __init__( self, xknx: XKNX, individual_address: IndividualAddress | None, cemi_received_callback: CEMIBytesCallbackType, local_ip: str, backbone_key: bytes, latency_ms: int | None = None, multicast_group: str = DEFAULT_MCAST_GRP, multicast_port: int = DEFAULT_MCAST_PORT, ) -> None: """Initialize SecureRouting class.""" self.backbone_key = backbone_key self.latency_ms = latency_ms or DEFAULT_LATENCY_TOLERANCE_MS super().__init__( xknx, individual_address=individual_address, cemi_received_callback=cemi_received_callback, local_ip=local_ip, multicast_group=multicast_group, multicast_port=multicast_port, ) def _init_transport(self) -> None: """Initialize transport.""" self.transport = SecureGroup( local_addr=(self.local_ip, 0), remote_addr=(self.multicast_group, self.multicast_port), backbone_key=self.backbone_key, latency_ms=self.latency_ms, ) xknx-3.6.0/xknx/io/self_description.py000066400000000000000000000156031475530762600200410ustar00rootroot00000000000000"""Abstraction to send DescriptionRequest and wait for DescriptionResponse.""" from __future__ import annotations from abc import ABC, abstractmethod import asyncio import logging from typing import TYPE_CHECKING, Final from xknx.exceptions import CommunicationError, XKNXException from xknx.io import util from xknx.io.gateway_scanner import GatewayDescriptor from xknx.knxip import ( HPAI, SRP, DescriptionRequest, DescriptionResponse, DIBTypeCode, KNXIPFrame, SearchRequestExtended, SearchResponseExtended, ) from xknx.util import asyncio_timeout from .const import DEFAULT_MCAST_PORT from .transport import UDPTransport if TYPE_CHECKING: from xknx.io.transport import KNXIPTransport logger = logging.getLogger("xknx.log") DESCRIPTION_TIMEOUT: Final = 2 async def request_description( gateway_ip: str, gateway_port: int = DEFAULT_MCAST_PORT, local_ip: str | None = None, local_port: int = 0, route_back: bool = False, ) -> GatewayDescriptor: """Set up a UDP transport to request a description from a KNXnet/IP device.""" local_ip = local_ip or util.find_local_ip(gateway_ip) if local_ip is None: # Fall back to default interface and use route back local_ip = await util.get_default_local_ip(gateway_ip) if local_ip is None: raise CommunicationError( f"No network interface found to request gateway info from {gateway_ip}:{gateway_port}" ) route_back = True try: local_ip = await util.validate_ip(local_ip, address_name="Local IP") gateway_ip = await util.validate_ip(gateway_ip, address_name="Gateway IP") except XKNXException as err: raise CommunicationError("Invalid address") from err transport = UDPTransport( local_addr=(local_ip, local_port), remote_addr=(gateway_ip, gateway_port), multicast=False, ) try: await transport.connect() except OSError as err: raise CommunicationError( "Could not setup socket to request gateway info" ) from err else: local_hpai: HPAI if route_back: local_hpai = HPAI() else: local_addr = transport.getsockname() local_hpai = HPAI(*local_addr) description_query = DescriptionQuery( transport=transport, local_hpai=local_hpai, ) await description_query.start() gateway = description_query.gateway_descriptor if gateway is None: raise CommunicationError( f"Could not fetch gateway info from {gateway_ip}:{gateway_port}" ) if gateway.core_version >= 2: search_extended_query = SearchExtendedQuery( transport=transport, local_hpai=local_hpai, ) await search_extended_query.start() gateway = search_extended_query.gateway_descriptor if gateway is None: raise CommunicationError( f"Could not fetch extended gateway info from {gateway_ip}:{gateway_port}" ) return gateway finally: transport.stop() class _SelfDescriptionQuery(ABC): """Base class for handling descriptions request-response cycles.""" expected_response_class: type[DescriptionResponse] | type[SearchResponseExtended] def __init__( self, transport: KNXIPTransport, local_hpai: HPAI, ) -> None: """Initialize Description class.""" self.transport = transport self.local_hpai = local_hpai self.gateway_descriptor: GatewayDescriptor | None = None self.response_received_event = asyncio.Event() @abstractmethod def create_knxipframe(self) -> KNXIPFrame: """Create KNX/IP Frame object to be sent to device.""" async def start(self) -> None: """Start. Send request and wait for an answer.""" callb = self.transport.register_callback( self.response_rec_callback, [self.expected_response_class.SERVICE_TYPE] ) frame = self.create_knxipframe() try: self.transport.send(frame) async with asyncio_timeout(DESCRIPTION_TIMEOUT): await self.response_received_event.wait() except asyncio.TimeoutError: logger.debug( "Error: KNX bus did not respond in time (%s secs) to request of type '%s'", DESCRIPTION_TIMEOUT, self.__class__.__name__, ) except CommunicationError as err: logger.warning("Sending %s failed: %s", frame.body.__class__.__name__, err) finally: # cleanup to not leave callbacks (for asyncio.CancelledError) self.transport.unregister_callback(callb) def response_rec_callback( self, knxipframe: KNXIPFrame, source: HPAI, _: KNXIPTransport ) -> None: """Verify and handle knxipframe. Callback from internal transport.""" if not isinstance(knxipframe.body, self.expected_response_class): logger.warning( "Wrong knxipframe for %s: %s", self.__class__.__name__, knxipframe ) return self.response_received_event.set() # Set gateway descriptor attribute gateway = GatewayDescriptor( ip_addr=self.transport.remote_addr[0], port=self.transport.remote_addr[1], local_ip=self.transport.getsockname()[0], ) gateway.parse_dibs(knxipframe.body.dibs) # type: ignore[attr-defined] self.gateway_descriptor = gateway class DescriptionQuery(_SelfDescriptionQuery): """Class to send a DescriptionRequest and wait for DescriptionResponse.""" expected_response_class = DescriptionResponse def create_knxipframe(self) -> KNXIPFrame: """Create KNX/IP Frame object to be sent to device.""" description_request = DescriptionRequest(control_endpoint=self.local_hpai) return KNXIPFrame.init_from_body(description_request) class SearchExtendedQuery(_SelfDescriptionQuery): """ Class to send a SearchRequestExtended and wait for SearchResponseExtended to a single device. Does only work with UDP transports. """ expected_response_class = SearchResponseExtended def create_knxipframe(self) -> KNXIPFrame: """Create KNX/IP Frame object to be sent to device.""" search_extended_request = SearchRequestExtended( discovery_endpoint=self.local_hpai, srps=[ SRP.request_device_description( [ DIBTypeCode.DEVICE_INFO, DIBTypeCode.SUPP_SVC_FAMILIES, DIBTypeCode.SECURED_SERVICE_FAMILIES, DIBTypeCode.TUNNELING_INFO, ] ) ], ) return KNXIPFrame.init_from_body(search_extended_request) xknx-3.6.0/xknx/io/transport/000077500000000000000000000000001475530762600161625ustar00rootroot00000000000000xknx-3.6.0/xknx/io/transport/__init__.py000066400000000000000000000003151475530762600202720ustar00rootroot00000000000000"""Package containing all objects for connecting to sockets.""" # ruff: noqa: F401 from .ip_transport import KNXIPTransport from .tcp_transport import TCPTransport from .udp_transport import UDPTransport xknx-3.6.0/xknx/io/transport/ip_transport.py000066400000000000000000000066771475530762600213000ustar00rootroot00000000000000""" Abstract base for a specific IP transports (TCP or UDP). * It starts and stops a socket * It handles callbacks for incoming frame service types """ from __future__ import annotations from abc import ABC, abstractmethod import asyncio from collections.abc import Callable import logging from typing import cast from xknx.exceptions import CommunicationError from xknx.knxip import HPAI, KNXIPFrame, KNXIPServiceType TransportCallbackType = Callable[[KNXIPFrame, HPAI, "KNXIPTransport"], None] knx_logger = logging.getLogger("xknx.knx") class KNXIPTransport(ABC): """Abstract base class for KNX/IP transports.""" callbacks: list[KNXIPTransport.Callback] local_hpai: HPAI remote_addr: tuple[str, int] transport: asyncio.BaseTransport | None class Callback: """Callback class for handling callbacks for different 'KNX service types' of received packets.""" def __init__( self, callback: TransportCallbackType, service_types: list[KNXIPServiceType] | None = None, ) -> None: """Initialize Callback class.""" self.callback = callback self.service_types = service_types or [] def has_service(self, service_type: KNXIPServiceType) -> bool: """Test if callback is listening for given service type.""" return not self.service_types or service_type in self.service_types def register_callback( self, callback: TransportCallbackType, service_types: list[KNXIPServiceType] | None = None, ) -> KNXIPTransport.Callback: """Register callback.""" if service_types is None: service_types = [] callb = KNXIPTransport.Callback(callback, service_types) self.callbacks.append(callb) return callb def unregister_callback(self, callb: KNXIPTransport.Callback) -> None: """Unregister callback.""" self.callbacks.remove(callb) def handle_knxipframe(self, knxipframe: KNXIPFrame, source: HPAI) -> None: """Handle KNXIP Frame and call all callbacks matching the service type ident.""" handled = False for callback in self.callbacks: if callback.has_service(knxipframe.header.service_type_ident): callback.callback(knxipframe, source, self) handled = True if not handled: knx_logger.debug( "Unhandled: %s from: %s", knxipframe.header.service_type_ident, source, ) @abstractmethod async def connect(self) -> None: """Connect transport.""" @abstractmethod def send(self, knxipframe: KNXIPFrame, addr: tuple[str, int] | None = None) -> None: """Send KNXIPFrame via transport.""" def getsockname(self) -> tuple[str, int]: """Return socket IP and port.""" if self.transport is None: raise CommunicationError( "No transport defined. Socket information not resolveable" ) return cast(tuple[str, int], self.transport.get_extra_info("sockname")) def getremote(self) -> str | None: """Return peername.""" return ( self.transport.get_extra_info("peername") if self.transport is not None else None ) def stop(self) -> None: """Stop socket.""" if self.transport is not None: self.transport.close() self.transport = None xknx-3.6.0/xknx/io/transport/tcp_transport.py000066400000000000000000000127641475530762600214500ustar00rootroot00000000000000""" TCPTransport is an abstraction for handling the complete TCP io. The module is build upon asyncio stream socket functions. """ from __future__ import annotations import asyncio from collections.abc import Callable import logging from xknx.exceptions import CommunicationError, CouldNotParseKNXIP, IncompleteKNXIPFrame from xknx.knxip import HPAI, HostProtocol, KNXIPFrame from .ip_transport import KNXIPTransport raw_socket_logger = logging.getLogger("xknx.raw_socket") logger = logging.getLogger("xknx.log") knx_logger = logging.getLogger("xknx.knx") class TCPTransport(KNXIPTransport): """Class for handling (sending and receiving) TCP packets.""" class TCPTransportFactory(asyncio.Protocol): """Abstraction for managing the asyncio-tcp protocol.""" def __init__( self, data_received_callback: Callable[[bytes], None], connection_lost_callback: Callable[[], None], ) -> None: """Initialize UDPTransportFactory class.""" self.transport: asyncio.BaseTransport | None = None self.data_received_callback = data_received_callback self.connection_lost_callback = connection_lost_callback # Workaround for issue of TCP Transport in ProactorEventLoop in py3.10 and py3.11 # on Windows returning bytearray instead of bytes which lead to errors in # cryptography (eg. X25519PublicKey.from_public_bytes() in IP Secure handshake) # https://github.com/python/cpython/issues/99941 try: if isinstance(asyncio.get_event_loop(), asyncio.ProactorEventLoop): # type: ignore[attr-defined, unused-ignore] # unused-ignore for Windows self.data_received_callback = lambda data: data_received_callback( bytes(data) ) except AttributeError: # asyncio.ProactorEventLoop is only available on Windows pass def connection_made(self, transport: asyncio.BaseTransport) -> None: """Assign transport. Callback after udp connection was made.""" self.transport = transport def data_received(self, data: bytes) -> None: """Call assigned callback. Callback for datagram received.""" raw_socket_logger.debug("Received via tcp: %s", data.hex()) self.data_received_callback(data) def connection_lost(self, exc: Exception | None) -> None: """Log error. Callback for connection lost.""" logger.debug("Closing TCP transport. %s", exc) self.connection_lost_callback() def __init__( self, remote_addr: tuple[str, int], connection_lost_cb: Callable[[], None] | None = None, ) -> None: """Initialize TCPTransport class.""" self.remote_addr = remote_addr self.remote_hpai = HPAI(*remote_addr, protocol=HostProtocol.IPV4_TCP) self.callbacks = [] self._connection_lost_cb = connection_lost_cb self.transport: asyncio.Transport | None = None self._buffer = b"" def data_received_callback(self, raw: bytes) -> None: """Parse and process KNXIP frame. Callback for having received data over TCP.""" if self._buffer: raw = self._buffer + raw self._buffer = b"" if not raw: return try: knxipframe, next_frame_part = KNXIPFrame.from_knx(raw) except IncompleteKNXIPFrame: self._buffer = raw raw_socket_logger.debug( "Incomplete KNX/IP frame. Waiting for rest: %s", raw.hex() ) return except CouldNotParseKNXIP as couldnotparseknxip: knx_logger.debug( "Unsupported KNXIPFrame from %s: %s in %s", self.remote_hpai, couldnotparseknxip.description, raw.hex(), ) else: knx_logger.debug( "Received from %s: %s", self.remote_hpai, knxipframe, ) self.handle_knxipframe(knxipframe, self.remote_hpai) # parse data after current KNX/IP frame if next_frame_part: self.data_received_callback(next_frame_part) async def connect(self) -> None: """Connect TCP socket.""" tcp_transport_factory = TCPTransport.TCPTransportFactory( data_received_callback=self.data_received_callback, connection_lost_callback=self._connection_lost, ) loop = asyncio.get_running_loop() (self.transport, _) = await loop.create_connection( lambda: tcp_transport_factory, host=self.remote_hpai.ip_addr, port=self.remote_hpai.port, ) def _connection_lost(self) -> None: """Call assigned callback. Callback for connection lost.""" # avoid calling the callback when the transport was stopped intentionally if self.transport is not None: self.stop() if self._connection_lost_cb: self._connection_lost_cb() def send(self, knxipframe: KNXIPFrame, addr: tuple[str, int] | None = None) -> None: """Send KNXIPFrame to socket. `addr` is ignored on TCP.""" knx_logger.debug( "Sending to %s: %s", self.remote_hpai, knxipframe, ) if self.transport is None: raise CommunicationError("Transport not connected") self.transport.write(knxipframe.to_knx()) xknx-3.6.0/xknx/io/transport/udp_transport.py000066400000000000000000000146331475530762600214470ustar00rootroot00000000000000""" UDPTransport is an abstraction for handling the complete UDP io. The module is build upon asyncio udp functions. Due to lame support of UDP multicast within asyncio some special treatment for multicast is necessary. """ from __future__ import annotations import asyncio from collections.abc import Callable import logging import socket import sys from xknx.exceptions import CommunicationError, CouldNotParseKNXIP from xknx.knxip import HPAI, KNXIPFrame from .ip_transport import KNXIPTransport raw_socket_logger = logging.getLogger("xknx.raw_socket") logger = logging.getLogger("xknx.log") knx_logger = logging.getLogger("xknx.knx") class UDPTransport(KNXIPTransport): """Class for handling (sending and receiving) UDP packets.""" class UDPTransportFactory(asyncio.DatagramProtocol): """Abstraction for managing the asyncio-udp transports.""" def __init__( self, data_received_callback: Callable[[bytes, tuple[str, int]], None] | None = None, ) -> None: """Initialize UDPTransportFactory class.""" self.transport: asyncio.BaseTransport | None = None self.data_received_callback = data_received_callback def connection_made(self, transport: asyncio.BaseTransport) -> None: """Assign transport. Callback after udp connection was made.""" self.transport = transport def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: """Call assigned callback. Callback for datagram received.""" raw_socket_logger.debug("Received from %s: %s", addr, data.hex()) if self.data_received_callback is not None: self.data_received_callback(data, addr) def error_received(self, exc: Exception) -> None: """Call when a send or receive operation raises an OSError.""" logger.warning("Error received: %s", exc) def connection_lost(self, exc: Exception | None) -> None: """Log error. Callback for connection lost.""" logger.debug("Closing UDP transport.") def __init__( self, local_addr: tuple[str, int], remote_addr: tuple[str, int], multicast: bool = False, ) -> None: """Initialize UDPTransport class.""" if not isinstance(local_addr, tuple): raise TypeError() if not isinstance(remote_addr, tuple): raise TypeError() self.local_addr = local_addr self.remote_addr = remote_addr self.multicast = multicast self.callbacks = [] self.transport: asyncio.DatagramTransport | None = None def data_received_callback(self, raw: bytes, source: tuple[str, int]) -> None: """Parse and process KNXIP frame. Callback for having received an UDP packet.""" if raw: try: knxipframe, _ = KNXIPFrame.from_knx(raw) except CouldNotParseKNXIP as couldnotparseknxip: knx_logger.debug( "Unsupported KNXIPFrame from %s:%s: %s in %s", source[0], source[1], couldnotparseknxip.description, raw.hex(), ) else: knx_logger.debug( "Received from %s:%s: %s", source[0], source[1], knxipframe, ) self.handle_knxipframe(knxipframe, HPAI(*source)) @staticmethod def create_multicast_sock( own_ip: str, remote_addr: tuple[str, int] ) -> socket.socket: """Create UDP multicast socket.""" sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setblocking(False) sock.setsockopt( socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(own_ip) ) sock.setsockopt( socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, socket.inet_aton(remote_addr[0]) + socket.inet_aton(own_ip), ) sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) if sys.platform == "win32": # '' represents INADDR_ANY sock.bind(("", remote_addr[1])) elif sys.platform == "darwin": # allows multiple sockets to the same port by multiple processes # behaves like SO_REUSEADDR for bind for INADDR_ANY # (GatewayScanner opens multiple sockets) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) sock.bind(("", remote_addr[1])) else: sock.bind((remote_addr[0], remote_addr[1])) # ignore multicast datagrams sent by the host itself # don't use when running multiple routing instances on a single host (interface) sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 0) return sock async def connect(self) -> None: """Connect UDP socket. Open UDP port and build multicast socket if necessary.""" udp_transport_factory = UDPTransport.UDPTransportFactory( data_received_callback=self.data_received_callback, ) loop = asyncio.get_running_loop() if self.multicast: sock = UDPTransport.create_multicast_sock( self.local_addr[0], self.remote_addr ) (self.transport, _) = await loop.create_datagram_endpoint( lambda: udp_transport_factory, sock=sock, ) else: (self.transport, _) = await loop.create_datagram_endpoint( lambda: udp_transport_factory, local_addr=self.local_addr, ) def send(self, knxipframe: KNXIPFrame, addr: tuple[str, int] | None = None) -> None: """Send KNXIPFrame to socket.""" _addr = addr or self.remote_addr knx_logger.debug("Sending to %s:%s: %s", _addr[0], _addr[1], knxipframe) if self.transport is None: raise CommunicationError("Transport not connected") if self.multicast: if addr is not None: logger.warning( "Multicast send to specific address is invalid. %s", knxipframe, ) self.transport.sendto(knxipframe.to_knx(), self.remote_addr) else: self.transport.sendto(knxipframe.to_knx(), addr=_addr) xknx-3.6.0/xknx/io/tunnel.py000066400000000000000000000552471475530762600160220ustar00rootroot00000000000000""" Abstraction for handling KNX/IP tunnels. Tunnels connect to KNX/IP devices directly via UDP or TCP and build a static connection. """ from __future__ import annotations from abc import abstractmethod import asyncio import logging from typing import TYPE_CHECKING from xknx.cemi import CEMIFrame from xknx.core import XknxConnectionState, XknxConnectionType from xknx.exceptions import CommunicationError, TunnellingAckError from xknx.knxip import ( HPAI, ConnectRequestInformation, DisconnectRequest, DisconnectResponse, HostProtocol, KNXIPFrame, KNXIPServiceType, TunnellingAck, TunnellingRequest, ) from xknx.telegram import IndividualAddress from .const import HEARTBEAT_RATE from .gateway_scanner import GatewayDescriptor from .interface import CEMIBytesCallbackType, Interface from .ip_secure import SecureSession from .request_response import Connect, ConnectionState, Disconnect, Tunnelling from .self_description import DescriptionQuery from .transport import KNXIPTransport, TCPTransport, UDPTransport if TYPE_CHECKING: from xknx.xknx import XKNX logger = logging.getLogger("xknx.log") class _Tunnel(Interface): """Class for handling KNX/IP tunnels.""" connection_type: XknxConnectionType transport: KNXIPTransport def __init__( self, xknx: XKNX, cemi_received_callback: CEMIBytesCallbackType, auto_reconnect: bool = True, auto_reconnect_wait: int = 3, ) -> None: """Initialize Tunnel class.""" self.xknx = xknx self.auto_reconnect = auto_reconnect self.auto_reconnect_wait = auto_reconnect_wait self.communication_channel: int | None = None self.local_hpai: HPAI = HPAI() self.sequence_number = 0 self.cemi_received_callback = cemi_received_callback self._data_endpoint_addr: tuple[str, int] | None = None self._heartbeat_task: asyncio.Task[None] | None = None self._initial_connection = True self._is_reconnecting = False self._reconnect_task: asyncio.Task[None] | None = None self._requested_address: IndividualAddress | None = None self._src_address = IndividualAddress(0) self._send_lock = asyncio.Lock() # self._tunnelling_request_confirmation_event = asyncio.Event() self._init_transport() self.transport.register_callback( self._request_received, [ KNXIPServiceType.TUNNELLING_REQUEST, KNXIPServiceType.DISCONNECT_REQUEST, ], ) @abstractmethod def _init_transport(self) -> None: """Initialize transport.""" # set up self.transport @abstractmethod async def setup_tunnel(self) -> None: """Set up tunnel before sending a ConnectionRequest.""" # eg. set local HPAI used for control and data endpoint #################### # # CONNECT DISCONNECT # #################### async def connect(self) -> bool: """Connect to a KNX tunneling interface. Returns True on success.""" self.xknx.connection_manager.connection_state_changed( XknxConnectionState.CONNECTING, self.connection_type ) try: await self.transport.connect() await self.setup_tunnel() await self._connect_request() except (OSError, CommunicationError) as ex: logger.debug( "Could not establish connection to KNX/IP interface. %s: %s", type(ex).__name__, ex, ) self.xknx.connection_manager.connection_state_changed( XknxConnectionState.DISCONNECTED ) if not self._initial_connection and self.auto_reconnect: self._reconnect_task = asyncio.create_task(self._reconnect()) return False # close transport to prevent open file descriptors self.transport.stop() raise CommunicationError( "Tunnel connection could not be established" ) from ex self._tunnel_established() self.xknx.connection_manager.connection_state_changed( XknxConnectionState.CONNECTED, self.connection_type ) return True def _tunnel_established(self) -> None: """Set up interface when the tunnel is ready.""" self._initial_connection = False self.sequence_number = 0 self.start_heartbeat() def _tunnel_lost(self) -> None: """Prepare for reconnection or shutdown when the connection is lost. Callback.""" self.stop_heartbeat() self.xknx.connection_manager.connection_state_changed( XknxConnectionState.DISCONNECTED ) self._data_endpoint_addr = None if self.auto_reconnect: self._reconnect_task = asyncio.create_task(self._reconnect()) else: raise CommunicationError("Tunnel connection closed.") async def _reconnect(self) -> None: """Reconnect to tunnel device.""" if self.transport.transport: await self._disconnect_request(True) self.transport.stop() await asyncio.sleep(self.auto_reconnect_wait) if await self.connect(): logger.info("Successfully reconnected to KNX bus.") def _stop_reconnect(self) -> None: """Stop reconnect task if running.""" if self._reconnect_task is not None: self._reconnect_task.cancel() self._reconnect_task = None async def disconnect(self) -> None: """Disconnect tunneling connection.""" self.stop_heartbeat() self.xknx.connection_manager.connection_state_changed( XknxConnectionState.DISCONNECTED ) self._data_endpoint_addr = None self._stop_reconnect() await self._disconnect_request(False) self.transport.stop() #################### # # OUTGOING REQUESTS # #################### async def _connect_request(self) -> bool: """Connect to tunnelling server. Set communication_channel and src_address.""" connect = Connect( transport=self.transport, local_hpai=self.local_hpai, cri=ConnectRequestInformation(individual_address=self._requested_address), ) await connect.start() if connect.success: self.communication_channel = connect.communication_channel # assign data_endpoint received from server self._data_endpoint_addr = ( None if connect.data_endpoint.route_back else connect.data_endpoint.addr_tuple ) # Use the individual address provided by the tunnelling server self._src_address = connect.crd.individual_address or IndividualAddress(0) self.xknx.current_address = self._src_address logger.debug( "Tunnel established. communication_channel=%s, address=%s", connect.communication_channel, self._src_address, ) return True raise CommunicationError( f"ConnectRequest failed. Status code: {connect.response_status_code}" ) async def _connectionstate_request(self) -> tuple[bool, str | None]: """Return state of tunnel. True if tunnel is in good shape.""" if self.communication_channel is None: raise CommunicationError("No active communication channel.") conn_state = ConnectionState( transport=self.transport, communication_channel_id=self.communication_channel, local_hpai=self.local_hpai, ) await conn_state.start() status_code: str | None = None if error_code := conn_state.response_status_code: status_code = error_code.name return conn_state.success, status_code async def _disconnect_request(self, ignore_error: bool = False) -> None: """Disconnect from tunnel device. Delete communication_channel.""" if self.communication_channel is not None: disconnect = Disconnect( transport=self.transport, communication_channel_id=self.communication_channel, local_hpai=self.local_hpai, ) await disconnect.start() if not disconnect.success and not ignore_error: self.communication_channel = None raise CommunicationError("Could not disconnect channel") logger.debug( "Tunnel disconnected (communication_channel: %s)", self.communication_channel, ) self.communication_channel = None async def request_description(self) -> GatewayDescriptor | None: """Request description from tunneling server.""" description = DescriptionQuery( transport=self.transport, local_hpai=self.local_hpai, ) await description.start() return description.gateway_descriptor async def send_cemi(self, cemi: CEMIFrame) -> None: """ Send CEMI Frame to tunnelling server. A transport layer confirmation shall be awaited before sending the next telegram. """ raw_cemi = cemi.to_knx() async with self._send_lock: try: await self._tunnelling_request(raw_cemi) finally: self._increase_sequence_number() async def _tunnelling_request(self, raw_cemi: bytes) -> None: """Send CEMI Frame to tunnelling server.""" if self.communication_channel is None: raise CommunicationError( "Sending telegram failed. No active communication channel." ) tunnelling_request = TunnellingRequest( communication_channel_id=self.communication_channel, sequence_counter=self.sequence_number, raw_cemi=raw_cemi, ) await self._send_tunnelling_request(tunnelling_request) @abstractmethod async def _send_tunnelling_request(self, frame: TunnellingRequest) -> None: """Send TunnellingRequest frame to tunnelling device.""" def _increase_sequence_number(self) -> None: """Increase sequence number.""" self.sequence_number = self.sequence_number + 1 & 0xFF #################### # # INCOMING REQUESTS # #################### def _request_received( self, knxipframe: KNXIPFrame, source: HPAI, _transport: KNXIPTransport ) -> None: """Handle incoming requests.""" if isinstance(knxipframe.body, TunnellingRequest): self._tunnelling_request_received(knxipframe.body) elif isinstance(knxipframe.body, DisconnectRequest): self._disconnect_request_received(knxipframe.body) else: logger.warning("Service not implemented: %s", knxipframe) def _tunnelling_request_received( self, tunneling_request: TunnellingRequest ) -> None: """Handle incoming tunnel request.""" self.cemi_received_callback(tunneling_request.raw_cemi) def _disconnect_request_received( self, disconnect_request: DisconnectRequest ) -> None: """Handle incoming disconnect request.""" logger.warning("Received DisconnectRequest from tunnelling server.") # We should not receive DisconnectRequest for other communication_channels # If we do we close our communication_channel before reconnection. if disconnect_request.communication_channel_id == self.communication_channel: disconnect_response = DisconnectResponse( communication_channel_id=self.communication_channel, ) self.transport.send(KNXIPFrame.init_from_body(disconnect_response)) self.communication_channel = None self._tunnel_lost() #################### # # HEARTBEAT # #################### def start_heartbeat(self) -> None: """Start heartbeat for monitoring state of tunnel, as suggested by 03.08.02 KNX Core 5.4.""" self._heartbeat_task = asyncio.create_task(self.do_heartbeat()) def stop_heartbeat(self) -> None: """Stop heartbeat task if running.""" if self._heartbeat_task is not None: self._heartbeat_task.cancel() self._heartbeat_task = None async def do_heartbeat(self) -> None: """Heartbeat: Worker task, endless loop for sending heartbeat requests.""" while True: try: await asyncio.sleep(HEARTBEAT_RATE) success, _ = await self._connectionstate_request() if not success: await self._do_heartbeat_failed() except CommunicationError as err: logger.warning(err) self._tunnel_lost() async def _do_heartbeat_failed(self) -> None: """Heartbeat: handling error.""" # first heartbeat failed - try 3 more times before disconnecting. for _heartbeats_failed in range(3): success, status = await self._connectionstate_request() if success: return # 3 retries failed if status is None: raise CommunicationError( "Heartbeat failed. No answer from tunnelling server." ) raise CommunicationError( f"Heartbeat failed. Tunnelling server repeatedly responded with status: {status}" ) class UDPTunnel(_Tunnel): """Class for handling KNX/IP UDP tunnels.""" connection_type = XknxConnectionType.TUNNEL_UDP transport: UDPTransport def __init__( self, xknx: XKNX, cemi_received_callback: CEMIBytesCallbackType, gateway_ip: str, gateway_port: int, local_ip: str, local_port: int = 0, route_back: bool = False, auto_reconnect: bool = True, auto_reconnect_wait: int = 3, ) -> None: """Initialize Tunnel class.""" self.gateway_ip = gateway_ip self.gateway_port = gateway_port self.local_ip = local_ip self.local_port = local_port self.route_back = route_back super().__init__( xknx=xknx, cemi_received_callback=cemi_received_callback, auto_reconnect=auto_reconnect, auto_reconnect_wait=auto_reconnect_wait, ) self.expected_sequence_number = 0 def _init_transport(self) -> None: """Initialize transport transport.""" self.transport = UDPTransport( local_addr=(self.local_ip, self.local_port), remote_addr=(self.gateway_ip, self.gateway_port), multicast=False, ) async def setup_tunnel(self) -> None: """Set up tunnel before sending a ConnectionRequest.""" self.expected_sequence_number = 0 if self.route_back: self.local_hpai = HPAI() return (local_addr, local_port) = self.transport.getsockname() self.local_hpai = HPAI(ip_addr=local_addr, port=local_port) # OUTGOING REQUESTS async def send_cemi(self, cemi: CEMIFrame) -> None: """ Send CEMI Frame to tunnelling server - with retry mechanism for UDP connection. A transport layer confirmation shall be awaited before sending the next telegram. If a TUNNELLING_REQUEST frame is not confirmed within the TUNNELLING_REQUEST_TIMEOUT time of one (1) second then the frame shall be repeated once with the same sequence counter value by the sending KNXnet/IP device. If the KNXnet/IP device does not receive a TUNNELLING_ACK frame within the TUNNELLING_REQUEST_TIMEOUT (= 1 second) or the status of a received TUNNELLING_ACK frame signals any kind of error condition, the sending device shall repeat the TUNNELLING_REQUEST frame once and then terminate the connection by sending a DISCONNECT_REQUEST frame to the other devices control endpoint. """ raw_cemi = cemi.to_knx() async with self._send_lock: try: try: await self._tunnelling_request(raw_cemi) except TunnellingAckError as err: logger.debug("%s. Retrying a second time.", err) else: return try: await self._tunnelling_request(raw_cemi) except TunnellingAckError as err: logger.debug("%s. Reconnecting tunnel.", err) else: return if self._reconnect_task is None or self._reconnect_task.done(): self._tunnel_lost() await self.xknx.connection_manager.connected.wait() try: await self._tunnelling_request(raw_cemi) except TunnellingAckError as err: raise CommunicationError( f"Resending the telegram repeatedly failed. {err}", True ) from None finally: self._increase_sequence_number() async def _send_tunnelling_request(self, frame: TunnellingRequest) -> None: """Send Telegram to tunnelling device.""" tunnelling = Tunnelling( transport=self.transport, data_endpoint=self._data_endpoint_addr, tunnelling_request=frame, ) await tunnelling.start() if not tunnelling.success: if error_code := tunnelling.response_status_code: reason = ( f"Received TUNNELLING_ACK with error code: {error_code.name} " f"for frame {frame}" ) else: reason = ( "Did not receive a TUNNELLING_ACK within 1 second for " f"frame with sequence_counter={frame.sequence_counter}" ) raise TunnellingAckError(reason) # INCOMING REQUESTS def _tunnelling_request_received( self, tunneling_request: TunnellingRequest ) -> None: """Handle incoming tunnelling request.""" if tunneling_request.sequence_counter == self.expected_sequence_number: self.expected_sequence_number = self.expected_sequence_number + 1 & 0xFF self._send_tunnelling_ack( tunneling_request.communication_channel_id, tunneling_request.sequence_counter, ) super()._tunnelling_request_received(tunneling_request) return if ( tunneling_request.sequence_counter == self.expected_sequence_number - 1 & 0xFF ): self._send_tunnelling_ack( tunneling_request.communication_channel_id, tunneling_request.sequence_counter, ) logger.debug( "Received TunnellingRequest with sequence number one less than expected. " "Discarding frame: %s", tunneling_request, ) return logger.warning( "Received TunnellingRequest with sequence number not equal to expected: %s. " "Discarding frame: %s", self.expected_sequence_number, tunneling_request, ) # Tunnelling server should repeat that frame and disconnect after that was also not ACKed. # some don't seem to initiate disconnection here so we take a shortcut and disconnect ourselves self._tunnel_lost() def _send_tunnelling_ack( self, communication_channel_id: int, sequence_counter: int ) -> None: """Send tunnelling ACK after tunnelling request received.""" ack = TunnellingAck( communication_channel_id=communication_channel_id, sequence_counter=sequence_counter, ) self.transport.send( KNXIPFrame.init_from_body(ack), addr=self._data_endpoint_addr ) class TCPTunnel(_Tunnel): """Class for handling KNX/IP TCP tunnels.""" connection_type = XknxConnectionType.TUNNEL_TCP transport: TCPTransport def __init__( self, xknx: XKNX, cemi_received_callback: CEMIBytesCallbackType, gateway_ip: str, gateway_port: int, individual_address: IndividualAddress | None = None, auto_reconnect: bool = True, auto_reconnect_wait: int = 3, ) -> None: """Initialize Tunnel class.""" self.gateway_ip = gateway_ip self.gateway_port = gateway_port super().__init__( xknx=xknx, cemi_received_callback=cemi_received_callback, auto_reconnect=auto_reconnect, auto_reconnect_wait=auto_reconnect_wait, ) # TCP always uses 0.0.0.0:0 self.local_hpai = HPAI(protocol=HostProtocol.IPV4_TCP) self._requested_address = individual_address def _init_transport(self) -> None: """Initialize transport transport.""" self.transport = TCPTransport( remote_addr=(self.gateway_ip, self.gateway_port), connection_lost_cb=self._tunnel_lost, ) async def setup_tunnel(self) -> None: """Set up tunnel before sending a ConnectionRequest.""" async def _send_tunnelling_request(self, frame: TunnellingRequest) -> None: """Send Telegram to tunnelling device.""" self.transport.send(KNXIPFrame.init_from_body(frame)) class SecureTunnel(TCPTunnel): """Class for handling KNX/IP secure TCP tunnels.""" connection_type = XknxConnectionType.TUNNEL_SECURE transport: SecureSession def __init__( self, xknx: XKNX, cemi_received_callback: CEMIBytesCallbackType, gateway_ip: str, gateway_port: int, user_id: int, user_password: str, auto_reconnect: bool = True, auto_reconnect_wait: int = 3, device_authentication_password: str | None = None, ) -> None: """Initialize SecureTunnel class.""" self._device_authentication_password = device_authentication_password self._user_id = user_id self._user_password = user_password super().__init__( xknx=xknx, cemi_received_callback=cemi_received_callback, gateway_ip=gateway_ip, gateway_port=gateway_port, auto_reconnect=auto_reconnect, auto_reconnect_wait=auto_reconnect_wait, ) def _init_transport(self) -> None: """Initialize transport transport.""" self.transport = SecureSession( remote_addr=(self.gateway_ip, self.gateway_port), user_id=self._user_id, user_password=self._user_password, device_authentication_password=self._device_authentication_password, connection_lost_cb=self._tunnel_lost, ) xknx-3.6.0/xknx/io/util.py000066400000000000000000000062721475530762600154640ustar00rootroot00000000000000"""Helper functions for XKNX io module.""" from __future__ import annotations import asyncio import ipaddress import logging import socket from typing import cast import ifaddr from xknx.exceptions import XKNXException from .const import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT logger = logging.getLogger("xknx.log") async def get_default_local_ip(remote_ip: str = DEFAULT_MCAST_GRP) -> str | None: """Return the local ip used for communication with remote_ip.""" with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.setblocking(False) # must be non-blocking for async loop = asyncio.get_running_loop() try: await loop.sock_connect(sock, (remote_ip, DEFAULT_MCAST_PORT)) local_ip = sock.getsockname()[0] logger.debug("Using local ip: %s", local_ip) return local_ip # type: ignore[no-any-return] except Exception: # pylint: disable=broad-except logger.warning( "The system could not auto detect the source ip for %s on your operating system", remote_ip, ) return None def get_local_ips() -> list[ifaddr.IP]: """Return list of local IPv4 addresses.""" return [ip for iface in ifaddr.get_adapters() for ip in iface.ips if ip.is_IPv4] def get_local_interface_name(local_ip: str) -> str: """Return the name of the interface with the given ip.""" return next((link.nice_name for link in get_local_ips() if link.ip == local_ip), "") def get_ip_for_adapter_name(name: str) -> str | None: """Return the ip for the given interface name.""" return next( ( ip.ip # type: ignore[misc] # IPv6 would return tuple for iface in ifaddr.get_adapters() if name in (iface.name, iface.nice_name) for ip in iface.ips if ip.is_IPv4 ), None, ) def find_local_ip(gateway_ip: str) -> str | None: """Find local IP address on same subnet as gateway.""" gateway = ipaddress.IPv4Address(gateway_ip) for link in get_local_ips(): network = ipaddress.IPv4Network((link.ip, link.network_prefix), strict=False) if gateway in network: logger.debug("Using interface: %s", link.nice_name) return cast(str, link.ip) logger.debug("No interface on same subnet as gateway found.") return None async def validate_ip(address: str, address_name: str = "IP address") -> str: """ Return IPv4 address parsed or resolved as a string. Valid addresses are IPv4 strings, adapter names or hostnames. Raises XKNXException if address is not a valid IPv4 address or cannot be resolved. """ try: ipaddress.IPv4Address(address) return address except ipaddress.AddressValueError as ex: logger.debug( "%s is not a valid IPv4 address: %s. Trying to resolve...", address_name, ex, ) if adapter_ip := get_ip_for_adapter_name(address): return adapter_ip try: return await asyncio.to_thread(socket.gethostbyname, address) except socket.gaierror as ex: raise XKNXException(f"Could not resolve {address_name}: {address}") from ex xknx-3.6.0/xknx/knxip/000077500000000000000000000000001475530762600146505ustar00rootroot00000000000000xknx-3.6.0/xknx/knxip/__init__.py000066400000000000000000000065071475530762600167710ustar00rootroot00000000000000"""Package containing all methods for serialization and deserialization of KNX/IP packets.""" from .body import KNXIPBody, KNXIPBodyResponse from .connect_request import ConnectRequest, ConnectRequestInformation from .connect_response import ConnectResponse, ConnectResponseData from .connectionstate_request import ConnectionStateRequest from .connectionstate_response import ConnectionStateResponse from .description_request import DescriptionRequest from .description_response import DescriptionResponse from .device_configuration_ack import DeviceConfigurationAck from .device_configuration_request import DeviceConfigurationRequest from .dib import ( DIB, DIBDeviceInformation, DIBGeneric, DIBSecuredServiceFamilies, DIBSuppSVCFamilies, DIBTunnelingInfo, ) from .disconnect_request import DisconnectRequest from .disconnect_response import DisconnectResponse from .error_code import ErrorCode from .header import KNXIPHeader from .hpai import HPAI from .knxip import KNXIPFrame from .knxip_enum import ( ConnectRequestType, DIBServiceFamily, DIBTypeCode, HostProtocol, KNXIPServiceType, KNXMedium, SearchRequestParameterType, TunnellingFeatureType, TunnellingLayer, ) from .routing_busy import RoutingBusy from .routing_indication import RoutingIndication from .routing_lost_message import RoutingLostMessage from .search_request import SearchRequest from .search_request_extended import SearchRequestExtended from .search_response import SearchResponse from .search_response_extended import SearchResponseExtended from .secure_wrapper import SecureWrapper from .session_authenticate import SessionAuthenticate from .session_request import SessionRequest from .session_response import SessionResponse from .session_status import SessionStatus from .srp import SRP from .timer_notify import TimerNotify from .tunnelling_ack import TunnellingAck from .tunnelling_feature import ( TunnellingFeatureGet, TunnellingFeatureInfo, TunnellingFeatureResponse, TunnellingFeatureSet, ) from .tunnelling_request import TunnellingRequest __all__ = [ "DIB", "HPAI", "SRP", "ConnectRequest", "ConnectRequestInformation", "ConnectRequestType", "ConnectResponse", "ConnectResponseData", "ConnectionStateRequest", "ConnectionStateResponse", "DIBDeviceInformation", "DIBGeneric", "DIBSecuredServiceFamilies", "DIBServiceFamily", "DIBSuppSVCFamilies", "DIBTunnelingInfo", "DIBTypeCode", "DescriptionRequest", "DescriptionResponse", "DeviceConfigurationAck", "DeviceConfigurationRequest", "DisconnectRequest", "DisconnectResponse", "ErrorCode", "HostProtocol", "KNXIPBody", "KNXIPBodyResponse", "KNXIPFrame", "KNXIPHeader", "KNXIPServiceType", "KNXMedium", "RoutingBusy", "RoutingIndication", "RoutingLostMessage", "SearchRequest", "SearchRequestExtended", "SearchRequestParameterType", "SearchResponse", "SearchResponseExtended", "SecureWrapper", "SessionAuthenticate", "SessionRequest", "SessionResponse", "SessionStatus", "TimerNotify", "TunnellingAck", "TunnellingFeatureGet", "TunnellingFeatureInfo", "TunnellingFeatureResponse", "TunnellingFeatureSet", "TunnellingFeatureType", "TunnellingLayer", "TunnellingRequest", ] xknx-3.6.0/xknx/knxip/body.py000066400000000000000000000016671475530762600161710ustar00rootroot00000000000000"""Basis class for all KNX/IP bodies.""" from __future__ import annotations from abc import ABC, abstractmethod from typing import ClassVar, cast from .error_code import ErrorCode from .knxip_enum import KNXIPServiceType class KNXIPBody(ABC): """Base class for all KNX/IP bodies.""" SERVICE_TYPE: ClassVar[KNXIPServiceType] = cast(KNXIPServiceType, None) @abstractmethod def calculated_length(self) -> int: """Get length of KNX/IP body.""" @abstractmethod def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" @abstractmethod def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" def __eq__(self, other: object) -> bool: """Equal operator.""" return self.__dict__ == other.__dict__ class KNXIPBodyResponse(KNXIPBody): """Base class for all KNX/IP response bodies.""" status_code: ErrorCode = cast(ErrorCode, None) xknx-3.6.0/xknx/knxip/connect_request.py000066400000000000000000000126131475530762600204260ustar00rootroot00000000000000""" Module for Serialization and Deserialization of a KNX Connect Request information. Connect requests are used to start a new tunnel connection on a KNX/IP device. """ from __future__ import annotations from xknx.exceptions import CouldNotParseKNXIP from xknx.telegram import IndividualAddress from .body import KNXIPBody from .hpai import HPAI from .knxip_enum import ConnectRequestType, KNXIPServiceType, TunnellingLayer class ConnectRequest(KNXIPBody): """Representation of a KNX Connect Request.""" SERVICE_TYPE = KNXIPServiceType.CONNECT_REQUEST def __init__( self, control_endpoint: HPAI | None = None, data_endpoint: HPAI | None = None, cri: ConnectRequestInformation | None = None, ) -> None: """Initialize ConnectRequest object.""" self.control_endpoint = control_endpoint or HPAI() self.data_endpoint = data_endpoint or HPAI() self.cri = cri or ConnectRequestInformation() def calculated_length(self) -> int: """Get length of KNX/IP body.""" return HPAI.LENGTH + HPAI.LENGTH + self.cri.calculated_length() def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" pos = self.control_endpoint.from_knx(raw) pos += self.data_endpoint.from_knx(raw[pos:]) pos += self.cri.from_knx(raw[pos:]) return pos def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return ( self.control_endpoint.to_knx() + self.data_endpoint.to_knx() + self.cri.to_knx() ) def __repr__(self) -> str: """Return object as readable string.""" return ( "' ) class ConnectRequestInformation: """ Representation of a KNX Connect Request Information (CRI). A Basic CRI requests a tunnel without requesting any specific IA. Using `individual_address` yields an Extended CRI which is only supported by Tunnelling v2 devices. """ CRI_LENGTH = 2 CRI_TUNNEL_LENGTH = 4 CRI_TUNNEL_EXT_LENGTH = 6 def __init__( self, connection_type: ConnectRequestType = ConnectRequestType.TUNNEL_CONNECTION, knx_layer: TunnellingLayer = TunnellingLayer.DATA_LINK_LAYER, individual_address: IndividualAddress | None = None, ) -> None: """Initialize ConnectRequest object.""" self.connection_type = connection_type self.knx_layer = knx_layer self.individual_address = individual_address def _is_tunnel_cri(self) -> bool: return self.connection_type == ConnectRequestType.TUNNEL_CONNECTION def calculated_length(self) -> int: """Get length of KNX/IP body.""" if self._is_tunnel_cri(): return ( ConnectRequestInformation.CRI_TUNNEL_EXT_LENGTH if self.individual_address else ConnectRequestInformation.CRI_TUNNEL_LENGTH ) return ConnectRequestInformation.CRI_LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" cri_length = raw[0] if len(raw) < cri_length: raise CouldNotParseKNXIP("CRI data has wrong length") if cri_length < ConnectRequestInformation.CRI_LENGTH: raise CouldNotParseKNXIP("CRI length too small") self.connection_type = ConnectRequestType(raw[1]) if self._is_tunnel_cri(): if cri_length == ConnectRequestInformation.CRI_TUNNEL_LENGTH: extended = False elif cri_length == ConnectRequestInformation.CRI_TUNNEL_EXT_LENGTH: extended = True else: raise CouldNotParseKNXIP("CRI has wrong length") self.knx_layer = TunnellingLayer(raw[2]) self.individual_address = ( IndividualAddress.from_knx(raw[4:6]) if extended else None ) elif cri_length != ConnectRequestInformation.CRI_LENGTH: raise CouldNotParseKNXIP("CRI has wrong length") return cri_length def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" _cri = bytes( ( self.calculated_length(), self.connection_type.value, ) ) if self._is_tunnel_cri(): _cri = _cri + bytes( ( self.knx_layer.value, 0x00, # Reserved ) ) if self.individual_address: _cri = _cri + self.individual_address.to_knx() return _cri def __eq__(self, other: object) -> bool: """Equal operator.""" return self.__dict__ == other.__dict__ def __repr__(self) -> str: """Return object as readable string.""" _tunnel_layer = ( f'knx_layer="{self.knx_layer.name}" ' if self._is_tunnel_cri() else "" ) _extended = ( f'individual_address="{self.individual_address}" ' if self._is_tunnel_cri() and self.individual_address else "" ) return ( "" ) xknx-3.6.0/xknx/knxip/connect_response.py000066400000000000000000000115711475530762600205760ustar00rootroot00000000000000""" Module for Serialization and Deserialization of a KNX Connect Response information. Connect requests are used to start a new tunnel connection on a KNX/IP device. With a Connect Response the receiving party acknowledges the valid processing of the request, assigns a communication channel and an individual address for the client. """ from __future__ import annotations from xknx.exceptions import CouldNotParseKNXIP from xknx.telegram import IndividualAddress from .body import KNXIPBodyResponse from .error_code import ErrorCode from .hpai import HPAI from .knxip_enum import ConnectRequestType, KNXIPServiceType class ConnectResponse(KNXIPBodyResponse): """Representation of a KNX Connect Response.""" SERVICE_TYPE = KNXIPServiceType.CONNECT_RESPONSE def __init__( self, communication_channel: int = 0, status_code: ErrorCode = ErrorCode.E_NO_ERROR, data_endpoint: HPAI | None = None, crd: ConnectResponseData | None = None, ) -> None: """Initialize ConnectResponse class.""" self.communication_channel = communication_channel self.status_code = status_code self.data_endpoint = data_endpoint or HPAI() self.crd = crd or ConnectResponseData() def calculated_length(self) -> int: """Get length of KNX/IP body.""" return 2 + HPAI.LENGTH + self.crd.calculated_length() def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" self.communication_channel = raw[0] self.status_code = ErrorCode(raw[1]) pos = 2 if self.status_code == ErrorCode.E_NO_ERROR: pos += self.data_endpoint.from_knx(raw[pos:]) pos += self.crd.from_knx(raw[pos:]) else: # do not parse HPAI and CRD in case of errors - just check length pos = len(raw) return pos def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return ( bytes((self.communication_channel, self.status_code.value)) + self.data_endpoint.to_knx() + self.crd.to_knx() ) def __repr__(self) -> str: """Return object as readable string.""" return ( "' ) class ConnectResponseData: """Representation of a KNX Connect Response Data block (CRD).""" CRD_LENGTH = 2 CRD_TUNNEL_LENGTH = 4 def __init__( self, request_type: ConnectRequestType = ConnectRequestType.TUNNEL_CONNECTION, individual_address: IndividualAddress | None = None, ) -> None: """Initialize ConnectResponseData object.""" self.request_type = request_type self.individual_address = individual_address def _is_tunnel_crd(self) -> bool: return self.request_type == ConnectRequestType.TUNNEL_CONNECTION def calculated_length(self) -> int: """Get length of KNX/IP body.""" return ( ConnectResponseData.CRD_TUNNEL_LENGTH if self._is_tunnel_crd() else ConnectResponseData.CRD_LENGTH ) def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" crd_length = raw[0] if len(raw) < crd_length: raise CouldNotParseKNXIP("CRD has wrong length") if crd_length < ConnectResponseData.CRD_LENGTH: raise CouldNotParseKNXIP("CRD length too small") self.request_type = ConnectRequestType(raw[1]) if self._is_tunnel_crd(): if crd_length != ConnectResponseData.CRD_TUNNEL_LENGTH: raise CouldNotParseKNXIP("CRD has wrong length") self.individual_address = IndividualAddress.from_knx(raw[2:4]) elif crd_length != ConnectResponseData.CRD_LENGTH: raise CouldNotParseKNXIP("CRD has wrong length") return crd_length def to_knx(self) -> bytes: """Serialize CRD (Connect Response Data Block).""" _crd = bytes( ( self.calculated_length(), self.request_type.value, ) ) if self._is_tunnel_crd(): assert self.individual_address is not None return _crd + self.individual_address.to_knx() return _crd def __eq__(self, other: object) -> bool: """Equal operator.""" return self.__dict__ == other.__dict__ def __repr__(self) -> str: """Return object as readable string.""" _address = ( f'individual_address="{self.individual_address}" ' if self._is_tunnel_crd() and self.individual_address else "" ) return f'' xknx-3.6.0/xknx/knxip/connectionstate_request.py000066400000000000000000000036661475530762600222050ustar00rootroot00000000000000""" Module for Serialization and Deserialization of a KNX Connetionstate Request information. Connectionstate requests are used to determine if a tunnel connection is still active and valid. """ from __future__ import annotations from xknx.exceptions import CouldNotParseKNXIP from .body import KNXIPBody from .hpai import HPAI from .knxip_enum import KNXIPServiceType class ConnectionStateRequest(KNXIPBody): """Representation of a KNX Connection State Request.""" SERVICE_TYPE = KNXIPServiceType.CONNECTIONSTATE_REQUEST def __init__( self, communication_channel_id: int = 1, control_endpoint: HPAI | None = None, ) -> None: """Initialize ConnectionStateRequest object.""" self.communication_channel_id = communication_channel_id self.control_endpoint = control_endpoint or HPAI() def calculated_length(self) -> int: """Get length of KNX/IP body.""" return 2 + HPAI.LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" def info_from_knx(info: bytes) -> int: """Parse info bytes.""" if len(info) < 2: raise CouldNotParseKNXIP("Info has wrong length") self.communication_channel_id = info[0] # info[1] is reserved return 2 pos = info_from_knx(raw) pos += self.control_endpoint.from_knx(raw[pos:]) return pos def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return ( bytes((self.communication_channel_id, 0x00)) # 2nd byte is reserved + self.control_endpoint.to_knx() ) def __repr__(self) -> str: """Return object as readable string.""" return ( "' ) xknx-3.6.0/xknx/knxip/connectionstate_response.py000066400000000000000000000036041475530762600223430ustar00rootroot00000000000000""" Module for Serialization and Deserialization of a KNX Connectionstate Response information. Connectionstate requests are used to determine if a tunnel connection is still active and valid. With a connectionstate response the receiving party acknowledges the valid processing of the request. """ from __future__ import annotations from xknx.exceptions import CouldNotParseKNXIP from .body import KNXIPBodyResponse from .error_code import ErrorCode from .knxip_enum import KNXIPServiceType class ConnectionStateResponse(KNXIPBodyResponse): """Representation of a KNX Connection State Response.""" SERVICE_TYPE = KNXIPServiceType.CONNECTIONSTATE_RESPONSE LENGTH = 2 def __init__( self, communication_channel_id: int = 1, status_code: ErrorCode = ErrorCode.E_NO_ERROR, ) -> None: """Initialize ConnectionStateResponse object.""" self.communication_channel_id = communication_channel_id self.status_code = status_code def calculated_length(self) -> int: """Get length of KNX/IP body.""" return ConnectionStateResponse.LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if len(raw) < ConnectionStateResponse.LENGTH: raise CouldNotParseKNXIP("ConnectionStateResponse info has wrong length") self.communication_channel_id = raw[0] self.status_code = ErrorCode(raw[1]) return ConnectionStateResponse.LENGTH def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return bytes((self.communication_channel_id, self.status_code.value)) def __repr__(self) -> str: """Return object as readable string.""" return ( "' ) xknx-3.6.0/xknx/knxip/description_request.py000066400000000000000000000023551475530762600213220ustar00rootroot00000000000000""" Module for Serialization and Deserialization of KNX Description Requests. The DESCRIPTION_REQUEST frame shall be sent by the KNXnet/IP Client to the control endpoint of the KNXnet/IP Server to obtain a self-description of the KNXnet/IP Server device. """ from __future__ import annotations from .body import KNXIPBody from .hpai import HPAI from .knxip_enum import KNXIPServiceType class DescriptionRequest(KNXIPBody): """Representation of a KNX Description Request.""" SERVICE_TYPE = KNXIPServiceType.DESCRIPTION_REQUEST def __init__(self, control_endpoint: HPAI | None = None) -> None: """Initialize SearchRequest object.""" self.control_endpoint = control_endpoint or HPAI() def calculated_length(self) -> int: """Get length of KNX/IP body.""" return HPAI.LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" return self.control_endpoint.from_knx(raw) def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return self.control_endpoint.to_knx() def __repr__(self) -> str: """Return object as readable string.""" return f'' xknx-3.6.0/xknx/knxip/description_response.py000066400000000000000000000036071475530762600214710ustar00rootroot00000000000000""" Module for Serialization and Deserialization of KNX Description Response. The DESCRIPTION_RESPONSE frame shall be sent by the KNXnet/IP Server as an answer to a received DESCRIPTION_REQUEST frame. It shall be addressed to the KNXnet/IP Clients control endpoint using the HPAI included in the received DESCRIPTION_REQUEST frame. The size of the KNXnet/IP body varies depending on the number of DIB structures sent by the KNXnet/IP Server in response to the KNXnet/IP Clients DESCRIPTION_REQUEST. """ from __future__ import annotations from .body import KNXIPBody from .dib import DIB, DIBDeviceInformation from .knxip_enum import KNXIPServiceType class DescriptionResponse(KNXIPBody): """Representation of a KNX Description Response.""" SERVICE_TYPE = KNXIPServiceType.DESCRIPTION_RESPONSE def __init__(self) -> None: """Initialize SearchResponse object.""" self.dibs: list[DIB] = [] def calculated_length(self) -> int: """Get length of KNX/IP body.""" return sum(dib.calculated_length() for dib in self.dibs) def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" pos = 0 while raw[pos:]: dib = DIB.determine_dib(raw[pos:]) pos += dib.from_knx(raw[pos:]) self.dibs.append(dib) return pos @property def device_name(self) -> str: """Return name of device.""" return next( (dib.name for dib in self.dibs if isinstance(dib, DIBDeviceInformation)), "UNKNOWN", ) def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return b"".join(dib.to_knx() for dib in self.dibs) def __repr__(self) -> str: """Return object as readable string.""" _dibs_str = ",\n".join(dib.__repr__() for dib in self.dibs) return f'' xknx-3.6.0/xknx/knxip/device_configuration_ack.py000066400000000000000000000042731475530762600222340ustar00rootroot00000000000000"""Module for Serialization and Deserialization of a KNX Device Configuration ACK.""" from __future__ import annotations from xknx.exceptions import CouldNotParseKNXIP from .body import KNXIPBodyResponse from .error_code import ErrorCode from .knxip_enum import KNXIPServiceType class DeviceConfigurationAck(KNXIPBodyResponse): """Representation of a KNX Device Configuration Ack.""" SERVICE_TYPE = KNXIPServiceType.DEVICE_CONFIGURATION_ACK BODY_LENGTH = 4 def __init__( self, communication_channel_id: int = 1, sequence_counter: int = 0, status_code: ErrorCode = ErrorCode.E_NO_ERROR, ) -> None: """Initialize DeviceConfigurationAck object.""" self.communication_channel_id = communication_channel_id self.sequence_counter = sequence_counter self.status_code = status_code def calculated_length(self) -> int: """Get length of KNX/IP body.""" return DeviceConfigurationAck.BODY_LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if raw[0] != DeviceConfigurationAck.BODY_LENGTH: # structure_length field raise CouldNotParseKNXIP("DeviceConfigurationAck body has invalid length") if len(raw) != DeviceConfigurationAck.BODY_LENGTH: raise CouldNotParseKNXIP("DeviceConfigurationAck body has wrong length") self.communication_channel_id = raw[1] self.sequence_counter = raw[2] self.status_code = ErrorCode(raw[3]) return DeviceConfigurationAck.BODY_LENGTH def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return bytes( ( DeviceConfigurationAck.BODY_LENGTH, self.communication_channel_id, self.sequence_counter, self.status_code.value, ) ) def __repr__(self) -> str: """Return object as readable string.""" return ( "' ) xknx-3.6.0/xknx/knxip/device_configuration_request.py000066400000000000000000000043351475530762600231650ustar00rootroot00000000000000"""Module for Serialization and Deserialization of a KNX Device Configuration Request.""" from __future__ import annotations from xknx.exceptions import CouldNotParseKNXIP from .body import KNXIPBody from .knxip_enum import KNXIPServiceType class DeviceConfigurationRequest(KNXIPBody): """Representation of a KNX Device Configuration Request.""" SERVICE_TYPE = KNXIPServiceType.DEVICE_CONFIGURATION_REQUEST HEADER_LENGTH = 4 def __init__( self, communication_channel_id: int = 1, sequence_counter: int = 0, raw_cemi: bytes = b"", ) -> None: """Initialize DeviceConfigurationRequest object.""" self.communication_channel_id = communication_channel_id self.sequence_counter = sequence_counter self.raw_cemi = raw_cemi def calculated_length(self) -> int: """Get length of KNX/IP body.""" return DeviceConfigurationRequest.HEADER_LENGTH + len(self.raw_cemi) def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if raw[0] != DeviceConfigurationRequest.HEADER_LENGTH: raise CouldNotParseKNXIP("connection header wrong length") if len(raw) < DeviceConfigurationRequest.HEADER_LENGTH: raise CouldNotParseKNXIP("connection header wrong length") self.communication_channel_id = raw[1] self.sequence_counter = raw[2] # raw[3] is reserved self.raw_cemi = raw[DeviceConfigurationRequest.HEADER_LENGTH :] return len(raw) def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return ( bytes( ( DeviceConfigurationRequest.HEADER_LENGTH, self.communication_channel_id, self.sequence_counter, 0x00, # Reserved ) ) + self.raw_cemi ) def __repr__(self) -> str: """Return object as readable string.""" return ( "' ) xknx-3.6.0/xknx/knxip/dib.py000066400000000000000000000322451475530762600157660ustar00rootroot00000000000000""" Module for serialization and deserialization of KNX DIB information. DIB is Description Information Block. A KNX/IP Search Response may contain several DIBs of different types: * DIBSuppSVCFamilies: Supported features of device * DIBDeviceInformation: Name, serial number, some unimportant flags * DIBGeneric: General Information (fallback for unknown dib type codes) """ from __future__ import annotations from abc import ABC, abstractmethod import socket from typing import NamedTuple, final from xknx.exceptions import CouldNotParseKNXIP from xknx.telegram import IndividualAddress from .knxip_enum import DIBServiceFamily, DIBTypeCode, KNXMedium DIB_HEADER_LENGTH = 2 # structure length and description type code class DIB(ABC): """ Base class for DIB (Description Information Block). This base class is only the interface for the derived classes. """ @abstractmethod def calculated_length(self) -> int: """Get length of KNX/IP object.""" # The structure shall always have an even number of octets which may have to be # achieved by padding with 00h in the last octet of the DIB structure. @abstractmethod def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" @abstractmethod def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" @staticmethod def determine_dib(raw: bytes) -> DIB: """Determine dib type out of dib type code.""" if len(raw) < 2: raise CouldNotParseKNXIP("could not parse DIB header") dtc = DIBTypeCode(raw[1]) if dtc == DIBTypeCode.DEVICE_INFO: return DIBDeviceInformation() if dtc == DIBTypeCode.SUPP_SVC_FAMILIES: return DIBSuppSVCFamilies() if dtc == DIBTypeCode.SECURED_SERVICE_FAMILIES: return DIBSecuredServiceFamilies() if dtc == DIBTypeCode.TUNNELING_INFO: return DIBTunnelingInfo() return DIBGeneric() class DIBGeneric(DIB): """ Module for serialization and deserialization of KNX DIB Generic. Fallback for not implemented DIBTypeCodes. """ def __init__(self) -> None: """Initialize DIBGeneric class.""" # DTC Description Type Code self.dtc: DIBTypeCode | int = 0 # IBD Information Block Data self.data = b"" def calculated_length(self) -> int: """Get length of KNX/IP object.""" data_length = len(self.data) return DIB_HEADER_LENGTH + data_length + data_length % 2 def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if len(raw) < 2: raise CouldNotParseKNXIP("could not parse DIB header") dib_length = raw[0] if len(raw) < dib_length: raise CouldNotParseKNXIP("DIB wrong length") try: self.dtc = DIBTypeCode(raw[1]) except ValueError: self.dtc = raw[1] self.data = raw[2:dib_length] return dib_length def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" if not isinstance(self.dtc, DIBTypeCode): try: self.dtc = DIBTypeCode(self.dtc) except ValueError: raise CouldNotParseKNXIP("DTC invalid") from None return ( bytes((self.calculated_length(), self.dtc.value)) + self.data + bytes(len(self.data) % 2) # padding ) def __repr__(self) -> str: """Return object as readable string.""" return f'' @final class DIBDeviceInformation(DIB): """Class for serialization and deserialization of KNX DIB Device Information Block.""" LENGTH = 54 def __init__(self) -> None: """Initialize DIBDeviceInformation class.""" self.knx_medium: KNXMedium = KNXMedium.TP1 self.programming_mode: bool = False self.individual_address: IndividualAddress = IndividualAddress(0) self.installation_number: int = 0 self.project_number: int = 0 self.serial_number: str = "" self.multicast_address: str = "224.0.23.12" self.mac_address: str = "" self.name: str = "" def calculated_length(self) -> int: """Get length of KNX/IP object.""" return DIBDeviceInformation.LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if len(raw) < DIBDeviceInformation.LENGTH: raise CouldNotParseKNXIP("wrong connection header length") if raw[0] != DIBDeviceInformation.LENGTH: raise CouldNotParseKNXIP("wrong connection header length") if DIBTypeCode(raw[1]) != DIBTypeCode.DEVICE_INFO: raise CouldNotParseKNXIP("DIB is no device info") self.knx_medium = KNXMedium(raw[2]) # last bit of device_status. All other bits are unused self.programming_mode = bool(raw[3]) self.individual_address = IndividualAddress.from_knx(raw[4:6]) installation_project_identifier = raw[6] * 256 + raw[7] self.project_number = installation_project_identifier >> 4 self.installation_number = installation_project_identifier & 15 self.serial_number = raw[8:14].hex(":") self.multicast_address = socket.inet_ntoa(raw[14:18]) self.mac_address = raw[18:24].hex(":") self.name = raw[24:54].decode(encoding="latin_1", errors="replace").rstrip("\0") return DIBDeviceInformation.LENGTH def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" def hex_notation_to_knx(colon_hex: str) -> bytes: """Serialize hex notation.""" return bytes.fromhex(colon_hex.replace(":", "")) def ip_to_knx(ip_addr: str) -> bytes: """Serialize ip.""" return socket.inet_aton(ip_addr) def name_str_to_knx(string: str) -> bytes: """Serialize name string.""" # pad with null bytes to length 30; ISO 8859-1 (latin_1) according to KNX specification return bytes(string[:30], "latin_1").ljust(30, b"\0") installation_project_identifier = ( (self.project_number * 16) + self.installation_number ).to_bytes(2, "big") return ( bytes( ( DIBDeviceInformation.LENGTH, DIBTypeCode.DEVICE_INFO.value, self.knx_medium.value, self.programming_mode, ) ) + self.individual_address.to_knx() + installation_project_identifier + hex_notation_to_knx(self.serial_number) + ip_to_knx(self.multicast_address) + hex_notation_to_knx(self.mac_address) + name_str_to_knx(self.name) ) def __repr__(self) -> str: """Return object as readable string.""" return ( "' ) class _DIBServiceFamilies(DIB): """Base class for serialization and deserialization of KNX DIB Service Families.""" type_code: DIBTypeCode class Family: """Class for storing a supported device family.""" def __init__(self, name: DIBServiceFamily, version: int) -> None: """Initialize DIBSuppSVCFamilies.Family.""" self.name = name self.version = version def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return bytes((self.name.value, self.version)) def __repr__(self) -> str: """Return object as readable string.""" return f'' def __eq__(self, other: object) -> bool: """Equal operator.""" return self.__dict__ == other.__dict__ def __init__(self) -> None: """Initialize DIBSuppSVCFamilies class.""" self.families: list[DIBSuppSVCFamilies.Family] = [] def supports(self, name: DIBServiceFamily, version: int | None = None) -> bool: """Return if device supports a given service family by name and optional minimum version.""" return any( name == family.name and (version is None or family.version >= version) for family in self.families ) def version(self, name: DIBServiceFamily) -> int | None: """Return version of a given service family.""" return next( (family.version for family in self.families if name == family.name), None, ) def calculated_length(self) -> int: """Get length of KNX/IP object.""" return len(self.families) * 2 + DIB_HEADER_LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if len(raw) < 2: raise CouldNotParseKNXIP("DIB header too small") length = raw[0] if (len(raw) < length) or (length % 2): raise CouldNotParseKNXIP("DIB wrong size") if DIBTypeCode(raw[1]) != self.type_code: raise CouldNotParseKNXIP( f"DIB has wrong type code for {self.__class__.__name__}" ) for pos in range(2, length, 2): name = DIBServiceFamily(raw[pos]) version = raw[pos + 1] self.families.append(DIBSuppSVCFamilies.Family(name, version)) return length def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return bytes( ( self.calculated_length(), self.type_code.value, ) ) + b"".join(family.to_knx() for family in self.families) def __repr__(self) -> str: """Return object as readable string.""" _families_str = ", ".join( f"{family.name} version: {family.version}" for family in self.families ) return f'<{self.__class__.__name__} families="[{_families_str}]" />' @final class DIBSuppSVCFamilies(_DIBServiceFamilies): """Class for serialization and deserialization of KNX DIB Supported Services.""" type_code = DIBTypeCode.SUPP_SVC_FAMILIES @final class DIBSecuredServiceFamilies(_DIBServiceFamilies): """Class for serialization and deserialization of KNX DIB Secured Service Families.""" type_code = DIBTypeCode.SECURED_SERVICE_FAMILIES class TunnelingSlotStatus(NamedTuple): """Class for storing tunneling slot status.""" usable: bool authorized: bool free: bool def __bytes__(self) -> bytes: """Serialize to KNX/IP raw data.""" return bytes( ( 0x00, # reserved self.usable << 2 | self.authorized << 1 | self.free, ) ) @final class DIBTunnelingInfo(DIB): """Class for serialization and deserialization of KNX DIB Tunneling Info.""" def __init__( self, slots: dict[IndividualAddress, TunnelingSlotStatus] | None = None ) -> None: """Initialize DIBTunnelingInfo class.""" self.max_apdu_length = 248 self.slots = slots or {} def calculated_length(self) -> int: """Get length of KNX/IP object.""" return 2 + 2 + len(self.slots) * 4 def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if len(raw) < 4: raise CouldNotParseKNXIP("DIB header too small") length = raw[0] if (len(raw) < length) or (length % 4): raise CouldNotParseKNXIP("DIB wrong size") if DIBTypeCode(raw[1]) != DIBTypeCode.TUNNELING_INFO: raise CouldNotParseKNXIP( f"DIB has wrong type code for {self.__class__.__name__}" ) self.max_apdu_length = int.from_bytes(raw[2:4], "big") for pos in range(4, length, 4): address = IndividualAddress.from_knx(raw[pos : pos + 2]) status = TunnelingSlotStatus( usable=bool(raw[pos + 3] >> 2 & 0b1), authorized=bool(raw[pos + 3] >> 1 & 0b1), free=bool(raw[pos + 3] & 0b1), ) self.slots[address] = status return length def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return ( bytes((self.calculated_length(), DIBTypeCode.TUNNELING_INFO.value)) + self.max_apdu_length.to_bytes(2, "big") + b"".join( address.to_knx() + bytes(status) for address, status in self.slots.items() ) ) def __repr__(self) -> str: """Return object as readable string.""" return ( f"<{self.__class__.__name__} max_adpu_lenght={self.max_apdu_length} " f"slots={self.slots}/>" ) xknx-3.6.0/xknx/knxip/disconnect_request.py000066400000000000000000000033341475530762600211260ustar00rootroot00000000000000""" Module for Serialization and Deserialization of a KNX Disconnect Request information. Disconnect requests are used to disconnect a tunnel from a KNX/IP device. """ from __future__ import annotations from xknx.exceptions import CouldNotParseKNXIP from .body import KNXIPBody from .hpai import HPAI from .knxip_enum import KNXIPServiceType class DisconnectRequest(KNXIPBody): """Representation of a KNX Disconnect Request.""" SERVICE_TYPE = KNXIPServiceType.DISCONNECT_REQUEST def __init__( self, communication_channel_id: int = 1, control_endpoint: HPAI | None = None, ) -> None: """Initialize DisconnectRequest object.""" self.communication_channel_id = communication_channel_id self.control_endpoint = control_endpoint or HPAI() def calculated_length(self) -> int: """Get length of KNX/IP body.""" return 2 + HPAI.LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if len(raw) < 2: raise CouldNotParseKNXIP("Disconnect info has wrong length") self.communication_channel_id = raw[0] # raw[1] is reserved return self.control_endpoint.from_knx(raw[2:]) + 2 def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return ( bytes((self.communication_channel_id, 0x00)) # 2nd byte is reserved + self.control_endpoint.to_knx() ) def __repr__(self) -> str: """Return object as readable string.""" return ( "' ) xknx-3.6.0/xknx/knxip/disconnect_response.py000066400000000000000000000035621475530762600212770ustar00rootroot00000000000000""" Module for Serialization and Deserialization of a KNX Disconnect Response information. Disconnect requests are used to disconnect a tunnel from a KNX/IP device. With a Disconnect Response the receiving party acknowledges the valid processing of the request. """ from __future__ import annotations from xknx.exceptions import CouldNotParseKNXIP from .body import KNXIPBodyResponse from .error_code import ErrorCode from .knxip_enum import KNXIPServiceType class DisconnectResponse(KNXIPBodyResponse): """Representation of a KNX Disconnect Response.""" SERVICE_TYPE = KNXIPServiceType.DISCONNECT_RESPONSE LENGTH = 2 def __init__( self, communication_channel_id: int = 1, status_code: ErrorCode = ErrorCode.E_NO_ERROR, ) -> None: """Initialize DisconnectResponse object.""" self.communication_channel_id = communication_channel_id self.status_code = status_code def calculated_length(self) -> int: """Get length of KNX/IP body.""" return DisconnectResponse.LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if len(raw) < DisconnectResponse.LENGTH: raise CouldNotParseKNXIP("Disconnect info has wrong length") self.communication_channel_id = raw[0] self.status_code = ErrorCode(raw[1]) return DisconnectResponse.LENGTH def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return bytes( ( self.communication_channel_id, self.status_code.value, ) ) def __repr__(self) -> str: """Return object as readable string.""" return ( "' ) xknx-3.6.0/xknx/knxip/error_code.py000066400000000000000000000036651475530762600173570ustar00rootroot00000000000000"""Module for KNX/IP Error codes.""" from enum import Enum class ErrorCode(Enum): """Enum class for KNX/IP error codes.""" # The connection state is normal. E_NO_ERROR = 0x00 # requested host protocol is not supported E_HOST_PROTOCOL_TYPE = 0x01 # requested protocol version is not supported E_VERSION_NOT_SUPPORTED = 0x02 # received sequence number is out of order. E_SEQUENCE_NUMBER = 0x04 # Any further undefined, possibly implementation specific error has occurred. # Core v2 E_ERROR = 0x0F # The KNXnet/IP Server device cannot find an active data # connection with the specified ID. E_CONNECTION_ID = 0x21 # The requested connection type is not supported E_CONNECTION_TYPE = 0x22 # One or more requested connection options are not supported E_CONNECTION_OPTION = 0x23 # The KNXnet/IP Server device cannot accept the new data connection # because its maximum amount of concurrent connections is already # occupied. E_NO_MORE_CONNECTIONS = 0x24 # KNXnet/IP Tunnelling device does not accept connection because the # Individual Address is used multiple times E_NO_MORE_UNIQUE_CONNECTIONS = 0x25 # The KNXnet/IP Server device detects an error concerning # the data connection with the specified ID. E_DATA_CONNECTION = 0x26 # The KNXnet/IP Server device detects an error concerning # the KNX subnetwork connection with the specified ID. E_KNX_CONNECTION = 0x27 # The Client is not authorised to use the requested IA in the Extended CRI. # Core v2 E_AUTHORISATION_ERROR = 0x28 # The requested tunnelling layer is not supported by the # KNXnet/IP Server device. E_TUNNELLING_LAYER = 0x29 # The IA requested in the Extended CRI is not a Tunnelling IA. # Core v2 E_NO_TUNNELLING_ADDRESS = 0x2D # The IA requested for this connection is in use. # Core v2 E_CONNECTION_IN_USE = 0x2E xknx-3.6.0/xknx/knxip/header.py000066400000000000000000000051201475530762600164500ustar00rootroot00000000000000"""Module for serialization and deserialization of KNX/IP Header.""" from __future__ import annotations from typing import Final from xknx.exceptions import CouldNotParseKNXIP, IncompleteKNXIPFrame from .body import KNXIPBody from .knxip_enum import KNXIPServiceType class KNXIPHeader: """Class for serialization and deserialization of KNX/IP Header.""" HEADERLENGTH: Final = 0x06 PROTOCOLVERSION: Final = 0x10 # The only valid protocol version at this time is 1.0 def __init__(self) -> None: """Initialize KNXIPHeader class.""" self.service_type_ident = KNXIPServiceType.ROUTING_INDICATION self.total_length = 0 # to be set later def from_knx(self, data: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if len(data) < KNXIPHeader.HEADERLENGTH: raise IncompleteKNXIPFrame("wrong connection header length") if data[0] != KNXIPHeader.HEADERLENGTH: raise CouldNotParseKNXIP("wrong connection header length") # set immediately, as we need it for tcp stream parsing before raising exception self.total_length = data[4] * 256 + data[5] if data[1] != KNXIPHeader.PROTOCOLVERSION: raise CouldNotParseKNXIP("wrong protocol version") try: self.service_type_ident = KNXIPServiceType(data[2] * 256 + data[3]) except ValueError: raise CouldNotParseKNXIP( f"KNXIPServiceType unknown: 0x{data[2:4].hex()}" ) from None return KNXIPHeader.HEADERLENGTH def set_length(self, body: KNXIPBody) -> None: """Set length of full KNX/IP packet from body + fixed header length.""" if not isinstance(body, KNXIPBody): raise TypeError() self.total_length = KNXIPHeader.HEADERLENGTH + body.calculated_length() def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return ( bytes((KNXIPHeader.HEADERLENGTH, KNXIPHeader.PROTOCOLVERSION)) + self.service_type_ident.value.to_bytes(2, "big") + self.total_length.to_bytes(2, "big") ) def __repr__(self) -> str: """Return object as readable string.""" return ( "' ) def __eq__(self, other: object) -> bool: """Equal operator.""" return self.__dict__ == other.__dict__ xknx-3.6.0/xknx/knxip/hpai.py000066400000000000000000000054261475530762600161520ustar00rootroot00000000000000""" Module for serialization and deserialization of KNX HPAI (Host Protocol Address Information) information. A HPAI contains an IP address and a port. """ from __future__ import annotations import socket from xknx.exceptions import ConversionError, CouldNotParseKNXIP from .knxip_enum import HostProtocol class HPAI: """Class for Module for Serialization and Deserialization.""" LENGTH = 0x08 def __init__( self, ip_addr: str = "0.0.0.0", port: int = 0, protocol: HostProtocol = HostProtocol.IPV4_UDP, ) -> None: """Initialize HPAI object.""" self.ip_addr = ip_addr self.port = port self.protocol = protocol @property def route_back(self) -> bool: """Return True if HPAI is a route back address information.""" return self.ip_addr == "0.0.0.0" @property def addr_tuple(self) -> tuple[str, int]: """Return tuple of ip address and port.""" return self.ip_addr, self.port def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if len(raw) < HPAI.LENGTH: raise CouldNotParseKNXIP("wrong HPAI length") if raw[0] != HPAI.LENGTH: raise CouldNotParseKNXIP("wrong HPAI length") try: self.protocol = HostProtocol(raw[1]) except ValueError as err: raise CouldNotParseKNXIP("unsupported host protocol code") from err self.ip_addr = socket.inet_ntoa(raw[2:6]) self.port = raw[6] * 256 + raw[7] return HPAI.LENGTH def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" try: return ( bytes((HPAI.LENGTH, self.protocol.value)) + socket.inet_aton(self.ip_addr) + self.port.to_bytes(2, "big") ) except (OSError, TypeError) as err: # OSError for invalid address strings; TypeError for non-strings raise ConversionError(f"Invalid IPv4 address: {self.ip_addr}") from err except (OverflowError, AttributeError) as err: # OverflowError for int < 0 or int > 65535; AttributeError for non-integers raise ConversionError(f"Port is not valid: {self.port}") from err def __repr__(self) -> str: """Representation of object.""" return f"HPAI('{self.ip_addr}', {self.port}, {self.protocol})" def __str__(self) -> str: """Return object as readable string.""" return f"{self.ip_addr}:{self.port}/{self.protocol.name[-3:].lower()}" def __eq__(self, other: object) -> bool: """Equal operator.""" return self.__dict__ == other.__dict__ def __hash__(self) -> int: """Hash function.""" return hash((self.ip_addr, self.port, self.protocol)) xknx-3.6.0/xknx/knxip/knxip.py000066400000000000000000000161501475530762600163560ustar00rootroot00000000000000""" Module for serialization and deserialization of KNX/IP packets. It consists of a header and a body. Depending on the service_type_ident different types of body classes are instantiated. """ from __future__ import annotations from xknx.exceptions import CouldNotParseKNXIP, IncompleteKNXIPFrame from .body import KNXIPBody from .connect_request import ConnectRequest from .connect_response import ConnectResponse from .connectionstate_request import ConnectionStateRequest from .connectionstate_response import ConnectionStateResponse from .description_request import DescriptionRequest from .description_response import DescriptionResponse from .device_configuration_ack import DeviceConfigurationAck from .device_configuration_request import DeviceConfigurationRequest from .disconnect_request import DisconnectRequest from .disconnect_response import DisconnectResponse from .header import KNXIPHeader from .knxip_enum import KNXIPServiceType from .routing_busy import RoutingBusy from .routing_indication import RoutingIndication from .routing_lost_message import RoutingLostMessage from .search_request import SearchRequest from .search_request_extended import SearchRequestExtended from .search_response import SearchResponse from .search_response_extended import SearchResponseExtended from .secure_wrapper import SecureWrapper from .session_authenticate import SessionAuthenticate from .session_request import SessionRequest from .session_response import SessionResponse from .session_status import SessionStatus from .timer_notify import TimerNotify from .tunnelling_ack import TunnellingAck from .tunnelling_feature import ( TunnellingFeatureGet, TunnellingFeatureInfo, TunnellingFeatureResponse, TunnellingFeatureSet, ) from .tunnelling_request import TunnellingRequest class KNXIPFrame: """Class for KNX/IP Frames.""" def __init__(self, header: KNXIPHeader, body: KNXIPBody) -> None: """Initialize object.""" self.header = header self.body = body @staticmethod def init_from_body(knxip_body: KNXIPBody) -> KNXIPFrame: """Return KNXIPFrame from KNXIPBody.""" header = KNXIPHeader() header.service_type_ident = knxip_body.__class__.SERVICE_TYPE header.set_length(knxip_body) return KNXIPFrame(header=header, body=knxip_body) @staticmethod def from_knx(data: bytes) -> tuple[KNXIPFrame, bytes]: """ Parse/deserialize from KNX/IP raw data. Returns a tuple of the KNXIPFrame and the rest of the data. Raises IncompleteKNXIPFrame if the data is not enough to parse the KNXIPFrame or CouldNotParseKNXIP on parsing error. """ header = KNXIPHeader() pos_body = header.from_knx(data) if len(data) < header.total_length: raise IncompleteKNXIPFrame("Incomplete data for KNXIPFrame") # limit data to self.header.total_length for streaming socket data raw_body = data[pos_body : header.total_length] body: KNXIPBody # Core if header.service_type_ident == KNXIPServiceType.SEARCH_REQUEST: body = SearchRequest() elif header.service_type_ident == KNXIPServiceType.SEARCH_REQUEST_EXTENDED: body = SearchRequestExtended() elif header.service_type_ident == KNXIPServiceType.SEARCH_RESPONSE: body = SearchResponse() elif header.service_type_ident == KNXIPServiceType.SEARCH_RESPONSE_EXTENDED: body = SearchResponseExtended() elif header.service_type_ident == KNXIPServiceType.DESCRIPTION_REQUEST: body = DescriptionRequest() elif header.service_type_ident == KNXIPServiceType.DESCRIPTION_RESPONSE: body = DescriptionResponse() elif header.service_type_ident == KNXIPServiceType.CONNECT_REQUEST: body = ConnectRequest() elif header.service_type_ident == KNXIPServiceType.CONNECT_RESPONSE: body = ConnectResponse() elif header.service_type_ident == KNXIPServiceType.CONNECTIONSTATE_REQUEST: body = ConnectionStateRequest() elif header.service_type_ident == KNXIPServiceType.CONNECTIONSTATE_RESPONSE: body = ConnectionStateResponse() elif header.service_type_ident == KNXIPServiceType.DISCONNECT_REQUEST: body = DisconnectRequest() elif header.service_type_ident == KNXIPServiceType.DISCONNECT_RESPONSE: body = DisconnectResponse() # Device Management elif header.service_type_ident == KNXIPServiceType.DEVICE_CONFIGURATION_REQUEST: body = DeviceConfigurationRequest() elif header.service_type_ident == KNXIPServiceType.DEVICE_CONFIGURATION_ACK: body = DeviceConfigurationAck() # Tunnelling elif header.service_type_ident == KNXIPServiceType.TUNNELLING_REQUEST: body = TunnellingRequest() elif header.service_type_ident == KNXIPServiceType.TUNNELLING_ACK: body = TunnellingAck() elif header.service_type_ident == KNXIPServiceType.TUNNELLING_FEATURE_GET: body = TunnellingFeatureGet() elif header.service_type_ident == KNXIPServiceType.TUNNELLING_FEATURE_INFO: body = TunnellingFeatureInfo() elif header.service_type_ident == KNXIPServiceType.TUNNELLING_FEATURE_RESPONSE: body = TunnellingFeatureResponse() elif header.service_type_ident == KNXIPServiceType.TUNNELLING_FEATURE_SET: body = TunnellingFeatureSet() # Routing elif header.service_type_ident == KNXIPServiceType.ROUTING_INDICATION: body = RoutingIndication() elif header.service_type_ident == KNXIPServiceType.ROUTING_BUSY: body = RoutingBusy() elif header.service_type_ident == KNXIPServiceType.ROUTING_LOST_MESSAGE: body = RoutingLostMessage() # Secure elif header.service_type_ident == KNXIPServiceType.SECURE_WRAPPER: body = SecureWrapper() elif header.service_type_ident == KNXIPServiceType.SESSION_AUTHENTICATE: body = SessionAuthenticate() elif header.service_type_ident == KNXIPServiceType.SESSION_REQUEST: body = SessionRequest() elif header.service_type_ident == KNXIPServiceType.SESSION_RESPONSE: body = SessionResponse() elif header.service_type_ident == KNXIPServiceType.SESSION_STATUS: body = SessionStatus() elif header.service_type_ident == KNXIPServiceType.TIMER_NOTIFY: body = TimerNotify() else: raise CouldNotParseKNXIP( f"KNXIPServiceType not implemented: {header.service_type_ident.name}" ) body.from_knx(raw_body) return KNXIPFrame(header=header, body=body), data[header.total_length :] def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return self.header.to_knx() + self.body.to_knx() def __repr__(self) -> str: """Return object as readable string.""" return f'' def __eq__(self, other: object) -> bool: """Equal operator.""" return self.__dict__ == other.__dict__ xknx-3.6.0/xknx/knxip/knxip_enum.py000066400000000000000000000134701475530762600174040ustar00rootroot00000000000000"""Module for KNX/IP Enum classes.""" from enum import Enum class KNXIPServiceType(Enum): """Enum class for KNX/IP Service Types.""" # 0x02 Core services SEARCH_REQUEST = 0x0201 SEARCH_RESPONSE = 0x0202 DESCRIPTION_REQUEST = 0x0203 DESCRIPTION_RESPONSE = 0x0204 CONNECT_REQUEST = 0x0205 CONNECT_RESPONSE = 0x0206 CONNECTIONSTATE_REQUEST = 0x0207 CONNECTIONSTATE_RESPONSE = 0x0208 DISCONNECT_REQUEST = 0x0209 DISCONNECT_RESPONSE = 0x020A SEARCH_REQUEST_EXTENDED = 0x020B SEARCH_RESPONSE_EXTENDED = 0x020C # 0x03 Device Management services DEVICE_CONFIGURATION_REQUEST = 0x0310 DEVICE_CONFIGURATION_ACK = 0x0311 # 0x04 Tunneling services TUNNELLING_REQUEST = 0x0420 TUNNELLING_ACK = 0x0421 TUNNELLING_FEATURE_GET = 0x0422 TUNNELLING_FEATURE_RESPONSE = 0x0423 TUNNELLING_FEATURE_SET = 0x0424 TUNNELLING_FEATURE_INFO = 0x0425 # 0x05 Routing services ROUTING_INDICATION = 0x0530 ROUTING_LOST_MESSAGE = 0x0531 ROUTING_BUSY = 0x0532 ROUTING_SYSTEM_BROADCAST = 0x0533 # 0x06 Remote Logging services # 0x07 Remote Configuration and Diagnosis services REMOTE_DIAG_REQUEST = 0x0740 REMOTE_DIAG_RESPONSE = 0x0741 REMOTE_CONFIG_REQUEST = 0x0742 REMOTE_RESET_REQUEST = 0x0743 # 0x08 Object Server services # 0x09 Security services SECURE_WRAPPER = 0x0950 SESSION_REQUEST = 0x0951 SESSION_RESPONSE = 0x0952 SESSION_AUTHENTICATE = 0x0953 SESSION_STATUS = 0x0954 TIMER_NOTIFY = 0x0955 class ConnectRequestType(Enum): """Enum class for KNX/IP Connect Request Types.""" # Data connection used to configure a KNXnet/IP device DEVICE_MGMT_CONNECTION = 0x03 # Data connection used to forward KNX telegrams between # two KNXnet/IP devices. TUNNEL_CONNECTION = 0x04 # Data connection used for configuration and data transfer # with a remote logging server. REMLOG_CONNECTION = 0x06 # Data connection used for data transfer with a remote # configuration server. REMCONF_CONNECTION = 0x07 # Data connection used for configuration and data transfer # with an Object Server in a KNXnet/IP device. OBJSVR_CONNECTION = 0x08 class TunnellingLayer(Enum): """Enum class for KNX/IP Tunnelling Layer.""" # Data Link Layer tunnel DATA_LINK_LAYER = 0x02 # Raw tunnel RAW_LAYER = 0x04 # Busmonitor tunnel BUSMONITOR_LAYER = 0x80 class DIBTypeCode(Enum): """Enum class for KNX/IP DIB Type Codes.""" # Device information e.g. KNX medium. DEVICE_INFO = 0x01 # Service families supported by the device. SUPP_SVC_FAMILIES = 0x02 # IP configuration IP_CONFIG = 0x03 # current configuration IP_CUR_CONFIG = 0x04 # KNX addresses KNX_ADDRESSES = 0x05 # KNX IP Secure SECURED_SERVICE_FAMILIES = 0x06 TUNNELING_INFO = 0x07 ADDITIONAL_DEVICE_INFO = 0x08 # DIB structure for further data defined by device manufacturer. MFR_DATA = 0xFE class HostProtocol(Enum): """Enum class for host protocol.""" IPV4_UDP = 0x01 IPV4_TCP = 0x02 class KNXMedium(Enum): """Enum class for KNX Medium.""" TP1 = 0x02 PL110 = 0x04 RF = 0x10 KNX_IP = 0x20 class DIBServiceFamily(Enum): """Enum class for KNX/IP DIB Service Family.""" # Core CORE = 0x02 # Device Management DEVICE_MANAGEMENT = 0x03 # Tunnelling TUNNELING = 0x04 # Routing ROUTING = 0x05 # Remote Logging REMOTE_LOGGING = 0x06 # Configuration and Diagnosis REMOTE_CONFIGURATION_DIAGNOSIS = 0x07 # Object Server'. OBJECT_SERVER = 0x08 # Security - Extended search response only SECURITY = 0x09 class SecureSessionStatusCode(Enum): """Enum class for KNX/IP Secure session status codes.""" # The user could successfully be authenticated STATUS_AUTHENTICATION_SUCCESS = 0x00 # An error occurred during secure session handshake STATUS_AUTHENTICATION_FAILED = 0x01 # The session is not (yet) authenticated STATUS_UNAUTHENTICATED = 0x02 # A timeout occurred during secure session handshake STATUS_TIMEOUT = 0x03 # Prevent inactivity on the secure session closing it with timeout error STATUS_KEEPALIVE = 0x04 # The secure session shall be closed STATUS_CLOSE = 0x05 class SearchRequestParameterType(Enum): """Search Request Parameter (SRP) is used to transfer additional information in a KNXnet/IP SEARCH_REQUEST_EXTENDED.""" # Used to test behavior of the KNXnet/IP server for unknown SRPs. Don't use! INVALID = 0x00 # Select only KNXnet/IP Servers that are currently in programming mode SELECT_BY_PROGRAMMING_MODE = 0x01 # Select only KNXnet/IP Servers that have the given MAC address SELECT_BY_MAC_ADDRESS = 0x02 # Select only KNXnet/IP Servers that support the given DIBServiceFamily in a given version SELECT_BY_SERVICE = 0x03 # The Client shall include this SRP to indicate that it is interested in the listed DIBs REQUEST_DIBS = 0x04 class TunnellingFeatureType(Enum): """Enum class for KNX/IP Tunnel Features services.""" # Getting the supported EMI type(s) (only cEMI allowed) SUPPORTED_EMI_TYPE = 0x01 # Getting the local Device Descriptor Type 0 for possible local device management DEVICE_DESCRIPTOR_TYPE_0 = 0x02 # Getting and informing on the bus connection status BUS_CONNECTION_STATUS = 0x03 # Getting the manufacturer code of the Bus Access Server MANUFACTURER_CODE = 0x04 # Getting and Setting the EMI type to use ACTIVE_EMI_TYPE = 0x05 # Getting, Setting and informing on the Interface Individual Address INTERFACE_INDIVIDUAL_ADDRESS = 0x06 # Getting and informing on the maximum APDU length MAX_APDU_LENGTH = 0x07 # Getting and Setting the Interface Feature Info service Enable INTERFACE_FEATURE_INFO_ENABLE = 0x08 xknx-3.6.0/xknx/knxip/routing_busy.py000066400000000000000000000036301475530762600177550ustar00rootroot00000000000000""" Module for Serialization and Deserialization of KNX RoutingBusy frames. RoutingBusy frames are used for flow control. """ from __future__ import annotations from xknx.exceptions import CouldNotParseKNXIP from .body import KNXIPBody from .knxip_enum import KNXIPServiceType class RoutingBusy(KNXIPBody): """Representation of a KNX RoutingBusy frame.""" SERVICE_TYPE = KNXIPServiceType.ROUTING_BUSY BODY_LENGTH = 6 def __init__( self, device_state: int = 0, wait_time: int = 100, control_field: int = 0, ) -> None: """Initialize RoutingBusy object.""" self.device_state = device_state self.wait_time = wait_time self.control_field = control_field def calculated_length(self) -> int: """Get length of KNX/IP body.""" return RoutingBusy.BODY_LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if raw[0] != RoutingBusy.BODY_LENGTH: # structure_length field raise CouldNotParseKNXIP("RoutingBusy body has invalid length") if len(raw) != RoutingBusy.BODY_LENGTH: raise CouldNotParseKNXIP("RoutingBusy has wrong length") self.device_state = raw[1] self.wait_time = raw[2] * 256 + raw[3] self.control_field = raw[4] * 256 + raw[5] return RoutingBusy.BODY_LENGTH def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return ( bytes((RoutingBusy.BODY_LENGTH, self.device_state)) + self.wait_time.to_bytes(2, "big") + self.control_field.to_bytes(2, "big") ) def __repr__(self) -> str: """Return object as readable string.""" return ( "" ) xknx-3.6.0/xknx/knxip/routing_indication.py000066400000000000000000000020471475530762600211150ustar00rootroot00000000000000""" Module for Serialization and Deserialization of KNX RoutingIndication frames. Routing indications are used to transport CEMI Messages. """ from __future__ import annotations from .body import KNXIPBody from .knxip_enum import KNXIPServiceType class RoutingIndication(KNXIPBody): """Representation of a KNX RoutingIndication frame.""" SERVICE_TYPE = KNXIPServiceType.ROUTING_INDICATION def __init__(self, raw_cemi: bytes = b"") -> None: """Initialize RoutingIndication object.""" self.raw_cemi = raw_cemi def calculated_length(self) -> int: """Get length of KNX/IP body.""" return len(self.raw_cemi) def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" self.raw_cemi = raw return len(raw) def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return self.raw_cemi def __repr__(self) -> str: """Return object as readable string.""" return f'' xknx-3.6.0/xknx/knxip/routing_lost_message.py000066400000000000000000000034501475530762600214600ustar00rootroot00000000000000""" Module for Serialization and Deserialization of KNX RoutingLostMessage frames. RoutingLostMessage frames are used for system supervision. """ from __future__ import annotations from xknx.exceptions import CouldNotParseKNXIP from .body import KNXIPBody from .knxip_enum import KNXIPServiceType class RoutingLostMessage(KNXIPBody): """Representation of a KNX RoutingLostMessage frame.""" SERVICE_TYPE = KNXIPServiceType.ROUTING_LOST_MESSAGE BODY_LENGTH = 4 def __init__( self, device_state: int = 0, lost_messages: int = 1, ) -> None: """Initialize RoutingLostMessage object.""" self.device_state = device_state self.lost_messages = lost_messages def calculated_length(self) -> int: """Get length of KNX/IP body.""" return RoutingLostMessage.BODY_LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if raw[0] != RoutingLostMessage.BODY_LENGTH: # structure_length field raise CouldNotParseKNXIP("RoutingLostMessage body has invalid length") if len(raw) != RoutingLostMessage.BODY_LENGTH: raise CouldNotParseKNXIP("RoutingLostMessage has wrong length") self.device_state = raw[1] self.lost_messages = raw[2] * 256 + raw[3] return RoutingLostMessage.BODY_LENGTH def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return bytes( (RoutingLostMessage.BODY_LENGTH, self.device_state) ) + self.lost_messages.to_bytes(2, "big") def __repr__(self) -> str: """Return object as readable string.""" return ( "" ) xknx-3.6.0/xknx/knxip/search_request.py000066400000000000000000000021731475530762600202420ustar00rootroot00000000000000""" Module for Serialization and Deserialization of KNX Search Requests. Search Requests are used to search for KNX/IP devices within the network. """ from __future__ import annotations from .body import KNXIPBody from .hpai import HPAI from .knxip_enum import KNXIPServiceType class SearchRequest(KNXIPBody): """Representation of a KNX Search Request.""" SERVICE_TYPE = KNXIPServiceType.SEARCH_REQUEST def __init__(self, discovery_endpoint: HPAI | None = None) -> None: """Initialize SearchRequest object.""" self.discovery_endpoint = discovery_endpoint or HPAI() def calculated_length(self) -> int: """Get length of KNX/IP body.""" return HPAI.LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" return self.discovery_endpoint.from_knx(raw) def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return self.discovery_endpoint.to_knx() def __repr__(self) -> str: """Return object as readable string.""" return f'' xknx-3.6.0/xknx/knxip/search_request_extended.py000066400000000000000000000033251475530762600221220ustar00rootroot00000000000000""" Module for Serialization and Deserialization of KNX Extended Search Requests. Extended Search Requests are used to search for KNX/IP devices within the network that support for instance IP Secure. See AN184 in version 03 from the KNX specifications. """ from __future__ import annotations from .body import KNXIPBody from .hpai import HPAI from .knxip_enum import KNXIPServiceType from .srp import SRP class SearchRequestExtended(KNXIPBody): """Representation of a KNX Search Request Extended.""" SERVICE_TYPE = KNXIPServiceType.SEARCH_REQUEST_EXTENDED def __init__( self, discovery_endpoint: HPAI | None = None, srps: list[SRP] | None = None, ) -> None: """Initialize SearchRequestExtended object.""" self.discovery_endpoint = discovery_endpoint or HPAI() self.srps = srps or [] def calculated_length(self) -> int: """Get length of KNX/IP body.""" return HPAI.LENGTH + sum(srp.payload_size for srp in self.srps) def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" pos: int = self.discovery_endpoint.from_knx(raw) while raw[pos:]: srp = SRP.from_knx(raw[pos:]) pos += len(srp) self.srps.append(srp) return pos def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return self.discovery_endpoint.to_knx() + b"".join( bytes(srp) for srp in self.srps ) def __repr__(self) -> str: """Return object as readable string.""" return ( "' ) xknx-3.6.0/xknx/knxip/search_response.py000066400000000000000000000042171475530762600204110ustar00rootroot00000000000000""" Module for Serialization and Deserialization of KNX Search Response. Search Requests are used to search for KNX/IP devices within the network. A search response contains all information of a found device (Name, serial number, supported features.). It supports an array-style access to the DIBs (use classname as index). Every KNXnet/ip server shall send a search response and one device supporting multiple KNX connections may send multiple search responses. """ from __future__ import annotations from .body import KNXIPBody from .dib import DIB, DIBDeviceInformation from .hpai import HPAI from .knxip_enum import KNXIPServiceType class SearchResponse(KNXIPBody): """Representation of a KNX Search Response.""" SERVICE_TYPE = KNXIPServiceType.SEARCH_RESPONSE def __init__(self, control_endpoint: HPAI | None = None) -> None: """Initialize SearchResponse object.""" self.control_endpoint = control_endpoint or HPAI() self.dibs: list[DIB] = [] def calculated_length(self) -> int: """Get length of KNX/IP body.""" return HPAI.LENGTH + sum(dib.calculated_length() for dib in self.dibs) def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" pos = self.control_endpoint.from_knx(raw) while raw[pos:]: dib = DIB.determine_dib(raw[pos:]) pos += dib.from_knx(raw[pos:]) self.dibs.append(dib) return pos @property def device_name(self) -> str: """Return name of device.""" return next( (dib.name for dib in self.dibs if isinstance(dib, DIBDeviceInformation)), "UNKNOWN", ) def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return self.control_endpoint.to_knx() + b"".join( dib.to_knx() for dib in self.dibs ) def __repr__(self) -> str: """Return object as readable string.""" _dibs_str = ",\n".join(dib.__str__() for dib in self.dibs) return ( "' ) xknx-3.6.0/xknx/knxip/search_response_extended.py000066400000000000000000000024411475530762600222660ustar00rootroot00000000000000""" Module for Serialization and Deserialization of KNX Search Response Extended. Search Requests Extended are used to search for KNX/IP devices within the network. A search response extended contains all information of a found device (Name, serial number, supported features.). It supports an array-style access to the DIBs (use classname as index). Every KNXnet/ip server shall send a search response extended and one device supporting multiple KNX connections may send multiple search responses. If there were any SRPs (Search Request Parameters) in the Search Request Extended and those were marked as mandatory the server shall only reply to those requests if it can fulfill the requirements in the SRP. """ from __future__ import annotations from .knxip_enum import KNXIPServiceType from .search_response import SearchResponse class SearchResponseExtended(SearchResponse): """Representation of a KNX Search Extended.""" SERVICE_TYPE = KNXIPServiceType.SEARCH_RESPONSE_EXTENDED def __repr__(self) -> str: """Return object as readable string.""" _dibs_str = ",\n".join(dib.__str__() for dib in self.dibs) return ( "' ) xknx-3.6.0/xknx/knxip/secure_wrapper.py000066400000000000000000000067051475530762600202600ustar00rootroot00000000000000""" Module for Serialization and Deserialization of KNX Secure Wrapper. When KNXnet/IP frames are to be sent over a secured connection, each frame including the KNXnet/IP header shall be completely encapsulated as encrypted payload inside a SECURE_WRAPPER frame that adds some extra information needed to decrypt the frame and for ensuring data integrity and freshness. """ from __future__ import annotations from typing import Final from xknx.exceptions import CouldNotParseKNXIP from .body import KNXIPBody from .knxip_enum import KNXIPServiceType # 2 octets secure session identifier # 6 octets sequence information # 6 octets KNX serial number # 2 octets message tag SECURITY_INFORMATION_LENGTH: Final = 16 # 6 octets for KNX/IP header # 2 for smallest payload (eg. SessionStatus) MINIMUM_PAYLOAD_LENGTH: Final = 2 MESSAGE_AUTHENTICATION_CODE_LENGTH: Final = 16 SECURE_WRAPPER_MINIMUM_LENGTH: Final = ( SECURITY_INFORMATION_LENGTH + MINIMUM_PAYLOAD_LENGTH + MESSAGE_AUTHENTICATION_CODE_LENGTH ) class SecureWrapper(KNXIPBody): """Representation of a KNX Secure Wrapper.""" SERVICE_TYPE = KNXIPServiceType.SECURE_WRAPPER def __init__( self, secure_session_id: int = 0, sequence_information: bytes = bytes(6), serial_number: bytes = bytes(6), message_tag: bytes = bytes(2), encrypted_data: bytes = bytes(0), message_authentication_code: bytes = bytes(16), ) -> None: """Initialize SecureWrapper object.""" self.secure_session_id = secure_session_id self.sequence_information = sequence_information self.serial_number = serial_number self.message_tag = message_tag self.encrypted_data = encrypted_data self.message_authentication_code = message_authentication_code def calculated_length(self) -> int: """Get length of KNX/IP body.""" return ( SECURITY_INFORMATION_LENGTH + len(self.encrypted_data) + MESSAGE_AUTHENTICATION_CODE_LENGTH ) def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if len(raw) < SECURE_WRAPPER_MINIMUM_LENGTH: raise CouldNotParseKNXIP("SecureWrapper has invalid length") self.secure_session_id = int.from_bytes(raw[:2], "big") self.sequence_information = raw[2:8] self.serial_number = raw[8:14] self.message_tag = raw[14:16] self.encrypted_data = raw[16:-MESSAGE_AUTHENTICATION_CODE_LENGTH] self.message_authentication_code = raw[-MESSAGE_AUTHENTICATION_CODE_LENGTH:] return len(raw) def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return ( self.secure_session_id.to_bytes(2, "big") + self.sequence_information + self.serial_number + self.message_tag + self.encrypted_data + self.message_authentication_code ) def __repr__(self) -> str: """Return object as readable string.""" return ( f"' ) xknx-3.6.0/xknx/knxip/session_authenticate.py000066400000000000000000000043441475530762600214500ustar00rootroot00000000000000""" Module for Serialization and Deserialization of KNX Session Authenticates. The SESSION_AUTHENTICATE shall be sent by the KNXnet/IP secure client to the control endpoint of the KNXnet/IP secure server after the Diffie-Hellman handshake to authenticate the user against the server device. """ from __future__ import annotations from typing import Final from xknx.exceptions import CouldNotParseKNXIP from .body import KNXIPBody from .knxip_enum import KNXIPServiceType class SessionAuthenticate(KNXIPBody): """Representation of a KNX Session Authenticate.""" SERVICE_TYPE = KNXIPServiceType.SESSION_AUTHENTICATE LENGTH: Final = 18 def __init__( self, user_id: int = 0x02, message_authentication_code: bytes = bytes(16), ) -> None: """Initialize SessionAuthenticate object.""" # 00h: Reserved, shall not be used # 01h: Management level access # 02h - 7Fh: User level access # 80h - FFh: Reserved, shall not be used # TODO: maybe use an Enum instead of int or raise an error in # to_knx() and handle in RequestResponse class self.user_id = user_id self.message_authentication_code = message_authentication_code def calculated_length(self) -> int: """Get length of KNX/IP body.""" return SessionAuthenticate.LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if len(raw) != SessionAuthenticate.LENGTH: raise CouldNotParseKNXIP("SessionAuthenticate has wrong length") self.user_id = raw[1] self.message_authentication_code = raw[2:] return SessionAuthenticate.LENGTH def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return ( bytes( ( 0x00, # reserved self.user_id, ) ) + self.message_authentication_code ) def __repr__(self) -> str: """Return object as readable string.""" return ( f"' ) xknx-3.6.0/xknx/knxip/session_request.py000066400000000000000000000035561475530762600204660ustar00rootroot00000000000000""" Module for Serialization and Deserialization of KNX Session Requests. The SESSION_REQUEST is used to initiate the secure connection setup handshake for a new secure communication session. """ from __future__ import annotations from typing import Final from xknx.exceptions import CouldNotParseKNXIP from .body import KNXIPBody from .hpai import HPAI from .knxip_enum import HostProtocol, KNXIPServiceType class SessionRequest(KNXIPBody): """Representation of a KNX Session Request.""" SERVICE_TYPE = KNXIPServiceType.SESSION_REQUEST # 8 octets for the UDP/TCP HPAI and 32 octets for the clients ECDH public value LENGTH: Final = 40 def __init__( self, control_endpoint: HPAI | None = None, ecdh_client_public_key: bytes = bytes(32), ) -> None: """Initialize SessionRequest object.""" self.control_endpoint = control_endpoint or HPAI(protocol=HostProtocol.IPV4_TCP) self.ecdh_client_public_key = ecdh_client_public_key def calculated_length(self) -> int: """Get length of KNX/IP body.""" return SessionRequest.LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if len(raw) != SessionRequest.LENGTH: raise CouldNotParseKNXIP("SessionRequest has wrong length") pos = self.control_endpoint.from_knx(raw) self.ecdh_client_public_key = raw[pos:] return SessionRequest.LENGTH def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return self.control_endpoint.to_knx() + self.ecdh_client_public_key def __repr__(self) -> str: """Return object as readable string.""" return ( f"' ) xknx-3.6.0/xknx/knxip/session_response.py000066400000000000000000000046201475530762600206250ustar00rootroot00000000000000""" Module for Serialization and Deserialization of KNX Session Responses. The SESSION_RESPONSE shall be sent by the KNXnet/IP secure server to the secure client's control endpoint in response to a received secure session request frame. """ from __future__ import annotations from typing import Final from xknx.exceptions import CouldNotParseKNXIP from .body import KNXIPBody from .knxip_enum import KNXIPServiceType class SessionResponse(KNXIPBody): """Representation of a KNX Session Response.""" SERVICE_TYPE = KNXIPServiceType.SESSION_RESPONSE # 2 octets secure session identifier # 32 octets for the servers ECDH public value # 16 octets for the message authentication code LENGTH: Final = 50 def __init__( self, secure_session_id: int = 0, ecdh_server_public_key: bytes = bytes(32), message_authentication_code: bytes = bytes(16), ) -> None: """Initialize SessionResponse object.""" self.ecdh_server_public_key = ecdh_server_public_key # secure session identifier 0 shall in general be reserved for # multicast data and shall not be used for unicast connections self.secure_session_id = secure_session_id self.message_authentication_code = message_authentication_code def calculated_length(self) -> int: """Get length of KNX/IP body.""" return SessionResponse.LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if len(raw) != SessionResponse.LENGTH: raise CouldNotParseKNXIP("SessionResponse has wrong length") self.secure_session_id = int.from_bytes(raw[:2], "big") self.ecdh_server_public_key = raw[2:34] self.message_authentication_code = raw[34:] return SessionResponse.LENGTH def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return ( self.secure_session_id.to_bytes(2, "big") + self.ecdh_server_public_key + self.message_authentication_code ) def __repr__(self) -> str: """Return object as readable string.""" return ( f"' ) xknx-3.6.0/xknx/knxip/session_status.py000066400000000000000000000034741475530762600203200ustar00rootroot00000000000000""" Module for Serialization and Deserialization of KNX Session Status. A SESSION_STATUS may be sent by the KNXnet/IP secure server to the KNXnet/IP secure client or by the KNXnet/IP secure client to the KNXnet/IP secure server in any stage of the secure session handshake to indicate an error condition or status information. """ from __future__ import annotations from typing import Final from xknx.exceptions import CouldNotParseKNXIP from .body import KNXIPBody from .knxip_enum import KNXIPServiceType, SecureSessionStatusCode class SessionStatus(KNXIPBody): """Representation of a KNX Session Status.""" SERVICE_TYPE = KNXIPServiceType.SESSION_STATUS LENGTH: Final = 2 def __init__( self, status: SecureSessionStatusCode = SecureSessionStatusCode.STATUS_KEEPALIVE, ) -> None: """Initialize SessionStatus object.""" self.status = status def calculated_length(self) -> int: """Get length of KNX/IP body.""" return SessionStatus.LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if len(raw) != SessionStatus.LENGTH: raise CouldNotParseKNXIP("SessionStatus has wrong length") try: self.status = SecureSessionStatusCode(raw[0]) except ValueError as err: raise CouldNotParseKNXIP( f"SessionStatus has unsupported status code: {raw[0]}" ) from err return SessionStatus.LENGTH def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return bytes( ( self.status.value, 0x00, # reserved ) ) def __repr__(self) -> str: """Return object as readable string.""" return f'' xknx-3.6.0/xknx/knxip/srp.py000066400000000000000000000133341475530762600160320ustar00rootroot00000000000000""" Module for handling Search Request Parameters (SRP). This can be used e.g. to restrict the set of devices that are expected to respond or to influence the type of DIBs which the client is interested in. If the M (Mandatory) bit is set, the server shall only respond to the search request if the complete SRP block is satisfied. """ from __future__ import annotations from xknx.exceptions import ConversionError, CouldNotParseKNXIP from .knxip_enum import DIBServiceFamily, DIBTypeCode, SearchRequestParameterType class SRP: """Search request parameter for a SearchRequestExtended.""" SRP_HEADER_SIZE = 2 MANDATORY_BIT_INDEX = 0x07 # service family and service version have 1 byte each SERVICE_PAYLOAD_LENGTH = 2 # mac address has 6 bytes MAC_ADDRESS_PAYLOAD_LENGTH = 6 def __init__( self, srp_type: SearchRequestParameterType, mandatory: bool = True, data: bytes = b"", ) -> None: """Initialize a SRP.""" self.type = srp_type self.mandatory = mandatory self.data = data self.payload_size = SRP.SRP_HEADER_SIZE if self.type == SearchRequestParameterType.SELECT_BY_SERVICE: self.validate_payload_length(SRP.SERVICE_PAYLOAD_LENGTH) self.payload_size = SRP.SRP_HEADER_SIZE + SRP.SERVICE_PAYLOAD_LENGTH elif self.type == SearchRequestParameterType.SELECT_BY_MAC_ADDRESS: self.validate_payload_length(SRP.MAC_ADDRESS_PAYLOAD_LENGTH) self.payload_size = SRP.SRP_HEADER_SIZE + SRP.MAC_ADDRESS_PAYLOAD_LENGTH elif self.type == SearchRequestParameterType.REQUEST_DIBS: if not self.data: raise ConversionError("Srp DIBs are not present.") # If the Client is interested in an odd number of DIBs # it shall add an additional Description Type 0 to make the structure length even if len(self.data) % 2: self.data += bytes([0]) self.payload_size = SRP.SRP_HEADER_SIZE + len(self.data) def validate_payload_length(self, size: int) -> None: """Validate the length of the payload.""" if not self.data or len(self.data) != size: raise ConversionError( "Srp parameter payload size does not match.", expected_size=size, srp_type=self.type, ) def __len__(self) -> int: """Get the payload length.""" return self.payload_size def __bytes__(self) -> bytes: """Convert this SRP to a bytes object.""" return ( bytes( [ self.payload_size, ( self.mandatory << SRP.MANDATORY_BIT_INDEX | self.type.value & SRP.MANDATORY_BIT_INDEX ), ] ) + self.data ) @staticmethod def from_knx(data: bytes) -> SRP: """Convert the bytes to a SRP object.""" if len(data) < SRP.SRP_HEADER_SIZE: raise CouldNotParseKNXIP("Data too short for SRP object.") size: int = data[0] if size > len(data): raise CouldNotParseKNXIP("SRP is larger than actual data size.") return SRP( srp_type=SearchRequestParameterType(data[1] & 0x7F), mandatory=bool(data[1] >> SRP.MANDATORY_BIT_INDEX), data=data[2:size], ) @staticmethod def with_programming_mode() -> SRP: """Create a SRP that limits the response to only devices that are currently in programming mode.""" return SRP( srp_type=SearchRequestParameterType.SELECT_BY_PROGRAMMING_MODE, mandatory=True, ) @staticmethod def with_mac_address(mac_address: bytes) -> SRP: """ Create a SRP that limits the response to only allow a device with the given MAC address. :param mac_address: The mac address to restrict this SRP to """ return SRP( srp_type=SearchRequestParameterType.SELECT_BY_MAC_ADDRESS, mandatory=True, data=mac_address, ) @staticmethod def with_service(family: DIBServiceFamily, family_version: int) -> SRP: """ Create a SRP that limits the response to only allow devices that support the given service family. :param family: DIBServiceFamily that this SRP should be limited to :param family_version: The minimum family version so that devices will send a search response extended back :return: Srp with the given parameter """ return SRP( srp_type=SearchRequestParameterType.SELECT_BY_SERVICE, mandatory=True, data=bytes([family.value, family_version]), ) @staticmethod def request_device_description(dibs: list[DIBTypeCode]) -> SRP: """ Create a SRP with a list of DIBs the server shall include in the response. The server may include in addition any number of other DIBs in the response. The server shall ignore Description types that are not recognized or not supported. :param dibs: the description types to include in the SRP :return: Srp with given parameters """ return SRP( srp_type=SearchRequestParameterType.REQUEST_DIBS, mandatory=False, data=bytes(dib.value for dib in dibs), ) def __eq__(self, other: object) -> bool: """Define equality for SRPs.""" if not isinstance(other, SRP): return False return ( self.payload_size == other.payload_size and self.type == other.type and self.mandatory == other.mandatory and self.data == other.data ) xknx-3.6.0/xknx/knxip/timer_notify.py000066400000000000000000000042261475530762600177360ustar00rootroot00000000000000""" Module for Serialization and Deserialization of KNX Timer Notify. This frame shall be sent during secure KNXnet/IP multicast group communication to keep the multicast group member's timer values synchronized. """ from __future__ import annotations from typing import Final from xknx.exceptions import CouldNotParseKNXIP from .body import KNXIPBody from .knxip_enum import KNXIPServiceType class TimerNotify(KNXIPBody): """Representation of a KNX Timer Notify.""" SERVICE_TYPE = KNXIPServiceType.TIMER_NOTIFY LENGTH: Final = 30 def __init__( self, timer_value: int = 0, serial_number: bytes = bytes(6), message_tag: bytes = bytes(2), message_authentication_code: bytes = bytes(16), ) -> None: """Initialize TimerNotify object.""" self.timer_value = timer_value self.serial_number = serial_number self.message_tag = message_tag self.message_authentication_code = message_authentication_code def calculated_length(self) -> int: """Get length of KNX/IP body.""" return TimerNotify.LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if len(raw) != TimerNotify.LENGTH: raise CouldNotParseKNXIP("TimerNotify has wrong length") self.timer_value = int.from_bytes(raw[:6], "big") self.serial_number = raw[6:12] self.message_tag = raw[12:14] self.message_authentication_code = raw[14:] return TimerNotify.LENGTH def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return ( self.timer_value.to_bytes(6, "big") + self.serial_number + self.message_tag + self.message_authentication_code ) def __repr__(self) -> str: """Return object as readable string.""" return ( "' ) xknx-3.6.0/xknx/knxip/tunnelling_ack.py000066400000000000000000000044171475530762600202250ustar00rootroot00000000000000""" Module for Serialization and Deserialization of a KNX Tunnelling ACK information. Tunneling requests are used to transmit a KNX telegram within an existing KNX tunnel connection. With a Tunnelling ACK the receiving party acknowledges the valid processing of the request. """ from __future__ import annotations from xknx.exceptions import CouldNotParseKNXIP from .body import KNXIPBodyResponse from .error_code import ErrorCode from .knxip_enum import KNXIPServiceType class TunnellingAck(KNXIPBodyResponse): """Representation of a KNX Tunnelling Ack.""" SERVICE_TYPE = KNXIPServiceType.TUNNELLING_ACK BODY_LENGTH = 4 def __init__( self, communication_channel_id: int = 1, sequence_counter: int = 0, status_code: ErrorCode = ErrorCode.E_NO_ERROR, ) -> None: """Initialize TunnellingAck object.""" self.communication_channel_id = communication_channel_id self.sequence_counter = sequence_counter self.status_code = status_code def calculated_length(self) -> int: """Get length of KNX/IP body.""" return TunnellingAck.BODY_LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if raw[0] != TunnellingAck.BODY_LENGTH: # structure_length field raise CouldNotParseKNXIP("TunnellingAck body has invalid length") if len(raw) != TunnellingAck.BODY_LENGTH: raise CouldNotParseKNXIP("TunnellingAck body has wrong length") self.communication_channel_id = raw[1] self.sequence_counter = raw[2] self.status_code = ErrorCode(raw[3]) return TunnellingAck.BODY_LENGTH def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return bytes( ( TunnellingAck.BODY_LENGTH, self.communication_channel_id, self.sequence_counter, self.status_code.value, ) ) def __repr__(self) -> str: """Return object as readable string.""" return ( "' ) xknx-3.6.0/xknx/knxip/tunnelling_feature.py000066400000000000000000000201201475530762600211070ustar00rootroot00000000000000""" Module for Serialization and Deserialization of a KNX Tunnelling Feature service. The Tunnelling Client shall use the Tunnelling Feature services TUNNELLING_FEATURE_GET and TUNNELLING_FEATURE_SET to read and respectively write features related to the Tunnelling interface and the Tunnelling host device. The Tunnelling Server shall use the service TUNNELLING_FEATURE_INFO to spontaneously report Tunnelling Clients about relevant changes in the state of itself or the Tunnelling connection. """ from __future__ import annotations import struct from xknx.exceptions import CouldNotParseKNXIP from xknx.management.application_layer_enum import ReturnCode from .body import KNXIPBody, KNXIPBodyResponse from .error_code import ErrorCode from .knxip_enum import KNXIPServiceType, TunnellingFeatureType class _TunnellingFeature: """Base representation of a KNX Tunnelling Feature interface request.""" HEADER_LENGTH = 4 FEATURE_ID_LENGTH = 2 def __init__( self, communication_channel_id: int = 1, sequence_counter: int = 0, status_code: ErrorCode = ErrorCode.E_NO_ERROR, feature_type: TunnellingFeatureType = TunnellingFeatureType.SUPPORTED_EMI_TYPE, data: bytes = b"", ) -> None: """Initialize _TunnellingFeature object.""" self.communication_channel_id = communication_channel_id self.sequence_counter = sequence_counter self.status_code = status_code self.feature_type = feature_type self.data = data def _has_data(self) -> bool: return True def calculated_length(self) -> int: """Get length of KNX/IP body.""" data_size = len(self.data) + (len(self.data) % 2) if self._has_data() else 0 return ( _TunnellingFeature.HEADER_LENGTH + _TunnellingFeature.FEATURE_ID_LENGTH + data_size ) def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if raw[0] != _TunnellingFeature.HEADER_LENGTH: # structure_length field raise CouldNotParseKNXIP("TunnellingFeature header has invalid length") self.communication_channel_id = raw[1] self.sequence_counter = raw[2] self.status_code = ErrorCode(raw[3]) self.feature_type = TunnellingFeatureType(raw[4]) self.data = raw[6:] if self._has_data() and len(self.data) == 0: raise CouldNotParseKNXIP("TunnellingFeature missing data") if not self._has_data() and len(self.data) > 0: raise CouldNotParseKNXIP("TunnellingFeature unexpected data") return len(raw) def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" data_size = len(self.data) + (len(self.data) % 2) if self._has_data() else 0 return struct.pack( # Append a NUL byte if data size is uneven f"!BBBBBx{data_size}s", _TunnellingFeature.HEADER_LENGTH, self.communication_channel_id, self.sequence_counter, self.status_code.value, self.feature_type.value, self.data, ) def __repr__(self) -> str: """Return object as readable string.""" _data = f'data="{self.data.hex()}" ' if self._has_data() else "" return ( f"<{self.__class__.__name__} " f'communication_channel_id="{self.communication_channel_id}" ' f'sequence_counter="{self.sequence_counter}" ' f'feature_type="{self.feature_type}" ' f"{_data}/>" ) class TunnellingFeatureGet(_TunnellingFeature, KNXIPBody): """Representation of a KNX Tunnelling Feature Get request.""" SERVICE_TYPE = KNXIPServiceType.TUNNELLING_FEATURE_GET def __init__( self, communication_channel_id: int = 1, sequence_counter: int = 0, feature_type: TunnellingFeatureType = TunnellingFeatureType.SUPPORTED_EMI_TYPE, ) -> None: """Initialize TunnellingFeatureGet object.""" super().__init__( communication_channel_id=communication_channel_id, sequence_counter=sequence_counter, feature_type=feature_type, ) def _has_data(self) -> bool: return False class TunnellingFeatureSet(_TunnellingFeature, KNXIPBody): """Representation of a KNX Tunnelling Feature Set request.""" SERVICE_TYPE = KNXIPServiceType.TUNNELLING_FEATURE_SET def __init__( self, communication_channel_id: int = 1, sequence_counter: int = 0, feature_type: TunnellingFeatureType = TunnellingFeatureType.SUPPORTED_EMI_TYPE, data: bytes = b"", ) -> None: """Initialize TunnellingFeatureSet object.""" super().__init__( communication_channel_id=communication_channel_id, sequence_counter=sequence_counter, feature_type=feature_type, data=data, ) class TunnellingFeatureInfo(_TunnellingFeature, KNXIPBody): """Representation of a KNX Tunnelling Feature Info indication.""" SERVICE_TYPE = KNXIPServiceType.TUNNELLING_FEATURE_INFO def __init__( self, communication_channel_id: int = 1, sequence_counter: int = 0, feature_type: TunnellingFeatureType = TunnellingFeatureType.SUPPORTED_EMI_TYPE, data: bytes = b"", ) -> None: """Initialize TunnellingFeatureInfo object.""" super().__init__( communication_channel_id=communication_channel_id, sequence_counter=sequence_counter, feature_type=feature_type, data=data, ) class TunnellingFeatureResponse(_TunnellingFeature, KNXIPBodyResponse): """Representation of a KNX Tunnelling Feature response.""" SERVICE_TYPE = KNXIPServiceType.TUNNELLING_FEATURE_RESPONSE def __init__( self, communication_channel_id: int = 1, sequence_counter: int = 0, feature_type: TunnellingFeatureType = TunnellingFeatureType.SUPPORTED_EMI_TYPE, return_code: ReturnCode = ReturnCode.E_SUCCESS, data: bytes = b"", ) -> None: """Initialize TunnellingFeatureSet object.""" super().__init__( communication_channel_id=communication_channel_id, sequence_counter=sequence_counter, feature_type=feature_type, data=data, ) self.return_code = return_code def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if raw[0] != _TunnellingFeature.HEADER_LENGTH: # structure_length field raise CouldNotParseKNXIP("TunnellingFeature header has invalid length") self.communication_channel_id = raw[1] self.sequence_counter = raw[2] self.status_code = ErrorCode(raw[3]) self.feature_type = TunnellingFeatureType(raw[4]) self.return_code = ReturnCode(raw[5]) self.data = raw[6:] if self.return_code is ReturnCode.E_SUCCESS and len(self.data) == 0: # Data may be omitted by some servers when an error occurred raise CouldNotParseKNXIP("TunnellingFeature missing data.") return len(raw) def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" data_size = len(self.data) + (len(self.data) % 2) if self._has_data() else 0 return struct.pack( # Append a NUL byte if data size is uneven f"!BBBBBB{data_size}s", _TunnellingFeature.HEADER_LENGTH, self.communication_channel_id, self.sequence_counter, self.status_code.value, self.feature_type.value, self.return_code.value, self.data, ) def __repr__(self) -> str: """Return object as readable string.""" return ( "' ) xknx-3.6.0/xknx/knxip/tunnelling_request.py000066400000000000000000000043501475530762600211530ustar00rootroot00000000000000""" Module for Serialization and Deserialization of a KNX Tunnelling Request information. Tunnelling requests are used to transmit a KNX telegram within an existing KNX tunnel connection. """ from __future__ import annotations from xknx.exceptions import CouldNotParseKNXIP from .body import KNXIPBody from .knxip_enum import KNXIPServiceType class TunnellingRequest(KNXIPBody): """Representation of a KNX Tunnelling Request.""" SERVICE_TYPE = KNXIPServiceType.TUNNELLING_REQUEST HEADER_LENGTH = 4 def __init__( self, communication_channel_id: int = 1, sequence_counter: int = 0, raw_cemi: bytes = b"", ) -> None: """Initialize TunnellingRequest object.""" self.communication_channel_id = communication_channel_id self.sequence_counter = sequence_counter self.raw_cemi = raw_cemi def calculated_length(self) -> int: """Get length of KNX/IP body.""" return TunnellingRequest.HEADER_LENGTH + len(self.raw_cemi) def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if raw[0] != TunnellingRequest.HEADER_LENGTH: raise CouldNotParseKNXIP("connection header wrong length") if len(raw) < TunnellingRequest.HEADER_LENGTH: raise CouldNotParseKNXIP("connection header wrong length") self.communication_channel_id = raw[1] self.sequence_counter = raw[2] # raw[3] is reserved self.raw_cemi = raw[TunnellingRequest.HEADER_LENGTH :] return len(raw) def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" return ( bytes( ( TunnellingRequest.HEADER_LENGTH, self.communication_channel_id, self.sequence_counter, 0x00, # Reserved ) ) + self.raw_cemi ) def __repr__(self) -> str: """Return object as readable string.""" return ( "' ) xknx-3.6.0/xknx/management/000077500000000000000000000000001475530762600156335ustar00rootroot00000000000000xknx-3.6.0/xknx/management/__init__.py000066400000000000000000000002221475530762600177400ustar00rootroot00000000000000"""Package for management procedures as described in KNX-Standard 3.5.2.""" # ruff: noqa: F401 from .management import Management, P2PConnection xknx-3.6.0/xknx/management/application_layer_enum.py000066400000000000000000000040601475530762600227300ustar00rootroot00000000000000"""Enums for KNX Application Layer.""" from enum import Enum class ReturnCode(Enum): """Enum class for Generic device management Return Codes.""" ## Basic positive Return Code # The service, function or command is executed successfully, without additional information. E_SUCCESS = 0x00 ## Generic negative Return Codes # Memory cannot be accessed or only with fault(s). E_MEMORY_ERROR = 0xF1 # Requested data will not fit into a Frame supported by this server. # This shall be used for Device limitations of the maximum supported Frame length # by accessing resources (Properties, Function Properties, memory…) of the device. E_LENGTH_EXCEEDS_MAX_APDU_LENGTH = 0xF4 # Writing data beyond what is reserved for the addressed Resource. E_DATA_OVERFLOW = 0xF5 # Write value too low. Preferable to give this instead of “Value not supported”. E_DATA_MIN = 0xF6 # Write value too high. Preferable to give this instead of “Value not supported”. E_DATA_MAX = 0xF7 # The service or function is supported, but request data is not valid for this receiver. E_DATA_VOID = 0xF8 # Data could generally be written, but not possible at this time. E_TEMPORARILY_NOT_AVAILABLE = 0xF9 # Read access attempted to a “write only” service or Resource. E_ACCESS_WRITE_ONLY = 0xFA # Write access attempted to a “read only” service or Resource. E_ACCESS_READ_ONLY = 0xFB # Access denied due to authorization reasons. A_Authorize as well as KNX Security E_ACCESS_DENIED = 0xFC # Interface Object or Property is not present, or index is out of range. E_ADDRESS_VOID = 0xFD # Write access with a wrong datatype (Datapoint length). E_DATA_TYPE_CONFLICT = 0xFE # The service, function or command has failed without a closer indication of the problem. E_ERROR = 0xFF ## Generic positive Return Codes # (01h-1Fh - None proposed) ## Specific positive Return Codes # (20h-5Fh - None proposed) ## Specific negative Return Codes # (A0h-DFh - None proposed) xknx-3.6.0/xknx/management/management.py000066400000000000000000000337451475530762600203350ustar00rootroot00000000000000"""Package for management communication.""" from __future__ import annotations import asyncio from collections.abc import AsyncGenerator, AsyncIterator, Callable, Generator from contextlib import asynccontextmanager import logging import time from typing import TYPE_CHECKING from xknx.exceptions import ( CommunicationError, ConfirmationError, ManagementConnectionError, ManagementConnectionRefused, ManagementConnectionTimeout, ) from xknx.telegram import GroupAddress, IndividualAddress, Telegram from xknx.telegram.apci import APCI from xknx.telegram.tpci import ( TAck, TConnect, TDataBroadcast, TDataConnected, TDisconnect, TNak, ) from xknx.util import asyncio_timeout if TYPE_CHECKING: from xknx.xknx import XKNX logger = logging.getLogger("xknx.management") MANAGAMENT_ACK_TIMEOUT = 3 MANAGAMENT_CONNECTION_TIMEOUT = 6 class Management: """Class for management procedures as described in KNX-Standard 3.5.2.""" def __init__(self, xknx: XKNX) -> None: """Initialize Management class.""" self.xknx = xknx self._connections: dict[IndividualAddress, P2PConnection] = {} self._broadcast_contexts: set[BroadcastContext] = set() def process(self, telegram: Telegram) -> None: """Process incoming telegrams.""" if isinstance(telegram.tpci, TDataConnected): ack = Telegram( destination_address=telegram.source_address, tpci=TAck(sequence_number=telegram.tpci.sequence_number), ) self.xknx.task_registry.background( self.xknx.cemi_handler.send_telegram(ack) ) if conn := self._connections.get(telegram.source_address): conn.process(telegram) return if telegram.tpci.numbered: logger.warning( "No active point-to-point connection for received telegram: %s", telegram, ) return if isinstance(telegram.tpci, TConnect): # refuse incoming connections # TODO: handle incoming telegrams for connections # not initiated by us, connection-less and broadcast disconnect = Telegram( destination_address=telegram.source_address, tpci=TDisconnect() ) self.xknx.task_registry.background( self.xknx.cemi_handler.send_telegram(disconnect) ) return if isinstance(telegram.tpci, TDataBroadcast): for context in self._broadcast_contexts: context.queue.put_nowait(telegram) return logger.debug("Unhandled management telegram: %r", telegram) return async def connect( self, address: IndividualAddress, rate_limit: int = 20 ) -> P2PConnection: """Open a point-to-point connection to a KNX device.""" if address in self._connections: raise ManagementConnectionError(f"Connection to {address} already exists.") p2p_connection = P2PConnection(self.xknx, address, rate_limit) try: await p2p_connection.connect() except ManagementConnectionError as exc: logger.error("Establishing connection to %s failed: %s", address, exc) raise self._connections[address] = p2p_connection def remove_connection_hook() -> None: """Remove connection from management.""" try: del self._connections[address] except KeyError: logger.error("Connection to %s already closed.", address) p2p_connection.disconnect_hook = remove_connection_hook return p2p_connection async def disconnect(self, address: IndividualAddress) -> None: """Close a point-to-point connection to a KNX device.""" connection = self._connections.get(address) if connection is None: logger.error( "Closing connection to %s failed - connection does not exist.", address, ) return try: await connection.disconnect() except ManagementConnectionError as exc: logger.error("Closing connection to %s failed: %s", connection.address, exc) raise @asynccontextmanager async def connection( self, address: IndividualAddress, rate_limit: int = 20 ) -> AsyncIterator[P2PConnection]: """Provide a point-to-point connection to a KNX device.""" conn = await self.connect(address, rate_limit) try: yield conn finally: await self.disconnect(address) async def send_broadcast(self, payload: APCI) -> None: """Send a broadcast message.""" await self.xknx.cemi_handler.send_telegram( Telegram( GroupAddress("0/0/0"), tpci=TDataBroadcast(), payload=payload, ) ) @asynccontextmanager async def broadcast(self) -> AsyncIterator[BroadcastContext]: """Provide a broadcast context.""" context = BroadcastContext() self._broadcast_contexts.add(context) try: yield context finally: self._broadcast_contexts.remove(context) class BroadcastContext: """Class providing broadcast contexts.""" def __init__(self) -> None: """Initialize BroadcastContext class.""" self.queue: asyncio.Queue[Telegram] = asyncio.Queue() async def receive( self, timeout: float | None = 3, ) -> AsyncGenerator[Telegram, None]: """Receive telegrams from the broadcast context.""" try: async with asyncio_timeout(timeout): while True: try: yield await self.queue.get() except GeneratorExit: return except asyncio.TimeoutError: return class P2PConnection: """Class to manage a point-to-point connection with a KNX device.""" def __init__( self, xknx: XKNX, address: IndividualAddress, rate_limit: int = 20 ) -> None: """Initialize P2PConnection class.""" self.xknx = xknx self.address = address self.disconnect_hook: Callable[[], None] self.rate_limit = rate_limit self.sequence_number = self._sequence_number_generator() self._expected_sequence_number = 0 self._connected = False self._last_response_time: float = 0 self._ack_waiter: asyncio.Future[TAck | TNak] | None = None self._response_waiter: asyncio.Future[Telegram] = ( asyncio.get_event_loop().create_future() ) @staticmethod def _sequence_number_generator() -> Generator[int, None, None]: """Generate sequence numbers.""" seq_num = 0 while True: yield seq_num seq_num = seq_num + 1 & 0xF async def connect(self) -> None: """Connect to the KNX device.""" connect = Telegram( destination_address=self.address, source_address=self.xknx.current_address, tpci=TConnect(), ) try: await self.xknx.cemi_handler.send_telegram(connect) except ConfirmationError as exc: self._response_waiter.cancel() raise ManagementConnectionError( f"Connection to {self.address} failed: {exc}" ) from exc except CommunicationError as exc: self._response_waiter.cancel() raise ManagementConnectionError("Error while sending Telegram") from exc self._connected = True async def disconnect(self) -> None: """Disconnect from the KNX device.""" if not self._connected: self.disconnect_hook() # remove connection from management class raise ManagementConnectionRefused( "Management connection disconnected by the peer." ) self._connected = False disconnect = Telegram( destination_address=self.address, source_address=self.xknx.current_address, tpci=TDisconnect(), ) try: await self.xknx.cemi_handler.send_telegram(disconnect) except ConfirmationError as exc: raise ManagementConnectionError( f"Disconnect from {self.address} failed: {exc}" ) from exc except CommunicationError as exc: raise ManagementConnectionError("Error while sending Telegram") from exc finally: if self._ack_waiter: self._ack_waiter.cancel() self._response_waiter.cancel() self.disconnect_hook() # remove connection from management class def process(self, telegram: Telegram) -> None: """Process incoming telegrams.""" if isinstance(telegram.tpci, TDisconnect): logger.info("%s disconnected management session.", self.address) self._connected = False if self._ack_waiter: self._response_waiter.set_exception(ManagementConnectionRefused()) if not self._response_waiter.done(): self._response_waiter.set_exception(ManagementConnectionRefused()) return if isinstance(telegram.tpci, TAck | TNak): if not self._ack_waiter: logger.warning("Received unexpected ACK/NAK: %s", telegram) return self._ack_waiter.set_result(telegram.tpci) return if self._response_waiter.done(): logger.warning( "Received unexpected point-to-point telegram for %s: %s", self.address, telegram, ) return if telegram.tpci.sequence_number != self._expected_sequence_number: logger.warning( "Received unexpected sequence number: %s (expected: %s)", telegram.tpci.sequence_number, self._expected_sequence_number, ) return self._response_waiter.set_result(telegram) self._expected_sequence_number = self._expected_sequence_number + 1 & 0xF async def _send_data(self, payload: APCI) -> None: """ Send a payload to the KNX device. A response has to be processed by `_receive` before sending the next telegram. """ if not self._connected: raise ManagementConnectionRefused( "Management connection disconnected by the peer." ) self._ack_waiter = asyncio.get_event_loop().create_future() seq_num = next(self.sequence_number) telegram = Telegram( destination_address=self.address, source_address=self.xknx.current_address, payload=payload, tpci=TDataConnected(sequence_number=seq_num), ) try: await self.xknx.cemi_handler.send_telegram(telegram) async with asyncio_timeout(MANAGAMENT_ACK_TIMEOUT): ack = await self._ack_waiter except asyncio.TimeoutError: logger.info( "%s: timeout while waiting for ACK. Resending Telegram.", self.address ) # resend once after 3 seconds without ACK # on timeout the Future is cancelled so create a new self._ack_waiter = asyncio.get_event_loop().create_future() await self.xknx.cemi_handler.send_telegram(telegram) try: async with asyncio_timeout(MANAGAMENT_ACK_TIMEOUT): ack = await self._ack_waiter except asyncio.TimeoutError: raise ManagementConnectionTimeout( "No ACK received for repeated telegram." ) from None except ConfirmationError as exc: raise ManagementConnectionError( f"Error while sending Telegram: {exc}" ) from exc except CommunicationError as exc: raise ManagementConnectionError("Error while sending Telegram") from exc finally: self._ack_waiter = None if isinstance(ack, TNak): raise ManagementConnectionError( f"Received no_ack from sending Telegram: {telegram}" ) if ack.sequence_number != seq_num: raise ManagementConnectionError( f"Ack sequence number {ack.sequence_number} does not match request sequence number of {telegram}" ) async def _receive(self, expected_payload: type[APCI] | None) -> Telegram: """Wait for a telegram from the KNX device.""" try: async with asyncio_timeout(MANAGAMENT_CONNECTION_TIMEOUT): telegram = await self._response_waiter except asyncio.TimeoutError: raise ManagementConnectionTimeout( f"Timeout while waiting for {expected_payload}" ) from None finally: # set up new Future for the next request self._response_waiter = asyncio.get_event_loop().create_future() if expected_payload and not isinstance(telegram.payload, expected_payload): raise ManagementConnectionError( f"Received unexpected telegram: {telegram.payload}" ) return telegram async def request(self, payload: APCI, expected: type[APCI] | None) -> Telegram: """Send a payload to the KNX device and wait for the response.""" if not self._connected: raise ManagementConnectionRefused( "Management connection disconnected by the peer." ) if self.rate_limit: # time in seconds since the last request operation time_diff = time.time() - self._last_response_time wait_time = 1 / self.rate_limit if time_diff < wait_time: await asyncio.sleep(wait_time - time_diff) await self._send_data(payload) response = await self._receive(expected) self._last_response_time = time.time() return response xknx-3.6.0/xknx/management/procedures.py000066400000000000000000000216751475530762600203730ustar00rootroot00000000000000"""Package for management procedures as described in KNX-Standard 3.5.2.""" from __future__ import annotations import logging from typing import TYPE_CHECKING from xknx.exceptions import ( ManagementConnectionError, ManagementConnectionRefused, ManagementConnectionTimeout, ) from xknx.telegram import Telegram, apci, tpci from xknx.telegram.address import ( IndividualAddress, IndividualAddressableType, ) if TYPE_CHECKING: from xknx import XKNX logger = logging.getLogger("xknx.management.procedures") async def dm_restart(xknx: XKNX, individual_address: IndividualAddressableType) -> None: """ Restart the device. :param xknx: XKNX object :param individual_address: address of device to reset """ async with xknx.management.connection( address=IndividualAddress(individual_address) ) as connection: logger.debug("Requesting a Basic Restart of %s.", individual_address) # A_Restart will not be ACKed by the device, so it is manually sent to avoid timeout and retry seq_num = next(connection.sequence_number) telegram = Telegram( destination_address=connection.address, source_address=xknx.current_address, payload=apci.Restart(), tpci=tpci.TDataConnected(sequence_number=seq_num), ) await xknx.cemi_handler.send_telegram(telegram) async def nm_individual_address_check( xknx: XKNX, individual_address: IndividualAddressableType ) -> bool: """ Check if the individual address is occupied on the network. :param xknx: XKNX object :param individual_address: address to check """ try: async with xknx.management.connection( address=IndividualAddress(individual_address) ) as connection: try: response = await connection.request( payload=apci.DeviceDescriptorRead(descriptor=0), expected=apci.DeviceDescriptorResponse, ) except ManagementConnectionTimeout as ex: # if nothing is received (-> timeout) IA is free logger.debug("No device answered to connection attempt. %s", ex) return False if isinstance(response.payload, apci.DeviceDescriptorResponse): # if response is received IA is occupied logger.debug("Device found at %s", individual_address) return True return False except ManagementConnectionRefused as ex: # if Disconnect is received immediately, IA is occupied logger.debug("Device does not support transport layer connections. %s", ex) return True async def nm_individual_address_read( xknx: XKNX, timeout: float | None = 3, raise_if_multiple: bool = False, ) -> list[IndividualAddress]: """ Request individual addresses of all devices that are in programming mode. :param xknx: XKNX object :param timeout: specifies the timeout in seconds, the KNX specification requires a timeout of 3s :param raise_if_multiple: if true, ManagementConnectionError is raised when multiple devices are in programming mode :returns: list of individual address of devices in programming mode """ addresses = [] # initialize queue or event handler gathering broadcasts async with xknx.management.broadcast() as bc_context: await xknx.management.send_broadcast(apci.IndividualAddressRead()) async for result in bc_context.receive(timeout=timeout): if isinstance(result.payload, apci.IndividualAddressResponse): addresses.append(result.source_address) if raise_if_multiple and (len(addresses) > 1): raise ManagementConnectionError( "More than one KNX device is in programming mode." ) return addresses async def nm_individual_address_write( xknx: XKNX, individual_address: IndividualAddressableType ) -> None: """ Write the individual address of a single device in programming mode. :param xknx: XKNX object :param individual_address: address to be written to KNX device """ logger.debug("Writing individual address %s to device.", individual_address) # check if the address is already occupied on the network individual_address = IndividualAddress(individual_address) address_found = await nm_individual_address_check(xknx, individual_address) if address_found: logger.debug( "Individual address %s already present on the bus", individual_address ) # check which devices are in programming mode dev_pgm_mode = await nm_individual_address_read( xknx, raise_if_multiple=True ) # raises exception if more than one device in programming mode if not dev_pgm_mode: logger.debug("No device in programming mode detected.") raise ManagementConnectionError("No device in programming mode detected.") # check if new and received addresses match if address_found: if individual_address != dev_pgm_mode[0]: logger.debug( "Device with address %s found and it is not in programming mode. Exiting to prevent address conflict.", individual_address, ) raise ManagementConnectionError( f"A device was found with {individual_address}, cannot continue with programming." ) # device in programming mode's address matches address that we want to write, so we can abort the operation safely logger.debug("Device already has requested address, no write operation needed.") else: await xknx.management.send_broadcast( payload=apci.IndividualAddressWrite(address=individual_address), ) logger.debug("Wrote new address %s to device.", individual_address) async with xknx.management.connection( address=IndividualAddress(individual_address) ) as connection: logger.debug( "Checking if device exists at %s and restarting it.", individual_address ) try: await connection.request( payload=apci.DeviceDescriptorRead(descriptor=0), expected=apci.DeviceDescriptorResponse, ) except ManagementConnectionTimeout as ex: # if nothing is received (-> timeout) IA is free raise ManagementConnectionError( f"No device answered to connection attempt after write address operation. {ex}" ) from None logger.debug("Restating device, exiting programming mode.") # A_Restart will not be ACKed by the device, so it is manually sent to avoid timeout and retry seq_num = next(connection.sequence_number) telegram = Telegram( destination_address=connection.address, source_address=xknx.current_address, payload=apci.Restart(), tpci=tpci.TDataConnected(sequence_number=seq_num), ) await xknx.cemi_handler.send_telegram(telegram) # for backwards compatibility nm_invididual_address_write = nm_individual_address_write async def nm_individual_address_serial_number_read( xknx: XKNX, serial: bytes, timeout: float = 3, ) -> IndividualAddress | None: """Read individual address from device with specified serial number.""" # initialize queue or event handler gathering broadcasts async with xknx.management.broadcast() as bc_context: await xknx.management.send_broadcast( payload=apci.IndividualAddressSerialRead(serial=serial) ) async for result in bc_context.receive(timeout=timeout): if ( isinstance(result.payload, apci.IndividualAddressSerialResponse) and result.payload.serial == serial ): return result.source_address return None async def nm_individual_address_serial_number_write( xknx: XKNX, serial: bytes, individual_address: IndividualAddressableType ) -> None: """Write individual address to device with specified serial number.""" individual_address = IndividualAddress(individual_address) await xknx.management.send_broadcast( payload=apci.IndividualAddressSerialWrite( address=individual_address, serial=serial, ) ) logger.debug( "Wrote new address %s to device with serial number %s.", individual_address, serial, ) address = await nm_individual_address_serial_number_read(xknx=xknx, serial=serial) if address is None: raise ManagementConnectionError(f"No reply received from {serial!r}.") if address != individual_address: raise ManagementConnectionError( f"Failed to write serial address {individual_address} to device with serial {serial!r}. Detected {address}" ) logger.debug( "New address %s validated on device with serial number %s.", individual_address, serial, ) xknx-3.6.0/xknx/profile/000077500000000000000000000000001475530762600151575ustar00rootroot00000000000000xknx-3.6.0/xknx/profile/__init__.py000066400000000000000000000011301475530762600172630ustar00rootroot00000000000000"""Module for Profile and resource management .""" # ruff: noqa: F401 from .const import ( ResourceDevicePropertyId, ResourceEModeChannelPropertyId, ResourceEModeDevicePropertyId, ResourceGenericPropertyId, ResourceGroupObjectTablePropertyId, ResourceKNXNETIPPropertyId, ResourceLTERoutingTablePropertyId, ResourceObjectType, ResourcePollingMasterInterfacePropertyId, ResourceProgramPropertyId, ResourcePropertyId, ResourceRFMediumPropertyId, ResourceRouterPropertyId, ResourceSecureInterfacePropertyId, ResourceTextCataloguePropertyId, ) xknx-3.6.0/xknx/profile/const.py000066400000000000000000000321121475530762600166560ustar00rootroot00000000000000"""Constants for Profile resources.""" from enum import IntEnum class ResourceObjectType(IntEnum): """Enum class for Resource Object Types.""" OBJECT_DEVICE = 0x0000 OBJECT_ADDRESS_TABLE = 0x0001 OBJECT_ASSOCIATION_TABLE = 0x0002 OBJECT_APPLICATION_PROGRAM = 0x0003 OBJECT_INTERFACE_PROGRAM = 0x0004 OBJECT_KNX_OBJECT_ASSOCIATION_TABLE = 0x0005 OBJECT_ROUTER = 0x0006 OBJECT_LTE_ADDRESS_ROUTING_TABLE = 0x0007 OBJECT_CEMI_SERVER = 0x0008 OBJECT_GROUP_OBJECT_TABLE = 0x0009 OBJECT_POLLING_MASTER = 0x000A OBJECT_KNXNETIP_PARAMETER = 0x000B OBJECT_FILESERVER = 0x000D OBJECT_E_MODE_CHANNEL = 0x000E OBJECT_ADJUSTED_E_MODE_CHANNEL = 0x000F OBJECT_TEXT_CATALOGUE = 0x0010 OBJECT_SECURITY = 0x0011 OBJECT_E_MODE_DEVICE = 0x0012 OBJECT_RF_MEDIUM_OBJECT = 0x0013 ResourcePropertyId = IntEnum class ResourceGenericPropertyId(ResourcePropertyId): """Enum base class for Property Ids.""" PID_OBJECT_TYPE = 1 # PDT_UNSIGNED_INT PID_OBJECT_NAME = 2 # PDT_UNSIGNED_CHAR[] PID_SEMAPHOR = 3 PID_GROUP_OBJECT_REFERENCE = 4 PID_LOAD_STATE_CONTROL = 5 # PDT_CONTROL PID_RUN_STATE_CONTROL = 6 # PDT_CONTROL PID_TABLE_REFERENCE = 7 # PDT_UNSIGNED_LONG PID_SERVICE_CONTROL = 8 # PDT_UNSIGNED_INT PID_FIRMWARE_REVISION = 9 # PDT_UNSIGNED_CHAR PID_SERVICES_SUPPORTED = 10 PID_SERIAL_NUMBER = 11 # PDT_GENERIC_06 PID_MANUFACTURER_ID = 12 # PDT_UNSIGNED_INT PID_PROGRAM_VERSION = 13 # PDT_GENERIC_05 PID_DEVICE_CONTROL = 14 # PDT_BITSET8/PDT_GENERIC_01 PID_ORDER_INFO = 15 # PDT_GENERIC_10 PID_PEI_TYPE = 16 # PDT_UNSIGNED_CHAR PID_PORT_CONFIGURATION = 17 # PDT_UNSIGNED_CHAR PID_POLL_GROUP_SETTINGS = 18 # PDT_POLL_GROUP_SETTINGS PID_MANUFACTURER_DATA = 19 # PDT_GENERIC_04 PID_ENABLE = 20 PID_DESCRIPTION = 21 # PDT_UNSIGNED_CHAR[] PID_TABLE = 23 PID_ENROL = 24 # PDT_FUNCTION PID_VERSION = 25 # PDT_VERSION/PDT_GENERIC_02 PID_GROUP_OBJECT_LINK = 26 # PDT_FUNCTION PID_MCB_TABLE = 27 # PDT_GENERIC_08[] PID_ERROR_CODE = 28 # PDT_ENUM8/PDT_UNSIGNED_CHAR PID_OBJECT_INDEX = 29 # PDT_UNSIGNED_CHAR PID_DOWNLOAD_COUNTER = 30 # PDT_UNSIGNED_INT class ResourceDevicePropertyId(ResourcePropertyId): """Extended class for Device Object (Type 0) Property Ids.""" PID_ROUTING_COUNT = 51 # PDT_UNSIGNED_CHAR PID_MAX_RETRY_COUNT = 52 # PDT_GENERIC_01 PID_ERROR_FLAGS = 53 # PDT_UNSIGNED_CHAR PID_PROGMODE = 54 # PDT_GENERIC_01 PID_PRODUCT_ID = 55 # PDT_GENERIC_10 PID_MAX_APDU_LENGTH = 56 # PDT_UNSIGNED_INT PID_SUBNET_ADDR = 57 # PDT_UNSIGNED_CHAR PID_DEVICE_ADDR = 58 # PDT_UNSIGNED_CHAR PID_PB_CONFIG = 59 # PDT_GENERIC_04 PID_ADDR_REPORT = 60 # PDT_GENERIC_06 PID_ADDR_CHECK = 61 # PDT_GENERIC_01 PID_OBJECT_VALUE = 62 # PDT_FUNCTION PID_OBJECTLINK = 63 # PDT_FUNCTION PID_APPLICATION = 64 # PDT_FUNCTION PID_PARAMETER = 65 # PDT_FUNCTION PID_OBJECTADDRESS = 66 # PDT_FUNCTION PID_PSU_TYPE = 67 # PDT_UNSIGNED_INT PID_PSU_STATUS = 68 # PDT_BINARY_INFORMATION PID_PSU_ENABLE = 69 # PDT_ENUM8 PID_DOMAIN_ADDRESS = 70 # PDT_UNSIGNED_INT PID_IO_LIST = 71 # PDT_UNSIGNED_INT PID_MGT_DESCRIPTOR_01 = 72 # PDT_GENERIC_10 PID_PL110_PARAM = 73 # PDT_GENERIC_01 PID_RF_REPEAT_COUNTER = 74 # PDT_UNSIGNED_CHAR PID_RECEIVE_BLOCK_TABLE = 75 # PDT_UNSIGNED_CHAR[16] PID_RANDOM_PAUSE_TABLE = 76 # PDT_UNSIGNED_CHAR[12] PID_RECEIVE_BLOCK_NR = 77 # PDT_UNSIGNED_CHAR PID_HARDWARE_TYPE = 78 # PDT_GENERIC_06 PID_RETRANSMITTER_NUMBER = 79 # PDT_UNSIGNED_CHAR PID_SERIAL_NR_TABLE = 80 # PDT_GENERIC_06[] PID_BIBAT_MASTER_ADDRESS = 81 # PDT_UNSIGNED_INT PID_RF_DOMAIN_ADDRESS = 82 # PDT_GENERIC_06 PID_DEVICE_DESCRIPTOR = 83 # PDT_GENERIC_02 PID_METERING_FILTER_TABLE = 84 # PDT_GENERIC_08[] PID_GROUP_TELEGR_RATE_LIMIT_TIME_BASE = 85 # PDT_UNSIGNED_INT PID_GROUP_TELEGR_RATE_LIMIT_NO_OF_TELEGR = 86 # PDT_UNSIGNED_INT PID_FEATURES_SUPPORTED = 89 # PDT_GENERIC_10 PID_CHANNEL_01_PARAM = 101 # PDT_GENERIC_01[] PID_CHANNEL_02_PARAM = 102 # PDT_GENERIC_01[] PID_CHANNEL_03_PARAM = 103 # PDT_GENERIC_01[] PID_CHANNEL_04_PARAM = 104 # PDT_GENERIC_01[] PID_CHANNEL_05_PARAM = 105 # PDT_GENERIC_01[] PID_CHANNEL_06_PARAM = 106 # PDT_GENERIC_01[] PID_CHANNEL_07_PARAM = 107 # PDT_GENERIC_01[] PID_CHANNEL_08_PARAM = 108 # PDT_GENERIC_01[] PID_CHANNEL_09_PARAM = 109 # PDT_GENERIC_01[] PID_CHANNEL_10_PARAM = 110 # PDT_GENERIC_01[] PID_CHANNEL_11_PARAM = 111 # PDT_GENERIC_01[] PID_CHANNEL_12_PARAM = 112 # PDT_GENERIC_01[] PID_CHANNEL_13_PARAM = 113 # PDT_GENERIC_01[] PID_CHANNEL_14_PARAM = 114 # PDT_GENERIC_01[] PID_CHANNEL_15_PARAM = 115 # PDT_GENERIC_01[] PID_CHANNEL_16_PARAM = 116 # PDT_GENERIC_01[] PID_CHANNEL_17_PARAM = 117 # PDT_GENERIC_01[] PID_CHANNEL_18_PARAM = 118 # PDT_GENERIC_01[] PID_CHANNEL_19_PARAM = 119 # PDT_GENERIC_01[] PID_CHANNEL_20_PARAM = 120 # PDT_GENERIC_01[] PID_CHANNEL_21_PARAM = 121 # PDT_GENERIC_01[] PID_CHANNEL_22_PARAM = 122 # PDT_GENERIC_01[] PID_CHANNEL_23_PARAM = 123 # PDT_GENERIC_01[] PID_CHANNEL_24_PARAM = 124 # PDT_GENERIC_01[] PID_CHANNEL_25_PARAM = 125 # PDT_GENERIC_01[] PID_CHANNEL_26_PARAM = 126 # PDT_GENERIC_01[] PID_CHANNEL_27_PARAM = 127 # PDT_GENERIC_01[] PID_CHANNEL_28_PARAM = 128 # PDT_GENERIC_01[] PID_CHANNEL_29_PARAM = 129 # PDT_GENERIC_01[] PID_CHANNEL_30_PARAM = 130 # PDT_GENERIC_01[] PID_CHANNEL_31_PARAM = 131 # PDT_GENERIC_01[] PID_CHANNEL_32_PARAM = 132 # PDT_GENERIC_01[] class ResourceProgramPropertyId(ResourcePropertyId): """Extended class for Program Object (Type 3 & 4) Property Ids.""" PID_OPERATION_MODE = 51 # PDT_FUNCTION/PDT_VARIABLE_LENGTH class ResourceRouterPropertyId(ResourcePropertyId): """Extended class for Router Object (Type 6) Property Ids.""" PID_MEDIUM_STATUS = 51 # PDT_GENERIC_01 PID_MAIN_LCCONFIG = 52 PID_SUB_LCCONFIG = 53 PID_MAIN_LCGRPCONFIG = 54 PID_SUB_LCGRPCONFIG = 55 PID_ROUTETABLE_CONTROL = 56 PID_COUPL_SERV_CONTROL = 57 PID_MAX_APDU_LENGTH_ROUTER = 58 # PDT_UNSIGNED_INT PID_L2_COUPLER_TYPE = 59 # PDT_BITSET8 PID_HOP_COUNT = 61 # PDT_UNSIGNED_INT PID_MEDIUM = 63 # PDT_ENUM8 PID_FILTER_TABLE_USE = 67 # PDT_BINARY_INFORMATION PID_PL110_SBC_CONTROL = 104 # PDT_FUNCTION PID_RF_SBC_CONTROL = 112 # PDT_FUNCTION PID_IP_SBC_CONTROL = 120 # PDT_FUNCTION class ResourceLTERoutingTablePropertyId(ResourcePropertyId): """Extended class for LTE Address Routing Table Object (Object Type 7) Property Ids.""" PID_LTE_ROUTESELECT = 51 PID_LTE_ROUTETABLE = 52 # PDT_GENERIC_05 class ResourceCEMIServerPropertyId(ResourcePropertyId): """Extended class for cEMI Server Object (Object Type 8) Property Ids.""" PID_MEDIUM_TYPE = 51 # PDT_BITSET16 PID_COMM_MODE = 52 # PDT_ENUM8 PID_MEDIUM_AVAILABILITY = 53 # PDT_BITSET16 PID_ADD_INFO_TYPES = 54 # PDT_ENUM8[] PID_TIME_BASE = 55 # PDT_UNSIGNED_INT PID_TRANSP_ENABLE = 56 # PDT_BINARY_INFORMATION PID_CEMI_SERVER_DEVICE_ADDRESS = 58 # PDT_UNSIGNED_CHAR PID_BIBAT_NEXTBLOCK = 59 # PDT_UNSIGNED_CHAR PID_RF_MODE_SELECT = 60 # PDT_ENUM8/PDT_UNSIGNED_CHAR PID_RF_MODE_SUPPORT = 61 # PDT_BITSET8/PDT_GENERIC_01 PID_RF_FILTERING_MODE_SELECT = 62 # PDT_ENUM8/PDT_UNSIGNED_CHAR PID_RF_FILTERING_MODE_SUPPORT = 63 # PDT_BITSET8/PDT_GENERIC_01 PID_COMM_MODES_SUPPORTED = 64 # PDT_BITSET16 PID_FILTERING_MODE_SUPPORT = 65 # PDT_BITSET16/PDT_GENERIC_02 PID_FILTERING_MODE_SELECT = 66 # PDT_BITSET16/PDT_GENERIC_02 PID_MAX_INTERFACE_APDU_LENGTH = 68 # PDT_UNSIGNED_INT PID_MAX_LOCAL_APDU_LENGTH = 69 # PDT_UNSIGNED_INT class ResourceGroupObjectTablePropertyId(ResourcePropertyId): """Extended class for Group Object Table Object (Object Type 9) Property Ids.""" PID_GO_DIAGNOSTICS = 66 # PDT_FUNCTION class ResourcePollingMasterInterfacePropertyId(ResourcePropertyId): """Extended class for Polling Master Interface Object (Object Type 10) Property Ids.""" PID_POLLING_STATE = 51 PID_POLLING_SLAVE_ADDR = 52 PID_POLL_CYCLE = 53 class ResourceKNXNETIPPropertyId(ResourcePropertyId): """Extended class for KNXNet/IP Object (Object Type 11) Property Ids.""" PID_PROJECT_INSTALLATION_ID = 51 # PDT_UNSIGNED_INT PID_KNX_INDIVIDUAL_ADDRESS = 52 # PDT_UNSIGNED_INT PID_ADDITIONAL_INDIVIDUAL_ADDRESSES = 53 # PDT_UNSIGNED_INT[] PID_CURRENT_IP_ASSIGNMENT_METHOD = 54 # PDT_UNSIGNED_CHAR PID_IP_ASSIGNMENT_METHOD = 55 # PDT_UNSIGNED_CHAR PID_IP_CAPABILITIES = 56 # PDT_BITSET8 PID_CURRENT_IP_ADDRESS = 57 # PDT_UNSIGNED_LONG PID_CURRENT_SUBNET_MASK = 58 # PDT_UNSIGNED_LONG PID_CURRENT_DEFAULT_GATEWAY = 59 # PDT_UNSIGNED_LONG PID_IP_ADDRESS = 60 # PDT_UNSIGNED_LONG PID_SUBNET_MASK = 61 # PDT_UNSIGNED_LONG PID_DEFAULT_GATEWAY = 62 # PDT_UNSIGNED_LONG PID_DHCP_BOOTP_SERVER = 63 # PDT_UNSIGNED_LONG PID_MAC_ADDRESS = 64 # PDT_GENERIC_06 PID_SYSTEM_SETUP_MULTICAST_ADDRESS = 65 # PDT_UNSIGNED_LONG PID_ROUTING_MULTICAST_ADDRESS = 66 # PDT_UNSIGNED_LONG PID_TTL = 67 # PDT_UNSIGNED_CHAR PID_KNXNETIP_DEVICE_CAPABILITIES = 68 # PDT_BITSET16 PID_KNXNETIP_DEVICE_STATE = 69 # PDT_UNSIGNED_CHAR PID_KNXNETIP_ROUTING_CAPABILITIES = 70 # PDT_UNSIGNED_CHAR PID_PRIORITY_FIFO_ENABLED = 71 # PDT_BINARY_INFORMATION PID_QUEUE_OVERFLOW_TO_IP = 72 # PDT_UNSIGNED_INT PID_QUEUE_OVERFLOW_TO_KNX = 73 # PDT_UNSIGNED_INT PID_MSG_TRANSMIT_TO_IP = 74 # PDT_UNSIGNED_LONG PID_MSG_TRANSMIT_TO_KNX = 75 # PDT_UNSIGNED_LONG PID_FRIENDLY_NAME = 76 # PDT_UNSIGNED_CHAR[30] PID_ROUTING_BUSY_WAIT_TIME = 78 # PDT_UNSIGNED_INT PID_TUNNELLING_ADDRESSES = 79 # PDT_UNSIGNED_CHAR[] class ResourceEModeChannelPropertyId(ResourcePropertyId): """Extended class for E-Mode Channel Object (Object Type 14 & 15) Property Ids.""" PID_CHAN_NUMBER = 51 # PDT_UNSIGNED_INT PID_CHAN_CODE = 52 # PDT_UNSIGNED_INT PID_CHAN_FLAGS = 53 # PDT_GENERIC_02 PID_CHAN_FB_LIST = 54 # PDT_UNSIGNED_INT[] PID_CHAN_ADJ_LISTS = 55 # PDT_UNSIGNED_CHAR[] PID_GO_CCODES_LIST = 61 # PDT_GENERIC_08[] PID_GO_CFLAGS_LIST = 62 # PDT_BITSET16[] PID_OBJECTLINK = 63 # PDT_FUNCTION PID_GO_SUBUNIT = 64 # PDT_UNSIGNED_CHAR[] PID_GO_NAME_LIST = 65 # PDT_GENERIC_10[]/PDT_REFERENCE[] PID_GO_DIAGNOSTICS = 66 # PDT_FUNCTION PID_PARAM_TYPES = 70 # PDT_GENERIC_10[] PID_PARAM_FLAGS = 71 # PDT_BITSET16[] PID_PARAM_NAMES = 72 # PDT_GENERIC_10[]/PDT_REFERENCE[] PID_PARAM_UNITS = 73 # PDT_GENERIC_10[]/PDT_REFERENCE[] PID_PARAM_VALUES = ( 79 # PDT_GENERIC_01[]/PDT_GENERIC_02[]/PDT_GENERIC_04[]/PDT_GENERIC_010[] ) class ResourceTextCataloguePropertyId(ResourcePropertyId): """Extended class for Text Catalogue Object (Object Type 16) Property Ids.""" PID_LOCALE_LIST = 51 # PDT_GENERIC_04[] PID_LOCALE_SELECTION = 52 # PDT_GENERIC_04 PID_ACTIVE_LOCALE = 53 # PDT_GENERIC_04 # PID_STRING_001 to PID_STRING_141 is [60-200] PID_STRING_001 = 60 # PDT_UTF_8 class ResourceSecureInterfacePropertyId(ResourcePropertyId): """Extended class for Secure Interface Object (Object Type 17) Property Ids.""" PID_SECURITY_MODE = 51 # PDT_FUNCTION PID_P2P_KEY_TABLE = 52 # PDT_GENERIC_20[] PID_GRP_KEY_TABLE = 53 # PDT_GENERIC_18[] PID_SECURITY_INDIVIDUAL_ADDRESS_TABLE = 54 # PDT_GENERIC_08[] PID_SECURITY_FAILURES_LOG = 55 # PDT_FUNCTION PID_TOOL_KEY = 56 # PDT_GENERIC_16 PID_SECURITY_REPORT = 57 # PDT_BITSET8 PID_SECURITY_REPORT_CONTROL = 58 # PDT_BINARY_INFORMATION PID_SEQUENCE_NUMBER_SENDING = 59 # PDT_GENERIC_06 PID_ZONE_KEY_TABLE = 60 # PDT_GENERIC_19[] PID_GO_SECURITY_FLAGS = 61 # PDT_GENERIC_01[] class ResourceEModeDevicePropertyId(ResourcePropertyId): """Extended class for E-Mode Device Object (Object Type 18) Property Ids.""" PID_LOCALISATION_MODE = 60 # PDT_BINARY_INFORMATION/PDT_UNSIGNED_CHAR PID_LOCALISATION_REPORT = 61 # PDT_UNSIGNED_INT PID_LOCALISATION_COMMAND = 62 # PDT_GENERIC_03 class ResourceRFMediumPropertyId(ResourcePropertyId): """Extended class for RF Medium Object (Object Type 19) Property Ids.""" PID_RF_MULTI_TYPE = 51 # PDT_BITSET8 PID_RF_MULTI_PHYSICAL_FEATURES = 52 # PDT_BITSET8 PID_RF_MULTI_CALL_CHANNEL = 53 # PDT_GENERIC_01 PID_RF_MULTI_OBJECT_LINK = 54 # PDT_FUNCTION PID_RF_MULTI_EXT_GA_REPEATED = 55 # PDT_FUNCTION PID_RF_RETRANSMITTER = 57 # PDT_BINARY_INFORMATION PID_RF_BIDIR_TIMEOUT = 60 # PDT_FUNCTION PID_RF_DIAG_SA_FILTER_TABLE = 61 # PDT_GENERIC_03[] PID_RF_DIAG_QUALITY_TABLE = 62 # PDT_GENERIC_04[] PID_RF_DIAG_PROBE = 63 # PDT_FUNCTION PID_TRANSMISSION_MODE = 70 # PDT_ENUM8 PID_RECEPTION_MODE = 71 # PDT_ENUM8 PID_TEST_SIGNAL = 72 # PDT_GENERIC_02 PID_FAST_ACK = 73 # PDT_GENERIC_02[] PID_FAST_ACK_ACTIVATE = 74 # PDT_BINARY_INFORMATION PID_RF_TYPES_SUPPORTED = 75 # PDT_BITSET8/PDT_GENERIC_01 xknx-3.6.0/xknx/py.typed000066400000000000000000000000001475530762600152040ustar00rootroot00000000000000xknx-3.6.0/xknx/remote_value/000077500000000000000000000000001475530762600162065ustar00rootroot00000000000000xknx-3.6.0/xknx/remote_value/__init__.py000066400000000000000000000035411475530762600203220ustar00rootroot00000000000000"""Module for handling values on the KNX bus.""" from .remote_value import GroupAddressesType, RemoteValue from .remote_value_by_length import RemoteValueByLength from .remote_value_climate_mode import ( RemoteValueBinaryHeatCool, RemoteValueBinaryOperationMode, RemoteValueControllerMode, RemoteValueOperationMode, ) from .remote_value_color_rgb import RemoteValueColorRGB from .remote_value_color_rgbw import RemoteValueColorRGBW from .remote_value_color_xyy import RemoteValueColorXYY from .remote_value_datetime import RemoteValueDate, RemoteValueDateTime, RemoteValueTime from .remote_value_dpt_value_1_ucount import RemoteValueDptValue1Ucount from .remote_value_raw import RemoteValueRaw from .remote_value_scaling import RemoteValueScaling from .remote_value_scene_number import RemoteValueSceneNumber from .remote_value_sensor import ( RemoteValueNumeric, RemoteValueSensor, RemoteValueString, ) from .remote_value_setpoint_shift import RemoteValueSetpointShift from .remote_value_step import RemoteValueStep from .remote_value_switch import RemoteValueSwitch from .remote_value_temp import RemoteValueTemp from .remote_value_updown import RemoteValueUpDown __all__ = [ "GroupAddressesType", "RemoteValue", "RemoteValueBinaryHeatCool", "RemoteValueBinaryOperationMode", "RemoteValueByLength", "RemoteValueColorRGB", "RemoteValueColorRGBW", "RemoteValueColorXYY", "RemoteValueControllerMode", "RemoteValueDate", "RemoteValueDateTime", "RemoteValueDptValue1Ucount", "RemoteValueNumeric", "RemoteValueOperationMode", "RemoteValueRaw", "RemoteValueScaling", "RemoteValueSceneNumber", "RemoteValueSensor", "RemoteValueSetpointShift", "RemoteValueStep", "RemoteValueString", "RemoteValueSwitch", "RemoteValueTemp", "RemoteValueTime", "RemoteValueUpDown", ] xknx-3.6.0/xknx/remote_value/remote_value.py000066400000000000000000000272431475530762600212570ustar00rootroot00000000000000""" Module for managing a remote value on the KNX bus. Remote value can be : - a group address for writing a KNX value, - a group address for reading a KNX value, - or a group of both representing the same value. """ from __future__ import annotations from abc import ABC from collections.abc import Callable, Iterator import logging from typing import TYPE_CHECKING, Generic, TypeVar, Union from xknx.dpt import DPTArray, DPTBase, DPTBinary from xknx.exceptions import ConversionError, CouldNotParseTelegram from xknx.telegram import GroupAddress, Telegram from xknx.telegram.address import ( DeviceGroupAddress, InternalGroupAddress, parse_device_group_address, ) from xknx.telegram.apci import GroupValueResponse, GroupValueWrite if TYPE_CHECKING: from xknx.telegram.address import DeviceAddressableType from xknx.xknx import XKNX logger = logging.getLogger("xknx.log") GroupAddressesType = Union[ "DeviceAddressableType", list[Union["DeviceAddressableType", None]], None ] ValueT = TypeVar("ValueT") RVCallbackType = Callable[[ValueT], None] class RemoteValue(ABC, Generic[ValueT]): """Class for managing remote knx value.""" dpt_class: type[DPTBase] | None = None def __init__( self, xknx: XKNX, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, sync_state: None | bool | int | float | str = None, device_name: str | None = None, feature_name: str | None = None, after_update_cb: RVCallbackType[ValueT] | None = None, ) -> None: """Initialize RemoteValue class.""" self.xknx: XKNX = xknx self.passive_group_addresses: list[DeviceGroupAddress] = [] def unpack_group_addresses( addresses: GroupAddressesType, ) -> DeviceGroupAddress | None: """Parse group addresses and assign passive addresses when given.""" if addresses is None: return None if not isinstance(addresses, list): return parse_device_group_address(addresses) if not addresses: # empty list return None active = addresses[0] passive = [ parse_device_group_address(addr) for addr in addresses[1:] if addr is not None ] self.passive_group_addresses.extend(passive) return parse_device_group_address(active) if active is not None else None self.group_address = unpack_group_addresses(group_address) self.group_address_state = unpack_group_addresses(group_address_state) self.device_name: str = "Unknown" if device_name is None else device_name self.feature_name: str = "Unknown" if feature_name is None else feature_name self._value: ValueT | None = None self.telegram: Telegram | None = None self.after_update_cb: RVCallbackType[ValueT] | None = after_update_cb self._sync_state = sync_state def register_state_updater(self) -> None: """Register RemoteValue for state updates.""" sync_state = ( self._sync_state if self._sync_state is not None else self.xknx.state_updater.default_use_updater ) if sync_state and self.group_address_state: self.xknx.state_updater.register_remote_value( self, tracker_options=sync_state ) def unregister_state_updater(self) -> None: """Unregister RemoteValue from state updates.""" try: self.xknx.state_updater.unregister_remote_value(self) except KeyError: # KeyError if it was never added to StateUpdater pass @property def value(self) -> ValueT | None: """Get current value.""" return self._value @value.setter def value(self, value: ValueT | None) -> None: """Set new value without creating a Telegram or calling after_update_cb. Raises ConversionError on invalid value.""" if value is not None: # raises ConversionError on invalid value self.to_knx(value) self._value = value def update_value(self, value: ValueT) -> None: """Set new value without creating a Telegram. Calls after_update_cb. Raises ConversionError on invalid value.""" self.value = value if self.after_update_cb is not None: self.after_update_cb(value) @property def initialized(self) -> bool: """Evaluate if remote value is initialized with group address.""" return bool( self.group_address_state or self.group_address or self.passive_group_addresses ) @property def readable(self) -> bool: """Evaluate if remote value should be read from bus.""" return bool(self.group_address_state) @property def writable(self) -> bool: """Evaluate if remote value has a group_address set.""" return bool(self.group_address) def has_group_address(self, group_address: DeviceGroupAddress) -> bool: """Test if device has given group address.""" def remote_value_addresses() -> Iterator[DeviceGroupAddress | None]: """Yield all group_addresses.""" yield self.group_address yield self.group_address_state yield from self.passive_group_addresses return group_address in remote_value_addresses() def from_knx(self, payload: DPTArray | DPTBinary) -> ValueT: """Convert current payload to value - to be implemented in derived class when `dpt_class` can't be used.""" if self.dpt_class is None: raise NotImplementedError( "Either `dpt_class` must be set or `from_knx` must be implemented" ) return self.dpt_class.from_knx(payload) # type: ignore[no-any-return] def to_knx(self, value: ValueT) -> DPTArray | DPTBinary: """Convert value to payload - to be implemented in derived class when `dpt_class` can't be used.""" if self.dpt_class is None: raise NotImplementedError( "Either `dpt_class` must be set or `to_knx` must be implemented" ) return self.dpt_class.to_knx(value) def process(self, telegram: Telegram, always_callback: bool = False) -> bool: """Process incoming or outgoing telegram.""" if not isinstance( telegram.destination_address, GroupAddress | InternalGroupAddress ) or not self.has_group_address(telegram.destination_address): return False if not isinstance(telegram.payload, GroupValueWrite | GroupValueResponse): raise CouldNotParseTelegram( "payload not a GroupValueWrite or GroupValueResponse", payload=str(telegram.payload), destination_address=str(telegram.destination_address), source_address=str(telegram.source_address), device_name=self.device_name, feature_name=self.feature_name, ) try: decoded_payload: ValueT if ( telegram.decoded_data is not None and telegram.decoded_data.transcoder is self.dpt_class ): decoded_payload = telegram.decoded_data.value # type: ignore[assignment] else: decoded_payload = self.from_knx(telegram.payload.value) except (ConversionError, CouldNotParseTelegram) as err: logger.warning( "Can not process %s for %s - %s: %s", telegram, self.device_name, self.feature_name, err, ) return False self.xknx.state_updater.update_received(self) if self._value is None or always_callback or self._value != decoded_payload: self._value = decoded_payload self.telegram = telegram if self.after_update_cb is not None: self.after_update_cb(decoded_payload) return True def _send(self, payload: DPTArray | DPTBinary, response: bool = False) -> None: """Send payload as telegram to KNX bus.""" if self.group_address is None: return telegram = Telegram( destination_address=self.group_address, payload=( GroupValueResponse(payload) if response else GroupValueWrite(payload) ), source_address=self.xknx.current_address, ) self.xknx.telegrams.put_nowait(telegram) def set(self, value: ValueT, response: bool = False) -> None: """Set new value.""" if not self.initialized: logger.info( "Setting value of uninitialized device: %s - %s (value: %s)", self.device_name, self.feature_name, value, ) return if not self.writable: logger.warning( "Attempted to set value for non-writable device: %s - %s (value: %s)", self.device_name, self.feature_name, value, ) return payload = self.to_knx(value) self._send(payload, response) # self._value is set and after_update_cb() called when the outgoing telegram is processed. def respond(self) -> None: """Send current payload as GroupValueResponse telegram to KNX bus.""" if self._value is None: return payload = self.to_knx(self._value) self._send(payload, response=True) async def read_state(self, wait_for_result: bool = False) -> None: """Send GroupValueRead telegram for state address to KNX bus.""" if self.group_address_state is not None: # pylint: disable=import-outside-toplevel # TODO: send a ReadRequest and start a timeout from here instead of ValueReader # cancel timeout form process(); delete ValueReader from xknx.core import ValueReader value_reader = ValueReader(self.xknx, self.group_address_state) if wait_for_result: if await value_reader.read() is None: logger.warning( "Could not sync group address '%s' (%s - %s)", self.group_address_state, self.device_name, self.feature_name, ) else: value_reader.send_group_read() @property def unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" return None def group_addr_str(self) -> str: """Return object as readable string.""" return ( f"<{self.group_address}, " f"{self.group_address_state}, " f"{list(map(str, self.passive_group_addresses))}, " f"{self.value!r} />" ) def __str__(self) -> str: """Return object as string representation.""" return ( f"<{self.__class__.__name__} " f'device_name="{self.device_name}" ' f'feature_name="{self.feature_name}" ' f"{self.group_addr_str()} />" ) def __eq__(self, other: object) -> bool: """Equal operator.""" for key, value in self.__dict__.items(): if key == "after_update_cb": continue if key not in other.__dict__: return False if other.__dict__[key] != value: return False for key in other.__dict__: if key == "after_update_cb": continue if key not in self.__dict__: return False return True xknx-3.6.0/xknx/remote_value/remote_value_by_length.py000066400000000000000000000072371475530762600233130ustar00rootroot00000000000000"""Module for managing remote value with payload length based DPT detection.""" from __future__ import annotations from collections.abc import Iterable from typing import TYPE_CHECKING from xknx.dpt import DPTArray, DPTBinary, DPTNumeric from xknx.exceptions import ConversionError, CouldNotParseTelegram from .remote_value import GroupAddressesType, RemoteValue, RVCallbackType if TYPE_CHECKING: from xknx.xknx import XKNX class RemoteValueByLength(RemoteValue[float]): """RemoteValue with DPT detection based on payload length of first received value.""" def __init__( self, xknx: XKNX, dpt_classes: Iterable[type[DPTNumeric]], group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, sync_state: bool | int | float | str = True, device_name: str | None = None, feature_name: str | None = None, after_update_cb: RVCallbackType[float] | None = None, ) -> None: """Initialize RemoteValueByLength class.""" _payload_lengths = set() for dpt_class in dpt_classes: if ( not issubclass(dpt_class, DPTNumeric) or dpt_class.payload_type is not DPTArray ): raise ConversionError( "Only DPTNumeric subclasses with payload_type DPTArray are supported" ) if dpt_class.payload_length in _payload_lengths: raise ConversionError( f"Duplicate payload_length {dpt_class.payload_length} in {dpt_classes}" ) _payload_lengths.add(dpt_class.payload_length) super().__init__( xknx, group_address, group_address_state, sync_state=sync_state, device_name=device_name, feature_name=feature_name, after_update_cb=after_update_cb, ) self._dpt_classes = dpt_classes self._internal_dpt_class: type[DPTNumeric] | None = None def to_knx(self, value: float) -> DPTArray: """Convert value to payload.""" if self._internal_dpt_class is None: raise ConversionError( f"RemoteValue DPT not initialized for {self.device_name}" ) return self._internal_dpt_class.to_knx(value) def from_knx(self, payload: DPTArray | DPTBinary) -> float: """Convert current payload to value.""" if self._internal_dpt_class is None: self._internal_dpt_class = self._determine_dpt_class(payload) return self._internal_dpt_class.from_knx(payload) @property def unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" if not self._internal_dpt_class: return None return self._internal_dpt_class.unit @property def ha_device_class(self) -> str | None: """Return a string representing the home assistant device class.""" if not self._internal_dpt_class: return None return getattr(self._internal_dpt_class, "ha_device_class", None) def _determine_dpt_class(self, payload: DPTArray | DPTBinary) -> type[DPTNumeric]: """Test if telegram payload may be parsed.""" if isinstance(payload, DPTArray): try: return next( dpt_class for dpt_class in self._dpt_classes if dpt_class.payload_type is DPTArray and dpt_class.payload_length == len(payload.value) ) except StopIteration: pass raise CouldNotParseTelegram("Payload invalid", payload=str(payload)) xknx-3.6.0/xknx/remote_value/remote_value_climate_mode.py000066400000000000000000000340071475530762600237550ustar00rootroot00000000000000""" Module for managing an climate mode remote values. DPT . """ from __future__ import annotations from abc import abstractmethod from typing import TYPE_CHECKING, Any, TypeVar from xknx.dpt import ( DPTArray, DPTBinary, DPTHVACContrMode, DPTHVACMode, DPTHVACStatus, ) from xknx.dpt.dpt_1 import HeatCool from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode, HVACStatus from xknx.exceptions import ConversionError, CouldNotParseTelegram from .remote_value import ( GroupAddressesType, RemoteValue, RVCallbackType, ) if TYPE_CHECKING: from xknx.xknx import XKNX HVACModeT = TypeVar( "HVACModeT", HVACControllerMode, HVACOperationMode, HVACStatus, HVACOperationMode | None, ) class RemoteValueClimateModeBase(RemoteValue[HVACModeT]): """Base class for binary climate mode remote values.""" @abstractmethod def supported_operation_modes(self) -> list[HVACOperationMode]: """Return a list of all supported operation modes.""" @abstractmethod def set_operation_mode(self, mode: HVACOperationMode) -> None: """Set operation mode. Return if not supported.""" @abstractmethod def supported_controller_modes(self) -> list[HVACControllerMode]: """Return a list of all supported controller modes.""" @abstractmethod def set_controller_mode(self, mode: HVACControllerMode) -> None: """Set controller mode. Return if not supported.""" class RemoteValueOperationMode(RemoteValueClimateModeBase[HVACOperationMode]): """Abstraction for remote value of KNX climate operation modes.""" dpt_class = DPTHVACMode def __init__( self, xknx: XKNX, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, sync_state: bool | int | float | str = True, device_name: str | None = None, feature_name: str = "Climate mode", after_update_cb: RVCallbackType[HVACOperationMode] | None = None, ) -> None: """Initialize remote value of KNX climate mode.""" super().__init__( xknx, group_address=group_address, group_address_state=group_address_state, sync_state=sync_state, device_name=device_name, feature_name=feature_name, after_update_cb=after_update_cb, ) def supported_operation_modes(self) -> list[HVACOperationMode]: """Return a list of all supported operation modes.""" return self.dpt_class.get_valid_values() def set_operation_mode(self, mode: HVACOperationMode) -> None: """Set operation mode. Return if not supported.""" if mode not in self.supported_operation_modes(): return super().set(mode) def supported_controller_modes(self) -> list[HVACControllerMode]: """Return a list of all supported controller modes.""" return [] def set_controller_mode(self, mode: HVACControllerMode) -> None: """Set controller mode. Return if not supported.""" return class RemoteValueControllerMode(RemoteValueClimateModeBase[HVACControllerMode]): """Abstraction for remote value of KNX climate controller modes.""" dpt_class = DPTHVACContrMode def __init__( self, xknx: XKNX, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, sync_state: bool | int | float | str = True, device_name: str | None = None, feature_name: str = "Controller Mode", after_update_cb: RVCallbackType[HVACControllerMode] | None = None, ) -> None: """Initialize remote value of KNX climate mode.""" super().__init__( xknx, group_address=group_address, group_address_state=group_address_state, sync_state=sync_state, device_name=device_name, feature_name=feature_name, after_update_cb=after_update_cb, ) def supported_operation_modes(self) -> list[HVACOperationMode]: """Return a list of all supported operation modes.""" return [] def set_operation_mode(self, mode: HVACOperationMode) -> None: """Set operation mode. Return if not supported.""" return def supported_controller_modes(self) -> list[HVACControllerMode]: """Return a list of all supported controller modes.""" return DPTHVACContrMode.get_valid_values() def set_controller_mode(self, mode: HVACControllerMode) -> None: """Set controller mode. Return if not supported.""" if mode not in self.supported_controller_modes(): return super().set(mode) class RemoteValueHVACStatus(RemoteValueClimateModeBase[HVACStatus]): """Abstraction for remote value of KNX climate HVAC status (Eberle status).""" dpt_class = DPTHVACStatus def __init__( self, xknx: XKNX, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, sync_state: bool | int | float | str = True, device_name: str | None = None, feature_name: str = "Controller status", after_update_cb: RVCallbackType[HVACStatus] | None = None, ) -> None: """Initialize remote value of KNX climate controller status.""" super().__init__( xknx, group_address=group_address, group_address_state=group_address_state, sync_state=sync_state, device_name=device_name, feature_name=feature_name, after_update_cb=after_update_cb, ) def supported_operation_modes(self) -> list[HVACOperationMode]: """Return a list of all supported operation modes.""" return [ HVACOperationMode.COMFORT, HVACOperationMode.STANDBY, HVACOperationMode.ECONOMY, HVACOperationMode.BUILDING_PROTECTION, ] def set_operation_mode(self, mode: HVACOperationMode) -> None: """Set operation mode. Return if not supported.""" if mode not in self.supported_operation_modes(): return if self._value is None: raise ConversionError( "HVACStatus value not initialized. Can not write new mode.", device_name=self.device_name, ) new_status = HVACStatus( mode=mode, dew_point=self._value.dew_point, heat_cool=self._value.heat_cool, inactive=self._value.inactive, frost_alarm=self._value.frost_alarm, ) return super().set(new_status) def supported_controller_modes(self) -> list[HVACControllerMode]: """Return a list of all supported controller modes.""" return [HVACControllerMode.HEAT, HVACControllerMode.COOL] def set_controller_mode(self, mode: HVACControllerMode) -> None: """Set controller mode. Return if not supported.""" if mode not in self.supported_controller_modes(): return if self._value is None: raise ConversionError( "HVACStatus value not initialized. Can not write new mode.", device_name=self.device_name, ) new_status = HVACStatus( mode=self._value.mode, dew_point=self._value.dew_point, heat_cool=HeatCool.HEAT if mode == HVACControllerMode.HEAT else HeatCool.COOL, inactive=self._value.inactive, frost_alarm=self._value.frost_alarm, ) super().set(new_status) class RemoteValueBinaryOperationMode( RemoteValueClimateModeBase[HVACOperationMode | None] ): """Abstraction for remote value of split up KNX climate modes.""" def __init__( self, xknx: XKNX, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, sync_state: bool | int | float | str = True, device_name: str | None = None, feature_name: str = "Climate mode binary", after_update_cb: RVCallbackType[HVACOperationMode | None] | None = None, operation_mode: HVACOperationMode | None = None, ) -> None: """Initialize remote value of KNX DPT 1 representing a climate operation mode.""" if not isinstance(operation_mode, HVACOperationMode): raise ConversionError( "Invalid operation mode type", operation_mode=str(operation_mode), device_name=str(device_name), feature_name=feature_name, ) self.operation_mode = operation_mode super().__init__( xknx, group_address=group_address, group_address_state=group_address_state, sync_state=sync_state, device_name=device_name, feature_name=feature_name, after_update_cb=after_update_cb, ) def supported_operation_modes(self) -> list[HVACOperationMode]: """Return a list of all supported operation modes.""" # all binary operation modes off -> Standby according to MDT return ( [self.operation_mode, HVACOperationMode.STANDBY] if self.operation_mode is not HVACOperationMode.STANDBY else [HVACOperationMode.STANDBY] ) def set_operation_mode(self, mode: HVACOperationMode) -> None: """Set operation mode. Return if not supported.""" super().set(mode) def supported_controller_modes(self) -> list[HVACControllerMode]: """Return a list of all supported controller modes.""" return [] def set_controller_mode(self, mode: HVACControllerMode) -> None: """Set controller mode. Return if not supported.""" return def to_knx(self, value: HVACOperationMode | None) -> DPTBinary: """Convert value to payload.""" if isinstance(value, HVACOperationMode): # foreign operation modes will set the RemoteValue to False return DPTBinary(value == self.operation_mode) raise ConversionError( "value invalid", value=value, device_name=self.device_name, feature_name=self.feature_name, ) def from_knx(self, payload: DPTArray | DPTBinary) -> HVACOperationMode | None: """Convert current payload to value.""" if payload.value == 1: return self.operation_mode if payload.value == 0: return None raise CouldNotParseTelegram( "Payload invalid", payload=str(payload), device_name=self.device_name, feature_name=self.feature_name, ) class RemoteValueBinaryHeatCool(RemoteValueClimateModeBase[HVACControllerMode]): """Abstraction for remote value of heat/cool controller mode.""" def __init__( self, xknx: XKNX, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, sync_state: bool | int | float | str = True, device_name: str | None = None, feature_name: str = "Controller mode Heat/Cool", after_update_cb: RVCallbackType[HVACControllerMode] | None = None, controller_mode: HVACControllerMode | None = None, ) -> None: """Initialize remote value of KNX DPT 1 representing a climate controller mode.""" if not isinstance(controller_mode, HVACControllerMode): raise ConversionError( "Invalid controller mode type", controller_mode=str(controller_mode), device_name=str(device_name), feature_name=feature_name, ) if controller_mode not in self.supported_controller_modes(): raise ConversionError( "Controller mode not supported for binary mode object", controller_mode=str(controller_mode), device_name=str(device_name), feature_name=feature_name, ) self.controller_mode = controller_mode super().__init__( xknx, group_address=group_address, group_address_state=group_address_state, sync_state=sync_state, device_name=device_name, feature_name=feature_name, after_update_cb=after_update_cb, ) def supported_operation_modes(self) -> list[HVACOperationMode]: """Return a list of all supported operation modes.""" return [] def set_operation_mode(self, mode: HVACOperationMode) -> None: """Set operation mode. Return if not supported.""" return def supported_controller_modes(self) -> list[HVACControllerMode]: """Return a list of all supported controller modes.""" return [HVACControllerMode.HEAT, HVACControllerMode.COOL] def set_controller_mode(self, mode: HVACControllerMode) -> None: """Set controller mode. Return if not supported.""" if mode not in self.supported_controller_modes(): return super().set(mode) def to_knx(self, value: Any) -> DPTBinary: """Convert value to payload.""" if isinstance(value, HVACControllerMode): # foreign operation modes will set the RemoteValue to False return DPTBinary(value == self.controller_mode) raise ConversionError( "value invalid", value=value, device_name=self.device_name, feature_name=self.feature_name, ) def from_knx(self, payload: DPTArray | DPTBinary) -> HVACControllerMode: """Convert current payload to value.""" if payload.value == 1: return self.controller_mode if payload.value == 0: # return the other operation mode return next( _op for _op in self.supported_controller_modes() if _op is not self.controller_mode ) raise CouldNotParseTelegram( "Payload invalid", payload=str(payload), device_name=self.device_name, feature_name=self.feature_name, ) xknx-3.6.0/xknx/remote_value/remote_value_color_rgb.py000066400000000000000000000005221475530762600232760ustar00rootroot00000000000000""" Module for managing an RGB remote value. DPT 232.600. """ from __future__ import annotations from xknx.dpt import DPTColorRGB, RGBColor from .remote_value import RemoteValue class RemoteValueColorRGB(RemoteValue[RGBColor]): """Abstraction for remote value of KNX DPT 232.600 (DPT_Color_RGB).""" dpt_class = DPTColorRGB xknx-3.6.0/xknx/remote_value/remote_value_color_rgbw.py000066400000000000000000000032511475530762600234670ustar00rootroot00000000000000""" Module for managing an RGBW remote value. DPT 251.600. """ from __future__ import annotations from typing import TYPE_CHECKING from xknx.dpt import DPTArray, DPTBinary, DPTColorRGBW, RGBWColor from .remote_value import GroupAddressesType, RemoteValue, RVCallbackType if TYPE_CHECKING: from xknx.xknx import XKNX class RemoteValueColorRGBW(RemoteValue[RGBWColor]): """Abstraction for remote value of KNX DPT 251.600 (DPT_Color_RGBW).""" def __init__( self, xknx: XKNX, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, sync_state: bool | int | float | str = True, device_name: str | None = None, feature_name: str = "Color RGBW", after_update_cb: RVCallbackType[RGBWColor] | None = None, ) -> None: """Initialize remote value of KNX DPT 251.600 (DPT_Color_RGBW).""" super().__init__( xknx, group_address, group_address_state, sync_state=sync_state, device_name=device_name, feature_name=feature_name, after_update_cb=after_update_cb, ) self._valid_value = RGBWColor() def to_knx(self, value: RGBWColor) -> DPTArray | DPTBinary: """Convert value to payload.""" return DPTColorRGBW.to_knx(value) def from_knx(self, payload: DPTArray | DPTBinary) -> RGBWColor: """ Convert current payload to value. If one element is invalid, use the last received value. """ new_value = DPTColorRGBW.from_knx(payload) self._valid_value = self._valid_value | new_value return self._valid_value xknx-3.6.0/xknx/remote_value/remote_value_color_xyy.py000066400000000000000000000005311475530762600233550ustar00rootroot00000000000000""" Module for managing an xyY-color remote value. DPT 242.600. """ from __future__ import annotations from xknx.dpt import DPTColorXYY, XYYColor from .remote_value import RemoteValue class RemoteValueColorXYY(RemoteValue[XYYColor]): """Abstraction for remote value of KNX DPT 242.600 (DPT_Colour_xyY).""" dpt_class = DPTColorXYY xknx-3.6.0/xknx/remote_value/remote_value_datetime.py000066400000000000000000000013451475530762600231260ustar00rootroot00000000000000""" Module for managing a remote date and time values. DPT 10.001, 11.001 and 19.001 """ from __future__ import annotations from xknx.dpt import DPTDate, DPTDateTime, DPTTime from xknx.dpt.dpt_10 import KNXTime from xknx.dpt.dpt_11 import KNXDate from xknx.dpt.dpt_19 import KNXDateTime from .remote_value import RemoteValue class RemoteValueTime(RemoteValue[KNXTime]): """Abstraction for remote value of KNX 3 octet time.""" dpt_class = DPTTime class RemoteValueDate(RemoteValue[KNXDate]): """Abstraction for remote value of KNX 3 octet date.""" dpt_class = DPTDate class RemoteValueDateTime(RemoteValue[KNXDateTime]): """Abstraction for remote value of KNX 8 octet datetime.""" dpt_class = DPTDateTime xknx-3.6.0/xknx/remote_value/remote_value_dpt_value_1_ucount.py000066400000000000000000000005021475530762600251240ustar00rootroot00000000000000""" Module for managing a DTP 5010 remote value. DPT 5.010. """ from __future__ import annotations from xknx.dpt import DPTValue1Ucount from .remote_value import RemoteValue class RemoteValueDptValue1Ucount(RemoteValue[int]): """Abstraction for remote value of KNX DPT 5.010.""" dpt_class = DPTValue1Ucount xknx-3.6.0/xknx/remote_value/remote_value_raw.py000066400000000000000000000050301475530762600221160ustar00rootroot00000000000000""" Module for managing a remote value typically used within a sensor. The module maps a given value_type to a DPT class and uses this class for serialization and deserialization of the KNX value. """ from __future__ import annotations from typing import TYPE_CHECKING from xknx.dpt.dpt import DPTArray, DPTBinary from xknx.exceptions import ConversionError, CouldNotParseTelegram from .remote_value import GroupAddressesType, RemoteValue, RVCallbackType if TYPE_CHECKING: from xknx.xknx import XKNX class RemoteValueRaw(RemoteValue[int]): """Abstraction for raw values.""" def __init__( self, xknx: XKNX, payload_length: int, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, sync_state: bool | int | float | str = True, device_name: str | None = None, feature_name: str = "Raw", after_update_cb: RVCallbackType[int] | None = None, ) -> None: """Initialize RemoteValueRaw class.""" self.payload_length = payload_length super().__init__( xknx, group_address, group_address_state, sync_state=sync_state, device_name=device_name, feature_name=feature_name, after_update_cb=after_update_cb, ) def to_knx(self, value: int) -> DPTArray | DPTBinary: """Convert value to payload.""" if self.payload_length == 0: try: return DPTBinary(value) except TypeError as err: raise ConversionError( "Could not init DPTBinary", value=str(value) ) from err try: return DPTArray(value.to_bytes(length=self.payload_length, byteorder="big")) except (AttributeError, OverflowError) as err: raise ConversionError("Could not init DPTArray", value=str(value)) from err def from_knx(self, payload: DPTArray | DPTBinary) -> int: """Convert current payload to value.""" if isinstance(payload, DPTBinary) and self.payload_length == 0: return payload.value if isinstance(payload, DPTArray) and len(payload.value) == self.payload_length: try: return int.from_bytes(payload.value, byteorder="big") except ValueError as err: raise ConversionError( "Could not parse payload", payload=payload ) from err raise CouldNotParseTelegram("Payload invalid", payload=str(payload)) xknx-3.6.0/xknx/remote_value/remote_value_scaling.py000066400000000000000000000044571475530762600227610ustar00rootroot00000000000000""" Module for managing a Scaling remote value. DPT 5.001. """ from __future__ import annotations from typing import TYPE_CHECKING from xknx.dpt import DPTArray, DPTBinary from xknx.exceptions import CouldNotParseTelegram from .remote_value import GroupAddressesType, RemoteValue, RVCallbackType if TYPE_CHECKING: from xknx.xknx import XKNX class RemoteValueScaling(RemoteValue[int]): """Abstraction for remote value of KNX DPT 5.001 (DPT_Scaling).""" def __init__( self, xknx: XKNX, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, sync_state: bool | int | float | str = True, device_name: str | None = None, feature_name: str = "Value", after_update_cb: RVCallbackType[int] | None = None, range_from: int = 0, range_to: int = 100, ) -> None: """Initialize remote value of KNX DPT 5.001 (DPT_Scaling).""" super().__init__( xknx, group_address, group_address_state, sync_state=sync_state, device_name=device_name, feature_name=feature_name, after_update_cb=after_update_cb, ) self.range_from = range_from self.range_to = range_to def to_knx(self, value: float) -> DPTArray: """Convert value to payload.""" knx_value = self._calc_to_knx(self.range_from, self.range_to, value) return DPTArray(knx_value) def from_knx(self, payload: DPTArray | DPTBinary) -> int: """Convert current payload to value.""" if isinstance(payload, DPTArray) and len(payload.value) == 1: return self._calc_from_knx(self.range_from, self.range_to, payload.value[0]) raise CouldNotParseTelegram("Payload invalid", payload=str(payload)) @property def unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" return "%" @staticmethod def _calc_from_knx(range_from: int, range_to: int, raw: int) -> int: delta = range_to - range_from return round((raw / 255) * delta) + range_from @staticmethod def _calc_to_knx(range_from: int, range_to: int, value: float) -> int: delta = range_to - range_from return round((value - range_from) / delta * 255) xknx-3.6.0/xknx/remote_value/remote_value_scene_number.py000066400000000000000000000005311475530762600237730ustar00rootroot00000000000000""" Module for managing a DTP Scene Number remote value. DPT 17.001. """ from __future__ import annotations from xknx.dpt import DPTSceneNumber from .remote_value import RemoteValue class RemoteValueSceneNumber(RemoteValue[int]): """Abstraction for remote value of KNX DPT 17.001 (DPT_Scene_Number).""" dpt_class = DPTSceneNumber xknx-3.6.0/xknx/remote_value/remote_value_sensor.py000066400000000000000000000056041475530762600226450ustar00rootroot00000000000000""" Module for managing a remote value typically used within a sensor. The module maps a given value_type to a DPT class and uses this class for serialization and deserialization of the KNX value. """ from __future__ import annotations from typing import TYPE_CHECKING, TypeVar from xknx.dpt import DPTBase, DPTNumeric, DPTString from xknx.exceptions import ConversionError from xknx.typing import DPTParsable from .remote_value import GroupAddressesType, RemoteValue, RVCallbackType if TYPE_CHECKING: from xknx.xknx import XKNX ValueT = TypeVar("ValueT") class _RemoteValueGeneric(RemoteValue[ValueT]): """Abstraction for generic DPT types.""" dpt_base_class: type[DPTBase] _default_dpt_class: type[DPTBase] | None = None dpt_class: type[DPTBase] def __init__( self, xknx: XKNX, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, sync_state: bool | int | float | str = True, value_type: DPTParsable | type[DPTBase] | None = None, device_name: str | None = None, feature_name: str = "Value", after_update_cb: RVCallbackType[ValueT] | None = None, ) -> None: """Initialize RemoteValueSensor class.""" try: if value_type is None: if self._default_dpt_class is None: raise ValueError self.dpt_class = self._default_dpt_class else: self.dpt_class = self.dpt_base_class.get_dpt(value_type) except ValueError: raise ConversionError( f"invalid value type for base class {self.dpt_base_class.dpt_name()}", value_type=value_type, device_name=device_name, ) from None super().__init__( xknx, group_address, group_address_state, sync_state=sync_state, device_name=device_name, feature_name=feature_name, after_update_cb=after_update_cb, ) @property def unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" return self.dpt_class.unit @property def ha_device_class(self) -> str | None: """Return a string representing the home assistant device class.""" return getattr(self.dpt_class, "ha_device_class", None) class RemoteValueSensor(_RemoteValueGeneric[int | float | str]): """Abstraction for sensor DPT types.""" dpt_base_class = DPTBase dpt_class: type[DPTBase] class RemoteValueNumeric(_RemoteValueGeneric[int | float]): """Abstraction for numeric DPT types.""" dpt_base_class = DPTNumeric dpt_class: type[DPTNumeric] class RemoteValueString(_RemoteValueGeneric[str]): """Abstraction for string DPT types.""" dpt_base_class = DPTString dpt_class: type[DPTString] _default_dpt_class = DPTString xknx-3.6.0/xknx/remote_value/remote_value_setpoint_shift.py000066400000000000000000000061171475530762600243760ustar00rootroot00000000000000""" Module for managing setpoint shifting. DPT 6.010. """ from __future__ import annotations from enum import Enum from typing import TYPE_CHECKING from xknx.dpt import DPTArray, DPTBinary, DPTTemperature, DPTValue1Count from xknx.exceptions import ConversionError, CouldNotParseTelegram from .remote_value import GroupAddressesType, RemoteValue, RVCallbackType if TYPE_CHECKING: from xknx.xknx import XKNX class SetpointShiftMode(Enum): """Enum for setting the setpoint shift mode.""" DPT6010 = DPTValue1Count DPT9002 = DPTTemperature class RemoteValueSetpointShift(RemoteValue[float]): """Abstraction for remote value of KNX DPT 6.010.""" def __init__( self, xknx: XKNX, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, sync_state: bool | int | float | str = True, device_name: str | None = None, after_update_cb: RVCallbackType[float] | None = None, setpoint_shift_mode: SetpointShiftMode | None = None, setpoint_shift_step: float = 0.1, ) -> None: """Initialize RemoteValueSetpointShift class.""" super().__init__( xknx, group_address, group_address_state, sync_state=sync_state, device_name=device_name, feature_name="Setpoint shift value", after_update_cb=after_update_cb, ) self._internal_dpt_class: type[DPTValue1Count | DPTTemperature] | None = ( setpoint_shift_mode.value if setpoint_shift_mode is not None else None ) self.setpoint_shift_step = setpoint_shift_step def to_knx(self, value: float) -> DPTArray: """Convert value to payload.""" if self._internal_dpt_class is None: raise ConversionError( f"Setpoint shift DPT not initialized for {self.device_name}" ) if self._internal_dpt_class == DPTValue1Count: converted_value = int(value / self.setpoint_shift_step) return DPTValue1Count.to_knx(converted_value) return DPTTemperature.to_knx(value) def from_knx(self, payload: DPTArray | DPTBinary) -> float: """Convert current payload to value.""" if self._internal_dpt_class is None: self._internal_dpt_class = self._determine_dpt_class(payload) payload_value = self._internal_dpt_class.from_knx(payload) if self._internal_dpt_class == DPTValue1Count: return payload_value * self.setpoint_shift_step return payload_value def _determine_dpt_class( self, payload: DPTArray | DPTBinary ) -> type[DPTValue1Count | DPTTemperature]: """Test if telegram payload may be parsed.""" if isinstance(payload, DPTArray): payload_length = len(payload.value) if payload_length == DPTTemperature.payload_length: return DPTTemperature if payload_length == DPTValue1Count.payload_length: return DPTValue1Count raise CouldNotParseTelegram("Payload invalid", payload=str(payload)) xknx-3.6.0/xknx/remote_value/remote_value_step.py000066400000000000000000000053041475530762600223040ustar00rootroot00000000000000""" Module for managing an DPT Step remote value. DPT 1.007. """ from __future__ import annotations from enum import Enum from typing import TYPE_CHECKING from xknx.dpt import DPTArray, DPTBinary from xknx.exceptions import ConversionError, CouldNotParseTelegram from .remote_value import GroupAddressesType, RemoteValue, RVCallbackType if TYPE_CHECKING: from xknx.xknx import XKNX class RemoteValueStep(RemoteValue["RemoteValueStep.Direction"]): """Abstraction for remote value of KNX DPT 1.007 / DPT_Step.""" class Direction(Enum): """Enum for indicating the direction.""" DECREASE = 0 INCREASE = 1 def __init__( self, xknx: XKNX, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, device_name: str | None = None, feature_name: str = "Step", after_update_cb: RVCallbackType[Direction] | None = None, invert: bool = False, ) -> None: """Initialize remote value of KNX DPT 1.007.""" super().__init__( xknx, group_address, group_address_state, device_name=device_name, feature_name=feature_name, after_update_cb=after_update_cb, ) self.invert = invert # from KNX Association System Specifications AS v1.5.00: # 1.007 DPT_Step 0 = Decrease 1 = Increase # 1.008 DPT_UpDown 0 = Up 1 = Down def to_knx(self, value: RemoteValueStep.Direction) -> DPTBinary: """Convert value to payload.""" if value == self.Direction.INCREASE: return DPTBinary(0) if self.invert else DPTBinary(1) if value == self.Direction.DECREASE: return DPTBinary(1) if self.invert else DPTBinary(0) raise ConversionError( "value invalid", value=value, device_name=self.device_name, feature_name=self.feature_name, ) def from_knx(self, payload: DPTArray | DPTBinary) -> RemoteValueStep.Direction: """Convert current payload to value.""" if payload.value == 1: return self.Direction.DECREASE if self.invert else self.Direction.INCREASE if payload.value == 0: return self.Direction.INCREASE if self.invert else self.Direction.DECREASE raise CouldNotParseTelegram( "Payload invalid", payload=payload, device_name=self.device_name, feature_name=self.feature_name, ) def increase(self) -> None: """Increase value.""" self.set(self.Direction.INCREASE) def decrease(self) -> None: """Decrease the value.""" self.set(self.Direction.DECREASE) xknx-3.6.0/xknx/remote_value/remote_value_switch.py000066400000000000000000000042751475530762600226400ustar00rootroot00000000000000""" Module for managing an DPT Switch remote value. DPT 1.001. """ from __future__ import annotations from typing import TYPE_CHECKING from xknx.dpt import DPTArray, DPTBinary from xknx.exceptions import ConversionError, CouldNotParseTelegram from .remote_value import GroupAddressesType, RemoteValue, RVCallbackType if TYPE_CHECKING: from xknx.xknx import XKNX class RemoteValueSwitch(RemoteValue[bool]): """Abstraction for remote value of KNX DPT 1.001 / DPT_Switch.""" def __init__( self, xknx: XKNX, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, sync_state: bool | int | float | str = True, device_name: str | None = None, feature_name: str = "State", after_update_cb: RVCallbackType[bool] | None = None, invert: bool = False, ) -> None: """Initialize remote value of KNX DPT 1.001.""" super().__init__( xknx, group_address, group_address_state, sync_state=sync_state, device_name=device_name, feature_name=feature_name, after_update_cb=after_update_cb, ) self.invert = bool(invert) def to_knx(self, value: bool) -> DPTBinary: """Convert value to payload.""" if isinstance(value, bool): return DPTBinary(value ^ self.invert) raise ConversionError( "value invalid", value=value, device_name=self.device_name, feature_name=self.feature_name, ) def from_knx(self, payload: DPTArray | DPTBinary) -> bool: """Convert current payload to value.""" if payload.value == 0: return self.invert if payload.value == 1: return not self.invert raise CouldNotParseTelegram( "Payload invalid", payload=payload, device_name=self.device_name, feature_name=self.feature_name, ) def off(self) -> None: """Set value to OFF.""" self.set(False) def on(self) -> None: """Set value to ON.""" # pylint: disable=invalid-name self.set(True) xknx-3.6.0/xknx/remote_value/remote_value_temp.py000066400000000000000000000005071475530762600222760ustar00rootroot00000000000000""" Module for managing a remote temperature value. DPT 9.001. """ from __future__ import annotations from xknx.dpt import DPTTemperature from .remote_value import RemoteValue class RemoteValueTemp(RemoteValue[float]): """Abstraction for remote value of KNX 9.001 (DPT_Value_Temp).""" dpt_class = DPTTemperature xknx-3.6.0/xknx/remote_value/remote_value_updown.py000066400000000000000000000050431475530762600226450ustar00rootroot00000000000000""" Module for managing an DPT Up/Down remote value. DPT 1.008. """ from __future__ import annotations from enum import Enum from typing import TYPE_CHECKING from xknx.dpt import DPTArray, DPTBinary from xknx.exceptions import ConversionError, CouldNotParseTelegram from .remote_value import GroupAddressesType, RemoteValue, RVCallbackType if TYPE_CHECKING: from xknx.xknx import XKNX class RemoteValueUpDown(RemoteValue["RemoteValueUpDown.Direction"]): """Abstraction for remote value of KNX DPT 1.008 / DPT_UpDown.""" class Direction(Enum): """Enum for indicating the direction.""" UP = 0 DOWN = 1 def __init__( self, xknx: XKNX, group_address: GroupAddressesType = None, group_address_state: GroupAddressesType = None, device_name: str | None = None, feature_name: str = "Up/Down", after_update_cb: RVCallbackType[Direction] | None = None, invert: bool = False, ) -> None: """Initialize remote value of KNX DPT 1.008.""" super().__init__( xknx, group_address, group_address_state, device_name=device_name, feature_name=feature_name, after_update_cb=after_update_cb, ) self.invert = invert def to_knx(self, value: RemoteValueUpDown.Direction) -> DPTBinary: """Convert value to payload.""" if value == self.Direction.UP: return DPTBinary(1) if self.invert else DPTBinary(0) if value == self.Direction.DOWN: return DPTBinary(0) if self.invert else DPTBinary(1) raise ConversionError( "value invalid", value=value, device_name=self.device_name, feature_name=self.feature_name, ) def from_knx(self, payload: DPTArray | DPTBinary) -> RemoteValueUpDown.Direction: """Convert current payload to value.""" if payload.value == 0: return self.Direction.DOWN if self.invert else self.Direction.UP if payload.value == 1: return self.Direction.UP if self.invert else self.Direction.DOWN raise CouldNotParseTelegram( "Payload invalid", payload=payload, device_name=self.device_name, feature_name=self.feature_name, ) def down(self) -> None: """Set value to down.""" self.set(self.Direction.DOWN) def up(self) -> None: """Set value to UP.""" # pylint: disable=invalid-name self.set(self.Direction.UP) xknx-3.6.0/xknx/secure/000077500000000000000000000000001475530762600150055ustar00rootroot00000000000000xknx-3.6.0/xknx/secure/__init__.py000066400000000000000000000003251475530762600171160ustar00rootroot00000000000000"""Classes for handling KNX IP Secure.""" from .keyring import Keyring, load_keyring from .util import bytes_xor, sha256_hash __all__ = [ "Keyring", "bytes_xor", "load_keyring", "sha256_hash", ] xknx-3.6.0/xknx/secure/data_secure.py000066400000000000000000000243231475530762600176420ustar00rootroot00000000000000"""Module for KNX Data Secure.""" from __future__ import annotations from collections.abc import Iterator from contextlib import contextmanager from copy import copy from datetime import datetime, timezone import logging import time from xknx.cemi import CEMILData from xknx.exceptions import DataSecureError from xknx.telegram.address import GroupAddress, IndividualAddress from xknx.telegram.apci import APCI, SecureAPDU from .data_secure_asdu import ( SecureData, SecurityAlgorithmIdentifier, SecurityALService, SecurityControlField, ) from .keyring import Keyring _LOGGER = logging.getLogger("xknx.data_secure") # Same timedelta in milliseconds as used in Falcon used for initial sequence_number_sending # py3.10 backwards compatibility - py3.11 "2018-01-05T00:00:00Z" is supported _SEQUENCE_NUMBER_INIT_TIMESTAMP = datetime.fromisoformat( "2018-01-05T00:00:00+00:00" ).timestamp() def _initial_sequence_number() -> int: """Return an initial sequence number for sending Data Secure Telegrams.""" return int((time.time() - _SEQUENCE_NUMBER_INIT_TIMESTAMP) * 1000) class DataSecure: """Class for KNX Data Secure handling.""" __slots__ = ( "_group_key_table", "_individual_address_table", "_sequence_number_sending", ) def __init__( self, *, group_key_table: dict[GroupAddress, bytes], individual_address_table: dict[IndividualAddress, int], last_sequence_number_sending: int | None = None, ) -> None: """Initialize DataSecure class.""" self._group_key_table = group_key_table self._individual_address_table = individual_address_table self._sequence_number_sending = ( last_sequence_number_sending or _initial_sequence_number() ) # Holds the last valid sequence number for each individual address. # Use sequence_number from keyfile as initial value or 0 from senders for all IAs ? if not 0 < self._sequence_number_sending < 0xFFFFFFFFFFFF: _local_time_info = ( f" Local time not set properly? {datetime.now(timezone.utc).isoformat()}" if not last_sequence_number_sending else "" ) raise DataSecureError( f"Initial sequence number out of range: {self._sequence_number_sending}" f"{_local_time_info}" ) _LOGGER.info( "Data Secure initialized for %s group addresses from %s individual addresses.", len(self._group_key_table), len(self._individual_address_table), ) _LOGGER.debug( "Data Secure initial sequence number: %s, groups: %s, senders: %s", self._sequence_number_sending, [str(ga) for ga in self._group_key_table], [str(ia) for ia in self._individual_address_table], ) @staticmethod def init_from_keyring(keyring: Keyring) -> DataSecure | None: """ Initialize DataSecure from Keyring. Return None if no Data Secure information is found in the Keyring. """ ga_key_table = keyring.get_data_secure_group_keys() ia_seq_table = keyring.get_data_secure_senders() # TODO: persist local individual_address_table and update from that file on start # to have more fresh initial sequence numbers if not ga_key_table: return None return DataSecure( group_key_table=ga_key_table, individual_address_table=ia_seq_table, ) def get_sequence_number(self) -> int: """Return current sequence number sending and increment local stored value.""" seq_nr = self._sequence_number_sending self._sequence_number_sending += 1 return seq_nr @contextmanager def check_sequence_number( self, source_address: IndividualAddress, received_sequence_number: int ) -> Iterator[None]: """ Check the last valid sequence number for incoming frames from `source_address`. Update the Security Individual Address Table if no further exception is raised. Raise `DataSecureError` if sequence number is invalid or sender is not known. """ try: last_valid_sequence_number = self._individual_address_table[source_address] except KeyError: raise DataSecureError( f"Source address not found in Security Individual Address Table: {source_address}", log_level=logging.INFO, ) from None if not received_sequence_number > last_valid_sequence_number: # TODO: implement and increment Security Failure Log counter (not when equal) raise DataSecureError( f"Sequence number too low for {source_address}: " f"{received_sequence_number} received, {last_valid_sequence_number} last valid", log_level=logging.WARNING, ) yield # Don't increment sequence number if exception is raised while decrypting (yield) self._individual_address_table[source_address] = received_sequence_number def received_cemi(self, cemi_data: CEMILData) -> CEMILData: """Handle received CEMI frame.""" # Data Secure frame if isinstance(cemi_data.payload, SecureAPDU): return self._received_secure_cemi(cemi_data, cemi_data.payload) # Plain group communication frame if isinstance(cemi_data.dst_addr, GroupAddress): if cemi_data.dst_addr in self._group_key_table: raise DataSecureError( f"Discarding frame with plain APDU for secure group address: {cemi_data}", log_level=logging.WARNING, ) return cemi_data # Plain point-to-point frame # No point to point key table is implemented at the moment as ETS can't even configure this # only way to communicate point-to-point with data secure currently is with tool key # - which we don't have return cemi_data def _received_secure_cemi( self, cemi_data: CEMILData, s_apdu: SecureAPDU ) -> CEMILData: """Handle received secured CEMI frame.""" if s_apdu.scf.service is not SecurityALService.S_A_DATA: raise DataSecureError( f"Only SecurityALService.S_A_DATA supported {cemi_data}", log_level=logging.DEBUG, ) if s_apdu.scf.system_broadcast or s_apdu.scf.tool_access: # TODO: handle incoming responses with tool key of sending device # when we can send with tool key raise DataSecureError( f"System broadcast and tool access not supported {cemi_data}", log_level=logging.DEBUG, ) # Secure group communication frame if isinstance(cemi_data.dst_addr, GroupAddress): if not (key := self._group_key_table.get(cemi_data.dst_addr)): raise DataSecureError( f"No key found for group address {cemi_data.dst_addr} from {cemi_data.src_addr}", log_level=logging.INFO, ) # Secure point-to-point frame else: # TODO: maybe possible to implement this over tool key raise DataSecureError( f"Secure Point-to-Point communication not supported {cemi_data}", log_level=logging.DEBUG, ) with self.check_sequence_number( source_address=cemi_data.src_addr, received_sequence_number=int.from_bytes( s_apdu.secured_data.sequence_number_bytes, "big" ), ): _address_fields_raw = ( cemi_data.src_addr.to_knx() + cemi_data.dst_addr.to_knx() ) plain_apdu_raw = s_apdu.secured_data.get_plain_apdu( key=key, scf=s_apdu.scf, address_fields_raw=_address_fields_raw, frame_flags=cemi_data.flags, tpci=cemi_data.tpci, ) decrypted_payload = APCI.from_knx(plain_apdu_raw) _LOGGER.debug("Unpacked APDU %s from %s", decrypted_payload, s_apdu) plain_cemi_data = copy(cemi_data) plain_cemi_data.payload = decrypted_payload return plain_cemi_data def outgoing_cemi(self, cemi_data: CEMILData) -> CEMILData: """Handle outgoing CEMI frame. Pass through as plain frame or encrypt.""" # Outgoing group communication frame if isinstance(cemi_data.dst_addr, GroupAddress): if key := self._group_key_table.get(cemi_data.dst_addr): scf = SecurityControlField( algorithm=SecurityAlgorithmIdentifier.CCM_ENCRYPTION, service=SecurityALService.S_A_DATA, system_broadcast=False, tool_access=False, ) return self._secure_data_cemi(key=key, scf=scf, cemi_data=cemi_data) return cemi_data # Outgoing secure point-to-point frames are sent plain. # Data Secure point-to-point is not supported. return cemi_data def _secure_data_cemi( self, key: bytes, scf: SecurityControlField, cemi_data: CEMILData, ) -> CEMILData: """Wrap encrypted payload of a plain CEMILData in a SecureAPDU.""" plain_apdu_raw: bytes | bytearray if cemi_data.payload is not None: plain_apdu_raw = cemi_data.payload.to_knx() else: # TODO: test if this is correct plain_apdu_raw = b"" # used in point-to-point eg. TConnect secure_asdu = SecureData.init_from_plain_apdu( key=key, apdu=plain_apdu_raw, scf=scf, sequence_number=self.get_sequence_number(), address_fields_raw=cemi_data.src_addr.to_knx() + cemi_data.dst_addr.to_knx(), frame_flags=cemi_data.flags, tpci=cemi_data.tpci, ) secure_cemi_data = copy(cemi_data) secure_cemi_data.payload = SecureAPDU(scf=scf, secured_data=secure_asdu) _LOGGER.debug( "Secured APDU %s with %s", cemi_data.payload, secure_cemi_data.payload ) return secure_cemi_data xknx-3.6.0/xknx/secure/data_secure_asdu.py000066400000000000000000000222241475530762600206540ustar00rootroot00000000000000"""Class for Data Secure Application layer service data units (ASDUs).""" from __future__ import annotations from enum import IntEnum from xknx.exceptions import DataSecureError from xknx.telegram.tpci import TPCI from .security_primitives import ( calculate_message_authentication_code_cbc, decrypt_ctr, encrypt_data_ctr, ) # Secure APCI is 0x03F1 - in block_0 it is used split into 2 octets _APCI_SEC_HIGH = 0x03 _APCI_SEC_LOW = 0xF1 # only Address Type (IA / GA) and Extended Frame format are used B0_AT_FIELD_FLAGS_MASK = 0b10001111 def block_0( sequence_number: bytes, address_fields_raw: bytes, frame_flags: int, tpci_int: int, payload_length: int, ) -> bytes: """Return Block 0 for KNX Data Secure.""" return ( sequence_number + address_fields_raw + bytes( ( 0, frame_flags & B0_AT_FIELD_FLAGS_MASK, (tpci_int << 2) + _APCI_SEC_HIGH, _APCI_SEC_LOW, 0, payload_length, ) ) ) def counter_0( sequence_number: bytes, address_fields_raw: bytes, ) -> bytes: """Return Block Counter 0 for KNX Data Secure.""" return sequence_number + address_fields_raw + b"\x00\x00\x00\x00\x01\x00" class SecurityAlgorithmIdentifier(IntEnum): """Enum representing the used security algorithm.""" CCM_AUTHENTICATION = 0b000 CCM_ENCRYPTION = 0b001 class SecurityALService(IntEnum): """Enum representing the used security application layer service.""" S_A_DATA = 0b000 S_A_SYNC_REQ = 0b001 S_A_SYNC_RES = 0b011 class SecurityControlField: """Class for KNX Data Secure Security Control Field (SCF).""" __slots__ = ("algorithm", "service", "system_broadcast", "tool_access") def __init__( self, tool_access: bool, algorithm: SecurityAlgorithmIdentifier, system_broadcast: bool, service: SecurityALService, ) -> None: """Initialize SecurityControlField class.""" self.tool_access = tool_access self.algorithm = algorithm self.system_broadcast = system_broadcast self.service = service @staticmethod def from_knx(raw: int) -> SecurityControlField: """Parse/deserialize from KNX raw data.""" tool_access = bool(raw & 0b10000000) sai = SecurityAlgorithmIdentifier(raw >> 4 & 0b111) system_broadcast = bool(raw & 0b1000) s_al_service = SecurityALService(raw & 0b111) return SecurityControlField( tool_access=tool_access, algorithm=sai, system_broadcast=system_broadcast, service=s_al_service, ) def to_knx(self) -> bytes: """Serialize to KNX raw data.""" raw = 0 raw |= self.tool_access << 7 raw |= self.algorithm << 4 raw |= self.system_broadcast << 3 raw |= self.service return raw.to_bytes(1, "big") def __str__(self) -> str: """Return object as readable string.""" return ( f"" ) class SecureData: """Class for KNX Data Secure ASDU for S-A_Data-service.""" __slots__ = ("message_authentication_code", "secured_apdu", "sequence_number_bytes") def __init__( self, sequence_number_bytes: bytes, secured_apdu: bytes, message_authentication_code: bytes, ) -> None: """Initialize SecureData class.""" self.sequence_number_bytes = sequence_number_bytes self.secured_apdu = secured_apdu self.message_authentication_code = message_authentication_code def __len__(self) -> int: """Return length of KNX Data Secure ASDU.""" return 10 + len(self.secured_apdu) # 10 = 6 bytes sequence number + 4 bytes MAC @staticmethod def init_from_plain_apdu( key: bytes, apdu: bytes, scf: SecurityControlField, sequence_number: int, address_fields_raw: bytes, frame_flags: int, tpci: TPCI, ) -> SecureData: """Serialize to KNX raw data.""" sequence_number_bytes = sequence_number.to_bytes(6, "big") if scf.algorithm == SecurityAlgorithmIdentifier.CCM_AUTHENTICATION: mac = calculate_message_authentication_code_cbc( key=key, additional_data=scf.to_knx() + apdu, block_0=block_0( sequence_number=sequence_number_bytes, address_fields_raw=address_fields_raw, frame_flags=frame_flags, tpci_int=tpci.to_knx(), payload_length=0, ), )[:4] secured_apdu = apdu elif scf.algorithm == SecurityAlgorithmIdentifier.CCM_ENCRYPTION: mac_cbc = calculate_message_authentication_code_cbc( key=key, additional_data=scf.to_knx(), payload=apdu, block_0=block_0( sequence_number=sequence_number_bytes, address_fields_raw=address_fields_raw, frame_flags=frame_flags, tpci_int=tpci.to_knx(), payload_length=len(apdu), ), )[:4] secured_apdu, mac = encrypt_data_ctr( key=key, counter_0=counter_0( sequence_number=sequence_number_bytes, address_fields_raw=address_fields_raw, ), mac_cbc=mac_cbc, payload=apdu, ) else: raise DataSecureError(f"Unknown secure algorithm {scf.algorithm}") return SecureData( sequence_number_bytes=sequence_number_bytes, secured_apdu=secured_apdu, message_authentication_code=mac, # only 4 bytes are used ) def to_knx(self) -> bytes: """Serialize to KNX raw data.""" return ( self.sequence_number_bytes + self.secured_apdu + self.message_authentication_code ) @staticmethod def from_knx(raw: bytes) -> SecureData: """Parse/deserialize from KNX raw data.""" return SecureData( sequence_number_bytes=raw[:6], secured_apdu=raw[6:-4], message_authentication_code=raw[-4:], ) def get_plain_apdu( self, key: bytes, scf: SecurityControlField, address_fields_raw: bytes, frame_flags: int, tpci: TPCI, ) -> bytes: """ Get plain APDU as raw bytes. Decrypted or verified depending on algorithm. Sequence number and sender individual address shall already be checked against Security Individual Address Table before calling this method. """ if scf.algorithm == SecurityAlgorithmIdentifier.CCM_ENCRYPTION: dec_payload, mac_tr = decrypt_ctr( key=key, counter_0=counter_0( sequence_number=self.sequence_number_bytes, address_fields_raw=address_fields_raw, ), mac=self.message_authentication_code, payload=self.secured_apdu, ) mac_cbc = calculate_message_authentication_code_cbc( key=key, additional_data=scf.to_knx(), payload=dec_payload, block_0=block_0( sequence_number=self.sequence_number_bytes, address_fields_raw=address_fields_raw, frame_flags=frame_flags, tpci_int=tpci.to_knx(), payload_length=len(dec_payload), ), )[:4] if mac_cbc != mac_tr: raise DataSecureError("Data Secure MAC verification failed") return dec_payload if scf.algorithm == SecurityAlgorithmIdentifier.CCM_AUTHENTICATION: mac = calculate_message_authentication_code_cbc( key=key, additional_data=scf.to_knx() + self.secured_apdu, block_0=block_0( sequence_number=self.sequence_number_bytes, address_fields_raw=address_fields_raw, frame_flags=frame_flags, tpci_int=tpci.to_knx(), payload_length=0, ), )[:4] if mac != self.message_authentication_code: raise DataSecureError("Data Secure MAC verification failed.") return self.secured_apdu raise DataSecureError(f"Unknown secure algorithm {scf.algorithm}") def __repr__(self) -> str: """Return object as readable string.""" return ( "' ) xknx-3.6.0/xknx/secure/keyring.py000066400000000000000000000462461475530762600170430ustar00rootroot00000000000000"""Keyring class for loading and decrypting knxkeys files.""" from __future__ import annotations from abc import ABC, abstractmethod import asyncio import base64 import enum from itertools import chain import logging import os from pathlib import Path from typing import Any from xml.dom.minidom import Attr, Document, Element, parse from xml.etree.ElementTree import ElementTree import xml.sax from xml.sax.handler import ContentHandler from xml.sax.xmlreader import AttributesImpl from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from xknx.exceptions.exception import InvalidSecureConfiguration from xknx.telegram import GroupAddress, IndividualAddress from .util import sha256_hash logger = logging.getLogger("xknx.core") class InterfaceType(enum.Enum): """Interface type enum.""" TUNNELING = "Tunneling" BACKBONE = "Backbone" USB = "USB" class AttributeReader(ABC): """Abstract base class for modelling attribute reader capabilities.""" @abstractmethod def parse_xml(self, node: Element) -> None: """Parse all needed attributes from the given node map.""" def decrypt_attributes( self, password_hash: bytes, initialization_vector: bytes ) -> None: """Decrypt attribute data.""" return @staticmethod def get_attribute_value(attribute: Attr | Any) -> Any: """Get a given attribute value from an attribute document.""" if isinstance(attribute, Attr): return attribute.value return attribute class XMLAssignedGroupAddress(AttributeReader): """Assigned Group Addresses to an interface in a knxkeys file.""" address: GroupAddress senders: list[IndividualAddress] def parse_xml(self, node: Element) -> None: """Parse all needed attributes from the given node map.""" attributes = node.attributes self.address = GroupAddress( self.get_attribute_value(attributes.get("Address", None)) ) self.senders = [ IndividualAddress(sender) for sender in ( self.get_attribute_value(attributes.get("Senders", "")) ).split() ] class XMLInterface(AttributeReader): """Interface in a knxkeys file.""" type: InterfaceType individual_address: IndividualAddress host: IndividualAddress | None = None user_id: int | None = None password: str | None = None decrypted_password: str | None = None decrypted_authentication: str | None = None authentication: str | None = None group_addresses: dict[GroupAddress, list[IndividualAddress]] def parse_xml(self, node: Element) -> None: """Parse all needed attributes from the given node map.""" attributes = node.attributes self.type = InterfaceType(self.get_attribute_value(attributes.get("Type"))) self.individual_address = IndividualAddress( self.get_attribute_value(attributes.get("IndividualAddress")) ) _host = self.get_attribute_value(attributes.get("Host")) self.host = IndividualAddress(_host) if _host else None _user_id = self.get_attribute_value(attributes.get("UserID")) self.user_id = int(_user_id) if _user_id else None self.password = self.get_attribute_value(attributes.get("Password")) self.authentication = self.get_attribute_value(attributes.get("Authentication")) self.group_addresses = {} for assigned_ga in filter(lambda x: x.nodeType != 3, node.childNodes): xml_group_address: XMLAssignedGroupAddress = XMLAssignedGroupAddress() xml_group_address.parse_xml(assigned_ga) self.group_addresses[xml_group_address.address] = xml_group_address.senders def decrypt_attributes( self, password_hash: bytes, initialization_vector: bytes ) -> None: """Decryt attributes.""" self.decrypted_password = ( extract_password( decrypt_aes128cbc( base64.b64decode(self.password), password_hash, initialization_vector, ) ) if self.password is not None else None ) self.decrypted_authentication = ( extract_password( decrypt_aes128cbc( base64.b64decode(self.authentication), password_hash, initialization_vector, ) ) if self.authentication is not None else None ) class XMLBackbone(AttributeReader): """Backbone in a knxkeys file.""" decrypted_key: bytes | None = None key: str | None = None latency: int | None = None multicast_address: str | None = None def parse_xml(self, node: Element) -> None: """Parse all needed attributes from the given node map.""" attributes = node.attributes self.key = self.get_attribute_value(attributes.get("Key")) if latency := self.get_attribute_value(attributes.get("Latency")): self.latency = int(latency) self.multicast_address = self.get_attribute_value( attributes.get("MulticastAddress") ) def decrypt_attributes( self, password_hash: bytes, initialization_vector: bytes ) -> None: """Decrypt attribute data.""" if self.key: self.decrypted_key = decrypt_aes128cbc( base64.b64decode(self.key), password_hash, initialization_vector ) class XMLGroupAddress(AttributeReader): """Group Address in a knxkeys file.""" address: GroupAddress decrypted_key: bytes | None = None key: str def parse_xml(self, node: Element) -> None: """Parse all needed attributes from the given node map.""" attributes = node.attributes self.address = GroupAddress(self.get_attribute_value(attributes.get("Address"))) self.key = self.get_attribute_value(attributes.get("Key")) def decrypt_attributes( self, password_hash: bytes, initialization_vector: bytes ) -> None: """Decrypt attribute data.""" if self.key: self.decrypted_key = decrypt_aes128cbc( base64.b64decode(self.key), password_hash, initialization_vector ) class XMLDevice(AttributeReader): """Device in a knxkeys file.""" individual_address: IndividualAddress tool_key: str decrypted_tool_key: bytes | None = None management_password: str decrypted_management_password: str | None = None decrypted_authentication: str | None = None authentication: str sequence_number: int def parse_xml(self, node: Element) -> None: """Parse all needed attributes from the given node map.""" attributes = node.attributes self.individual_address = IndividualAddress( self.get_attribute_value(attributes.get("IndividualAddress")) ) self.tool_key = self.get_attribute_value(attributes.get("ToolKey")) self.management_password = self.get_attribute_value( attributes.get("ManagementPassword") ) self.authentication = self.get_attribute_value(attributes.get("Authentication")) self.sequence_number = int( self.get_attribute_value(attributes.get("SequenceNumber", 0)) ) def decrypt_attributes( self, password_hash: bytes, initialization_vector: bytes ) -> None: """Decrypt attributes.""" self.decrypted_tool_key = ( decrypt_aes128cbc( base64.b64decode(self.tool_key), password_hash, initialization_vector ) if self.tool_key is not None else None ) self.decrypted_authentication = ( extract_password( decrypt_aes128cbc( base64.b64decode(self.authentication), password_hash, initialization_vector, ) ) if self.authentication is not None else None ) self.decrypted_management_password = ( extract_password( decrypt_aes128cbc( base64.b64decode(self.management_password), password_hash, initialization_vector, ) ) if self.management_password is not None else None ) class Keyring(AttributeReader): """Class for loading and decrypting knxkeys XML files.""" backbone: XMLBackbone | None = None interfaces: list[XMLInterface] group_addresses: list[XMLGroupAddress] devices: list[XMLDevice] project_name: str created_by: str created: str signature: bytes xmlns: str def __init__(self) -> None: """Initialize the Keyring.""" self.interfaces = [] self.devices = [] self.group_addresses = [] def get_device_by_interface(self, interface: XMLInterface) -> XMLDevice | None: """Get the device for a given interface.""" for device in self.devices: if device.individual_address == interface.host: return device return None def get_tunnel_host_by_interface( self, tunnelling_slot: IndividualAddress ) -> IndividualAddress | None: """Get the tunnel host for a given interface.""" return next( ( interface.host for interface in self.interfaces if interface.type is InterfaceType.TUNNELING and interface.individual_address == tunnelling_slot ), None, ) def get_tunnel_interfaces_by_host( self, host: IndividualAddress ) -> list[XMLInterface]: """Get all tunnel interfaces of a given host individual address.""" return [ tunnel for tunnel in self.interfaces if tunnel.type is InterfaceType.TUNNELING and tunnel.host == host ] def get_tunnel_interface_by_host_and_user_id( self, host: IndividualAddress, user_id: int ) -> XMLInterface | None: """Get the tunnel interface with the given host and user id.""" return next( ( tunnel for tunnel in self.get_tunnel_interfaces_by_host(host) if tunnel.user_id == user_id ), None, ) def get_tunnel_interface_by_individual_address( self, tunnelling_slot: IndividualAddress ) -> XMLInterface | None: """Get the interface with the given tunneling address.""" return next( ( tunnel for tunnel in self.interfaces if tunnel.type is InterfaceType.TUNNELING and tunnel.individual_address == tunnelling_slot ), None, ) def get_interface_by_individual_address( self, individual_address: IndividualAddress ) -> XMLInterface | None: """Get the interface with the given individual address. Any interface type.""" return next( ( interface for interface in self.interfaces if interface.individual_address == individual_address ), None, ) def get_data_secure_group_keys( self, receiver: IndividualAddress | None = None ) -> dict[GroupAddress, bytes]: """ Get data secure group keys. If `receiver` is None, all data secure sending devices are returned. Else the result is filtered by the given receiver. """ ga_key_table = { group_address.address: group_address.decrypted_key for group_address in self.group_addresses if group_address.decrypted_key is not None } if receiver is None: return ga_key_table rcv_interface = self.get_interface_by_individual_address( individual_address=receiver ) if rcv_interface is None: return {} return { ga: key for ga, key in ga_key_table.items() if ga in rcv_interface.group_addresses } def get_data_secure_senders(self) -> dict[IndividualAddress, int]: """ Get all data secure sending device addresses. Sequence numbers are sourced from devices list or default to 0. """ ia_seq_table: dict[IndividualAddress, int] = {} for interface in self.interfaces: for senders in interface.group_addresses.values(): ia_seq_table |= {sender: 0 for sender in senders} # devices are only available if the full project was exported for device in self.devices: ia_seq_table[device.individual_address] = device.sequence_number # TODO: check if this should default to 0 or if devices without a sequence number # in keyfile should be excluded from the table (are there non-secure devices listed?) return ia_seq_table def parse_xml(self, node: Element) -> None: """Parse all needed attributes from the given node map.""" attributes = node.attributes self.project_name = self.get_attribute_value(attributes.get("Project")) self.created_by = self.get_attribute_value(attributes.get("CreatedBy")) self.created = self.get_attribute_value(attributes.get("Created")) self.signature = base64.b64decode( self.get_attribute_value(attributes.get("Signature")) ) self.xmlns = self.get_attribute_value(attributes.get("xmlns")) for sub_node in filter(lambda x: x.nodeType != 3, node.childNodes): if sub_node.nodeName == "Interface": interface: XMLInterface = XMLInterface() interface.parse_xml(sub_node) self.interfaces.append(interface) if sub_node.nodeName == "Backbone": backbone: XMLBackbone = XMLBackbone() backbone.parse_xml(sub_node) self.backbone = backbone if sub_node.nodeName == "Devices": device_doc: Element for device_doc in filter( lambda x: x.nodeType != 3, sub_node.childNodes ): device: XMLDevice = XMLDevice() device.parse_xml(device_doc) self.devices.append(device) elif sub_node.nodeName == "GroupAddresses": ga_doc: Element for ga_doc in filter(lambda x: x.nodeType != 3, sub_node.childNodes): xml_ga: XMLGroupAddress = XMLGroupAddress() xml_ga.parse_xml(ga_doc) self.group_addresses.append(xml_ga) def decrypt(self, password: str) -> None: """Decrypt all data.""" hashed_password = hash_keyring_password(password.encode("utf-8")) initialization_vector = sha256_hash(self.created.encode("utf-8"))[:16] for xml_element in chain(self.interfaces, self.group_addresses, self.devices): xml_element.decrypt_attributes(hashed_password, initialization_vector) if self.backbone is not None: self.backbone.decrypt_attributes(hashed_password, initialization_vector) async def load_keyring( path: str | os.PathLike[Any], password: str, validate_signature: bool = True ) -> Keyring: """Load a .knxkeys file from the given path in an executor.""" return await asyncio.to_thread( sync_load_keyring, path, password, validate_signature=validate_signature, ) def sync_load_keyring( path: str | os.PathLike[Any], password: str, validate_signature: bool = True ) -> Keyring: """Load a .knxkeys file from the given path.""" _path = Path(path) if validate_signature and not verify_keyring_signature(_path, password): raise InvalidSecureConfiguration( "Signature verification of keyring file failed. Invalid password or malformed file content." ) keyring: Keyring = Keyring() try: with _path.open(encoding="utf-8") as file: dom: Document = parse(file) keyring.parse_xml(dom.getElementsByTagName("Keyring")[0]) keyring.decrypt(password) return keyring except Exception as exception: logger.exception("There was an error during loading the knxkeys file.") raise InvalidSecureConfiguration() from exception class KeyringSAXContentHandler(ContentHandler): """SAX parser for keyring signature verification.""" _attribute_blacklist = ("xmlns", "Signature") def __init__(self, keyring_password: str) -> None: """Initialize.""" self.hashed_password = hash_keyring_password(keyring_password.encode("utf-8")) self.output = bytearray() super().__init__() def endDocument(self) -> None: """Receive notification of the end of a document.""" self.append_string(base64.b64encode(self.hashed_password)) def startElement(self, name: str, attrs: AttributesImpl) -> None: """Start Element.""" self.output.append(1) self.append_string(name) for attr_name, attr_value in sorted(attrs.items()): if attr_name not in self._attribute_blacklist: self.append_string(attr_name) self.append_string(attr_value) def endElement(self, name: str) -> None: """Receive notification of the end of an element.""" self.output.append(2) def append_string(self, value: str | bytes) -> None: """Append a string to a byte array for signature verification.""" if isinstance(value, str): value = value.encode("utf-8") self.output.append(len(value)) self.output.extend(value) def verify_keyring_signature(path: str | os.PathLike[Any], password: str) -> bool: """Verify the signature of the given knxkeys file.""" handler = KeyringSAXContentHandler(password) signature: bytes _path = Path(path) with _path.open(encoding="utf-8") as file: element = ElementTree().parse(file) signature = base64.b64decode(element.attrib.get("Signature", "")) with _path.open(encoding="utf-8") as file: parser = xml.sax.make_parser() parser.setContentHandler(handler) parser.parse(file) # type: ignore[no-untyped-call] return sha256_hash(handler.output)[:16] == signature def decrypt_aes128cbc( encrypted_data: bytes, key: bytes, initialization_vector: bytes ) -> bytes: """Decrypt data with AES 128 CBC.""" cipher = Cipher(algorithms.AES(key), modes.CBC(initialization_vector)) decryptor = cipher.decryptor() return bytes(decryptor.update(encrypted_data) + decryptor.finalize()) def hash_keyring_password(password: bytes) -> bytes: """Hash a given keyring password.""" kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=16, salt=b"1.keyring.ets.knx.org", iterations=65_536, ) return kdf.derive(password) def extract_password(data: bytes) -> str: """Extract the password.""" if not data: return "" length: int = data[-1] res: bytes = data[8:-length] return res.decode("utf-8") xknx-3.6.0/xknx/secure/security_primitives.py000066400000000000000000000064321475530762600215060ustar00rootroot00000000000000"""Encryption and Decryption functions for KNX Secure.""" from __future__ import annotations from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from .util import byte_pad def calculate_message_authentication_code_cbc( key: bytes, additional_data: bytes, payload: bytes = b"", block_0: bytes = bytes(16), ) -> bytes: """Calculate the message authentication code (MAC) for a message with AES-CBC.""" blocks = ( block_0 + len(additional_data).to_bytes(2, "big") + additional_data + payload ) y_cipher = Cipher(algorithms.AES(key), modes.CBC(bytes(16))) y_encryptor = y_cipher.encryptor() y_blocks = ( y_encryptor.update(byte_pad(blocks, block_size=16)) + y_encryptor.finalize() ) # only calculate, no ctr encryption return y_blocks[-16:] def decrypt_ctr( key: bytes, counter_0: bytes, mac: bytes, payload: bytes = b"", ) -> tuple[bytes, bytes]: """ Decrypt data from SecureWrapper. MAC will be decoded first with counter 0. Returns a tuple of (KNX/IP frame bytes, MAC TR for verification). """ cipher = Cipher(algorithms.AES(key), modes.CTR(counter_0)) decryptor = cipher.decryptor() mac_tr = decryptor.update(mac) # MAC is encrypted with counter 0 decrypted_data = decryptor.update(payload) + decryptor.finalize() return (decrypted_data, mac_tr) def encrypt_data_ctr( key: bytes, counter_0: bytes, mac_cbc: bytes, payload: bytes = b"", ) -> tuple[bytes, bytes]: """ Encrypt data with AES-CTR. Payload is expected a full Plain KNX/IP frame with header. MAC shall be encrypted with counter 0, KNXnet/IP frame with incremented counters. Returns a tuple of encrypted data (if there is any) and encrypted MAC. """ s_cipher = Cipher(algorithms.AES(key), modes.CTR(counter_0)) s_encryptor = s_cipher.encryptor() mac = s_encryptor.update(mac_cbc) encrypted_data = s_encryptor.update(payload) + s_encryptor.finalize() return (encrypted_data, mac) def derive_device_authentication_password(device_authentication_password: str) -> bytes: """Derive device authentication password.""" kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=16, salt=b"device-authentication-code.1.secure.ip.knx.org", iterations=65536, ) return kdf.derive(device_authentication_password.encode("latin-1")) def derive_user_password(password_string: str) -> bytes: """Derive user password.""" kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=16, salt=b"user-password.1.secure.ip.knx.org", iterations=65536, ) return kdf.derive(password_string.encode("latin-1")) def generate_ecdh_key_pair() -> tuple[X25519PrivateKey, bytes]: """ Generate an ECDH key pair. Return the private key and the raw bytes of the public key. """ private_key = X25519PrivateKey.generate() public_key_raw = private_key.public_key().public_bytes( serialization.Encoding.Raw, serialization.PublicFormat.Raw ) return private_key, public_key_raw xknx-3.6.0/xknx/secure/util.py000066400000000000000000000015061475530762600163360ustar00rootroot00000000000000"""Utilities for KNX Secure.""" from cryptography.hazmat.primitives import hashes def bytes_xor(a: bytes, b: bytes) -> bytes: # pylint: disable=invalid-name """ XOR two bytes values. Different lengths raise ValueError. """ if len(a) != len(b): raise ValueError("Length of a and b must be equal.") return (int.from_bytes(a, "big") ^ int.from_bytes(b, "big")).to_bytes(len(a), "big") def byte_pad(data: bytes, block_size: int) -> bytes: """Pad data with 0x00 until its length is a multiple of block_size.""" if remainder := len(data) % block_size: return data + bytes(block_size - remainder) return data def sha256_hash(data: bytes) -> bytes: """Calculate SHA256 hash of data.""" digest = hashes.Hash(hashes.SHA256()) digest.update(data) return digest.finalize() xknx-3.6.0/xknx/telegram/000077500000000000000000000000001475530762600153175ustar00rootroot00000000000000xknx-3.6.0/xknx/telegram/__init__.py000066400000000000000000000007071475530762600174340ustar00rootroot00000000000000""" Module for handling KNX primitives. * KNX Addresses * KNX Telegrams """ # ruff: noqa: F401 from .address import GroupAddress, GroupAddressType, IndividualAddress from .address_filter import AddressFilter from .telegram import Telegram, TelegramDecodedData, TelegramDirection __all__ = [ "AddressFilter", "GroupAddress", "GroupAddressType", "IndividualAddress", "Telegram", "TelegramDecodedData", "TelegramDirection", ] xknx-3.6.0/xknx/telegram/address.py000066400000000000000000000306001475530762600173150ustar00rootroot00000000000000""" Module for serialization/deserialization and handling of KNX addresses. The module can handle: * individual addresses of devices. * (logical) group addresses. * xknx internal group addresses. The module supports all different writings of group addresses: * 3rn level: "1/2/3" * 2nd level: "1/2" * Free format: "123" """ from __future__ import annotations from abc import ABC, abstractmethod from enum import Enum from re import compile as re_compile from typing import ClassVar, Union from xknx.exceptions import CouldNotParseAddress from xknx.typing import Self GroupAddressableType = Union["GroupAddress", str, int] IndividualAddressableType = Union["IndividualAddress", str, int] InternalGroupAddressableType = Union["InternalGroupAddress", str] DeviceAddressableType = GroupAddressableType | InternalGroupAddressableType DeviceGroupAddress = Union["GroupAddress", "InternalGroupAddress"] INVALID_PREFIX_MESSAGE = "Invalid prefix for internal group address" def parse_device_group_address( address: DeviceAddressableType, ) -> DeviceGroupAddress: """Parse an Addressable type to GroupAddress or InternalGroupAddress.""" try: group_address = GroupAddress(address) # type: ignore[arg-type] # InternalGroupAddress will raise except CouldNotParseAddress as ex: if isinstance(address, str | InternalGroupAddress): try: return InternalGroupAddress(address) except CouldNotParseAddress as internal_ex: # prefer to raise original exception from GroupAddress if internal_ex.message != INVALID_PREFIX_MESSAGE: raise internal_ex raise ex if group_address.raw == 0: raise CouldNotParseAddress(address, "Broadcast address invalid for devices") return group_address class BaseAddress(ABC): """Base class for all knx address types.""" __slots__ = ("raw",) raw: int @abstractmethod def __init__( self, address: IndividualAddressableType | GroupAddressableType ) -> None: """Initialize Address instance. To be implemented in derived class.""" @classmethod def from_knx(cls: type[Self], raw: bytes) -> Self: """Parse/deserialize from KNX/IP raw data.""" return cls(int.from_bytes(raw, "big")) def to_knx(self) -> bytes: """ Serialize to KNX/IP raw data. Returns a bytes object with length of 2 from the raw value. """ return int.to_bytes(self.raw, 2, "big") def __eq__(self, other: object | None) -> bool: """ Implement the equal operator. Returns `True` if we check against the same subclass and the raw Value matches. """ return isinstance(other, self.__class__) and self.raw == other.raw def __hash__(self) -> int: """Hash Address so it can be used as dict key.""" return hash((self.__class__, self.raw)) class IndividualAddress(BaseAddress): """Class for handling KNX individual addresses.""" __slots__ = () MAX_AREA = 15 MAX_MAIN = 15 MAX_LINE = 255 ADDRESS_RE = re_compile( r"^(?P\d{1,2})\.(?P
\d{1,2})\.(?P\d{1,3})$" ) def __init__(self, address: IndividualAddressableType) -> None: """Initialize IndividualAddress class.""" if isinstance(address, int): self.raw = address elif isinstance(address, IndividualAddress): self.raw = address.raw elif isinstance(address, str): if address.isdigit(): self.raw = int(address) else: self.raw = self.__string_to_int(address) else: raise CouldNotParseAddress(address, message="Invalid type") if not 0 <= self.raw <= 65535: raise CouldNotParseAddress( address, message="Address out of range (0..65535)" ) def __string_to_int(self, address: str) -> int: """ Parse `address` as string to an integer and do some simple checks. Returns the integer representation of `address` if all checks are valid: * string matches against the regular expression * area, main and line are inside its range In any other case, we raise an `CouldNotParseAddress` exception. """ match = self.ADDRESS_RE.match(address) if not match: raise CouldNotParseAddress(address, message="Invalid format") area = int(match.group("area")) main = int(match.group("main")) line = int(match.group("line")) if area > self.MAX_AREA: raise CouldNotParseAddress( address, message=f"Area part out of range (0..{self.MAX_AREA})" ) if main > self.MAX_MAIN: raise CouldNotParseAddress( address, message=f"Line part out of range (0..{self.MAX_MAIN})" ) if line > self.MAX_LINE: raise CouldNotParseAddress( address, message=f"Device part out of range (0..{self.MAX_LINE})" ) return (area << 12) + (main << 8) + line @property def area(self) -> int: """Return area part of individual address.""" return (self.raw >> 12) & self.MAX_AREA @property def main(self) -> int: """Return main part of individual address.""" return (self.raw >> 8) & self.MAX_MAIN @property def line(self) -> int: """Return line part of individual address.""" return self.raw & self.MAX_LINE @property def is_device(self) -> bool: """Return `True` if this address is a valid device address.""" return self.line != 0 @property def is_line(self) -> bool: """Return `True` if this address is a valid line address.""" return not self.is_device def __str__(self) -> str: """Return object as in KNX notation (e.g. '1.2.3').""" return f"{self.area}.{self.main}.{self.line}" def __repr__(self) -> str: """Return this object as parsable string.""" return f'IndividualAddress("{self}")' class GroupAddressType(Enum): """ Possible types of `GroupAddress`. KNX knows three types of group addresses: * FREE, a integer or hex representation * SHORT, a representation like '1/123', without middle groups * LONG, a representation like '1/2/34', with middle groups """ FREE = 0 SHORT = 2 LONG = 3 class GroupAddress(BaseAddress): """Class for handling KNX group addresses.""" __slots__ = () # overridden by XKNX class on initialization to have consistent global string representation address_format: ClassVar[GroupAddressType] = GroupAddressType.LONG MAX_MAIN = 31 MAX_MIDDLE = 7 MAX_SUB_LONG = 255 MAX_SUB_SHORT = 2047 MAX_FREE = 65535 ADDRESS_RE = re_compile( r"^(?P
\d{1,2})(/(?P\d{1,2}))?/(?P\d{1,4})$" ) def __init__(self, address: GroupAddressableType) -> None: """Initialize GroupAddress class.""" if isinstance(address, int): self.raw = address elif isinstance(address, GroupAddress): self.raw = address.raw elif isinstance(address, str): if address.isdigit(): self.raw = int(address) else: self.raw = self.__string_to_int(address) else: raise CouldNotParseAddress(address, message="Invalid type") if not 0 <= self.raw <= 65535: raise CouldNotParseAddress( address, message="Address out of range (0..65535)" ) def __string_to_int(self, address: str) -> int: """ Parse `address` as string to an integer and do some simple checks. Returns the integer representation of `address` if all checks are valid: * string matches against the regular expression * main, middle and sub are inside its range In any other case, we raise an `CouldNotParseAddress` exception. """ match = self.ADDRESS_RE.match(address) if not match: raise CouldNotParseAddress(address, message="Invalid format") main = int(match.group("main")) middle = ( int(match.group("middle")) if match.group("middle") is not None else None ) sub = int(match.group("sub")) if main > self.MAX_MAIN: raise CouldNotParseAddress( address, message=f"Main group out of range (0..{self.MAX_MAIN})" ) if middle is not None: if middle > self.MAX_MIDDLE: raise CouldNotParseAddress( address, message=f"Middle group out of range (0..{self.MAX_MIDDLE})" ) if sub > self.MAX_SUB_LONG: raise CouldNotParseAddress( address, message=f"Sub group out of range (0..{self.MAX_SUB_LONG})" ) elif sub > self.MAX_SUB_SHORT: raise CouldNotParseAddress( address, message=f"Sub group out of range (0..{self.MAX_SUB_SHORT})" ) return ( (main << 11) + (middle << 8) + sub if middle is not None else (main << 11) + sub ) @property def main(self) -> int | None: """ Return the main group part as an integer. Works only if the group dont uses `GroupAddressType.FREE`, returns `None` in any other case. """ return ( (self.raw >> 11) & self.MAX_MAIN if self.address_format != GroupAddressType.FREE else None ) @property def middle(self) -> int | None: """ Return the middle group part as an integer. Works only if the group uses `GroupAddressType.LONG`, returns `None` in any other case. """ return ( (self.raw >> 8) & self.MAX_MIDDLE if self.address_format == GroupAddressType.LONG else None ) @property def sub(self) -> int: """ Return the sub group part as an integer. Works with any `GroupAddressType`, as we always have sub groups. """ if self.address_format == GroupAddressType.SHORT: return self.raw & self.MAX_SUB_SHORT if self.address_format == GroupAddressType.LONG: return self.raw & self.MAX_SUB_LONG return self.raw def __str__(self) -> str: """ Return object as in KNX notation (e.g. '1/2/3'). Honors the used `GroupAddressType` of this group. """ if self.address_format == GroupAddressType.LONG: return f"{self.main}/{self.middle}/{self.sub}" if self.address_format == GroupAddressType.SHORT: return f"{self.main}/{self.sub}" return f"{self.sub}" def __repr__(self) -> str: """Return object as parsable string.""" return f'GroupAddress("{self}")' class InternalGroupAddress: """Class for handling addresses used internally in xknx devices only.""" __slots__ = ("raw",) def __init__(self, address: str | InternalGroupAddress) -> None: """Initialize InternalGroupAddress class.""" self.raw: str if isinstance(address, InternalGroupAddress): self.raw = address.raw return if not isinstance(address, str): raise CouldNotParseAddress(address, message="Invalid type") prefix_length = 1 if len(address) < 2 or address[0].lower() != "i": raise CouldNotParseAddress(address, message=INVALID_PREFIX_MESSAGE) if address[1] in "-_": prefix_length = 2 _raw = address[prefix_length:].strip() if not _raw: raise CouldNotParseAddress(address, message="No chars after prefix") self.raw = f"i-{_raw}" def __str__(self) -> str: """Return object as readable string (e.g. 'i-123').""" return self.raw def __repr__(self) -> str: """Return object as parsable string.""" return f'InternalGroupAddress("{self.raw}")' def __eq__(self, other: object | None) -> bool: """ Implement the equal operator. Returns `True` if we check against the same subclass and the raw Value matches. """ return isinstance(other, self.__class__) and self.raw == other.raw def __hash__(self) -> int: """Hash Address so it can be used as dict key.""" return hash((self.__class__, self.raw)) xknx-3.6.0/xknx/telegram/address_filter.py000066400000000000000000000136231475530762600206700ustar00rootroot00000000000000""" AddressFilter provides a mechanism for filtering KNX addresses with patterns. Patterns can be for level3 KNX group addresses: AddressFilter("1/*/2-5") AddressFilter("1/1-3,4,5/*") AddressFilter("1/2/-10) for level2 KNX group addresses: AddressFilter("*/2-5") AddressFilter("1-3,4,5/*") AddressFilter("2/-10") for free format KNX group addresses: AddressFilter("2-5") AddressFilter("1-3,4,5") AddressFilter("-10") for xknx internal group addresses: AddressFilter("i-test") AddressFilter("i-t?st") AddressFilter("i-t*t") """ from __future__ import annotations from fnmatch import fnmatch from xknx.exceptions import ConversionError from .address import GroupAddress, InternalGroupAddress, parse_device_group_address class AddressFilter: """Class for filtering Addresses according to patterns.""" def __init__(self, pattern: str) -> None: """Initialize AddressFilter class.""" self.level_filters: list[AddressFilter.LevelFilter] = [] self.internal_group_address_pattern: str | None = None self._parse_pattern(pattern) def _parse_pattern(self, pattern: str) -> None: if pattern.startswith("i"): self.internal_group_address_pattern = InternalGroupAddress(pattern).raw return for part in pattern.split("/"): self.level_filters.append(AddressFilter.LevelFilter(part)) if len(self.level_filters) > 3: raise ConversionError("Too many parts within pattern.", pattern=pattern) def match(self, address: str | int | GroupAddress | InternalGroupAddress) -> bool: """Test if provided address matches Addressfilter.""" if isinstance(address, str | int): address = parse_device_group_address(address) if isinstance(address, GroupAddress) and self.level_filters: if len(self.level_filters) == 3: return self._match_level3(address) if len(self.level_filters) == 2: return self._match_level2(address) return self._match_free(address) if ( isinstance(address, InternalGroupAddress) and self.internal_group_address_pattern ): return fnmatch(address.raw, self.internal_group_address_pattern) return False def _match_level3(self, address: GroupAddress) -> bool: if address.main is None or address.middle is None: raise ConnectionError( f"Match level 3 incompatible with address level {GroupAddress.address_format}" ) return bool( self.level_filters[0].match(address.main) and self.level_filters[1].match(address.middle) and self.level_filters[2].match(address.sub) ) def _match_level2(self, address: GroupAddress) -> bool: if address.main is None: raise ConnectionError( f"Match level 2 incompatible with address level {GroupAddress.address_format}" ) return bool( self.level_filters[0].match(address.main) and self.level_filters[1].match(address.sub) ) def _match_free(self, address: GroupAddress) -> bool: return bool(self.level_filters[0].match(address.sub)) class Range: """Class for filtering patterns like "8", "*", "8-10".""" def __init__(self, pattern: str) -> None: """Initialize Range object.""" self.range_from: int = 0 self.range_to: int = 0 self._parse_pattern(pattern) def _parse_pattern(self, pattern: str) -> None: if pattern == "*": self._init_wildcard() elif pattern.isdigit(): self._init_digit(pattern) elif "-" in pattern: self._init_range(pattern) self.range_to = self._adjust_range(self.range_to) self.range_from = self._adjust_range(self.range_from) self._flip_range_if_necessary() def _init_wildcard(self) -> None: self.range_from = 0 self.range_to = GroupAddress.MAX_FREE def _init_digit(self, pattern: str) -> None: digit = int(pattern) self.range_from = digit self.range_to = digit def _init_range(self, pattern: str) -> None: (range_from, range_to) = pattern.split("-") self.range_from = int(range_from) if range_from else 0 self.range_to = int(range_to) if range_to else GroupAddress.MAX_FREE @staticmethod def _adjust_range(digit: int) -> int: if digit > GroupAddress.MAX_FREE: return GroupAddress.MAX_FREE if digit < 0: return 0 return digit def _flip_range_if_necessary(self) -> None: if self.range_from > self.range_to: self.range_to, self.range_from = self.range_from, self.range_to def get_range(self) -> tuple[int, int]: """Return the range (from,to) of this pattern.""" return self.range_from, self.range_to def match(self, digit: int) -> bool: """Return if given digit is within range of pattern.""" return bool(self.range_from <= digit <= self.range_to) class LevelFilter: """Class for filtering patterns like "8,11-14,20".""" def __init__(self, pattern: str) -> None: """Initialize LevelFilter.""" self.ranges: list[AddressFilter.Range] = [] self._parse_pattern(pattern) def _parse_pattern(self, pattern: str) -> None: for part in pattern.split(","): self.ranges.append(AddressFilter.Range(part)) def match(self, digit: int) -> bool: """Return if given digit is within range of pattern.""" return any(_range.match(digit) for _range in self.ranges) xknx-3.6.0/xknx/telegram/apci.py000066400000000000000000001542401475530762600166130ustar00rootroot00000000000000""" Module for serialization and deserialization of APCI payloads. APCI stands for Application Layer Protocol Control Information. An APCI payload contains a service and payload. For example, a GroupValueWrite is a service that takes a DPT as a value. """ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum import struct from typing import ClassVar, cast from xknx.dpt import DPTArray, DPTBinary from xknx.exceptions import ConversionError from xknx.secure.data_secure_asdu import SecureData, SecurityControlField from xknx.telegram.address import IndividualAddress def encode_cmd_and_payload( cmd: APCIService | APCIUserService | APCIExtendedService, encoded_payload: int = 0, appended_payload: bytes | None = None, ) -> bytearray: """Encode cmd and payload.""" data = bytearray( [ (cmd.value >> 8) & 0b11, (cmd.value & 0xFF) | (encoded_payload & DPTBinary.APCI_BITMASK), ] ) if appended_payload: data.extend(appended_payload) return data class APCIService(Enum): """Enum class for APCI services.""" GROUP_READ = 0x0000 GROUP_RESPONSE = 0x0040 GROUP_WRITE = 0x0080 INDIVIDUAL_ADDRESS_WRITE = 0x00C0 INDIVIDUAL_ADDRESS_READ = 0x0100 INDIVIDUAL_ADDRESS_RESPONSE = 0x140 ADC_READ = 0x0180 ADC_RESPONSE = 0x1C0 MEMORY_EXTENDED_WRITE = 0x1FB MEMORY_EXTENDED_WRITE_RESPONSE = 0x1FC MEMORY_EXTENDED_READ = 0x1FD MEMORY_EXTENDED_READ_RESPONSE = 0x1FE MEMORY_READ = 0x0200 MEMORY_RESPONSE = 0x0240 MEMORY_WRITE = 0x0280 USER_MESSAGE = 0x02C0 DEVICE_DESCRIPTOR_READ = 0x0300 DEVICE_DESCRIPTOR_RESPONSE = 0x0340 RESTART = 0x0380 ESCAPE = 0x03C0 class APCIUserService(Enum): """Enum class for user message APCI services.""" USER_MEMORY_READ = 0x02C0 USER_MEMORY_RESPONSE = 0x02C1 USER_MEMORY_WRITE = 0x02C2 USER_MANUFACTURER_INFO_READ = 0x02C5 USER_MANUFACTURER_INFO_RESPONSE = 0x02C6 FUNCTION_PROPERTY_COMMAND = 0x02C7 FUNCTION_PROPERTY_STATE_READ = 0x02C8 FUNCTION_PROPERTY_STATE_RESPONSE = 0x02C9 class APCIExtendedService(Enum): """Enum class for extended APCI services.""" AUTHORIZE_REQUEST = 0x03D1 AUTHORIZE_RESPONSE = 0x03D2 PROPERTY_VALUE_READ = 0x03D5 PROPERTY_VALUE_RESPONSE = 0x03D6 PROPERTY_VALUE_WRITE = 0x03D7 PROPERTY_DESCRIPTION_READ = 0x03D8 PROPERTY_DESCRIPTION_RESPONSE = 0x03D9 INDIVIDUAL_ADDRESS_SERIAL_READ = 0x03DC INDIVIDUAL_ADDRESS_SERIAL_RESPONSE = 0x03DD INDIVIDUAL_ADDRESS_SERIAL_WRITE = 0x03DE # DataSecure APCI_SEC = 0x03F1 @dataclass(slots=True) class APCI(ABC): """ Base class for ACPI services. This base class is only the interface for the derived classes. """ CODE: ClassVar[APCIService | APCIUserService | APCIExtendedService] = cast( APCIService, None ) @abstractmethod def calculated_length(self) -> int: """Get length of APCI payload - to be implemented in derived class.""" @abstractmethod def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data - to be implemented in derived class.""" # shall return bytearray instead of bytes so TPCI can be # added to first 6 bits of first byte in-place later @classmethod @abstractmethod def from_knx(cls, raw: bytes) -> APCI: """ Parse/deserialize from KNX/IP raw data - to be implemented in derived class. `raw` shall be a complete APDU. Return APCI instance based on APCI service. There are only 16 possible APCI services. The `APCIService.USER_MESSAGE` and `APCIService.ESCAPE` service have several sub-services. """ apci = (raw[0] * 256 + raw[1]) & 0x03FF service = apci & 0x03C0 if service == APCIService.GROUP_READ.value: return GroupValueRead.from_knx(raw) if service == APCIService.GROUP_WRITE.value: return GroupValueWrite.from_knx(raw) if service == APCIService.GROUP_RESPONSE.value: return GroupValueResponse.from_knx(raw) if service == APCIService.INDIVIDUAL_ADDRESS_WRITE.value: return IndividualAddressWrite.from_knx(raw) if service == APCIService.INDIVIDUAL_ADDRESS_READ.value: return IndividualAddressRead.from_knx(raw) if service == APCIService.INDIVIDUAL_ADDRESS_RESPONSE.value: return IndividualAddressResponse.from_knx(raw) if service == APCIService.ADC_READ.value: return ADCRead.from_knx(raw) if service == APCIService.ADC_RESPONSE.value: if apci == APCIService.MEMORY_EXTENDED_WRITE.value: return MemoryExtendedWrite.from_knx(raw) if apci == APCIService.MEMORY_EXTENDED_WRITE_RESPONSE.value: return MemoryExtendedWriteResponse.from_knx(raw) if apci == APCIService.MEMORY_EXTENDED_READ.value: return MemoryExtendedRead.from_knx(raw) if apci == APCIService.MEMORY_EXTENDED_READ_RESPONSE.value: return MemoryExtendedReadResponse.from_knx(raw) return ADCResponse.from_knx(raw) if service == APCIService.MEMORY_READ.value: return MemoryRead.from_knx(raw) if service == APCIService.MEMORY_WRITE.value: return MemoryWrite.from_knx(raw) if service == APCIService.MEMORY_RESPONSE.value: return MemoryResponse.from_knx(raw) if service == APCIService.USER_MESSAGE.value: if apci == APCIUserService.USER_MEMORY_READ.value: return UserMemoryRead.from_knx(raw) if apci == APCIUserService.USER_MEMORY_RESPONSE.value: return UserMemoryResponse.from_knx(raw) if apci == APCIUserService.USER_MEMORY_WRITE.value: return UserMemoryWrite.from_knx(raw) if apci == APCIUserService.USER_MANUFACTURER_INFO_READ.value: return UserManufacturerInfoRead.from_knx(raw) if apci == APCIUserService.USER_MANUFACTURER_INFO_RESPONSE.value: return UserManufacturerInfoResponse.from_knx(raw) if apci == APCIUserService.FUNCTION_PROPERTY_COMMAND.value: return FunctionPropertyCommand.from_knx(raw) if apci == APCIUserService.FUNCTION_PROPERTY_STATE_READ.value: return FunctionPropertyStateRead.from_knx(raw) if apci == APCIUserService.FUNCTION_PROPERTY_STATE_RESPONSE.value: return FunctionPropertyStateResponse.from_knx(raw) if service == APCIService.DEVICE_DESCRIPTOR_READ.value: return DeviceDescriptorRead.from_knx(raw) if service == APCIService.DEVICE_DESCRIPTOR_RESPONSE.value: return DeviceDescriptorResponse.from_knx(raw) if service == APCIService.RESTART.value: return Restart.from_knx(raw) if service == APCIService.ESCAPE.value: if apci == APCIExtendedService.AUTHORIZE_REQUEST.value: return AuthorizeRequest.from_knx(raw) if apci == APCIExtendedService.AUTHORIZE_RESPONSE.value: return AuthorizeResponse.from_knx(raw) if apci == APCIExtendedService.PROPERTY_VALUE_READ.value: return PropertyValueRead.from_knx(raw) if apci == APCIExtendedService.PROPERTY_VALUE_WRITE.value: return PropertyValueWrite.from_knx(raw) if apci == APCIExtendedService.PROPERTY_VALUE_RESPONSE.value: return PropertyValueResponse.from_knx(raw) if apci == APCIExtendedService.PROPERTY_DESCRIPTION_READ.value: return PropertyDescriptionRead.from_knx(raw) if apci == APCIExtendedService.PROPERTY_DESCRIPTION_RESPONSE.value: return PropertyDescriptionResponse.from_knx(raw) if apci == APCIExtendedService.INDIVIDUAL_ADDRESS_SERIAL_READ.value: return IndividualAddressSerialRead.from_knx(raw) if apci == APCIExtendedService.INDIVIDUAL_ADDRESS_SERIAL_RESPONSE.value: return IndividualAddressSerialResponse.from_knx(raw) if apci == APCIExtendedService.INDIVIDUAL_ADDRESS_SERIAL_WRITE.value: return IndividualAddressSerialWrite.from_knx(raw) if apci == APCIExtendedService.APCI_SEC.value: return SecureAPDU.from_knx(raw) raise ConversionError(f"Class not implemented for APCI {apci:#012b}.") @dataclass(slots=True) class GroupValueRead(APCI): """ GroupValueRead service. Does not have any payload. """ CODE: ClassVar = APCIService.GROUP_READ def calculated_length(self) -> int: """Get length of APCI payload.""" return 1 @classmethod def from_knx(cls, raw: bytes) -> GroupValueRead: """Parse/deserialize from KNX/IP raw data.""" # Nothing to parse, but must be implemented explicitly. return cls() def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" return encode_cmd_and_payload(self.CODE) def __str__(self) -> str: """Return object as readable string.""" return "" @dataclass(slots=True) class GroupValueWrite(APCI): """ GroupValueRead service. Takes a value (DPT) as payload. """ CODE: ClassVar = APCIService.GROUP_WRITE value: DPTBinary | DPTArray def calculated_length(self) -> int: """Get length of APCI payload.""" if isinstance(self.value, DPTBinary): return 1 return 1 + len(self.value.value) @classmethod def from_knx(cls, raw: bytes) -> GroupValueWrite: """Parse/deserialize from KNX/IP raw data.""" if len(raw) == 2: return cls(value=DPTBinary(raw[1] & DPTBinary.APCI_BITMASK)) return cls(value=DPTArray(raw[2:])) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if isinstance(self.value, DPTBinary): return encode_cmd_and_payload(self.CODE, encoded_payload=self.value.value) return encode_cmd_and_payload( self.CODE, appended_payload=bytes(self.value.value) ) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class GroupValueResponse(APCI): """ GroupValueResponse service. Takes a value (DPT) as payload. """ CODE: ClassVar = APCIService.GROUP_RESPONSE value: DPTBinary | DPTArray def calculated_length(self) -> int: """Get length of APCI payload.""" if isinstance(self.value, DPTBinary): return 1 return 1 + len(self.value.value) @classmethod def from_knx(cls, raw: bytes) -> GroupValueResponse: """Parse/deserialize from KNX/IP raw data.""" if len(raw) == 2: return cls(value=DPTBinary(raw[1] & DPTBinary.APCI_BITMASK)) return cls(value=DPTArray(raw[2:])) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if isinstance(self.value, DPTBinary): return encode_cmd_and_payload(self.CODE, encoded_payload=self.value.value) return encode_cmd_and_payload( self.CODE, appended_payload=bytes(self.value.value) ) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class IndividualAddressWrite(APCI): """ IndividualAddressWrite service. Payload contains the serial number and (new) address of the device. """ CODE: ClassVar = APCIService.INDIVIDUAL_ADDRESS_WRITE address: IndividualAddress def calculated_length(self) -> int: """Get length of APCI payload.""" return 3 @classmethod def from_knx(cls, raw: bytes) -> IndividualAddressWrite: """Parse/deserialize from KNX/IP raw data.""" (raw_address,) = struct.unpack("!H", raw[2:]) return cls(address=IndividualAddress(raw_address)) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" return encode_cmd_and_payload(self.CODE, appended_payload=self.address.to_knx()) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class IndividualAddressRead(APCI): """IndividualAddressRead service.""" CODE: ClassVar = APCIService.INDIVIDUAL_ADDRESS_READ def calculated_length(self) -> int: """Get length of APCI payload.""" return 1 @classmethod def from_knx(cls, raw: bytes) -> IndividualAddressRead: """Parse/deserialize from KNX/IP raw data.""" # Nothing to parse, but must be implemented explicitly. return cls() def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" return encode_cmd_and_payload(self.CODE) def __str__(self) -> str: """Return object as readable string.""" return "" @dataclass(slots=True) class IndividualAddressResponse(APCI): """ IndividualAddressResponse service. There is no payload, since the Telegram's source address is used as a response address. """ CODE: ClassVar = APCIService.INDIVIDUAL_ADDRESS_RESPONSE def calculated_length(self) -> int: """Get length of APCI payload.""" return 1 @classmethod def from_knx(cls, raw: bytes) -> IndividualAddressResponse: """Parse/deserialize from KNX/IP raw data.""" # Nothing to parse, but must be implemented explicitly. return cls() def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" return encode_cmd_and_payload(self.CODE) def __str__(self) -> str: """Return object as readable string.""" return "" @dataclass(slots=True) class ADCRead(APCI): """ ADCRead service. Payload contains the channel and number of samples to take. """ CODE: ClassVar = APCIService.ADC_READ channel: int count: int = 1 def calculated_length(self) -> int: """Get length of APCI payload.""" return 2 @classmethod def from_knx(cls, raw: bytes) -> ADCRead: """Parse/deserialize from KNX/IP raw data.""" channel, count = struct.unpack("!BB", raw[1:]) return cls( channel=channel & DPTBinary.APCI_BITMASK, count=count, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" payload = struct.pack("!BB", self.channel, self.count) return encode_cmd_and_payload( self.CODE, encoded_payload=payload[0], appended_payload=payload[1:] ) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class ADCResponse(APCI): """ ADCResponse service. Payload contains the channel, number of samples and value. """ CODE: ClassVar = APCIService.ADC_RESPONSE channel: int count: int = 1 value: int = 0 def calculated_length(self) -> int: """Get length of APCI payload.""" return 4 @classmethod def from_knx(cls, raw: bytes) -> ADCResponse: """Parse/deserialize from KNX/IP raw data.""" channel, count, value = struct.unpack("!BBH", raw[1:]) return cls( channel=channel & DPTBinary.APCI_BITMASK, count=count, value=value, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" payload = struct.pack("!BBH", self.channel, self.count, self.value) return encode_cmd_and_payload( self.CODE, encoded_payload=payload[0], appended_payload=payload[1:] ) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class MemoryExtendedWrite(APCI): """ MemoryExtendedWrite service. Payload indicates address (16 MiB), count (1-255 bytes) and data. """ CODE: ClassVar = APCIService.MEMORY_EXTENDED_WRITE address: int data: bytes count: int = None # type: ignore[assignment] def __post_init__(self) -> None: """Post-initialization steps.""" if self.count is None: self.count = len(self.data) # type: ignore[unreachable] def calculated_length(self) -> int: """Get length of APCI payload.""" return 5 + len(self.data) @classmethod def from_knx(cls, raw: bytes) -> MemoryExtendedWrite: """Parse/deserialize from KNX/IP raw data.""" count = raw[2] address = int.from_bytes(raw[3:6], "big") data = raw[6:] return cls( count=count, address=address, data=data, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if not 0 <= self.address <= 0xFFFFFF: raise ConversionError("Address out of range.") if not 0 <= self.count <= 250: raise ConversionError("Count out of range.") size = len(self.data) payload = struct.pack(f"!BI{size}s", self.count, self.address, self.data) # suppress first byte of address payload = payload[:1] + payload[2:] return encode_cmd_and_payload(self.CODE, 0, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class MemoryExtendedWriteResponse(APCI): """ MemoryExtendedWriteResponse service. Payload indicates return code, address (16 MiB) and confirmation data. """ CODE: ClassVar = APCIService.MEMORY_EXTENDED_WRITE_RESPONSE return_code: int address: int confirmation_data: bytes = b"" def calculated_length(self) -> int: """Get length of APCI payload.""" return 5 + len(self.confirmation_data) @classmethod def from_knx(cls, raw: bytes) -> MemoryExtendedWriteResponse: """Parse/deserialize from KNX/IP raw data.""" return_code = raw[2] address = int.from_bytes(raw[3:6], "big") confirmation_data = raw[6:] return cls( return_code=return_code, address=address, confirmation_data=confirmation_data, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if not 0 <= self.address <= 0xFFFFFF: raise ConversionError("Address out of range.") if not 0 <= self.return_code <= 255: raise ConversionError("Return code out of range.") size = len(self.confirmation_data) payload = struct.pack( f"!BI{size}s", self.return_code, self.address, self.confirmation_data ) # suppress first byte of address payload = payload[:1] + payload[2:] return encode_cmd_and_payload(self.CODE, 0, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class MemoryExtendedRead(APCI): """ MemoryExtendedRead service. Payload indicates count and address (16 MiB). """ CODE: ClassVar = APCIService.MEMORY_EXTENDED_READ count: int address: int def calculated_length(self) -> int: """Get length of APCI payload.""" return 5 @classmethod def from_knx(cls, raw: bytes) -> MemoryExtendedRead: """Parse/deserialize from KNX/IP raw data.""" count = raw[2] address = int.from_bytes(raw[3:6], "big") return cls( count=count, address=address, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if not 0 <= self.address <= 0xFFFFFF: raise ConversionError("Address out of range.") if not 0 <= self.count <= 250: raise ConversionError("Count out of range.") payload = struct.pack("!BI", self.count, self.address) # suppress first byte of address payload = payload[:1] + payload[2:] return encode_cmd_and_payload(self.CODE, 0, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return ( f'' ) @dataclass(slots=True) class MemoryExtendedReadResponse(APCI): """ MemoryExtendedReadResponse service. Payload indicates return code, address (16 MiB) and data. """ CODE: ClassVar = APCIService.MEMORY_EXTENDED_READ_RESPONSE return_code: int address: int data: bytes = b"" def calculated_length(self) -> int: """Get length of APCI payload.""" return 5 + len(self.data) @classmethod def from_knx(cls, raw: bytes) -> MemoryExtendedReadResponse: """Parse/deserialize from KNX/IP raw data.""" return_code = raw[2] address = int.from_bytes(raw[3:6], "big") data = raw[6:] return cls( return_code=return_code, address=address, data=data, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if not 0 <= self.address <= 0xFFFFFF: raise ConversionError("Address out of range.") if not 0 <= self.return_code <= 255: raise ConversionError("Return code out of range.") size = len(self.data) payload = struct.pack(f"!BI{size}s", self.return_code, self.address, self.data) # suppress first byte of address payload = payload[:1] + payload[2:] return encode_cmd_and_payload(self.CODE, 0, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class MemoryRead(APCI): """ MemoryRead service. Payload indicates address (64 KiB) and count (1-63 bytes). """ CODE: ClassVar = APCIService.MEMORY_READ address: int count: int = 1 def calculated_length(self) -> int: """Get length of APCI payload.""" return 3 @classmethod def from_knx(cls, raw: bytes) -> MemoryRead: """Parse/deserialize from KNX/IP raw data.""" count, address = struct.unpack("!BH", raw[1:]) return cls( address=address, count=count & DPTBinary.APCI_BITMASK, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if not 0 <= self.address <= 0xFFFF: raise ConversionError("Address out of range.") if not 0 <= self.count <= 0x3F: raise ConversionError("Count out of range.") payload = struct.pack("!BH", self.count, self.address) return encode_cmd_and_payload( self.CODE, encoded_payload=payload[0], appended_payload=payload[1:] ) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class MemoryWrite(APCI): """ MemoryWrite service. Payload indicates address (64 KiB), count (1-63 bytes) and data. """ CODE: ClassVar = APCIService.MEMORY_WRITE address: int data: bytes count: int = None # type: ignore[assignment] def __post_init__(self) -> None: """Post-initialization steps.""" if self.count is None: self.count = len(self.data) # type: ignore[unreachable] def calculated_length(self) -> int: """Get length of APCI payload.""" return 3 + len(self.data) @classmethod def from_knx(cls, raw: bytes) -> MemoryWrite: """Parse/deserialize from KNX/IP raw data.""" size = len(raw) - 4 count, address, data = struct.unpack(f"!BH{size}s", raw[1:]) return cls( count=count & DPTBinary.APCI_BITMASK, address=address, data=data, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if not 0 <= self.address <= 0xFFFF: raise ConversionError("Address out of range.") if not 0 <= self.count <= 0x3F: raise ConversionError("Count out of range.") size = len(self.data) payload = struct.pack(f"!BH{size}s", self.count, self.address, self.data) return encode_cmd_and_payload( self.CODE, encoded_payload=payload[0], appended_payload=payload[1:] ) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class MemoryResponse(APCI): """ MemoryResponse service. Payload indicates address (64 KiB), count (1-63 bytes) and data. """ CODE: ClassVar = APCIService.MEMORY_RESPONSE address: int data: bytes count: int = None # type: ignore[assignment] def __post_init__(self) -> None: """Post-initialization steps.""" if self.count is None: self.count = len(self.data) # type: ignore[unreachable] def calculated_length(self) -> int: """Get length of APCI payload.""" return 3 + len(self.data) @classmethod def from_knx(cls, raw: bytes) -> MemoryResponse: """Parse/deserialize from KNX/IP raw data.""" size = len(raw) - 4 count, address, data = struct.unpack(f"!BH{size}s", raw[1:]) return cls( count=count & DPTBinary.APCI_BITMASK, address=address, data=data, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if not 0 <= self.address <= 0xFFFF: raise ConversionError("Address out of range.") if not 0 <= self.count <= 0x3F: raise ConversionError("Count out of range.") size = len(self.data) payload = struct.pack(f"!BH{size}s", self.count, self.address, self.data) return encode_cmd_and_payload( self.CODE, encoded_payload=payload[0], appended_payload=payload[1:] ) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class DeviceDescriptorRead(APCI): """ DeviceDescriptorRead service. Payload contains the descriptor. """ CODE: ClassVar = APCIService.DEVICE_DESCRIPTOR_READ descriptor: int = 0 def calculated_length(self) -> int: """Get length of APCI payload.""" return 1 @classmethod def from_knx(cls, raw: bytes) -> DeviceDescriptorRead: """Parse/deserialize from KNX/IP raw data.""" return cls(descriptor=raw[1] & 0x3F) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if not 0 <= self.descriptor <= 0x3F: raise ConversionError("Descriptor out of range.") return encode_cmd_and_payload(self.CODE, encoded_payload=self.descriptor) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class DeviceDescriptorResponse(APCI): """ DeviceDescriptorResponse service. Payload contains the descriptor and value. """ CODE: ClassVar = APCIService.DEVICE_DESCRIPTOR_RESPONSE descriptor: int = 0 value: int = 0 def calculated_length(self) -> int: """Get length of APCI payload.""" return 3 @classmethod def from_knx(cls, raw: bytes) -> DeviceDescriptorResponse: """Parse/deserialize from KNX/IP raw data.""" descriptor, value = struct.unpack("!BH", raw[1:]) return cls(descriptor=descriptor & 0x3F, value=value) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if not 0 <= self.descriptor <= 0x3F: raise ConversionError("Descriptor out of range.") payload = struct.pack("!H", self.value) return encode_cmd_and_payload( self.CODE, encoded_payload=self.descriptor, appended_payload=payload ) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class Restart(APCI): """ Restart service. Does not take any payload. """ # Requests a Basic Restart of the communication partner. # Master reset is not implemented yet. CODE: ClassVar = APCIService.RESTART def calculated_length(self) -> int: """Get length of APCI payload.""" return 1 @classmethod def from_knx(cls, raw: bytes) -> Restart: """Parse/deserialize from KNX/IP raw data.""" # Nothing to parse, but must be implemented explicitly. return cls() def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" return encode_cmd_and_payload(self.CODE) def __str__(self) -> str: """Return object as readable string.""" return "" @dataclass(slots=True) class UserMemoryRead(APCI): """ UserMemoryRead service. Payload indicates address (1 MiB) and count (1-15 bytes). """ CODE: ClassVar = APCIUserService.USER_MEMORY_READ address: int = 0 count: int = 1 def calculated_length(self) -> int: """Get length of APCI payload.""" return 4 @classmethod def from_knx(cls, raw: bytes) -> UserMemoryRead: """Parse/deserialize from KNX/IP raw data.""" byte0, address = struct.unpack("!BH", raw[2:]) return cls( count=byte0 & 0x0F, address=(((byte0 & 0xF0) >> 4) << 16) + address, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if not 0 <= self.address <= 0xFFFFF: raise ConversionError("Address out of range.") if not 0 <= self.count <= 0xF: raise ConversionError("Count out of range.") byte0 = (((self.address & 0x0F0000) >> 16) << 4) | (self.count & 0x0F) address = self.address & 0xFFFF payload = struct.pack("!BH", byte0, address) return encode_cmd_and_payload(self.CODE, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class UserMemoryWrite(APCI): """ UserMemoryWrite service. Payload indicates address (1 MiB), count and data. """ CODE: ClassVar = APCIUserService.USER_MEMORY_WRITE address: int data: bytes count: int = None # type: ignore[assignment] def __post_init__(self) -> None: """Post-initialization steps.""" if self.count is None: self.count = len(self.data) # type: ignore[unreachable] def calculated_length(self) -> int: """Get length of APCI payload.""" return 4 + len(self.data) @classmethod def from_knx(cls, raw: bytes) -> UserMemoryWrite: """Parse/deserialize from KNX/IP raw data.""" size = len(raw) - 5 byte0, address, data = struct.unpack(f"!BH{size}s", raw[2:]) return cls( address=(((byte0 & 0xF0) >> 4) << 16) + address, data=data, count=byte0 & 0x0F, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if not 0 <= self.address <= 0xFFFFF: raise ConversionError("Address out of range.") if not 0 <= self.count <= 0xF: raise ConversionError("Count out of range.") byte0 = (((self.address & 0x0F0000) >> 16) << 4) | (self.count & 0x0F) address = self.address & 0xFFFF size = len(self.data) payload = struct.pack(f"!BH{size}s", byte0, address, self.data) return encode_cmd_and_payload(self.CODE, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class UserMemoryResponse(APCI): """ UserMemoryResponse service. Payload indicates address (1 MiB), count and data. """ CODE: ClassVar = APCIUserService.USER_MEMORY_RESPONSE address: int data: bytes count: int = None # type: ignore[assignment] def __post_init__(self) -> None: """Post-initialization steps.""" if self.count is None: self.count = len(self.data) # type: ignore[unreachable] def calculated_length(self) -> int: """Get length of APCI payload.""" return 4 + len(self.data) @classmethod def from_knx(cls, raw: bytes) -> UserMemoryResponse: """Parse/deserialize from KNX/IP raw data.""" size = len(raw) - 5 byte0, address, data = struct.unpack(f"!BH{size}s", raw[2:]) return cls( address=(((byte0 & 0xF0) >> 4) << 16) + address, data=data, count=byte0 & 0x0F, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if not 0 <= self.address <= 0xFFFFF: raise ConversionError("Address out of range.") if not 0 <= self.count <= 0xF: raise ConversionError("Count out of range.") byte0 = (((self.address & 0x0F0000) >> 16) << 4) | (self.count & 0x0F) address = self.address & 0xFFFF size = len(self.data) payload = struct.pack(f"!BH{size}s", byte0, address, self.data) return encode_cmd_and_payload(self.CODE, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class UserManufacturerInfoRead(APCI): """UserManufacturerInfoRead service.""" CODE: ClassVar = APCIUserService.USER_MANUFACTURER_INFO_READ def calculated_length(self) -> int: """Get length of APCI payload.""" return 1 @classmethod def from_knx(cls, raw: bytes) -> UserManufacturerInfoRead: """Parse/deserialize from KNX/IP raw data.""" # Nothing to parse, but must be implemented explicitly. return cls() def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" return encode_cmd_and_payload(self.CODE) def __str__(self) -> str: """Return object as readable string.""" return "" @dataclass(slots=True) class UserManufacturerInfoResponse(APCI): """UserManufacturerInfoResponse service.""" CODE: ClassVar = APCIUserService.USER_MANUFACTURER_INFO_RESPONSE manufacturer_id: int = 0 data: bytes = b"\x00\x00" def calculated_length(self) -> int: """Get length of APCI payload.""" return 4 @classmethod def from_knx(cls, raw: bytes) -> UserManufacturerInfoResponse: """Parse/deserialize from KNX/IP raw data.""" manufacturer_id, data = struct.unpack("!B2s", raw[2:]) return cls(manufacturer_id=manufacturer_id, data=data) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" payload = struct.pack("!B2s", self.manufacturer_id, self.data) return encode_cmd_and_payload(self.CODE, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class FunctionPropertyCommand(APCI): """FunctionPropertyCommand service.""" CODE: ClassVar = APCIUserService.FUNCTION_PROPERTY_COMMAND object_index: int = 0 property_id: int = 0 data: bytes = b"" def calculated_length(self) -> int: """Get length of APCI payload.""" return 3 + len(self.data) @classmethod def from_knx(cls, raw: bytes) -> FunctionPropertyCommand: """Parse/deserialize from KNX/IP raw data.""" size = len(raw) - 4 object_index, property_id, data = struct.unpack(f"!BB{size}s", raw[2:]) return cls( object_index=object_index, property_id=property_id, data=data, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" size = len(self.data) payload = struct.pack( f"!BB{size}s", self.object_index, self.property_id, self.data ) return encode_cmd_and_payload(self.CODE, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class FunctionPropertyStateRead(APCI): """FunctionPropertyStateRead service.""" CODE: ClassVar = APCIUserService.FUNCTION_PROPERTY_STATE_READ object_index: int = 0 property_id: int = 0 data: bytes = b"" def calculated_length(self) -> int: """Get length of APCI payload.""" return 3 + len(self.data) @classmethod def from_knx(cls, raw: bytes) -> FunctionPropertyStateRead: """Parse/deserialize from KNX/IP raw data.""" size = len(raw) - 4 object_index, property_id, data = struct.unpack(f"!BB{size}s", raw[2:]) return cls( object_index=object_index, property_id=property_id, data=data, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" size = len(self.data) payload = struct.pack( f"!BB{size}s", self.object_index, self.property_id, self.data ) return encode_cmd_and_payload(self.CODE, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class FunctionPropertyStateResponse(APCI): """FunctionPropertyStateResponse service.""" CODE: ClassVar = APCIUserService.FUNCTION_PROPERTY_STATE_RESPONSE object_index: int = 0 property_id: int = 0 return_code: int = 0 data: bytes = b"" def calculated_length(self) -> int: """Get length of APCI payload.""" return 4 + len(self.data) @classmethod def from_knx(cls, raw: bytes) -> FunctionPropertyStateResponse: """Parse/deserialize from KNX/IP raw data.""" size = len(raw) - 5 ( object_index, property_id, return_code, data, ) = struct.unpack(f"!BBB{size}s", raw[2:]) return cls( object_index=object_index, property_id=property_id, return_code=return_code, data=data, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" size = len(self.data) payload = struct.pack( f"!BBB{size}s", self.object_index, self.property_id, self.return_code, self.data, ) return encode_cmd_and_payload(self.CODE, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class AuthorizeRequest(APCI): """AuthorizeRequest service.""" CODE: ClassVar = APCIExtendedService.AUTHORIZE_REQUEST key: int = 0 def calculated_length(self) -> int: """Get length of APCI payload.""" return 6 @classmethod def from_knx(cls, raw: bytes) -> AuthorizeRequest: """Parse/deserialize from KNX/IP raw data.""" _, key = struct.unpack("!BI", raw[2:]) return cls(key=key) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" payload = struct.pack("!BI", 0, self.key) return encode_cmd_and_payload(self.CODE, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class AuthorizeResponse(APCI): """AuthorizeResponse service.""" CODE: ClassVar = APCIExtendedService.AUTHORIZE_RESPONSE level: int = 0 def calculated_length(self) -> int: """Get length of APCI payload.""" return 2 @classmethod def from_knx(cls, raw: bytes) -> AuthorizeResponse: """Parse/deserialize from KNX/IP raw data.""" (level,) = struct.unpack("!B", raw[2:]) return cls(level=level) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" payload = struct.pack("!B", self.level) return encode_cmd_and_payload(self.CODE, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class PropertyValueRead(APCI): """ PropertyValueRead service. Payload indicates object, property, count and start. """ CODE: ClassVar = APCIExtendedService.PROPERTY_VALUE_READ object_index: int = 0 property_id: int = 0 count: int = 1 start_index: int = 1 def calculated_length(self) -> int: """Get length of APCI payload.""" return 5 @classmethod def from_knx(cls, raw: bytes) -> PropertyValueRead: """Parse/deserialize from KNX/IP raw data.""" ( object_index, property_id, count, start_index, ) = struct.unpack("!BBBB", raw[2:]) return cls( object_index=object_index, property_id=property_id, count=count >> 4, start_index=(count & 0xF) * 256 + start_index, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if not 0 <= self.count <= 0xF: raise ConversionError("Count out of range.") payload = struct.pack( "!BBBB", self.object_index, self.property_id, (self.count << 4) + (self.start_index >> 8), self.start_index & 0xFF, ) return encode_cmd_and_payload(self.CODE, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return ( "" ) @dataclass(slots=True) class PropertyValueWrite(APCI): """ PropertyValueWrite service. Payload indicates object, property, count, start and data itself. """ CODE: ClassVar = APCIExtendedService.PROPERTY_VALUE_WRITE object_index: int = 0 property_id: int = 0 count: int = 1 start_index: int = 0 data: bytes = b"" def calculated_length(self) -> int: """Get length of APCI payload.""" return 5 + len(self.data) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if not 0 <= self.count <= 0xF: raise ConversionError("Count out of range.") size = len(self.data) payload = struct.pack( f"!BBBB{size}s", self.object_index, self.property_id, (self.count << 4) + (self.start_index >> 8), self.start_index & 0xFF, self.data, ) return encode_cmd_and_payload(self.CODE, appended_payload=payload) @classmethod def from_knx(cls, raw: bytes) -> PropertyValueWrite: """Parse/deserialize from KNX/IP raw data.""" size = len(raw) - 6 ( object_index, property_id, count, start_index, data, ) = struct.unpack(f"!BBBB{size}s", raw[2:]) return cls( object_index=object_index, property_id=property_id, count=count >> 4, start_index=(count & 0xF) * 256 + start_index, data=data, ) def __str__(self) -> str: """Return object as readable string.""" return ( "" ) @dataclass(slots=True) class PropertyValueResponse(APCI): """ PropertyValueResponse service. Payload indicates object, property, count, start and data itself. Size of the payload depends on the data. """ CODE: ClassVar = APCIExtendedService.PROPERTY_VALUE_RESPONSE object_index: int = 0 property_id: int = 0 count: int = 1 start_index: int = 0 data: bytes = b"" def calculated_length(self) -> int: """Get length of APCI payload.""" return 5 + len(self.data) @classmethod def from_knx(cls, raw: bytes) -> PropertyValueResponse: """Parse/deserialize from KNX/IP raw data.""" size = len(raw) - 6 ( object_index, property_id, count, start_index, data, ) = struct.unpack(f"!BBBB{size}s", raw[2:]) return cls( object_index=object_index, property_id=property_id, count=count >> 4, start_index=(count & 0xF) * 256 + start_index, data=data, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if not 0 <= self.count <= 0xF: raise ConversionError("Count out of range.") size = len(self.data) payload = struct.pack( f"!BBBB{size}s", self.object_index, self.property_id, (self.count << 4) + (self.start_index >> 8), self.start_index & 0xFF, self.data, ) return encode_cmd_and_payload(self.CODE, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return ( "" ) @dataclass(slots=True) class PropertyDescriptionRead(APCI): """PropertyDescriptionRead service.""" CODE: ClassVar = APCIExtendedService.PROPERTY_DESCRIPTION_READ object_index: int = 0 property_id: int = 0 property_index: int = 0 def calculated_length(self) -> int: """Get length of APCI payload.""" return 4 @classmethod def from_knx(cls, raw: bytes) -> PropertyDescriptionRead: """Parse/deserialize from KNX/IP raw data.""" object_index, property_id, property_index = struct.unpack("!BBB", raw[2:]) return cls( object_index=object_index, property_id=property_id, property_index=property_index, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" payload = struct.pack( "!BBB", self.object_index, self.property_id, self.property_index ) return encode_cmd_and_payload(self.CODE, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class PropertyDescriptionResponse(APCI): """PropertyDescriptionResponse service.""" CODE: ClassVar = APCIExtendedService.PROPERTY_DESCRIPTION_RESPONSE object_index: int = 0 property_id: int = 0 property_index: int = 0 type_: int = 0 max_count: int = 1 access: int = 0 def calculated_length(self) -> int: """Get length of APCI payload.""" return 8 @classmethod def from_knx(cls, raw: bytes) -> PropertyDescriptionResponse: """Parse/deserialize from KNX/IP raw data.""" ( object_index, property_id, property_index, type_, max_count, access, ) = struct.unpack("!BBBBHB", raw[2:]) return cls( object_index=object_index, property_id=property_id, property_index=property_index, type_=type_, max_count=max_count & 0x0FFF, access=access, ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if not 0 <= self.max_count <= 0x0FFF: raise ConversionError("Max count out of range.") payload = struct.pack( "!BBBBHB", self.object_index, self.property_id, self.property_index, self.type_, self.max_count & 0x0FFF, self.access, ) return encode_cmd_and_payload(self.CODE, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class IndividualAddressSerialRead(APCI): """IndividualAddressSerialRead service.""" CODE: ClassVar = APCIExtendedService.INDIVIDUAL_ADDRESS_SERIAL_READ serial: bytes def calculated_length(self) -> int: """Get length of APCI payload.""" return 7 @classmethod def from_knx(cls, raw: bytes) -> IndividualAddressSerialRead: """Parse/deserialize from KNX/IP raw data.""" (serial,) = struct.unpack("!6s", raw[2:]) return cls(serial=serial) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if len(self.serial) != 6: raise ConversionError("Serial must be 6 bytes.") payload = struct.pack("!6s", self.serial) return encode_cmd_and_payload(self.CODE, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class IndividualAddressSerialResponse(APCI): """IndividualAddressSerialResponse service.""" CODE: ClassVar = APCIExtendedService.INDIVIDUAL_ADDRESS_SERIAL_RESPONSE serial: bytes address: IndividualAddress def calculated_length(self) -> int: """Get length of APCI payload.""" return 11 @classmethod def from_knx(cls, raw: bytes) -> IndividualAddressSerialResponse: """Parse/deserialize from KNX/IP raw data.""" serial, raw_address, _ = struct.unpack("!6sHH", raw[2:]) return cls( serial=serial, address=IndividualAddress(raw_address), ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if len(self.serial) != 6: raise ConversionError("Serial must be 6 bytes.") payload = struct.pack("!6s2sH", self.serial, self.address.to_knx(), 0) return encode_cmd_and_payload(self.CODE, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class IndividualAddressSerialWrite(APCI): """IndividualAddressSerialWrite service.""" CODE: ClassVar = APCIExtendedService.INDIVIDUAL_ADDRESS_SERIAL_WRITE serial: bytes address: IndividualAddress def calculated_length(self) -> int: """Get length of APCI payload.""" return 13 @classmethod def from_knx(cls, raw: bytes) -> IndividualAddressSerialWrite: """Parse/deserialize from KNX/IP raw data.""" serial, raw_address, _ = struct.unpack("!6sHI", raw[2:]) return cls( serial=serial, address=IndividualAddress(raw_address), ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" if len(self.serial) != 6: raise ConversionError("Serial must be 6 bytes.") payload = struct.pack("!6s2sI", self.serial, self.address.to_knx(), 0) return encode_cmd_and_payload(self.CODE, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return f'' @dataclass(slots=True) class SecureAPDU(APCI): """SecureAPDU service.""" CODE: ClassVar = APCIExtendedService.APCI_SEC scf: SecurityControlField secured_data: SecureData def calculated_length(self) -> int: """Get length of APCI payload.""" return 2 + len(self.secured_data) @classmethod def from_knx(cls, raw: bytes) -> SecureAPDU: """Parse/deserialize from KNX/IP raw data.""" return cls( scf=SecurityControlField.from_knx(raw[2]), secured_data=SecureData.from_knx(raw[3:]), ) def to_knx(self) -> bytearray: """Serialize to KNX/IP raw data.""" payload = self.scf.to_knx() + self.secured_data.to_knx() return encode_cmd_and_payload(self.CODE, appended_payload=payload) def __str__(self) -> str: """Return object as readable string.""" return f'' xknx-3.6.0/xknx/telegram/telegram.py000066400000000000000000000066231475530762600175000ustar00rootroot00000000000000""" Module for KNX Telegrams. The telegram class is the lightweight data transfer object between * business logic (Lights, Covers, etc) and * underlying KNX/IP abstraction (CEMIHandler). It contains * the group address (e.g. GroupAddress("1/2/3")) * the direction (Incoming or Outgoing) * and the payload (e.g. GroupValueWrite(DPTBinary(False))) * the source address (e.g. IndividualAddress("1.2.3")) * the TPCI (Transport Layer Control Information) (e.g. TDataGroup()) """ from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from xknx.dpt import DPTBase, DPTComplexData, DPTEnumData from .address import GroupAddress, IndividualAddress, InternalGroupAddress from .apci import APCI from .tpci import TPCI, TDataBroadcast, TDataGroup, TDataIndividual class TelegramDirection(Enum): """Enum class for the communication direction of a telegram (from KNX bus or to KNX bus).""" INCOMING = "Incoming" OUTGOING = "Outgoing" @dataclass(slots=True) class TelegramDecodedData: """Context for a telegram.""" transcoder: type[DPTBase] value: bool | int | float | str | DPTComplexData | DPTEnumData def __str__(self) -> str: """Return object as readable string.""" return ( f"{self.value}{' ' + self.transcoder.unit if self.transcoder.unit is not None else ''}" f" ({self.transcoder.dpt_name()})" ) @dataclass(slots=True) class Telegram: """Class for KNX telegrams.""" destination_address: GroupAddress | IndividualAddress | InternalGroupAddress direction: TelegramDirection = TelegramDirection.OUTGOING payload: APCI | None = None source_address: IndividualAddress = field( default_factory=lambda: IndividualAddress(0) ) tpci: TPCI = None # type: ignore[assignment] # set in __post_init__ decoded_data: TelegramDecodedData | None = None def __post_init__(self) -> None: """Initialize Telegram class.""" if self.tpci is None: if isinstance(self.destination_address, GroupAddress): # type: ignore[unreachable] if self.destination_address.raw == 0: self.tpci = TDataBroadcast() else: self.tpci = TDataGroup() elif isinstance(self.destination_address, IndividualAddress): self.tpci = TDataIndividual() else: # InternalGroupAddress self.tpci = TDataGroup() def __eq__(self, other: object) -> bool: """Equal operator. Omit decoded_data for comparison.""" return ( isinstance(other, Telegram) and self.destination_address == other.destination_address and self.direction == other.direction and self.payload == other.payload and self.source_address == other.source_address and self.tpci == other.tpci ) def __str__(self) -> str: """Return object as readable string.""" data = f'payload="{self.payload}"' if self.payload else f'tpci="{self.tpci}"' decoded_data = ( f' data="{self.decoded_data}"' if self.decoded_data is not None else "" ) return ( "" ) xknx-3.6.0/xknx/telegram/tpci.py000066400000000000000000000115231475530762600166320ustar00rootroot00000000000000""" Module for serialization and deserialization of TPCI payloads. TPCI stands for Transport Layer Protocol Control Information. """ from __future__ import annotations from abc import ABC from typing import ClassVar from xknx.exceptions import ConversionError CONTROL_BIT_MASK = 0x80 NUMBERED_BIT_MASK = 0x40 class TPCI(ABC): """ Base class for TCPI services. This base class is only the interface for the derived classes. """ __slots__ = () control: ClassVar[bool] numbered: ClassVar[bool] sequence_number: int = 0 control_flags: ClassVar[int | None] = None def to_knx(self) -> int: """Serialize to KNX/IP raw data.""" return ( self.control << 7 | self.numbered << 6 | (self.sequence_number & 0xF) << 2 | (self.control_flags or 0) ) def __eq__(self, other: object) -> bool: """Equal operator.""" return ( isinstance(other, self.__class__) and self.sequence_number == other.sequence_number ) def __repr__(self) -> str: """Return object as readable string.""" _sequence_number = ( f"sequence_number={self.sequence_number}" if self.numbered else "" ) return f"{self.__class__.__name__}({_sequence_number})" @staticmethod def resolve(raw_tpci: int, dst_is_group_address: bool, dst_is_zero: bool) -> TPCI: """ Return TPCI instance from TPCI command. See KNX Specifications 03_03_04 Transport Layer v01.02.02 AS §2 TPDU """ control = raw_tpci & CONTROL_BIT_MASK numbered = raw_tpci & NUMBERED_BIT_MASK sequence_number = (raw_tpci >> 2) & 0xF if dst_is_group_address: if control or numbered: raise ConversionError("Invalid TPCI flags in group addressed frame.") if not sequence_number: if dst_is_zero: return TDataBroadcast() return TDataGroup() if sequence_number == 1: # TDataTagGroup uses sequence number field as flag return TDataTagGroup() if not numbered and sequence_number: raise ConversionError("Sequence number not allowed for unnumbered TPCI") if not control: # data - last 2 bits are part of APCI if numbered: return TDataConnected(sequence_number=sequence_number) return TDataIndividual() # unnumbered control control_flags = raw_tpci & 0b11 if not numbered: if control_flags == 0: return TConnect() if control_flags == 1: return TDisconnect() # numbered control if control_flags == 0b10: return TAck(sequence_number=sequence_number) if control_flags == 0b11: return TNak(sequence_number=sequence_number) raise ConversionError(f"Unknown TPCI {raw_tpci:#10b}.") class TDataGroup(TPCI): """T_Data_Group class.""" __slots__ = () control = False numbered = False def to_knx(self) -> int: """Serialize to KNX/IP raw data.""" return 0 class TDataBroadcast(TPCI): """T_Data_Broadcast class.""" __slots__ = () control = False numbered = False def to_knx(self) -> int: """Serialize to KNX/IP raw data.""" return 0 class TDataTagGroup(TPCI): """T_Data_Tag_Group class.""" __slots__ = () control = False numbered = False sequence_number = 0b0001 class TDataIndividual(TPCI): """T_Data_Individual class.""" __slots__ = () control = False numbered = False def to_knx(self) -> int: """Serialize to KNX/IP raw data.""" return 0 class TDataConnected(TPCI): """T_Data_Connected class.""" __slots__ = ("sequence_number",) control = False numbered = True def __init__(self, sequence_number: int) -> None: """Initialize TDataConnected.""" self.sequence_number = sequence_number class TConnect(TPCI): """T_Connect class.""" __slots__ = () control = True numbered = False control_flags = 0b00 class TDisconnect(TPCI): """T_Disconnect class.""" __slots__ = () control = True numbered = False control_flags = 0b01 class TAck(TPCI): """T_Ack class.""" __slots__ = ("sequence_number",) control = True numbered = True control_flags = 0b10 def __init__(self, sequence_number: int) -> None: """Initialize TAck.""" self.sequence_number = sequence_number class TNak(TPCI): """T_Nak class.""" __slots__ = ("sequence_number",) control = True numbered = True control_flags = 0b11 def __init__(self, sequence_number: int) -> None: """Initialize TNak.""" self.sequence_number = sequence_number xknx-3.6.0/xknx/tools/000077500000000000000000000000001475530762600146575ustar00rootroot00000000000000xknx-3.6.0/xknx/tools/__init__.py000066400000000000000000000004601475530762600167700ustar00rootroot00000000000000"""Module for communication tools.""" # ruff: noqa: F401 from .group_communication import ( group_value_read, group_value_response, group_value_write, read_group_value, ) __all__ = [ "group_value_read", "group_value_response", "group_value_write", "read_group_value", ] xknx-3.6.0/xknx/tools/group_communication.py000066400000000000000000000065401475530762600213170ustar00rootroot00000000000000"""Package for convenience functions for KNX group communication.""" from __future__ import annotations import logging from typing import TYPE_CHECKING, Any from xknx.core.value_reader import ValueReader from xknx.dpt import DPTArray, DPTBase, DPTBinary from xknx.telegram import Telegram from xknx.telegram.address import DeviceAddressableType, parse_device_group_address from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite from xknx.typing import DPTParsable if TYPE_CHECKING: from xknx.xknx import XKNX logger = logging.getLogger("xknx.tools") def group_value_read( xknx: XKNX, group_address: DeviceAddressableType, ) -> None: """Send a GroupValueRead telegram.""" telegram = Telegram( destination_address=parse_device_group_address(group_address), payload=GroupValueRead(), ) logger.debug("Sending GroupValueRead telegram to %s", group_address) xknx.telegrams.put_nowait(telegram) def group_value_response( xknx: XKNX, group_address: DeviceAddressableType, value: Any, value_type: DPTParsable | type[DPTBase] | None = None, ) -> None: """Send a GroupValueResponse telegram.""" payload = _parse_payload(value, value_type) telegram = Telegram( destination_address=parse_device_group_address(group_address), payload=GroupValueResponse(payload), ) logger.debug( "Sending GroupValueResponse telegram with value '%s' of type '%s' to %s.", value, value_type or "raw", group_address, ) xknx.telegrams.put_nowait(telegram) def group_value_write( xknx: XKNX, group_address: DeviceAddressableType, value: Any, value_type: DPTParsable | type[DPTBase] | None = None, ) -> None: """Send a GroupValueWrite telegram.""" payload = _parse_payload(value, value_type) telegram = Telegram( destination_address=parse_device_group_address(group_address), payload=GroupValueWrite(payload), ) logger.debug( "Sending GroupValueWrite telegram with value '%s' of type '%s' to %s.", value, value_type or "raw", group_address, ) xknx.telegrams.put_nowait(telegram) async def read_group_value( xknx: XKNX, group_address: DeviceAddressableType, value_type: DPTParsable | type[DPTBase] | None = None, ) -> int | tuple[int, ...] | Any | None: """Read a value from a KNX group address.""" transcoder = _parse_dpt(value_type) value_reader = ValueReader(xknx, parse_device_group_address(group_address)) response = await value_reader.read() if response is not None: assert isinstance(response.payload, GroupValueWrite | GroupValueResponse) if transcoder is not None: return transcoder.from_knx(response.payload.value) return response.payload.value.value return None def _parse_dpt(value_type: DPTParsable | type[DPTBase] | None) -> type[DPTBase] | None: if value_type is None: return None return DPTBase.get_dpt(value_type) def _parse_payload( value: Any, value_type: DPTParsable | type[DPTBase] | None = None, ) -> DPTBinary | DPTArray: if isinstance(value, DPTArray | DPTBinary): return value if transcoder := _parse_dpt(value_type): return transcoder.to_knx(value) if isinstance(value, int): return DPTBinary(value) return DPTArray(value) xknx-3.6.0/xknx/typing/000077500000000000000000000000001475530762600150315ustar00rootroot00000000000000xknx-3.6.0/xknx/typing/__init__.py000066400000000000000000000015201475530762600171400ustar00rootroot00000000000000"""Types used by XKNX.""" from collections.abc import Callable import sys from typing import TYPE_CHECKING, TypedDict, TypeVar if sys.version_info >= (3, 11): from typing import Self as Self else: from typing_extensions import Self as Self if TYPE_CHECKING: from xknx.core.connection_manager import XknxConnectionState from xknx.devices import Device from xknx.telegram import Telegram CallbackType = Callable[[], None] ConnectionChangeCallbackType = Callable[["XknxConnectionState"], None] DeviceT = TypeVar("DeviceT", bound="Device") DeviceCallbackType = Callable[[DeviceT], None] TelegramCallbackType = Callable[["Telegram"], None] class DPTMainSubDict(TypedDict): """DPT type dictionary in accordance to xknxproject DPTType data.""" main: int sub: int | None DPTParsable = str | int | DPTMainSubDict xknx-3.6.0/xknx/util/000077500000000000000000000000001475530762600144745ustar00rootroot00000000000000xknx-3.6.0/xknx/util/__init__.py000066400000000000000000000005631475530762600166110ustar00rootroot00000000000000"""Helper functions for XKNX.""" import sys # Backport of `asyncio.timeout` to be able to replace `asyncio.wait_for` # in py3.9 and py3.10 see https://github.com/python/cpython/pull/98518 if sys.version_info[:2] < (3, 11): from async_timeout import timeout as asyncio_timeout else: from asyncio import timeout as asyncio_timeout __all__ = ["asyncio_timeout"] xknx-3.6.0/xknx/xknx.py000066400000000000000000000151771475530762600150740ustar00rootroot00000000000000"""XKNX is an Asynchronous Python module for reading and writing KNX/IP packets.""" from __future__ import annotations import asyncio import logging from logging.handlers import TimedRotatingFileHandler from pathlib import Path import signal from sys import platform from types import TracebackType from xknx.cemi import CEMIHandler from xknx.core import ( ConnectionManager, GroupAddressDPT, TaskRegistry, TelegramQueue, ) from xknx.core.state_updater import StateUpdater, TrackerOptionType from xknx.devices import Device, Devices from xknx.io import ( DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT, ConnectionConfig, knx_interface_factory, ) from xknx.management import Management from xknx.telegram import GroupAddress, GroupAddressType, IndividualAddress, Telegram from xknx.typing import ( ConnectionChangeCallbackType, DeviceCallbackType, TelegramCallbackType, ) from .__version__ import __version__ as VERSION logger = logging.getLogger("xknx.log") class XKNX: """Class for reading and writing KNX/IP packets.""" def __init__( self, address_format: GroupAddressType = GroupAddressType.LONG, telegram_received_cb: TelegramCallbackType | None = None, device_updated_cb: DeviceCallbackType[Device] | None = None, connection_state_changed_cb: ConnectionChangeCallbackType | None = None, rate_limit: int = 0, multicast_group: str = DEFAULT_MCAST_GRP, multicast_port: int = DEFAULT_MCAST_PORT, log_directory: str | None = None, state_updater: TrackerOptionType = False, daemon_mode: bool = False, connection_config: ConnectionConfig | None = None, ) -> None: """Initialize XKNX class.""" self.connection_manager = ConnectionManager() self.knxip_interface = knx_interface_factory( self, connection_config=connection_config or ConnectionConfig() ) self.management = Management(self) self.telegrams: asyncio.Queue[Telegram | None] = asyncio.Queue() self.telegram_queue = TelegramQueue(self) self.cemi_handler = CEMIHandler(self) self.state_updater = StateUpdater(self, default_tracker_option=state_updater) self.task_registry = TaskRegistry(self) self.group_address_dpt = GroupAddressDPT() self.current_address = IndividualAddress(0) self.daemon_mode = daemon_mode self.multicast_group = multicast_group self.multicast_port = multicast_port self.rate_limit = rate_limit self.sigint_received = asyncio.Event() self.started = asyncio.Event() self.devices = Devices(started=self.started) self.version = VERSION GroupAddress.address_format = address_format # for global string representation if log_directory is not None: self.setup_logging(log_directory) if telegram_received_cb is not None: self.telegram_queue.register_telegram_received_cb(telegram_received_cb) if device_updated_cb is not None: self.devices.register_device_updated_cb(device_updated_cb) if connection_state_changed_cb is not None: self.connection_manager.register_connection_state_changed_cb( connection_state_changed_cb ) def __del__(self) -> None: """Destructor. Cleaning up if this was not done before.""" if self.started.is_set(): try: loop = asyncio.get_event_loop() loop.run_until_complete(self.stop()) except RuntimeError as exp: logger.warning("Could not close loop, reason: %s", exp) async def __aenter__(self) -> XKNX: """Start XKNX from context manager.""" await self.start() return self async def __aexit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, traceback: TracebackType | None, ) -> None: """Stop XKNX from context manager.""" await self.stop() async def start(self) -> None: """Start XKNX module. Connect to KNX/IP devices and start state updater.""" if self.knxip_interface.connection_config.threaded: await self.connection_manager.register_loop() self.task_registry.start() logger.info( "XKNX v%s starting %s connection to KNX bus.", VERSION, self.knxip_interface.connection_config.connection_type.name.lower(), ) await self.knxip_interface.start() await self.telegram_queue.start() self.state_updater.start() self.devices.async_start_device_tasks() self.started.set() if self.daemon_mode: await self.loop_until_sigint() async def join(self) -> None: """Wait until all telegrams were processed.""" await self.telegrams.join() async def stop(self) -> None: """Stop XKNX module.""" self.devices.async_remove_device_tasks() self.task_registry.stop() self.state_updater.stop() await self.join() await self.telegram_queue.stop() await self.knxip_interface.stop() self.started.clear() async def loop_until_sigint(self) -> None: """Loop until Crtl-C was pressed.""" def sigint_handler() -> None: """End loop.""" self.sigint_received.set() if platform == "win32": logger.warning("Windows does not support signals") else: loop = asyncio.get_running_loop() loop.add_signal_handler(signal.SIGINT, sigint_handler) logger.warning("Press Ctrl+C to stop") await self.sigint_received.wait() @staticmethod def setup_logging(log_directory: str) -> None: """Configure logging to file.""" log_path = Path(log_directory) if not log_path.is_dir(): logger.warning("The provided log directory does not exist.") return _handler = TimedRotatingFileHandler( filename=log_path / "xknx.log", when="midnight", backupCount=7, encoding="utf-8", ) _formatter = logging.Formatter( "%(asctime)s | %(name)s | %(levelname)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) _handler.setFormatter(_formatter) _handler.setLevel(logging.DEBUG) for log_namespace in [ "xknx.cemi", "xknx.log", "xknx.knx", "xknx.raw_socket", "xknx.telegram", "xknx.state_updater", ]: _logger = logging.getLogger(log_namespace) _logger.addHandler(_handler)