├── .github ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── bug_report_de.yml │ └── config.yml ├── dependabot.yml └── workflows │ ├── codeql.yml │ ├── lock.yml │ ├── pylint.yml │ ├── python-publish.yml │ └── test-run.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .yamllint ├── LICENSE ├── README.md ├── changelog.md ├── codecov.yml ├── cov_hahomematic.sh ├── docs ├── calculated_climate_sensors.md ├── input_select_helper.md ├── rssi_fix.md └── unignore.md ├── example.py ├── example_local.py ├── hahomematic ├── __init__.py ├── async_support.py ├── caches │ ├── __init__.py │ ├── dynamic.py │ ├── persistent.py │ └── visibility.py ├── central │ ├── __init__.py │ ├── decorators.py │ └── xml_rpc_server.py ├── client │ ├── __init__.py │ ├── json_rpc.py │ └── xml_rpc.py ├── const.py ├── context.py ├── converter.py ├── decorators.py ├── exceptions.py ├── hmcli.py ├── model │ ├── __init__.py │ ├── calculated │ │ ├── __init__.py │ │ ├── climate.py │ │ ├── data_point.py │ │ ├── operating_voltage_level.py │ │ └── support.py │ ├── custom │ │ ├── __init__.py │ │ ├── climate.py │ │ ├── const.py │ │ ├── cover.py │ │ ├── data_point.py │ │ ├── definition.py │ │ ├── light.py │ │ ├── lock.py │ │ ├── siren.py │ │ ├── support.py │ │ ├── switch.py │ │ └── valve.py │ ├── data_point.py │ ├── decorators.py │ ├── device.py │ ├── event.py │ ├── generic │ │ ├── __init__.py │ │ ├── action.py │ │ ├── binary_sensor.py │ │ ├── button.py │ │ ├── data_point.py │ │ ├── number.py │ │ ├── select.py │ │ ├── sensor.py │ │ ├── switch.py │ │ └── text.py │ ├── hub │ │ ├── __init__.py │ │ ├── binary_sensor.py │ │ ├── button.py │ │ ├── data_point.py │ │ ├── number.py │ │ ├── select.py │ │ ├── sensor.py │ │ ├── switch.py │ │ └── text.py │ ├── support.py │ └── update.py ├── py.typed ├── rega_scripts │ ├── fetch_all_device_data.fn │ ├── get_program_descriptions.fn │ ├── get_serial.fn │ ├── get_system_variable_descriptions.fn │ ├── set_program_state.fn │ └── set_system_variable.fn ├── support.py └── validator.py ├── hahomematic_support ├── __init__.py ├── client_local.py └── ruff.toml ├── mypy.ini ├── pylint └── ruff.toml ├── pyproject.toml ├── requirements.txt ├── requirements_test.txt ├── requirements_test_pre_commit.txt ├── script ├── bootstrap ├── ruff.toml ├── run-in-env.sh └── setup ├── setup.cfg └── tests ├── bandit.yaml ├── conftest.py ├── const.py ├── helper.py ├── ruff.toml ├── test_action.py ├── test_binary_sensor.py ├── test_button.py ├── test_central.py ├── test_central_pydevccu.py ├── test_climate.py ├── test_cover.py ├── test_decorator.py ├── test_device.py ├── test_entity.py ├── test_event.py ├── test_json_rpc.py ├── test_light.py ├── test_lock.py ├── test_number.py ├── test_select.py ├── test_sensor.py ├── test_siren.py ├── test_support.py ├── test_switch.py ├── test_text.py └── test_valve.py /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to hahomematic 2 | 3 | If there is anything you want to see in hahomematic (features, bugfixes etc.), you can fork this repository, make your changes and then create a pull request (against the devel-branch). Just like with most of the other repositories here. 4 | 5 | Please restrict your changes to the actual code within the hahomematic-folder. The repository-maintainers are keeping track of changes and will updated the changelog and setup.py. 6 | 7 | If you can't do it yourself, feel free to create an issue and we'll have a look at it. 8 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: SukramJ 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Report an issue with HomematicIP (local) 2 | description: Report an issue with HomematicIP (local) 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | This issue form is for reporting bugs only! Use the discussions for feature requests and support stuff. 8 | - type: checkboxes 9 | attributes: 10 | label: I agree to the following 11 | options: 12 | - label: I have read the [documentation](https://github.com/sukramj/custom_homematic#custom_homematic) 13 | required: true 14 | - label: I have read the [FAQs](https://github.com/sukramj/custom_homematic#frequently-asked-questions) 15 | required: true 16 | - label: I am aware of the latest [release notes](https://github.com/sukramj/custom_homematic/releases) 17 | required: true 18 | - label: I am aware that an integration that was installed via HACS also needs to be updated via HACS 19 | required: true 20 | - label: The Backend (CCU/Homegear/...) is working as expected, devices are taught in and are controllable by its own UI. 21 | required: true 22 | - label: I am running the latest version of the custom_component (and Home Assistant) 23 | required: true 24 | - type: textarea 25 | validations: 26 | required: true 27 | attributes: 28 | label: The problem 29 | description: >- 30 | Describe the issue you are experiencing here, to communicate to the 31 | maintainers. Tell us what you were trying to do and what happened, and show code samples if used. 32 | 33 | Sample code and screenshots are much better then long texts! 34 | Provide a clear and concise description of what the problem is. 35 | - type: markdown 36 | attributes: 37 | value: | 38 | ## Environment 39 | - type: input 40 | id: version 41 | validations: 42 | required: true 43 | attributes: 44 | label: What version of HomematicIP (local) has the issue? 45 | placeholder: 1.8x.x 46 | description: > 47 | Can be found in: HACS -> Integrations 48 | - type: input 49 | attributes: 50 | label: What was the last working version of HomematicIP (local)? 51 | placeholder: 1.8x.x 52 | description: > 53 | If known, otherwise leave blank. 54 | - type: dropdown 55 | validations: 56 | required: true 57 | attributes: 58 | label: What type of installation are you running? 59 | description: > 60 | Can be found in: [Settings -> System-> Repairs -> Three Dots in Upper Right -> System information](https://my.home-assistant.io/redirect/system_health/). 61 | 62 | [![Open your Home Assistant instance and show health information about your system.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) 63 | options: 64 | - Home Assistant OS 65 | - Home Assistant Container 66 | - Home Assistant Supervised 67 | - Home Assistant Core 68 | - type: dropdown 69 | validations: 70 | required: true 71 | attributes: 72 | label: What type of installation are you running for your homematic backend? 73 | options: 74 | - RaspberryMatic Standalone 75 | - RaspberryMatic HA-Addon 76 | - Debmatic 77 | - CCU3 78 | - CCU2 79 | - Homegear 80 | - type: input 81 | attributes: 82 | label: Which version of your homematic backend are you running? 83 | placeholder: 3.67.x 84 | description: > 85 | If known, otherwise leave blank. 86 | - type: input 87 | attributes: 88 | label: What hardware are you running for your system? 89 | placeholder: Proxmox, Pi3, Pi4, Pi5 90 | description: > 91 | If known, otherwise leave blank. 92 | - type: markdown 93 | attributes: 94 | value: | 95 | # Details 96 | - type: checkboxes 97 | attributes: 98 | label: Which config details do you use 99 | options: 100 | - label: TLS 101 | required: false 102 | - label: callback data ([see](https://github.com/sukramj/custom_homematic#callback_host-and-callback_port)) 103 | required: false 104 | - type: checkboxes 105 | attributes: 106 | label: Which interfaces do you use? 107 | options: 108 | - label: Homematic IP 109 | required: false 110 | - label: Homematic (Bidcos-RF) 111 | required: false 112 | - label: Groups (Heating-Group) 113 | required: false 114 | - label: BidCos-Wired (HM-Wired) 115 | required: false 116 | - label: CuXD 117 | required: false 118 | - label: CCU-Jack 119 | required: false 120 | 121 | - type: textarea 122 | attributes: 123 | label: Diagnostics information (very helpful) (no logs here) 124 | placeholder: "drag-and-drop the diagnostics data file here (do not copy-and-paste the content)" 125 | description: >- 126 | This integrations provides the ability to [download diagnostic data](https://www.home-assistant.io/docs/configuration/troubleshooting/#debug-logs-and-diagnostics). 127 | 128 | **It would really help if you could download the diagnostics data for the device you are having issues with, 129 | and drag-and-drop that file into the textbox below.** 130 | 131 | It generally allows pinpointing defects and thus resolving issues faster. 132 | - type: textarea 133 | attributes: 134 | label: Protocol file extract (very helpful). Anything in the protocols that might be useful for us? The log (Setting/System/Protocols -> load unchanged protocol) is the best source to support trouble shooting! 135 | description: For example, error message, or stack traces. Don't switch to DEBUG level if not requested. 136 | render: txt 137 | - type: textarea 138 | attributes: 139 | label: Additional information 140 | description: > 141 | If you have any additional information for us, use the field below. e.g. how to reproduce the issue, screenshots, ... 142 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report_de.yml: -------------------------------------------------------------------------------- 1 | name: Melde ein Problem mit HomematicIP (local) 2 | description: Melde ein Problem mit HomematicIP (local) 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Dieses Formular dient ausschließlich zur Meldung von Fehlern! Nutze die Diskussionen für Funktions- und Support-Anfragen. 8 | - type: checkboxes 9 | attributes: 10 | label: Ich stimme dem Folgenden zu 11 | options: 12 | - label: Ich habe die [Dokumentation](https://github.com/sukramj/custom_homematic#custom_homematic) gelesen 13 | required: true 14 | - label: Ich habe die [FAQs](https://github.com/sukramj/custom_homematic#frequently-asked-questions) gelesen 15 | required: true 16 | - label: Ich kenne die neuesten [Versionshinweise](https://github.com/sukramj/custom_homematic/releases) 17 | required: true 18 | - label: Mir ist klar, dass eine Integration, die über HACS installiert wurde, auch über HACS aktualisiert werden muss 19 | required: true 20 | - label: Das Backend (CCU/Homegear/...) funktioniert wie erwartet, Geräte können angelernt werden und sind über deren eigene Benutzeroberfläche steuerbar 21 | required: true 22 | - label: Ich verwende die neueste Version der Integration (und von Home Assistant). 23 | required: true 24 | - type: textarea 25 | validations: 26 | required: true 27 | attributes: 28 | label: Das Problem 29 | description: >- 30 | Beschreiben hier das Problem. Erzähle was Du versucht hast, um das Problem selber zu lösen, und was dabei passiert ist. Zeige ggf. Codebeispiele. 31 | 32 | Beispielcode und Screenshots sind viel besser als lange Texte! 33 | Beschreibe das Problem klar und eindeutig! 34 | - type: markdown 35 | attributes: 36 | value: | 37 | ## Systemumgebung 38 | - type: input 39 | id: version 40 | validations: 41 | required: true 42 | attributes: 43 | label: Bei welcher Version von HomematicIP (lokal) tritt das Problem auf? 44 | placeholder: 1.8x.x 45 | description: > 46 | Zu finden unter: HACS -> Integrationen 47 | - type: input 48 | attributes: 49 | label: Welche war die letzte funktionierende Version von HomematicIP (local)? 50 | placeholder: 1.8x.x 51 | description: > 52 | Falls bekannt, andernfalls leer lassen. 53 | - type: dropdown 54 | validations: 55 | required: true 56 | attributes: 57 | label: Welche Art von Installation verwendest Du? 58 | description: > 59 | Zu finden unter: [Einstellungen -> System-> Reparaturen -> Drei Punkte oben rechts -> System information](https://my.home-assistant.io/redirect/system_health/). 60 | 61 | [![Öffnen die Home Assistant-Instanz und zeige Sie Gesundheitsinformationen zu deinem System an.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) 62 | options: 63 | - Home Assistant OS 64 | - Home Assistant Container 65 | - Home Assistant Supervised 66 | - Home Assistant Core 67 | - type: dropdown 68 | validations: 69 | required: true 70 | attributes: 71 | label: Welche Art von Installation verwendest Du als Homematic-Backend? 72 | options: 73 | - RaspberryMatic Einzeln 74 | - RaspberryMatic HA-Addon 75 | - Debmatic 76 | - CCU3 77 | - CCU2 78 | - Homegear 79 | - type: input 80 | attributes: 81 | label: Welche Version des Homematic-Backends verwendest Du? 82 | placeholder: 3.81.x 83 | description: > 84 | Falls bekannt, andernfalls leer lassen. 85 | - type: input 86 | attributes: 87 | label: Welche Hardware verwendest Du für das System? 88 | placeholder: Proxmox, Pi3, Pi4, Pi5 89 | description: > 90 | Falls bekannt, andernfalls leer lassen. 91 | - type: markdown 92 | attributes: 93 | value: | 94 | # Details 95 | - type: checkboxes 96 | attributes: 97 | label: Welche Konfigurationsdetails verwendest Du? 98 | options: 99 | - label: TLS 100 | required: false 101 | - label: Callback Daten ([siehe](https://github.com/sukramj/custom_homematic#callback_host-and-callback_port)) 102 | required: false 103 | - type: checkboxes 104 | attributes: 105 | label: Welche Schnittstellen werden verwendet? 106 | options: 107 | - label: Homematic IP 108 | required: false 109 | - label: Homematic (Bidcos-RF) 110 | required: false 111 | - label: Groups (Heating-Group) 112 | required: false 113 | - label: BidCos-Wired (HM-Wired) 114 | required: false 115 | - label: CuXD 116 | required: false 117 | - label: CCU-Jack 118 | required: false 119 | 120 | - type: textarea 121 | attributes: 122 | label: Diagnoseinformationen (sehr hilfreich!) (keine Protokolle hier) 123 | placeholder: "Ziehe die Diagnosedatendatei per Drag-and-Drop hierher (kopiere nicht den Inhalt)" 124 | description: >- 125 | Diese Integration bieten die Möglichkeit [Diagnosedaten herunterzuladen](https://www.home-assistant.io/docs/configuration/troubleshooting/#debug-logs-and-diagnostics). 126 | 127 | **Es ist sehr hilfreich, wenn Du die Diagnosedaten für das Gerät, mit dem das Probleme besteht, herunterladen und die Datei per Drag-and-Drop in das Textfeld unten ziehen könntest.** 128 | 129 | Dadurch können im Allgemeinen Fehler genauer lokalisiert und Probleme schneller gelöst werden. 130 | - type: textarea 131 | attributes: 132 | label: Auszug aus der Protokolldatei (sehr hilfreich). Gibt es in den Protokollen etwas, das nützlich sein könnte? Das Protokoll (Einstellungen/System/Protokolle -> Unveränderte Protokolle laden) ist die beste Quelle zur Fehlerbehebung! 133 | description: Beispielsweise Fehlermeldungen. Wechsele nicht zum DEBUG, wenn das nicht angefordert wurde. 134 | render: txt 135 | - type: textarea 136 | attributes: 137 | label: Weitere Informationen 138 | description: > 139 | Wenn Du weitere Informationen hast, verwende das Feld unten, z.B. wie das Problem reproduziert werden kann, Screenshots usw. 140 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Feature Requests 4 | url: https://github.com/sukramj/hahomematic/discussions 5 | about: Please use our Discussion Forum for making feature requests. 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 15 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | # yamllint disable-line rule:truthy 4 | on: 5 | pull_request: ~ 6 | schedule: 7 | - cron: "44 3 * * 2" 8 | 9 | jobs: 10 | analyze: 11 | name: Analyze 12 | runs-on: ubuntu-latest 13 | permissions: 14 | actions: read 15 | contents: read 16 | security-events: write 17 | 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | language: ["python"] 22 | 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | 27 | # Initializes the CodeQL tools for scanning. 28 | - name: Initialize CodeQL 29 | uses: github/codeql-action/init@v3 30 | with: 31 | languages: ${{ matrix.language }} 32 | # If you wish to specify custom queries, you can do so here or in a config file. 33 | # By default, queries listed here will override any specified in a config file. 34 | # Prefix the list here with "+" to use these queries and those in the config file. 35 | 36 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 37 | # queries: security-extended,security-and-quality 38 | 39 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 40 | # If this step fails, then you should remove it and run the build manually (see below) 41 | - name: Autobuild 42 | uses: github/codeql-action/autobuild@v3 43 | 44 | # ℹ️ Command-line programs to run using the OS shell. 45 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 46 | 47 | # If the Autobuild fails above, remove it and uncomment the following three lines. 48 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 49 | 50 | # - run: | 51 | # echo "Run, Build Application using script" 52 | # ./location_of_script_within_repo/buildscript.sh 53 | 54 | - name: Perform CodeQL Analysis 55 | uses: github/codeql-action/analyze@v3 56 | with: 57 | category: "/language:${{matrix.language}}" 58 | -------------------------------------------------------------------------------- /.github/workflows/lock.yml: -------------------------------------------------------------------------------- 1 | name: Lock 2 | 3 | # yamllint disable-line rule:truthy 4 | on: 5 | schedule: 6 | - cron: "0 1 * * *" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | lock: 11 | if: github.repository_owner == 'SukramJ' 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: dessant/lock-threads@v5.0.1 15 | with: 16 | github-token: ${{ github.token }} 17 | issue-inactive-days: "7" 18 | issue-lock-reason: "" 19 | pr-inactive-days: "3" 20 | pr-lock-reason: "" 21 | -------------------------------------------------------------------------------- /.github/workflows/pylint.yml: -------------------------------------------------------------------------------- 1 | name: Pylint 2 | 3 | # yamllint disable-line rule:truthy 4 | on: 5 | pull_request: ~ 6 | workflow_dispatch: 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ["3.13"] 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install pylint 23 | pip install -r requirements_test.txt 24 | - name: Analysing the code with pylint 25 | run: | 26 | pylint $(git ls-files 'hahomematic/**/*.py') 27 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package to PyPI when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | # yamllint disable-line rule:truthy 12 | on: 13 | release: 14 | types: [published] 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | release-build: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - uses: actions/setup-python@v5 27 | with: 28 | python-version: "3.13" 29 | 30 | - name: Build release distributions 31 | run: | 32 | # NOTE: put your own distribution build steps here. 33 | python -m pip install build 34 | pip install -r requirements_test.txt 35 | python -m build 36 | 37 | - name: Upload distributions 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: release-dists 41 | path: dist/ 42 | 43 | pypi-publish: 44 | runs-on: ubuntu-latest 45 | needs: 46 | - release-build 47 | permissions: 48 | # IMPORTANT: this permission is mandatory for trusted publishing 49 | id-token: write 50 | 51 | environment: 52 | name: pypi 53 | 54 | steps: 55 | - name: Retrieve release distributions 56 | uses: actions/download-artifact@v4 57 | with: 58 | name: release-dists 59 | path: dist/ 60 | 61 | - name: Publish release distributions to PyPI 62 | uses: pypa/gh-action-pypi-publish@release/v1 63 | with: 64 | packages-dir: dist/ 65 | -------------------------------------------------------------------------------- /.github/workflows/test-run.yaml: -------------------------------------------------------------------------------- 1 | name: "Test-Run" 2 | 3 | # yamllint disable-line rule:truthy 4 | on: 5 | pull_request: ~ 6 | workflow_dispatch: 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ["3.13"] 14 | steps: 15 | - name: Set Timezone 16 | uses: szenius/set-timezone@v2.0 17 | with: 18 | timezoneLinux: "Europe/Berlin" 19 | timezoneMacos: "Europe/Berlin" 20 | timezoneWindows: "Europe/Berlin" 21 | - uses: actions/checkout@v4 22 | - name: Set up Python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install -r requirements_test.txt 30 | - name: Run tests and collect coverage 31 | run: pytest --cov=hahomematic tests --asyncio-mode=legacy 32 | - name: Upload coverage to Codecov 33 | uses: codecov/codecov-action@v5 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.xml 3 | *.json 4 | *.tar.gz 5 | 6 | # Setuptools distribution folder. 7 | /dist/ 8 | 9 | # Python egg metadata, regenerated from source files by setuptools. 10 | /*.egg-info 11 | 12 | # Tox stuff, lint 13 | /.tox/ 14 | /.pylint.d/ 15 | /*_mj.py 16 | /.idea/* 17 | build 18 | 19 | unignore 20 | venv 21 | 22 | .coverage 23 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/charliermarsh/ruff-pre-commit 3 | rev: v0.11.12 4 | hooks: 5 | - id: ruff 6 | args: 7 | - --fix 8 | - id: ruff-format 9 | files: ^((hahomematic|hahomematic_support|pylint|script|tests)/.+)?[^/]+\.py$ 10 | - repo: https://github.com/codespell-project/codespell 11 | rev: v2.4.0 12 | hooks: 13 | - id: codespell 14 | args: 15 | - --ignore-words-list=empty 16 | - --skip="./.*,*.csv,*.json" 17 | - --quiet-level=2 18 | exclude_types: [csv, json] 19 | exclude: ^tests/fixtures/|hahomematic/rega_scripts|.github/ISSUE_TEMPLATE/*_de.yml 20 | - repo: https://github.com/PyCQA/bandit 21 | rev: 1.8.3 22 | hooks: 23 | - id: bandit 24 | args: 25 | - --quiet 26 | - --format=custom 27 | - --configfile=tests/bandit.yaml 28 | files: ^(hahomematic|hahomematic_support|script|tests)/.+\.py$ 29 | - repo: https://github.com/pre-commit/pre-commit-hooks 30 | rev: v5.0.0 31 | hooks: 32 | - id: check-executables-have-shebangs 33 | stages: [manual] 34 | - id: check-json 35 | exclude: (.vscode|.devcontainer) 36 | - id: no-commit-to-branch 37 | args: 38 | - --branch=devel 39 | - --branch=master 40 | - repo: https://github.com/adrienverge/yamllint.git 41 | rev: v1.37.1 42 | hooks: 43 | - id: yamllint 44 | - repo: https://github.com/pre-commit/mirrors-prettier 45 | rev: v3.1.0 46 | hooks: 47 | - id: prettier 48 | - repo: https://github.com/cdce8p/python-typing-update 49 | rev: v0.7.0 50 | hooks: 51 | # Run `python-typing-update` hook manually from time to time 52 | # to update python typing syntax. 53 | # Will require manual work, before submitting changes! 54 | # pre-commit run --hook-stage manual python-typing-update --all-files 55 | - id: python-typing-update 56 | stages: [manual] 57 | args: 58 | - --py313-plus 59 | - --force 60 | - --keep-updates 61 | files: ^(hahomematic|hahomematic_support|tests|script)/.+\.py$ 62 | - repo: local 63 | hooks: 64 | # Run mypy through our wrapper script in order to get the possible 65 | # pyenv and/or virtualenv activated; it may not have been e.g. if 66 | # committing from a GUI tool that was not launched from an activated 67 | # shell. 68 | - id: mypy 69 | name: mypy 70 | entry: script/run-in-env.sh mypy 71 | language: script 72 | types_or: [python, pyi] 73 | require_serial: true 74 | files: ^(hahomematic|hahomematic_support|pylint)/.+\.py$ 75 | - id: pylint 76 | name: pylint 77 | entry: script/run-in-env.sh pylint -j 0 78 | language: script 79 | types_or: [python, pyi] 80 | files: ^(hahomematic|hahomematic_support)/.+\.py$ 81 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | ignore: | 2 | .github/FUNDING.yml 3 | rules: 4 | braces: 5 | level: error 6 | min-spaces-inside: 0 7 | max-spaces-inside: 1 8 | min-spaces-inside-empty: -1 9 | max-spaces-inside-empty: -1 10 | brackets: 11 | level: error 12 | min-spaces-inside: 0 13 | max-spaces-inside: 0 14 | min-spaces-inside-empty: -1 15 | max-spaces-inside-empty: -1 16 | colons: 17 | level: error 18 | max-spaces-before: 0 19 | max-spaces-after: 1 20 | commas: 21 | level: error 22 | max-spaces-before: 0 23 | min-spaces-after: 1 24 | max-spaces-after: 1 25 | comments: 26 | level: error 27 | require-starting-space: true 28 | min-spaces-from-content: 2 29 | comments-indentation: 30 | level: error 31 | document-end: 32 | level: error 33 | present: false 34 | document-start: 35 | level: error 36 | present: false 37 | empty-lines: 38 | level: error 39 | max: 1 40 | max-start: 0 41 | max-end: 1 42 | hyphens: 43 | level: error 44 | max-spaces-after: 1 45 | indentation: 46 | level: error 47 | spaces: 2 48 | indent-sequences: true 49 | check-multi-line-strings: false 50 | key-duplicates: 51 | level: error 52 | line-length: disable 53 | new-line-at-end-of-file: 54 | level: error 55 | new-lines: 56 | level: error 57 | type: unix 58 | trailing-spaces: 59 | level: error 60 | truthy: 61 | level: error 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Daniel Perna 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hahomematic 2 | 3 | `hahomematic` is a Python 3 module for [Home Assistant](https://www.home-assistant.io/) to interact with [HomeMatic](https://www.eq-3.com/products/homematic.html) and [HomematicIP](https://www.homematic-ip.com/en/start.html) devices. Some other devices (f.ex. Bosch, Intertechno) might be supported as well. 4 | 5 | This is intended to become the successor of [pyhomematic](https://github.com/danielperna84/pyhomematic). 6 | 7 | It can be installed by using the [custom_component](https://github.com/sukramj/custom_homematic). 8 | Necessary installation instructions can be found [here](https://github.com/sukramj/custom_homematic/wiki/Installation). 9 | 10 | ## Project goal and features 11 | 12 | [pyhomematic](https://github.com/danielperna84/pyhomematic) has the requirement to manually add support for devices to make them usable in [Home Assistant](https://www.home-assistant.io/). `hahomematic` automatically create entities for each parameter on each channel on every device (if it not black listed). To achieve this, all paramsets (`VALUES`) are fetched (and cached for quick successive startups). 13 | 14 | On top of that it is possible to add custom entity-classes to implement more complex entities, if it makes sense for a device, much like the [devicetypes](https://github.com/danielperna84/pyhomematic/tree/master/pyhomematic/devicetypes) of [pyhomematic](https://github.com/danielperna84/pyhomematic). This will be needed for thermostats, lights, covers, climate, lock, siren etc.. 15 | 16 | Helpers for automatic re-connecting after a restart of the CCU are provided as well. 17 | 18 | ## Requirements 19 | 20 | Due to a bug in previous version of the CCU2 / CCU3, `hahomematic` requires at least the following version for usage with HomematicIP devices: 21 | 22 | - CCU2: 2.53.27 23 | - CCU3: 3.53.26 24 | 25 | More information about this bug can be found here: https://github.com/jens-maus/RaspberryMatic/issues/843. Other CCU-like platforms that leverage the buggy version of the `HmIPServer` aren't supported as well. 26 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | branch: dev 3 | coverage: 4 | status: 5 | project: 6 | default: 7 | target: auto 8 | threshold: 0.09 9 | comment: false 10 | 11 | # To make partial tests possible, 12 | # we need to carry forward. 13 | flag_management: 14 | default_rules: 15 | carryforward: false 16 | individual_flags: 17 | - name: full-suite 18 | paths: 19 | - ".*" 20 | carryforward: true 21 | -------------------------------------------------------------------------------- /cov_hahomematic.sh: -------------------------------------------------------------------------------- 1 | pytest --cov=hahomematic --cov-config=.coveragerc --cov-report html tests/ -------------------------------------------------------------------------------- /docs/calculated_climate_sensors.md: -------------------------------------------------------------------------------- 1 | # Short description of calculated sensors 2 | 3 | ## Operating Voltage Level 4 | 5 | requires: operating voltage, max voltage(battery voltage\*qty), min voltage (low bat limit default) 6 | 7 | Calculated sensor to define the left over capacity within the usable voltage range. 8 | 9 | ## Climate sensors 10 | 11 | ### Apparent Temperature (Feels like) 12 | 13 | requires: temperature, humidity, wind speed 14 | 15 | Calculated sensor that displays a perceived temperature using temperature, humidity and wind speed. 16 | 17 | ### Dew Point 18 | 19 | requires: temperature, humidity 20 | 21 | The temperature, with constant pressure and water-vapour content, to which air must be cooled for saturation to occur. 22 | 23 | ### Frost Point 24 | 25 | requires: temperature, humidity 26 | 27 | The temperature to which a sample of air must be cooled, at constant pressure and humidity, to reach saturation with respect to ice. 28 | 29 | ### Vapor Concentration (Absolute Humidity) 30 | 31 | requires: temperature, humidity 32 | 33 | The vapor concentration or absolute humidity of a mixture of water vapor and dry air is defined as the ratio of the mass of water vapor Mw to the volume V occupied by the mixture. 34 | 35 | Dv = Mw / V expressed in g/m3 36 | -------------------------------------------------------------------------------- /docs/input_select_helper.md: -------------------------------------------------------------------------------- 1 | # input_select Helper 2 | 3 | ## Siren 4 | 5 | ```yaml 6 | input_select: 7 | acoustic_alarm_selection: 8 | name: ACOUSTIC_ALARM_SELECTION 9 | options: 10 | - DISABLE_ACOUSTIC_SIGNAL 11 | - FREQUENCY_RISING 12 | - FREQUENCY_FALLING 13 | - FREQUENCY_RISING_AND_FALLING 14 | - FREQUENCY_ALTERNATING_LOW_HIGH 15 | - FREQUENCY_ALTERNATING_LOW_MID_HIGH 16 | - FREQUENCY_HIGHON_OFF 17 | - FREQUENCY_HIGHON_LONGOFF 18 | - FREQUENCY_LOWON_OFF_HIGHON_OFF 19 | - FREQUENCY_LOWON_LONGOFF_HIGHON_LONGOFF 20 | - LOW_BATTERY 21 | - DISARMED 22 | - INTERNALLY_ARMED 23 | - EXTERNALLY_ARMED 24 | - DELAYED_INTERNALLY_ARMED 25 | - DELAYED_EXTERNALLY_ARMED 26 | - EVENT 27 | - ERROR 28 | initial: DISABLE_ACOUSTIC_SIGNAL 29 | icon: mdi:alarm-light 30 | optical_alarm_selection: 31 | name: OPTICAL_ALARM_SELECTION 32 | options: 33 | - DISABLE_OPTICAL_SIGNAL 34 | - BLINKING_ALTERNATELY_REPEATING 35 | - BLINKING_BOTH_REPEATING 36 | - DOUBLE_FLASHING_REPEATING 37 | - FLASHING_BOTH_REPEATING 38 | - CONFIRMATION_SIGNAL_0 39 | - CONFIRMATION_SIGNAL_1 40 | - CONFIRMATION_SIGNAL_2 41 | initial: DISABLE_OPTICAL_SIGNAL 42 | icon: mdi:alarm-light-outline 43 | ``` 44 | -------------------------------------------------------------------------------- /docs/rssi_fix.md: -------------------------------------------------------------------------------- 1 | # About RSSI values 2 | 3 | If you are using the generated RSSI entities, you might notice that the values do not always match what you see in the CCU WebUI. 4 | In short, this is because the values shown in the WebUI (and returned in the Homematic API) are wrong. 5 | This integration applies strategies to fix the reported values as good as it can so you can use it without worrying about the technical details. 6 | 7 | If you are interested in a further explanation, continue reading. 8 | 9 | ## Technical details 10 | 11 | The RSSI ([Received Signal Strength Indicator](https://en.wikipedia.org/wiki/Received_signal_strength_indication)) value indicates how good the communication between two radio devices is (e.g. CCU and one of your Homematic devices). 12 | It can be measured in various units, Homematic uses dBm ([decibel-milliwatts](https://en.wikipedia.org/wiki/DBm)). 13 | The valid range is determined by the chipset used. 14 | For Homematic it is -127 to 0 dBm. 15 | The closer the value is to 0, the stronger the signal has been. 16 | 17 | Unfortunately some implementation details in Homematic lead to values being reported outside this range. 18 | This is probably because of wrong datatypes used for the conversion and internal conventions. It results in the following reported ranges: 19 | 20 | - 0, 1, -256, 256, 128, -128, 65536, -65536: All used in one place or another to indicate "unknown" 21 | - 1 to 127: A missing inversion of the value, so it is fixed by multiplying with -1 22 | - 129 to 256: A wrongly used datatype, it is fixed by subtracting 256 from the value 23 | - -129 to -256: A wrongly used datatype, it is fixed by subtracting 256 from the inverted value 24 | 25 | These are the exact conversions that are applied in Home Assistant: 26 | 27 | | Range | Converted value | Reason | 28 | | ------------------- | ------------------- | ----------------------------- | 29 | | <= -256 | None/unknown | Invalid | 30 | | > -256 and < -129 | (value \* -1) - 256 | Translates to > -127 and < 0 | 31 | | >= -129 and <= -127 | None/unknown | Invalid | 32 | | > -127 and < 0 | value | The real range, used as is | 33 | | >= 0 and <= 1 | None/unknown | Translates to None/unknown | 34 | | > 1 and < 127 | value \* -1 | Translates to > -127 and < -1 | 35 | | >= 127 and <= 129 | None/unknown | Invalid | 36 | | > 129 and < 256 | value - 256 | Translates to > -127 and < 0 | 37 | | >= 256 | None/unknown | Invalid | 38 | -------------------------------------------------------------------------------- /docs/unignore.md: -------------------------------------------------------------------------------- 1 | # unignore 2 | 3 | _Hahomematic_ maintains [multiple lists](https://github.com/sukramj/hahomematic/blob/devel/hahomematic/caches/visibility.py#L86) of parameters that should be ignored when entities are created for _Home-Assistant_. 4 | These parameters are filtered out to provide a better user experience for the majority of the users. 5 | 6 | But there is also a group of users that wants to do more... _things_. 7 | 8 | These advanced users can use the _unignore mechanism_ provided by _hahomematic_. 9 | 10 | You must accept the following before using the _unignore mechanism_: 11 | 12 | - Use at your own risk! 13 | - Customization to entities must be done with HA customisations 14 | - Excessive writing of parameters from `MASTER` paramset can cause damage of the device 15 | 16 | ### Using the UI 17 | 18 | The _unignore mechanism_ can be configured with the UI of the custom component starting with version 1.65.0. 19 | 20 | - goto to the integrations page 21 | - press configure 22 | - go to the second page (interface) 23 | - enable _advanced configuration_ and go to the next page 24 | - the integration will automatically be reloaded after finishing the options flow. 25 | 26 | Various patterns mentioned below can be found and selected in the drop down list. 27 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | """Example for hahomematic.""" 2 | 3 | # !/usr/bin/python3 4 | from __future__ import annotations 5 | 6 | import asyncio 7 | import logging 8 | import sys 9 | 10 | from hahomematic import const 11 | from hahomematic.central import CentralConfig 12 | from hahomematic.client import InterfaceConfig 13 | from hahomematic.model.custom import validate_custom_data_point_definition 14 | 15 | logging.basicConfig(level=logging.DEBUG) 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | CCU_HOST = "192.168.1.173" 19 | CCU_USERNAME = "Admin" 20 | CCU_PASSWORD = "" 21 | 22 | 23 | class Example: 24 | """Example for hahomematic.""" 25 | 26 | # Create a server that listens on LOCAL_HOST:* and identifies itself as myserver. 27 | got_devices = False 28 | 29 | def __init__(self): 30 | """Init example.""" 31 | self.SLEEPCOUNTER = 0 32 | self.central = None 33 | 34 | def _systemcallback(self, name, *args, **kwargs): 35 | self.got_devices = True 36 | if ( 37 | name == const.BackendSystemEvent.NEW_DEVICES 38 | and kwargs 39 | and kwargs.get("device_descriptions") 40 | and len(kwargs["device_descriptions"]) > 0 41 | ): 42 | self.got_devices = True 43 | return 44 | if ( 45 | name == const.BackendSystemEvent.DEVICES_CREATED 46 | and kwargs 47 | and kwargs.get("new_data_points") 48 | and len(kwargs["new_data_points"]) > 0 49 | ): 50 | if len(kwargs["new_data_points"]) > 1: 51 | self.got_devices = True 52 | return 53 | 54 | async def example_run(self): 55 | """Process the example.""" 56 | central_name = "ccu-dev" 57 | interface_configs = { 58 | InterfaceConfig( 59 | central_name=central_name, 60 | interface=const.Interface.HMIP_RF, 61 | port=2010, 62 | ), 63 | InterfaceConfig( 64 | central_name=central_name, 65 | interface=const.Interface.BIDCOS_RF, 66 | port=2001, 67 | ), 68 | InterfaceConfig( 69 | central_name=central_name, 70 | interface=const.Interface.VIRTUAL_DEVICES, 71 | port=9292, 72 | remote_path="/groups", 73 | ), 74 | } 75 | self.central = CentralConfig( 76 | name=central_name, 77 | host=CCU_HOST, 78 | username=CCU_USERNAME, 79 | password=CCU_PASSWORD, 80 | central_id="1234", 81 | storage_folder="homematicip_local", 82 | interface_configs=interface_configs, 83 | default_callback_port=54321, 84 | ).create_central() 85 | 86 | # For testing we set a short INIT_TIMEOUT 87 | const.INIT_TIMEOUT = 10 88 | # Add callbacks to handle the events and see what happens on the system. 89 | self.central.register_backend_system_callback(self._systemcallback) 90 | 91 | await self.central.start() 92 | while not self.got_devices and self.SLEEPCOUNTER < 20: 93 | _LOGGER.info("Waiting for devices") 94 | self.SLEEPCOUNTER += 1 95 | await asyncio.sleep(1) 96 | await asyncio.sleep(5) 97 | 98 | for i in range(16): 99 | _LOGGER.info("Sleeping (%i)", i) 100 | await asyncio.sleep(2) 101 | # Stop the central_1 thread so Python can exit properly. 102 | await self.central.stop() 103 | 104 | 105 | # validate the device description 106 | if validate_custom_data_point_definition(): 107 | example = Example() 108 | asyncio.run(example.example_run()) 109 | sys.exit(0) 110 | -------------------------------------------------------------------------------- /hahomematic/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | hahomematic is a Python 3 module. 3 | 4 | The lib interacts with HomeMatic and HomematicIP devices. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import asyncio 10 | import logging 11 | import signal 12 | import sys 13 | import threading 14 | from typing import Final 15 | 16 | from hahomematic import central as hmcu 17 | from hahomematic.const import VERSION 18 | 19 | if sys.stdout.isatty(): 20 | logging.basicConfig(level=logging.INFO) 21 | 22 | __version__: Final = VERSION 23 | _LOGGER: Final = logging.getLogger(__name__) 24 | 25 | 26 | # pylint: disable=unused-argument 27 | # noinspection PyUnusedLocal 28 | def signal_handler(sig, frame): # type: ignore[no-untyped-def] 29 | """Handle signal to shut down central.""" 30 | _LOGGER.info("Got signal: %s. Shutting down central", str(sig)) 31 | signal.signal(signal.SIGINT, signal.SIG_DFL) 32 | for central in hmcu.CENTRAL_INSTANCES.values(): 33 | asyncio.run_coroutine_threadsafe(central.stop(), asyncio.get_running_loop()) 34 | 35 | 36 | if threading.current_thread() is threading.main_thread() and sys.stdout.isatty(): 37 | signal.signal(signal.SIGINT, signal_handler) 38 | -------------------------------------------------------------------------------- /hahomematic/async_support.py: -------------------------------------------------------------------------------- 1 | """Module with support for loop interaction.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | from collections.abc import Callable, Collection, Coroutine 7 | from concurrent.futures import ThreadPoolExecutor 8 | from concurrent.futures._base import CancelledError 9 | from functools import wraps 10 | import logging 11 | from time import monotonic 12 | from typing import Any, Final, cast 13 | 14 | from hahomematic.const import BLOCK_LOG_TIMEOUT 15 | from hahomematic.exceptions import HaHomematicException 16 | from hahomematic.support import debug_enabled, reduce_args 17 | 18 | _LOGGER: Final = logging.getLogger(__name__) 19 | 20 | 21 | class Looper: 22 | """Helper class for event loop support.""" 23 | 24 | def __init__(self) -> None: 25 | """Init the loop helper.""" 26 | self._tasks: Final[set[asyncio.Future[Any]]] = set() 27 | self._loop = asyncio.get_event_loop() 28 | 29 | async def block_till_done(self) -> None: 30 | """Block until all pending work is done.""" 31 | # To flush out any call_soon_threadsafe 32 | await asyncio.sleep(0) 33 | start_time: float | None = None 34 | current_task = asyncio.current_task() 35 | while tasks := [task for task in self._tasks if task is not current_task and not cancelling(task)]: 36 | await self._await_and_log_pending(tasks) 37 | 38 | if start_time is None: 39 | # Avoid calling monotonic() until we know 40 | # we may need to start logging blocked tasks. 41 | start_time = 0 42 | elif start_time == 0: 43 | # If we have waited twice then we set the start 44 | # time 45 | start_time = monotonic() 46 | elif monotonic() - start_time > BLOCK_LOG_TIMEOUT: 47 | # We have waited at least three loops and new tasks 48 | # continue to block. At this point we start 49 | # logging all waiting tasks. 50 | for task in tasks: 51 | _LOGGER.debug("Waiting for task: %s", task) 52 | 53 | async def _await_and_log_pending(self, pending: Collection[asyncio.Future[Any]]) -> None: 54 | """Await and log tasks that take a long time.""" 55 | wait_time = 0 56 | while pending: 57 | _, pending = await asyncio.wait(pending, timeout=BLOCK_LOG_TIMEOUT) 58 | if not pending: 59 | return 60 | wait_time += BLOCK_LOG_TIMEOUT 61 | for task in pending: 62 | _LOGGER.debug("Waited %s seconds for task: %s", wait_time, task) 63 | 64 | def create_task(self, target: Coroutine[Any, Any, Any], name: str) -> None: 65 | """Add task to the executor pool.""" 66 | try: 67 | self._loop.call_soon_threadsafe(self._async_create_task, target, name) 68 | except CancelledError: 69 | _LOGGER.debug( 70 | "create_task: task cancelled for %s", 71 | name, 72 | ) 73 | return 74 | 75 | def _async_create_task[_R](self, target: Coroutine[Any, Any, _R], name: str) -> asyncio.Task[_R]: 76 | """Create a task from within the event_loop. This method must be run in the event_loop.""" 77 | task = self._loop.create_task(target, name=name) 78 | self._tasks.add(task) 79 | task.add_done_callback(self._tasks.remove) 80 | return task 81 | 82 | def run_coroutine(self, coro: Coroutine, name: str) -> Any: 83 | """Call coroutine from sync.""" 84 | try: 85 | return asyncio.run_coroutine_threadsafe(coro, self._loop).result() 86 | except CancelledError: # pragma: no cover 87 | _LOGGER.debug( 88 | "run_coroutine: coroutine interrupted for %s", 89 | name, 90 | ) 91 | return None 92 | 93 | def async_add_executor_job[_T]( 94 | self, 95 | target: Callable[..., _T], 96 | *args: Any, 97 | name: str, 98 | executor: ThreadPoolExecutor | None = None, 99 | ) -> asyncio.Future[_T]: 100 | """Add an executor job from within the event_loop.""" 101 | try: 102 | task = self._loop.run_in_executor(executor, target, *args) 103 | self._tasks.add(task) 104 | task.add_done_callback(self._tasks.remove) 105 | except (TimeoutError, CancelledError) as err: # pragma: no cover 106 | message = f"async_add_executor_job: task cancelled for {name} [{reduce_args(args=err.args)}]" 107 | _LOGGER.debug(message) 108 | raise HaHomematicException(message) from err 109 | return task 110 | 111 | def cancel_tasks(self) -> None: 112 | """Cancel running tasks.""" 113 | for task in self._tasks.copy(): 114 | if not task.cancelled(): 115 | task.cancel() 116 | 117 | 118 | def cancelling(task: asyncio.Future[Any]) -> bool: 119 | """Return True if task is cancelling.""" 120 | return bool((cancelling_ := getattr(task, "cancelling", None)) and cancelling_()) 121 | 122 | 123 | def loop_check[**_P, _R](func: Callable[_P, _R]) -> Callable[_P, _R]: 124 | """Annotation to mark method that must be run within the event loop.""" 125 | 126 | _with_loop: set = set() 127 | 128 | @wraps(func) 129 | def wrapper_loop_check(*args: _P.args, **kwargs: _P.kwargs) -> _R: 130 | """Wrap loop check.""" 131 | return_value = func(*args, **kwargs) 132 | 133 | try: 134 | asyncio.get_running_loop() 135 | loop_running = True 136 | except Exception: 137 | loop_running = False 138 | 139 | if not loop_running and func not in _with_loop: 140 | _with_loop.add(func) 141 | _LOGGER.warning("Method %s must run in the event_loop. No loop detected.", func.__name__) 142 | 143 | return return_value 144 | 145 | setattr(func, "_loop_check", True) 146 | return cast(Callable[_P, _R], wrapper_loop_check) if debug_enabled() else func 147 | -------------------------------------------------------------------------------- /hahomematic/caches/__init__.py: -------------------------------------------------------------------------------- 1 | """Module for the caches.""" 2 | 3 | from __future__ import annotations 4 | -------------------------------------------------------------------------------- /hahomematic/central/decorators.py: -------------------------------------------------------------------------------- 1 | """Decorators for central used within hahomematic.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | from collections.abc import Awaitable, Callable 7 | from datetime import datetime 8 | from functools import wraps 9 | import logging 10 | from typing import Any, Final, cast 11 | 12 | from hahomematic import central as hmcu, client as hmcl 13 | from hahomematic.central import xml_rpc_server as xmlrpc 14 | from hahomematic.const import BackendSystemEvent 15 | from hahomematic.exceptions import HaHomematicException 16 | from hahomematic.support import reduce_args 17 | 18 | _LOGGER: Final = logging.getLogger(__name__) 19 | _INTERFACE_ID: Final = "interface_id" 20 | 21 | 22 | def callback_backend_system(system_event: BackendSystemEvent) -> Callable: 23 | """Check if backend_system_callback is set and call it AFTER original function.""" 24 | 25 | def decorator_backend_system_callback[**_P, _R]( 26 | func: Callable[_P, _R | Awaitable[_R]], 27 | ) -> Callable[_P, _R | Awaitable[_R]]: 28 | """Decorate callback system events.""" 29 | 30 | @wraps(func) 31 | async def async_wrapper_backend_system_callback(*args: _P.args, **kwargs: _P.kwargs) -> _R: 32 | """Wrap async callback system events.""" 33 | return_value = cast(_R, await func(*args, **kwargs)) # type: ignore[misc] 34 | await _exec_backend_system_callback(*args, **kwargs) 35 | return return_value 36 | 37 | @wraps(func) 38 | def wrapper_backend_system_callback(*args: _P.args, **kwargs: _P.kwargs) -> _R: 39 | """Wrap callback system events.""" 40 | return_value = cast(_R, func(*args, **kwargs)) 41 | try: 42 | unit = args[0] 43 | central: hmcu.CentralUnit | None = None 44 | if isinstance(unit, hmcu.CentralUnit): 45 | central = unit 46 | if central is None and isinstance(unit, xmlrpc.RPCFunctions): 47 | central = unit.get_central(interface_id=str(args[1])) 48 | if central: 49 | central.looper.create_task( 50 | _exec_backend_system_callback(*args, **kwargs), 51 | name="wrapper_backend_system_callback", 52 | ) 53 | except Exception as ex: 54 | _LOGGER.warning( 55 | "EXEC_BACKEND_SYSTEM_CALLBACK failed: Problem with identifying central: %s", 56 | reduce_args(args=ex.args), 57 | ) 58 | return return_value 59 | 60 | async def _exec_backend_system_callback(*args: Any, **kwargs: Any) -> None: 61 | """Execute the callback for a system event.""" 62 | 63 | if not ((len(args) > 1 and not kwargs) or (len(args) == 1 and kwargs)): 64 | _LOGGER.warning("EXEC_BACKEND_SYSTEM_CALLBACK failed: *args not supported for callback_system_event") 65 | try: 66 | args = args[1:] 67 | interface_id: str = args[0] if len(args) > 0 else str(kwargs[_INTERFACE_ID]) 68 | if client := hmcl.get_client(interface_id=interface_id): 69 | client.modified_at = datetime.now() 70 | client.central.fire_backend_system_callback(system_event=system_event, **kwargs) 71 | except Exception as ex: # pragma: no cover 72 | _LOGGER.warning( 73 | "EXEC_BACKEND_SYSTEM_CALLBACK failed: Unable to reduce kwargs for backend_system_callback" 74 | ) 75 | raise HaHomematicException( 76 | f"args-exception backend_system_callback [{reduce_args(args=ex.args)}]" 77 | ) from ex 78 | 79 | if asyncio.iscoroutinefunction(func): 80 | return async_wrapper_backend_system_callback 81 | return wrapper_backend_system_callback 82 | 83 | return decorator_backend_system_callback 84 | 85 | 86 | def callback_event[**_P, _R]( 87 | func: Callable[_P, _R], 88 | ) -> Callable: 89 | """Check if event_callback is set and call it AFTER original function.""" 90 | 91 | @wraps(func) 92 | async def async_wrapper_event_callback(*args: _P.args, **kwargs: _P.kwargs) -> _R: 93 | """Wrap callback events.""" 94 | return_value = cast(_R, await func(*args, **kwargs)) # type: ignore[misc] 95 | _exec_event_callback(*args, **kwargs) 96 | return return_value 97 | 98 | def _exec_event_callback(*args: Any, **kwargs: Any) -> None: 99 | """Execute the callback for a data_point event.""" 100 | try: 101 | args = args[1:] 102 | interface_id: str = args[0] if len(args) > 1 else str(kwargs[_INTERFACE_ID]) 103 | if client := hmcl.get_client(interface_id=interface_id): 104 | client.modified_at = datetime.now() 105 | client.central.fire_backend_parameter_callback(*args, **kwargs) 106 | except Exception as ex: # pragma: no cover 107 | _LOGGER.warning("EXEC_DATA_POINT_EVENT_CALLBACK failed: Unable to reduce kwargs for event_callback") 108 | raise HaHomematicException(f"args-exception event_callback [{reduce_args(args=ex.args)}]") from ex 109 | 110 | return async_wrapper_event_callback 111 | -------------------------------------------------------------------------------- /hahomematic/context.py: -------------------------------------------------------------------------------- 1 | """Collection of context variables.""" 2 | 3 | from __future__ import annotations 4 | 5 | from contextvars import ContextVar 6 | 7 | # context var for storing if call is running within a service 8 | IN_SERVICE_VAR: ContextVar[bool] = ContextVar("in_service_var", default=False) 9 | -------------------------------------------------------------------------------- /hahomematic/converter.py: -------------------------------------------------------------------------------- 1 | """Converters used by hahomematic.""" 2 | 3 | from __future__ import annotations 4 | 5 | import ast 6 | import logging 7 | from typing import Any, Final, cast 8 | 9 | from hahomematic.const import Parameter 10 | from hahomematic.support import reduce_args 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | def _convert_cpv_to_hm_level(cpv: Any) -> Any: 16 | """Convert combined parameter value for hm level.""" 17 | if isinstance(cpv, str) and cpv.startswith("0x"): 18 | return ast.literal_eval(cpv) / 100 / 2 19 | return cpv 20 | 21 | 22 | def _convert_cpv_to_hmip_level(cpv: Any) -> Any: 23 | """Convert combined parameter value for hmip level.""" 24 | return int(cpv) / 100 25 | 26 | 27 | def convert_hm_level_to_cpv(hm_level: Any) -> Any: 28 | """Convert hm level to combined parameter value.""" 29 | return format(int(hm_level * 100 * 2), "#04x") 30 | 31 | 32 | CONVERTABLE_PARAMETERS: Final = (Parameter.COMBINED_PARAMETER, Parameter.LEVEL_COMBINED) 33 | 34 | _COMBINED_PARAMETER_TO_HM_CONVERTER: Final = { 35 | Parameter.LEVEL_COMBINED: _convert_cpv_to_hm_level, 36 | Parameter.LEVEL: _convert_cpv_to_hmip_level, 37 | Parameter.LEVEL_2: _convert_cpv_to_hmip_level, 38 | } 39 | 40 | _COMBINED_PARAMETER_NAMES: Final = {"L": Parameter.LEVEL, "L2": Parameter.LEVEL_2} 41 | 42 | 43 | def _convert_combined_parameter_to_paramset(cpv: str) -> dict[str, Any]: 44 | """Convert combined parameter to paramset.""" 45 | paramset: dict[str, Any] = {} 46 | for cp_param_value in cpv.split(","): 47 | cp_param, value = cp_param_value.split("=") 48 | if parameter := _COMBINED_PARAMETER_NAMES.get(cp_param): 49 | if converter := _COMBINED_PARAMETER_TO_HM_CONVERTER.get(parameter): 50 | paramset[parameter] = converter(value) 51 | else: 52 | paramset[parameter] = value 53 | return paramset 54 | 55 | 56 | def _convert_level_combined_to_paramset(lcv: str) -> dict[str, Any]: 57 | """Convert combined parameter to paramset.""" 58 | if "," in lcv: 59 | l1_value, l2_value = lcv.split(",") 60 | if converter := _COMBINED_PARAMETER_TO_HM_CONVERTER.get(Parameter.LEVEL_COMBINED): 61 | return { 62 | Parameter.LEVEL: converter(l1_value), 63 | Parameter.LEVEL_SLATS: converter(l2_value), 64 | } 65 | return {} 66 | 67 | 68 | _COMBINED_PARAMETER_TO_PARAMSET_CONVERTER: Final = { 69 | Parameter.COMBINED_PARAMETER: _convert_combined_parameter_to_paramset, 70 | Parameter.LEVEL_COMBINED: _convert_level_combined_to_paramset, 71 | } 72 | 73 | 74 | def convert_combined_parameter_to_paramset(parameter: str, cpv: str) -> dict[str, Any]: 75 | """Convert combined parameter to paramset.""" 76 | try: 77 | if converter := _COMBINED_PARAMETER_TO_PARAMSET_CONVERTER.get(parameter): # type: ignore[call-overload] 78 | return cast(dict[str, Any], converter(cpv)) 79 | _LOGGER.debug("CONVERT_COMBINED_PARAMETER_TO_PARAMSET: No converter found for %s: %s", parameter, cpv) 80 | except Exception as ex: 81 | _LOGGER.debug("CONVERT_COMBINED_PARAMETER_TO_PARAMSET: Convert failed %s", reduce_args(args=ex.args)) 82 | return {} 83 | -------------------------------------------------------------------------------- /hahomematic/exceptions.py: -------------------------------------------------------------------------------- 1 | """Module for HaHomematicExceptions.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | from collections.abc import Awaitable, Callable 7 | from functools import wraps 8 | import logging 9 | from typing import Any, Final, cast 10 | 11 | _LOGGER: Final = logging.getLogger(__name__) 12 | 13 | 14 | class BaseHomematicException(Exception): 15 | """hahomematic base exception.""" 16 | 17 | def __init__(self, name: str, *args: Any) -> None: 18 | """Init the HaHomematicException.""" 19 | if args and isinstance(args[0], BaseException): 20 | self.name = args[0].__class__.__name__ 21 | args = _reduce_args(args=args[0].args) 22 | else: 23 | self.name = name 24 | super().__init__(_reduce_args(args=args)) 25 | 26 | 27 | class ClientException(BaseHomematicException): 28 | """hahomematic Client exception.""" 29 | 30 | def __init__(self, *args: Any) -> None: 31 | """Init the ClientException.""" 32 | super().__init__("ClientException", *args) 33 | 34 | 35 | class UnsupportedException(BaseHomematicException): 36 | """hahomematic unsupported exception.""" 37 | 38 | def __init__(self, *args: Any) -> None: 39 | """Init the UnsupportedException.""" 40 | super().__init__("UnsupportedException", *args) 41 | 42 | 43 | class ValidationException(BaseHomematicException): 44 | """hahomematic validation exception.""" 45 | 46 | def __init__(self, *args: Any) -> None: 47 | """Init the ValidationException.""" 48 | super().__init__("ValidationException", *args) 49 | 50 | 51 | class NoConnectionException(BaseHomematicException): 52 | """hahomematic NoConnectionException exception.""" 53 | 54 | def __init__(self, *args: Any) -> None: 55 | """Init the NoConnection.""" 56 | super().__init__("NoConnectionException", *args) 57 | 58 | 59 | class NoClientsException(BaseHomematicException): 60 | """hahomematic NoClientsException exception.""" 61 | 62 | def __init__(self, *args: Any) -> None: 63 | """Init the NoClientsException.""" 64 | super().__init__("NoClientsException", *args) 65 | 66 | 67 | class AuthFailure(BaseHomematicException): 68 | """hahomematic AuthFailure exception.""" 69 | 70 | def __init__(self, *args: Any) -> None: 71 | """Init the AuthFailure.""" 72 | super().__init__("AuthFailure", *args) 73 | 74 | 75 | class HaHomematicException(BaseHomematicException): 76 | """hahomematic HaHomematicException exception.""" 77 | 78 | def __init__(self, *args: Any) -> None: 79 | """Init the HaHomematicException.""" 80 | super().__init__("HaHomematicException", *args) 81 | 82 | 83 | class HaHomematicConfigException(BaseHomematicException): 84 | """hahomematic HaHomematicConfigException exception.""" 85 | 86 | def __init__(self, *args: Any) -> None: 87 | """Init the HaHomematicConfigException.""" 88 | super().__init__("HaHomematicConfigException", *args) 89 | 90 | 91 | class InternalBackendException(BaseHomematicException): 92 | """hahomematic InternalBackendException exception.""" 93 | 94 | def __init__(self, *args: Any) -> None: 95 | """Init the InternalBackendException.""" 96 | super().__init__("InternalBackendException", *args) 97 | 98 | 99 | def _reduce_args(args: tuple[Any, ...]) -> tuple[Any, ...] | Any: 100 | """Return the first arg, if there is only one arg.""" 101 | return args[0] if len(args) == 1 else args 102 | 103 | 104 | def log_exception[**_P, _R]( 105 | ex_type: type[BaseException], 106 | logger: logging.Logger = _LOGGER, 107 | level: int = logging.ERROR, 108 | extra_msg: str = "", 109 | re_raise: bool = False, 110 | ex_return: Any = None, 111 | ) -> Callable: 112 | """Decorate methods for exception logging.""" 113 | 114 | def decorator_log_exception( 115 | func: Callable[_P, _R | Awaitable[_R]], 116 | ) -> Callable[_P, _R | Awaitable[_R]]: 117 | """Decorate log exception method.""" 118 | 119 | function_name = func.__name__ 120 | 121 | @wraps(func) 122 | async def async_wrapper_log_exception(*args: _P.args, **kwargs: _P.kwargs) -> _R: 123 | """Wrap async methods.""" 124 | try: 125 | return_value = cast(_R, await func(*args, **kwargs)) # type: ignore[misc] 126 | except ex_type as ex: 127 | message = ( 128 | f"{function_name.upper()} failed: {ex_type.__name__} [{_reduce_args(args=ex.args)}] {extra_msg}" 129 | ) 130 | logger.log(level, message) 131 | if re_raise: 132 | raise 133 | return cast(_R, ex_return) 134 | return return_value 135 | 136 | @wraps(func) 137 | def wrapper_log_exception(*args: _P.args, **kwargs: _P.kwargs) -> _R: 138 | """Wrap sync methods.""" 139 | return cast(_R, func(*args, **kwargs)) 140 | 141 | if asyncio.iscoroutinefunction(func): 142 | return async_wrapper_log_exception 143 | return wrapper_log_exception 144 | 145 | return decorator_log_exception 146 | -------------------------------------------------------------------------------- /hahomematic/hmcli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | """Commandline tool to query HomeMatic hubs via XML-RPC.""" 3 | 4 | from __future__ import annotations 5 | 6 | import argparse 7 | import sys 8 | from typing import Any 9 | from xmlrpc.client import ServerProxy 10 | 11 | from hahomematic import __version__ 12 | from hahomematic.const import ParamsetKey 13 | from hahomematic.support import build_xml_rpc_headers, build_xml_rpc_uri, get_tls_context 14 | 15 | 16 | def main() -> None: 17 | """Start the cli.""" 18 | parser = argparse.ArgumentParser( 19 | description="Commandline tool to query HomeMatic hubs via XML-RPC", 20 | ) 21 | parser.add_argument("--version", action="version", version=__version__) 22 | parser.add_argument( 23 | "--host", 24 | "-H", 25 | required=True, 26 | type=str, 27 | help="Hostname / IP address to connect to", 28 | ) 29 | parser.add_argument( 30 | "--port", 31 | "-p", 32 | required=True, 33 | type=int, 34 | help="Port to connect to", 35 | ) 36 | parser.add_argument( 37 | "--path", 38 | type=str, 39 | help="Path, used for heating groups", 40 | ) 41 | parser.add_argument( 42 | "--username", 43 | "-U", 44 | nargs="?", 45 | help="Username required for access", 46 | ) 47 | parser.add_argument( 48 | "--password", 49 | "-P", 50 | nargs="?", 51 | help="Password required for access", 52 | ) 53 | parser.add_argument( 54 | "--tls", 55 | "-t", 56 | action="store_true", 57 | help="Enable TLS encryption", 58 | ) 59 | parser.add_argument( 60 | "--verify", 61 | "-v", 62 | action="store_true", 63 | help="Verify TLS encryption", 64 | ) 65 | parser.add_argument( 66 | "--json", 67 | "-j", 68 | action="store_true", 69 | help="Output as JSON", 70 | ) 71 | parser.add_argument( 72 | "--address", 73 | "-a", 74 | required=True, 75 | type=str, 76 | help="Address of HomeMatic device, including channel", 77 | ) 78 | parser.add_argument( 79 | "--paramset_key", 80 | default=ParamsetKey.VALUES, 81 | choices=[ParamsetKey.VALUES, ParamsetKey.MASTER], 82 | help="Paramset of HomeMatic device. Default: VALUES", 83 | ) 84 | parser.add_argument( 85 | "--parameter", 86 | required=True, 87 | help="Parameter of HomeMatic device", 88 | ) 89 | parser.add_argument( 90 | "--value", 91 | type=str, 92 | help="Value to set for parameter. Use 0/1 for boolean", 93 | ) 94 | parser.add_argument( 95 | "--type", 96 | choices=["int", "float", "bool"], 97 | help="Type of value when setting a value. Using str if not provided", 98 | ) 99 | args = parser.parse_args() 100 | 101 | url = build_xml_rpc_uri( 102 | host=args.host, 103 | port=args.port, 104 | path=args.path, 105 | tls=args.tls, 106 | ) 107 | headers = build_xml_rpc_headers(username=args.username, password=args.password) 108 | context = None 109 | if args.tls: 110 | context = get_tls_context(verify_tls=args.verify) 111 | proxy = ServerProxy(url, context=context, headers=headers) 112 | 113 | try: 114 | if args.paramset_key == ParamsetKey.VALUES and args.value is None: 115 | proxy.getValue(args.address, args.parameter) 116 | if args.json: 117 | pass 118 | else: 119 | pass 120 | sys.exit(0) 121 | elif args.paramset_key == ParamsetKey.VALUES and args.value: 122 | value: Any 123 | if args.type == "int": 124 | value = int(args.value) 125 | elif args.type == "float": 126 | value = float(args.value) 127 | elif args.type == "bool": 128 | value = bool(int(args.value)) 129 | else: 130 | value = args.value 131 | proxy.setValue(args.address, args.parameter, value) 132 | sys.exit(0) 133 | elif args.paramset_key == ParamsetKey.MASTER and args.value is None: 134 | paramset: dict[str, Any] | None 135 | if (paramset := proxy.getParamset(args.address, args.paramset_key)) and paramset.get( # type: ignore[assignment] 136 | args.parameter 137 | ): 138 | if args.json: 139 | pass 140 | else: 141 | pass 142 | sys.exit(0) 143 | elif args.paramset_key == ParamsetKey.MASTER and args.value: 144 | if args.type == "int": 145 | value = int(args.value) 146 | elif args.type == "float": 147 | value = float(args.value) 148 | elif args.type == "bool": 149 | value = bool(int(args.value)) 150 | else: 151 | value = args.value 152 | proxy.putParamset(args.address, args.paramset_key, {args.parameter: value}) 153 | sys.exit(0) 154 | except Exception: 155 | sys.exit(1) 156 | 157 | 158 | if __name__ == "__main__": 159 | main() 160 | -------------------------------------------------------------------------------- /hahomematic/model/__init__.py: -------------------------------------------------------------------------------- 1 | """Module for the HaHomematic model.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Final 7 | 8 | from hahomematic.const import ( 9 | CLICK_EVENTS, 10 | DEVICE_ERROR_EVENTS, 11 | IMPULSE_EVENTS, 12 | Flag, 13 | Operations, 14 | Parameter, 15 | ParameterData, 16 | ParamsetKey, 17 | ) 18 | from hahomematic.decorators import inspector 19 | from hahomematic.model import device as hmd 20 | from hahomematic.model.calculated import create_calculated_data_points 21 | from hahomematic.model.event import create_event_and_append_to_channel 22 | from hahomematic.model.generic import create_data_point_and_append_to_channel 23 | 24 | __all__ = ["create_data_points_and_events"] 25 | 26 | # Some parameters are marked as INTERNAL in the paramset and not considered by default, 27 | # but some are required and should be added here. 28 | _ALLOWED_INTERNAL_PARAMETERS: Final[tuple[Parameter, ...]] = (Parameter.DIRECTION,) 29 | _LOGGER: Final = logging.getLogger(__name__) 30 | 31 | 32 | @inspector() 33 | def create_data_points_and_events(device: hmd.Device) -> None: 34 | """Create the data points associated to this device.""" 35 | for channel in device.channels.values(): 36 | for paramset_key, paramsset_key_descriptions in channel.paramset_descriptions.items(): 37 | if not device.central.parameter_visibility.is_relevant_paramset( 38 | channel=channel, 39 | paramset_key=paramset_key, 40 | ): 41 | continue 42 | for ( 43 | parameter, 44 | parameter_data, 45 | ) in paramsset_key_descriptions.items(): 46 | parameter_is_un_ignored = channel.device.central.parameter_visibility.parameter_is_un_ignored( 47 | channel=channel, 48 | paramset_key=paramset_key, 49 | parameter=parameter, 50 | ) 51 | if channel.device.central.parameter_visibility.should_skip_parameter( 52 | channel=channel, 53 | paramset_key=paramset_key, 54 | parameter=parameter, 55 | parameter_is_un_ignored=parameter_is_un_ignored, 56 | ): 57 | continue 58 | _process_parameter( 59 | channel=channel, 60 | paramset_key=paramset_key, 61 | parameter=parameter, 62 | parameter_data=parameter_data, 63 | parameter_is_un_ignored=parameter_is_un_ignored, 64 | ) 65 | 66 | create_calculated_data_points(channel=channel) 67 | 68 | 69 | def _process_parameter( 70 | channel: hmd.Channel, 71 | paramset_key: ParamsetKey, 72 | parameter: str, 73 | parameter_data: ParameterData, 74 | parameter_is_un_ignored: bool, 75 | ) -> None: 76 | """Process individual parameter to create data points and events.""" 77 | 78 | if paramset_key == ParamsetKey.MASTER and parameter_is_un_ignored and parameter_data["OPERATIONS"] == 0: 79 | # required to fix hm master paramset operation values 80 | parameter_data["OPERATIONS"] = 3 81 | 82 | if _should_create_event(parameter_data=parameter_data, parameter=parameter): 83 | create_event_and_append_to_channel( 84 | channel=channel, 85 | parameter=parameter, 86 | parameter_data=parameter_data, 87 | ) 88 | if _should_skip_data_point( 89 | parameter_data=parameter_data, parameter=parameter, parameter_is_un_ignored=parameter_is_un_ignored 90 | ): 91 | _LOGGER.debug( 92 | "CREATE_DATA_POINTS: Skipping %s (no event or internal)", 93 | parameter, 94 | ) 95 | return 96 | # CLICK_EVENTS are allowed for Buttons 97 | if parameter not in IMPULSE_EVENTS and (not parameter.startswith(DEVICE_ERROR_EVENTS) or parameter_is_un_ignored): 98 | create_data_point_and_append_to_channel( 99 | channel=channel, 100 | paramset_key=paramset_key, 101 | parameter=parameter, 102 | parameter_data=parameter_data, 103 | ) 104 | 105 | 106 | def _should_create_event(parameter_data: ParameterData, parameter: str) -> bool: 107 | """Determine if an event should be created for the parameter.""" 108 | return bool( 109 | parameter_data["OPERATIONS"] & Operations.EVENT 110 | and (parameter in CLICK_EVENTS or parameter.startswith(DEVICE_ERROR_EVENTS) or parameter in IMPULSE_EVENTS) 111 | ) 112 | 113 | 114 | def _should_skip_data_point(parameter_data: ParameterData, parameter: str, parameter_is_un_ignored: bool) -> bool: 115 | """Determine if a data point should be skipped.""" 116 | return bool( 117 | (not parameter_data["OPERATIONS"] & Operations.EVENT and not parameter_data["OPERATIONS"] & Operations.WRITE) 118 | or ( 119 | parameter_data["FLAGS"] & Flag.INTERNAL 120 | and parameter not in _ALLOWED_INTERNAL_PARAMETERS 121 | and not parameter_is_un_ignored 122 | ) 123 | ) 124 | -------------------------------------------------------------------------------- /hahomematic/model/calculated/__init__.py: -------------------------------------------------------------------------------- 1 | """Module for HaHomematic calculated data points.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Final 7 | 8 | from hahomematic.decorators import inspector 9 | from hahomematic.model import device as hmd 10 | from hahomematic.model.calculated.climate import ApparentTemperature, DewPoint, FrostPoint, VaporConcentration 11 | from hahomematic.model.calculated.data_point import CalculatedDataPoint 12 | from hahomematic.model.calculated.operating_voltage_level import OperatingVoltageLevel 13 | 14 | __all__ = [ 15 | "ApparentTemperature", 16 | "CalculatedDataPoint", 17 | "DewPoint", 18 | "FrostPoint", 19 | "OperatingVoltageLevel", 20 | "VaporConcentration", 21 | "create_calculated_data_points", 22 | ] 23 | 24 | _CALCULATED_DATA_POINTS: Final = (ApparentTemperature, DewPoint, FrostPoint, OperatingVoltageLevel, VaporConcentration) 25 | _LOGGER: Final = logging.getLogger(__name__) 26 | 27 | 28 | @inspector() 29 | def create_calculated_data_points(channel: hmd.Channel) -> None: 30 | """Decides which data point category should be used, and creates the required data points.""" 31 | for cdp in _CALCULATED_DATA_POINTS: 32 | if cdp.is_relevant_for_model(channel=channel): 33 | channel.add_data_point(data_point=cdp(channel=channel)) 34 | -------------------------------------------------------------------------------- /hahomematic/model/custom/__init__.py: -------------------------------------------------------------------------------- 1 | """Module for HaHomematic custom data point.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Final 7 | 8 | from hahomematic.decorators import inspector 9 | from hahomematic.model import device as hmd 10 | from hahomematic.model.custom.climate import ( 11 | PROFILE_DICT, 12 | PROFILE_PREFIX, 13 | SIMPLE_PROFILE_DICT, 14 | SIMPLE_WEEKDAY_LIST, 15 | WEEKDAY_DICT, 16 | BaseCustomDpClimate, 17 | ClimateActivity, 18 | ClimateMode, 19 | ClimateProfile, 20 | CustomDpIpThermostat, 21 | CustomDpRfThermostat, 22 | CustomDpSimpleRfThermostat, 23 | ScheduleProfile, 24 | ScheduleWeekday, 25 | ) 26 | from hahomematic.model.custom.cover import ( 27 | CustomDpBlind, 28 | CustomDpCover, 29 | CustomDpGarage, 30 | CustomDpIpBlind, 31 | CustomDpWindowDrive, 32 | ) 33 | from hahomematic.model.custom.data_point import CustomDataPoint 34 | from hahomematic.model.custom.definition import ( 35 | data_point_definition_exists, 36 | get_custom_configs, 37 | get_required_parameters, 38 | validate_custom_data_point_definition, 39 | ) 40 | from hahomematic.model.custom.light import ( 41 | CustomDpColorDimmer, 42 | CustomDpColorDimmerEffect, 43 | CustomDpColorTempDimmer, 44 | CustomDpDimmer, 45 | CustomDpIpDrgDaliLight, 46 | CustomDpIpFixedColorLight, 47 | CustomDpIpRGBWLight, 48 | LightOffArgs, 49 | LightOnArgs, 50 | ) 51 | from hahomematic.model.custom.lock import ( 52 | BaseCustomDpLock, 53 | CustomDpButtonLock, 54 | CustomDpIpLock, 55 | CustomDpRfLock, 56 | LockState, 57 | ) 58 | from hahomematic.model.custom.siren import BaseCustomDpSiren, CustomDpIpSiren, CustomDpIpSirenSmoke, SirenOnArgs 59 | from hahomematic.model.custom.switch import CustomDpSwitch 60 | from hahomematic.model.custom.valve import CustomDpIpIrrigationValve 61 | 62 | __all__ = [ 63 | "BaseCustomDpClimate", 64 | "BaseCustomDpLock", 65 | "BaseCustomDpSiren", 66 | "ClimateActivity", 67 | "ClimateMode", 68 | "ClimateProfile", 69 | "CustomDataPoint", 70 | "CustomDpBlind", 71 | "CustomDpButtonLock", 72 | "CustomDpColorDimmer", 73 | "CustomDpColorDimmerEffect", 74 | "CustomDpColorTempDimmer", 75 | "CustomDpCover", 76 | "CustomDpDimmer", 77 | "CustomDpGarage", 78 | "CustomDpIpBlind", 79 | "CustomDpIpDrgDaliLight", 80 | "CustomDpIpFixedColorLight", 81 | "CustomDpIpIrrigationValve", 82 | "CustomDpIpLock", 83 | "CustomDpIpRGBWLight", 84 | "CustomDpIpSiren", 85 | "CustomDpIpSirenSmoke", 86 | "CustomDpIpThermostat", 87 | "CustomDpRfLock", 88 | "CustomDpRfThermostat", 89 | "CustomDpSimpleRfThermostat", 90 | "CustomDpSwitch", 91 | "CustomDpWindowDrive", 92 | "LightOffArgs", 93 | "LightOnArgs", 94 | "LockState", 95 | "PROFILE_DICT", 96 | "PROFILE_PREFIX", 97 | "SIMPLE_PROFILE_DICT", 98 | "SIMPLE_WEEKDAY_LIST", 99 | "ScheduleProfile", 100 | "ScheduleWeekday", 101 | "SirenOnArgs", 102 | "WEEKDAY_DICT", 103 | "create_custom_data_points", 104 | "get_required_parameters", 105 | "validate_custom_data_point_definition", 106 | ] 107 | 108 | _LOGGER: Final = logging.getLogger(__name__) 109 | 110 | 111 | @inspector() 112 | def create_custom_data_points(device: hmd.Device) -> None: 113 | """Decides which data point category should be used, and creates the required data points.""" 114 | 115 | if device.ignore_for_custom_data_point: 116 | _LOGGER.debug( 117 | "CREATE_CUSTOM_DATA_POINTS: Ignoring for custom data point: %s, %s, %s due to ignored", 118 | device.interface_id, 119 | device, 120 | device.model, 121 | ) 122 | return 123 | if data_point_definition_exists(device.model): 124 | _LOGGER.debug( 125 | "CREATE_CUSTOM_DATA_POINTS: Handling custom data point integration: %s, %s, %s", 126 | device.interface_id, 127 | device, 128 | device.model, 129 | ) 130 | 131 | # Call the custom creation function. 132 | for custom_config in get_custom_configs(model=device.model): 133 | for channel in device.channels.values(): 134 | custom_config.make_ce_func(channel, custom_config) 135 | -------------------------------------------------------------------------------- /hahomematic/model/custom/const.py: -------------------------------------------------------------------------------- 1 | """Constants used by hahomematic custom data points.""" 2 | 3 | from __future__ import annotations 4 | 5 | from enum import Enum, StrEnum 6 | 7 | 8 | class DeviceProfile(StrEnum): 9 | """Enum for device profiles.""" 10 | 11 | IP_BUTTON_LOCK = "IPButtonLock" 12 | IP_COVER = "IPCover" 13 | IP_DIMMER = "IPDimmer" 14 | IP_DRG_DALI = "IPDRGDALI" 15 | IP_FIXED_COLOR_LIGHT = "IPFixedColorLight" 16 | IP_GARAGE = "IPGarage" 17 | IP_HDM = "IPHdm" 18 | IP_IRRIGATION_VALVE = "IPIrrigationValve" 19 | IP_LOCK = "IPLock" 20 | IP_RGBW_LIGHT = "IPRGBW" 21 | IP_SIMPLE_FIXED_COLOR_LIGHT = "IPSimpleFixedColorLight" 22 | IP_SIMPLE_FIXED_COLOR_LIGHT_WIRED = "IPSimpleFixedColorLightWired" 23 | IP_SIREN = "IPSiren" 24 | IP_SIREN_SMOKE = "IPSirenSmoke" 25 | IP_SWITCH = "IPSwitch" 26 | IP_THERMOSTAT = "IPThermostat" 27 | IP_THERMOSTAT_GROUP = "IPThermostatGroup" 28 | RF_BUTTON_LOCK = "RFButtonLock" 29 | RF_COVER = "RfCover" 30 | RF_DIMMER = "RfDimmer" 31 | RF_DIMMER_COLOR = "RfDimmer_Color" 32 | RF_DIMMER_COLOR_FIXED = "RfDimmer_Color_Fixed" 33 | RF_DIMMER_COLOR_TEMP = "RfDimmer_Color_Temp" 34 | RF_DIMMER_WITH_VIRT_CHANNEL = "RfDimmerWithVirtChannel" 35 | RF_LOCK = "RfLock" 36 | RF_SIREN = "RfSiren" 37 | RF_SWITCH = "RfSwitch" 38 | RF_THERMOSTAT = "RfThermostat" 39 | RF_THERMOSTAT_GROUP = "RfThermostatGroup" 40 | SIMPLE_RF_THERMOSTAT = "SimpleRfThermostat" 41 | 42 | 43 | class CDPD(StrEnum): 44 | """Enum for custom data point definitions.""" 45 | 46 | ALLOW_UNDEFINED_GENERIC_DPS = "allow_undefined_generic_dps" 47 | DEFAULT_DPS = "default_dps" 48 | INCLUDE_DEFAULT_DPS = "include_default_dps" 49 | DEVICE_GROUP = "device_group" 50 | DEVICE_DEFINITIONS = "device_definitions" 51 | ADDITIONAL_DPS = "additional_dps" 52 | FIELDS = "fields" 53 | REPEATABLE_FIELDS = "repeatable_fields" 54 | VISIBLE_REPEATABLE_FIELDS = "visible_repeatable_fields" 55 | PRIMARY_CHANNEL = "primary_channel" 56 | SECONDARY_CHANNELS = "secondary_channels" 57 | VISIBLE_FIELDS = "visible_fields" 58 | 59 | 60 | class Field(Enum): 61 | """Enum for fields.""" 62 | 63 | ACOUSTIC_ALARM_ACTIVE = "acoustic_alarm_active" 64 | ACOUSTIC_ALARM_SELECTION = "acoustic_alarm_selection" 65 | ACTIVE_PROFILE = "active_profile" 66 | AUTO_MODE = "auto_mode" 67 | BOOST_MODE = "boost_mode" 68 | BUTTON_LOCK = "button_lock" 69 | CHANNEL_COLOR = "channel_color" 70 | CHANNEL_LEVEL = "channel_level" 71 | CHANNEL_LEVEL_2 = "channel_level_2" 72 | CHANNEL_STATE = "channel_state" 73 | COLOR = "color" 74 | COLOR_BEHAVIOUR = "color_behaviour" 75 | COLOR_LEVEL = "color_temp" 76 | COLOR_TEMPERATURE = "color_temperature" 77 | COMBINED_PARAMETER = "combined_parameter" 78 | COMFORT_MODE = "comfort_mode" 79 | CONCENTRATION = "concentration" 80 | CONTROL_MODE = "control_mode" 81 | CURRENT = "current" 82 | DEVICE_OPERATION_MODE = "device_operation_mode" 83 | DIRECTION = "direction" 84 | DOOR_COMMAND = "door_command" 85 | DOOR_STATE = "door_state" 86 | DURATION = "duration" 87 | DURATION_UNIT = "duration_unit" 88 | DUTYCYCLE = "dutycycle" 89 | DUTY_CYCLE = "duty_cycle" 90 | EFFECT = "effect" 91 | ENERGY_COUNTER = "energy_counter" 92 | ERROR = "error" 93 | FREQUENCY = "frequency" 94 | HEATING_COOLING = "heating_cooling" 95 | HEATING_VALVE_TYPE = "heating_valve_type" 96 | HUE = "hue" 97 | HUMIDITY = "humidity" 98 | INHIBIT = "inhibit" 99 | LEVEL = "level" 100 | LEVEL_2 = "level_2" 101 | LEVEL_COMBINED = "level_combined" 102 | LOCK_STATE = "lock_state" 103 | LOCK_TARGET_LEVEL = "lock_target_level" 104 | LOWBAT = "lowbat" 105 | LOWERING_MODE = "lowering_mode" 106 | LOW_BAT = "low_bat" 107 | LOW_BAT_LIMIT = "low_bat_limit" 108 | MANU_MODE = "manu_mode" 109 | MIN_MAX_VALUE_NOT_RELEVANT_FOR_MANU_MODE = "min_max_value_not_relevant_for_manu_mode" 110 | ON_TIME_UNIT = "on_time_unit" 111 | ON_TIME_VALUE = "on_time_value" 112 | OPEN = "open" 113 | OPERATING_VOLTAGE = "operating_voltage" 114 | OPERATION_MODE = "channel_operation_mode" 115 | OPTICAL_ALARM_ACTIVE = "optical_alarm_active" 116 | OPTICAL_ALARM_SELECTION = "optical_alarm_selection" 117 | OPTIMUM_START_STOP = "optimum_start_stop" 118 | PARTY_MODE = "party_mode" 119 | POWER = "power" 120 | PROGRAM = "program" 121 | RAMP_TIME_TO_OFF_UNIT = "ramp_time_to_off_unit" 122 | RAMP_TIME_TO_OFF_VALUE = "ramp_time_to_off_value" 123 | RAMP_TIME_UNIT = "ramp_time_unit" 124 | RAMP_TIME_VALUE = "ramp_time_value" 125 | RSSI_DEVICE = "rssi_device" 126 | RSSI_PEER = "rssi_peer" 127 | SABOTAGE = "sabotage" 128 | SATURATION = "saturation" 129 | SECTION = "section" 130 | SETPOINT = "setpoint" 131 | SET_POINT_MODE = "set_point_mode" 132 | SMOKE_DETECTOR_ALARM_STATUS = "smoke_detector_alarm_status" 133 | SMOKE_DETECTOR_COMMAND = "smoke_detector_command" 134 | STATE = "state" 135 | STOP = "stop" 136 | SWITCH_MAIN = "switch_main" 137 | SWITCH_V1 = "vswitch_1" 138 | SWITCH_V2 = "vswitch_2" 139 | TEMPERATURE = "temperature" 140 | TEMPERATURE_MAXIMUM = "temperature_maximum" 141 | TEMPERATURE_MINIMUM = "temperature_minimum" 142 | TEMPERATURE_OFFSET = "temperature_offset" 143 | VALVE_STATE = "valve_state" 144 | VOLTAGE = "voltage" 145 | WEEK_PROGRAM_POINTER = "week_program_pointer" 146 | -------------------------------------------------------------------------------- /hahomematic/model/custom/support.py: -------------------------------------------------------------------------------- 1 | """Support classes used by hahomematic custom data points.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Callable, Mapping 6 | from dataclasses import dataclass 7 | 8 | from hahomematic.const import Parameter 9 | from hahomematic.model.custom.const import Field 10 | 11 | 12 | @dataclass(frozen=True, kw_only=True, slots=True) 13 | class CustomConfig: 14 | """Data for custom data_point creation.""" 15 | 16 | make_ce_func: Callable 17 | channels: tuple[int | None, ...] = (1,) 18 | extended: ExtendedConfig | None = None 19 | 20 | 21 | @dataclass(frozen=True, kw_only=True, slots=True) 22 | class ExtendedConfig: 23 | """Extended data for custom data_point creation.""" 24 | 25 | fixed_channels: Mapping[int, Mapping[Field, Parameter]] | None = None 26 | additional_data_points: Mapping[int | tuple[int, ...], tuple[Parameter, ...]] | None = None 27 | 28 | @property 29 | def required_parameters(self) -> tuple[Parameter, ...]: 30 | """Return vol.Required parameters from extended config.""" 31 | required_parameters: list[Parameter] = [] 32 | if fixed_channels := self.fixed_channels: 33 | for mapping in fixed_channels.values(): 34 | required_parameters.extend(mapping.values()) 35 | 36 | if additional_dps := self.additional_data_points: 37 | for parameters in additional_dps.values(): 38 | required_parameters.extend(parameters) 39 | 40 | return tuple(required_parameters) 41 | -------------------------------------------------------------------------------- /hahomematic/model/custom/switch.py: -------------------------------------------------------------------------------- 1 | """Module for data points implemented using the switch category.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Mapping 6 | from enum import StrEnum 7 | import logging 8 | from typing import Any, Final 9 | 10 | from hahomematic.const import DataPointCategory, Parameter 11 | from hahomematic.model import device as hmd 12 | from hahomematic.model.custom import definition as hmed 13 | from hahomematic.model.custom.const import DeviceProfile, Field 14 | from hahomematic.model.custom.data_point import CustomDataPoint 15 | from hahomematic.model.custom.support import CustomConfig, ExtendedConfig 16 | from hahomematic.model.data_point import CallParameterCollector, bind_collector 17 | from hahomematic.model.decorators import state_property 18 | from hahomematic.model.generic import DpAction, DpBinarySensor, DpSwitch 19 | from hahomematic.model.support import TimerMixin 20 | 21 | _LOGGER: Final = logging.getLogger(__name__) 22 | 23 | 24 | class _StateChangeArg(StrEnum): 25 | """Enum with switch state change arguments.""" 26 | 27 | OFF = "off" 28 | ON = "on" 29 | 30 | 31 | class CustomDpSwitch(CustomDataPoint, TimerMixin): 32 | """Class for HomeMatic switch data point.""" 33 | 34 | _category = DataPointCategory.SWITCH 35 | 36 | def _init_data_point_fields(self) -> None: 37 | """Init the data_point fields.""" 38 | TimerMixin.__init__(self) 39 | super()._init_data_point_fields() 40 | self._dp_state: DpSwitch = self._get_data_point(field=Field.STATE, data_point_type=DpSwitch) 41 | self._dp_on_time_value: DpAction = self._get_data_point(field=Field.ON_TIME_VALUE, data_point_type=DpAction) 42 | self._dp_channel_state: DpBinarySensor = self._get_data_point( 43 | field=Field.CHANNEL_STATE, data_point_type=DpBinarySensor 44 | ) 45 | 46 | @property 47 | def channel_value(self) -> bool | None: 48 | """Return the current channel value of the switch.""" 49 | return self._dp_channel_state.value 50 | 51 | @state_property 52 | def value(self) -> bool | None: 53 | """Return the current value of the switch.""" 54 | return self._dp_state.value 55 | 56 | @bind_collector() 57 | async def turn_on(self, collector: CallParameterCollector | None = None, on_time: float | None = None) -> None: 58 | """Turn the switch on.""" 59 | if on_time is not None: 60 | self.set_timer_on_time(on_time=on_time) 61 | if not self.is_state_change(on=True): 62 | return 63 | 64 | if (timer := self.get_and_start_timer()) is not None: 65 | await self._dp_on_time_value.send_value(value=timer, collector=collector, do_validate=False) 66 | await self._dp_state.turn_on(collector=collector) 67 | 68 | @bind_collector() 69 | async def turn_off(self, collector: CallParameterCollector | None = None) -> None: 70 | """Turn the switch off.""" 71 | self.reset_timer_on_time() 72 | if not self.is_state_change(off=True): 73 | return 74 | await self._dp_state.turn_off(collector=collector) 75 | 76 | def is_state_change(self, **kwargs: Any) -> bool: 77 | """Check if the state changes due to kwargs.""" 78 | if (on_time_running := self.timer_on_time_running) is not None and on_time_running is True: 79 | return True 80 | if self.timer_on_time is not None: 81 | return True 82 | if kwargs.get(_StateChangeArg.ON) is not None and self.value is not True: 83 | return True 84 | if kwargs.get(_StateChangeArg.OFF) is not None and self.value is not False: 85 | return True 86 | return super().is_state_change(**kwargs) 87 | 88 | 89 | def make_ip_switch( 90 | channel: hmd.Channel, 91 | custom_config: CustomConfig, 92 | ) -> None: 93 | """Create HomematicIP switch data point.""" 94 | hmed.make_custom_data_point( 95 | channel=channel, 96 | data_point_class=CustomDpSwitch, 97 | device_profile=DeviceProfile.IP_SWITCH, 98 | custom_config=custom_config, 99 | ) 100 | 101 | 102 | # Case for device model is not relevant. 103 | # HomeBrew (HB-) devices are always listed as HM-. 104 | DEVICES: Mapping[str, CustomConfig | tuple[CustomConfig, ...]] = { 105 | "ELV-SH-BS2": CustomConfig(make_ce_func=make_ip_switch, channels=(4, 8)), 106 | "HmIP-BS2": CustomConfig(make_ce_func=make_ip_switch, channels=(4, 8)), 107 | "HmIP-BSL": CustomConfig(make_ce_func=make_ip_switch, channels=(4,)), 108 | "HmIP-BSM": CustomConfig(make_ce_func=make_ip_switch, channels=(4,)), 109 | "HmIP-DRSI1": CustomConfig( 110 | make_ce_func=make_ip_switch, 111 | channels=(3,), 112 | extended=ExtendedConfig( 113 | additional_data_points={ 114 | 0: (Parameter.ACTUAL_TEMPERATURE,), 115 | } 116 | ), 117 | ), 118 | "HmIP-DRSI4": CustomConfig( 119 | make_ce_func=make_ip_switch, 120 | channels=(6, 10, 14, 18), 121 | extended=ExtendedConfig( 122 | additional_data_points={ 123 | 0: (Parameter.ACTUAL_TEMPERATURE,), 124 | } 125 | ), 126 | ), 127 | "HmIP-FSI": CustomConfig(make_ce_func=make_ip_switch, channels=(3,)), 128 | "HmIP-FSM": CustomConfig(make_ce_func=make_ip_switch, channels=(2,)), 129 | "HmIP-MOD-OC8": CustomConfig(make_ce_func=make_ip_switch, channels=(10, 14, 18, 22, 26, 30, 34, 38)), 130 | "HmIP-PCBS": CustomConfig(make_ce_func=make_ip_switch, channels=(3,)), 131 | "HmIP-PCBS-BAT": CustomConfig(make_ce_func=make_ip_switch, channels=(3,)), 132 | "HmIP-PCBS2": CustomConfig(make_ce_func=make_ip_switch, channels=(4, 8)), 133 | "HmIP-PS": CustomConfig(make_ce_func=make_ip_switch, channels=(3,)), 134 | "HmIP-SCTH230": CustomConfig(make_ce_func=make_ip_switch, channels=(8,)), 135 | "HmIP-USBSM": CustomConfig(make_ce_func=make_ip_switch, channels=(3,)), 136 | "HmIP-WGC": CustomConfig(make_ce_func=make_ip_switch, channels=(3,)), 137 | "HmIP-WHS2": CustomConfig(make_ce_func=make_ip_switch, channels=(2, 6)), 138 | "HmIPW-DRS": CustomConfig( 139 | make_ce_func=make_ip_switch, 140 | channels=(2, 6, 10, 14, 18, 22, 26, 30), 141 | extended=ExtendedConfig( 142 | additional_data_points={ 143 | 0: (Parameter.ACTUAL_TEMPERATURE,), 144 | } 145 | ), 146 | ), 147 | "HmIPW-FIO6": CustomConfig(make_ce_func=make_ip_switch, channels=(8, 12, 16, 20, 24, 28)), 148 | } 149 | hmed.ALL_DEVICES[DataPointCategory.SWITCH] = DEVICES 150 | -------------------------------------------------------------------------------- /hahomematic/model/custom/valve.py: -------------------------------------------------------------------------------- 1 | """Module for data points implemented using the valve category.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Mapping 6 | from enum import StrEnum 7 | import logging 8 | from typing import Any, Final 9 | 10 | from hahomematic.const import DataPointCategory 11 | from hahomematic.model import device as hmd 12 | from hahomematic.model.custom import definition as hmed 13 | from hahomematic.model.custom.const import DeviceProfile, Field 14 | from hahomematic.model.custom.data_point import CustomDataPoint 15 | from hahomematic.model.custom.support import CustomConfig 16 | from hahomematic.model.data_point import CallParameterCollector, bind_collector 17 | from hahomematic.model.decorators import state_property 18 | from hahomematic.model.generic import DpAction, DpBinarySensor, DpSwitch 19 | from hahomematic.model.support import TimerMixin 20 | 21 | _LOGGER: Final = logging.getLogger(__name__) 22 | 23 | 24 | class _StateChangeArg(StrEnum): 25 | """Enum with valve state change arguments.""" 26 | 27 | OFF = "off" 28 | ON = "on" 29 | 30 | 31 | class CustomDpIpIrrigationValve(CustomDataPoint, TimerMixin): 32 | """Class for HomeMatic irrigation valve data point.""" 33 | 34 | _category = DataPointCategory.VALVE 35 | 36 | def _init_data_point_fields(self) -> None: 37 | """Init the data_point fields.""" 38 | TimerMixin.__init__(self) 39 | super()._init_data_point_fields() 40 | self._dp_state: DpSwitch = self._get_data_point(field=Field.STATE, data_point_type=DpSwitch) 41 | self._dp_on_time_value: DpAction = self._get_data_point(field=Field.ON_TIME_VALUE, data_point_type=DpAction) 42 | self._dp_channel_state: DpBinarySensor = self._get_data_point( 43 | field=Field.CHANNEL_STATE, data_point_type=DpBinarySensor 44 | ) 45 | 46 | @property 47 | def channel_value(self) -> bool | None: 48 | """Return the current channel value of the valve.""" 49 | return self._dp_channel_state.value 50 | 51 | @state_property 52 | def value(self) -> bool | None: 53 | """Return the current value of the valve.""" 54 | return self._dp_state.value 55 | 56 | @bind_collector() 57 | async def open(self, collector: CallParameterCollector | None = None, on_time: float | None = None) -> None: 58 | """Turn the valve on.""" 59 | if on_time is not None: 60 | self.set_timer_on_time(on_time=on_time) 61 | if not self.is_state_change(on=True): 62 | return 63 | 64 | if (timer := self.get_and_start_timer()) is not None: 65 | await self._dp_on_time_value.send_value(value=timer, collector=collector, do_validate=False) 66 | await self._dp_state.turn_on(collector=collector) 67 | 68 | @bind_collector() 69 | async def close(self, collector: CallParameterCollector | None = None) -> None: 70 | """Turn the valve off.""" 71 | self.reset_timer_on_time() 72 | if not self.is_state_change(off=True): 73 | return 74 | await self._dp_state.turn_off(collector=collector) 75 | 76 | def is_state_change(self, **kwargs: Any) -> bool: 77 | """Check if the state changes due to kwargs.""" 78 | if (on_time_running := self.timer_on_time_running) is not None and on_time_running is True: 79 | return True 80 | if self.timer_on_time is not None: 81 | return True 82 | if kwargs.get(_StateChangeArg.ON) is not None and self.value is not True: 83 | return True 84 | if kwargs.get(_StateChangeArg.OFF) is not None and self.value is not False: 85 | return True 86 | return super().is_state_change(**kwargs) 87 | 88 | 89 | def make_ip_irrigation_valve( 90 | channel: hmd.Channel, 91 | custom_config: CustomConfig, 92 | ) -> None: 93 | """Create HomematicIP irrigation valve data point.""" 94 | hmed.make_custom_data_point( 95 | channel=channel, 96 | data_point_class=CustomDpIpIrrigationValve, 97 | device_profile=DeviceProfile.IP_IRRIGATION_VALVE, 98 | custom_config=custom_config, 99 | ) 100 | 101 | 102 | # Case for device model is not relevant. 103 | # HomeBrew (HB-) devices are always listed as HM-. 104 | DEVICES: Mapping[str, CustomConfig | tuple[CustomConfig, ...]] = { 105 | "ELV-SH-WSM": CustomConfig(make_ce_func=make_ip_irrigation_valve, channels=(4,)), 106 | "HmIP-WSM": CustomConfig(make_ce_func=make_ip_irrigation_valve, channels=(4,)), 107 | } 108 | hmed.ALL_DEVICES[DataPointCategory.VALVE] = DEVICES 109 | -------------------------------------------------------------------------------- /hahomematic/model/decorators.py: -------------------------------------------------------------------------------- 1 | """Decorators for data points used within hahomematic.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Callable, Mapping 6 | from datetime import datetime 7 | from enum import Enum 8 | from typing import Any, ParamSpec, TypeVar 9 | 10 | __all__ = [ 11 | "config_property", 12 | "get_public_attributes_for_config_property", 13 | "get_public_attributes_for_info_property", 14 | "get_public_attributes_for_state_property", 15 | "info_property", 16 | "state_property", 17 | ] 18 | 19 | P = ParamSpec("P") 20 | T = TypeVar("T") 21 | 22 | 23 | # pylint: disable=invalid-name 24 | class generic_property[_GETTER, _SETTER](property): 25 | """Generic property implementation.""" 26 | 27 | fget: Callable[[Any], _GETTER] | None 28 | fset: Callable[[Any, _SETTER], None] | None 29 | fdel: Callable[[Any], None] | None 30 | 31 | def __init__( 32 | self, 33 | fget: Callable[[Any], _GETTER] | None = None, 34 | fset: Callable[[Any, _SETTER], None] | None = None, 35 | fdel: Callable[[Any], None] | None = None, 36 | doc: str | None = None, 37 | ) -> None: 38 | """Init the generic property.""" 39 | super().__init__(fget, fset, fdel, doc) 40 | if doc is None and fget is not None: 41 | doc = fget.__doc__ 42 | self.__doc__ = doc 43 | 44 | def getter(self, fget: Callable[[Any], _GETTER], /) -> generic_property: 45 | """Return generic getter.""" 46 | return type(self)(fget, self.fset, self.fdel, self.__doc__) # pragma: no cover 47 | 48 | def setter(self, fset: Callable[[Any, _SETTER], None], /) -> generic_property: 49 | """Return generic setter.""" 50 | return type(self)(self.fget, fset, self.fdel, self.__doc__) 51 | 52 | def deleter(self, fdel: Callable[[Any], None], /) -> generic_property: 53 | """Return generic deleter.""" 54 | return type(self)(self.fget, self.fset, fdel, self.__doc__) 55 | 56 | def __get__(self, obj: Any, gtype: type | None = None, /) -> _GETTER: 57 | """Return the attribute.""" 58 | if obj is None: 59 | return self # type: ignore[return-value] 60 | if self.fget is None: 61 | raise AttributeError("unreadable attribute") # pragma: no cover 62 | return self.fget(obj) 63 | 64 | def __set__(self, obj: Any, value: Any, /) -> None: 65 | """Set the attribute.""" 66 | if self.fset is None: 67 | raise AttributeError("can't set attribute") # pragma: no cover 68 | self.fset(obj, value) 69 | 70 | def __delete__(self, obj: Any, /) -> None: 71 | """Delete the attribute.""" 72 | if self.fdel is None: 73 | raise AttributeError("can't delete attribute") # pragma: no cover 74 | self.fdel(obj) 75 | 76 | 77 | # pylint: disable=invalid-name 78 | class config_property[_GETTER, _SETTER](generic_property[_GETTER, _SETTER]): 79 | """Decorate to mark own config properties.""" 80 | 81 | 82 | # pylint: disable=invalid-name 83 | class info_property[_GETTER, _SETTER](generic_property[_GETTER, _SETTER]): 84 | """Decorate to mark own info properties.""" 85 | 86 | 87 | # pylint: disable=invalid-name 88 | class state_property[_GETTER, _SETTER](generic_property[_GETTER, _SETTER]): 89 | """Decorate to mark own value properties.""" 90 | 91 | 92 | def _get_public_attributes_by_class_decorator(data_object: Any, class_decorator: type) -> Mapping[str, Any]: 93 | """Return the object attributes by decorator.""" 94 | pub_attributes = [ 95 | y 96 | for y in dir(data_object.__class__) 97 | if not y.startswith("_") and isinstance(getattr(data_object.__class__, y), class_decorator) 98 | ] 99 | return {x: _get_text_value(getattr(data_object, x)) for x in pub_attributes} 100 | 101 | 102 | def _get_text_value(value: Any) -> Any: 103 | """Convert value to text.""" 104 | if isinstance(value, (list, tuple, set)): 105 | return tuple(_get_text_value(v) for v in value) 106 | if isinstance(value, Enum): 107 | return str(value) 108 | if isinstance(value, datetime): 109 | return datetime.timestamp(value) 110 | return value 111 | 112 | 113 | def get_public_attributes_for_config_property(data_object: Any) -> Mapping[str, Any]: 114 | """Return the object attributes by decorator config_property.""" 115 | return _get_public_attributes_by_class_decorator(data_object=data_object, class_decorator=config_property) 116 | 117 | 118 | def get_public_attributes_for_info_property(data_object: Any) -> Mapping[str, Any]: 119 | """Return the object attributes by decorator info_property.""" 120 | return _get_public_attributes_by_class_decorator(data_object=data_object, class_decorator=info_property) 121 | 122 | 123 | def get_public_attributes_for_state_property(data_object: Any) -> Mapping[str, Any]: 124 | """Return the object attributes by decorator state_property.""" 125 | return _get_public_attributes_by_class_decorator(data_object=data_object, class_decorator=state_property) 126 | -------------------------------------------------------------------------------- /hahomematic/model/event.py: -------------------------------------------------------------------------------- 1 | """Functions for event creation.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any, Final 7 | 8 | from hahomematic import support as hms 9 | from hahomematic.async_support import loop_check 10 | from hahomematic.const import ( 11 | CLICK_EVENTS, 12 | DATA_POINT_EVENTS, 13 | DEVICE_ERROR_EVENTS, 14 | IMPULSE_EVENTS, 15 | DataPointCategory, 16 | DataPointUsage, 17 | EventType, 18 | Operations, 19 | ParameterData, 20 | ParamsetKey, 21 | ) 22 | from hahomematic.decorators import inspector 23 | from hahomematic.exceptions import HaHomematicException 24 | from hahomematic.model import device as hmd 25 | from hahomematic.model.data_point import BaseParameterDataPoint 26 | from hahomematic.model.support import DataPointNameData, get_event_name 27 | 28 | __all__ = [ 29 | "ClickEvent", 30 | "DeviceErrorEvent", 31 | "GenericEvent", 32 | "ImpulseEvent", 33 | "create_event_and_append_to_channel", 34 | ] 35 | 36 | _LOGGER: Final = logging.getLogger(__name__) 37 | 38 | 39 | class GenericEvent(BaseParameterDataPoint[Any, Any]): 40 | """Base class for events.""" 41 | 42 | _category = DataPointCategory.EVENT 43 | _event_type: EventType 44 | 45 | def __init__( 46 | self, 47 | channel: hmd.Channel, 48 | parameter: str, 49 | parameter_data: ParameterData, 50 | ) -> None: 51 | """Initialize the event handler.""" 52 | self._unique_id_prefix = f"event_{channel.central.name}" 53 | super().__init__( 54 | channel=channel, 55 | paramset_key=ParamsetKey.VALUES, 56 | parameter=parameter, 57 | parameter_data=parameter_data, 58 | ) 59 | 60 | @property 61 | def usage(self) -> DataPointUsage: 62 | """Return the data_point usage.""" 63 | if (forced_by_com := self._enabled_by_channel_operation_mode) is None: 64 | return self._get_data_point_usage() 65 | return DataPointUsage.EVENT if forced_by_com else DataPointUsage.NO_CREATE 66 | 67 | @property 68 | def event_type(self) -> EventType: 69 | """Return the event_type of the event.""" 70 | return self._event_type 71 | 72 | async def event(self, value: Any) -> None: 73 | """Handle event for which this handler has subscribed.""" 74 | if self.event_type in DATA_POINT_EVENTS: 75 | self.fire_data_point_updated_callback(parameter=self.parameter.lower()) 76 | self._set_modified_at() 77 | self.fire_event(value) 78 | 79 | @loop_check 80 | def fire_event(self, value: Any) -> None: 81 | """Do what is needed to fire an event.""" 82 | self._central.fire_homematic_callback(event_type=self.event_type, event_data=self.get_event_data(value=value)) 83 | 84 | def _get_data_point_name(self) -> DataPointNameData: 85 | """Create the name for the data_point.""" 86 | return get_event_name( 87 | channel=self._channel, 88 | parameter=self._parameter, 89 | ) 90 | 91 | def _get_data_point_usage(self) -> DataPointUsage: 92 | """Generate the usage for the data_point.""" 93 | return DataPointUsage.EVENT 94 | 95 | 96 | class ClickEvent(GenericEvent): 97 | """class for handling click events.""" 98 | 99 | _event_type = EventType.KEYPRESS 100 | 101 | 102 | class DeviceErrorEvent(GenericEvent): 103 | """class for handling device error events.""" 104 | 105 | _event_type = EventType.DEVICE_ERROR 106 | 107 | async def event(self, value: Any) -> None: 108 | """Handle event for which this handler has subscribed.""" 109 | 110 | old_value, new_value = self.write_value(value=value) 111 | 112 | if ( 113 | isinstance(new_value, bool) 114 | and ((old_value is None and new_value is True) or (isinstance(old_value, bool) and old_value != new_value)) 115 | ) or ( 116 | isinstance(new_value, int) 117 | and ((old_value is None and new_value > 0) or (isinstance(old_value, int) and old_value != new_value)) 118 | ): 119 | self.fire_event(value=new_value) 120 | 121 | 122 | class ImpulseEvent(GenericEvent): 123 | """class for handling impulse events.""" 124 | 125 | _event_type = EventType.IMPULSE 126 | 127 | 128 | @inspector() 129 | def create_event_and_append_to_channel(channel: hmd.Channel, parameter: str, parameter_data: ParameterData) -> None: 130 | """Create action event data_point.""" 131 | _LOGGER.debug( 132 | "CREATE_EVENT_AND_APPEND_TO_DEVICE: Creating event for %s, %s, %s", 133 | channel.address, 134 | parameter, 135 | channel.device.interface_id, 136 | ) 137 | if (event_t := _determine_event_type(parameter=parameter, parameter_data=parameter_data)) and ( 138 | event := _safe_create_event( 139 | event_t=event_t, channel=channel, parameter=parameter, parameter_data=parameter_data 140 | ) 141 | ): 142 | channel.add_data_point(event) 143 | 144 | 145 | def _determine_event_type(parameter: str, parameter_data: ParameterData) -> type[GenericEvent] | None: 146 | event_t: type[GenericEvent] | None = None 147 | if parameter_data["OPERATIONS"] & Operations.EVENT: 148 | if parameter in CLICK_EVENTS: 149 | event_t = ClickEvent 150 | if parameter.startswith(DEVICE_ERROR_EVENTS): 151 | event_t = DeviceErrorEvent 152 | if parameter in IMPULSE_EVENTS: 153 | event_t = ImpulseEvent 154 | return event_t 155 | 156 | 157 | def _safe_create_event( 158 | event_t: type[GenericEvent], 159 | channel: hmd.Channel, 160 | parameter: str, 161 | parameter_data: ParameterData, 162 | ) -> GenericEvent: 163 | """Safely create a event and handle exceptions.""" 164 | try: 165 | return event_t( 166 | channel=channel, 167 | parameter=parameter, 168 | parameter_data=parameter_data, 169 | ) 170 | except Exception as ex: 171 | raise HaHomematicException( 172 | f"CREATE_EVENT_AND_APPEND_TO_CHANNEL: Unable to create event:{hms.reduce_args(args=ex.args)}" 173 | ) from ex 174 | -------------------------------------------------------------------------------- /hahomematic/model/generic/__init__.py: -------------------------------------------------------------------------------- 1 | """Module for HaHomematic generic data points.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Mapping 6 | import logging 7 | from typing import Final 8 | 9 | from hahomematic import support as hms 10 | from hahomematic.const import ( 11 | CLICK_EVENTS, 12 | VIRTUAL_REMOTE_MODELS, 13 | Operations, 14 | Parameter, 15 | ParameterData, 16 | ParameterType, 17 | ParamsetKey, 18 | ) 19 | from hahomematic.decorators import inspector 20 | from hahomematic.exceptions import HaHomematicException 21 | from hahomematic.model import device as hmd 22 | from hahomematic.model.generic.action import DpAction 23 | from hahomematic.model.generic.binary_sensor import DpBinarySensor 24 | from hahomematic.model.generic.button import DpButton 25 | from hahomematic.model.generic.data_point import GenericDataPoint 26 | from hahomematic.model.generic.number import BaseDpNumber, DpFloat, DpInteger 27 | from hahomematic.model.generic.select import DpSelect 28 | from hahomematic.model.generic.sensor import DpSensor 29 | from hahomematic.model.generic.switch import DpSwitch 30 | from hahomematic.model.generic.text import DpText 31 | from hahomematic.model.support import is_binary_sensor 32 | 33 | __all__ = [ 34 | "BaseDpNumber", 35 | "DpAction", 36 | "DpBinarySensor", 37 | "DpButton", 38 | "DpFloat", 39 | "DpInteger", 40 | "DpSelect", 41 | "DpSensor", 42 | "DpSwitch", 43 | "DpText", 44 | "GenericDataPoint", 45 | "create_data_point_and_append_to_channel", 46 | ] 47 | 48 | _LOGGER: Final = logging.getLogger(__name__) 49 | _BUTTON_ACTIONS: Final[tuple[str, ...]] = ("RESET_MOTION", "RESET_PRESENCE") 50 | 51 | # data points that should be wrapped in a new data point on a new category. 52 | _SWITCH_DP_TO_SENSOR: Final[Mapping[str | tuple[str, ...], Parameter]] = { 53 | ("HmIP-eTRV", "HmIP-HEATING"): Parameter.LEVEL, 54 | } 55 | 56 | 57 | @inspector() 58 | def create_data_point_and_append_to_channel( 59 | channel: hmd.Channel, 60 | paramset_key: ParamsetKey, 61 | parameter: str, 62 | parameter_data: ParameterData, 63 | ) -> None: 64 | """Decides which generic category should be used, and creates the required data points.""" 65 | _LOGGER.debug( 66 | "CREATE_DATA_POINTS: Creating data_point for %s, %s, %s", 67 | channel.address, 68 | parameter, 69 | channel.device.interface_id, 70 | ) 71 | 72 | if (dp_t := _determine_data_point_type(channel, parameter, parameter_data)) and ( 73 | dp := _safe_create_data_point( 74 | dp_t=dp_t, channel=channel, paramset_key=paramset_key, parameter=parameter, parameter_data=parameter_data 75 | ) 76 | ): 77 | _LOGGER.debug( 78 | "CREATE_DATA_POINT_AND_APPEND_TO_CHANNEL: %s: %s %s", 79 | dp.category, 80 | channel.address, 81 | parameter, 82 | ) 83 | channel.add_data_point(dp) 84 | if _check_switch_to_sensor(data_point=dp): 85 | dp.force_to_sensor() 86 | 87 | 88 | def _determine_data_point_type( 89 | channel: hmd.Channel, parameter: str, parameter_data: ParameterData 90 | ) -> type[GenericDataPoint] | None: 91 | """Determine the type of data point based on parameter and operations.""" 92 | p_type = parameter_data["TYPE"] 93 | p_operations = parameter_data["OPERATIONS"] 94 | dp_t: type[GenericDataPoint] | None = None 95 | if p_operations & Operations.WRITE: 96 | if p_type == ParameterType.ACTION: 97 | if p_operations == Operations.WRITE: 98 | if parameter in _BUTTON_ACTIONS or channel.device.model in VIRTUAL_REMOTE_MODELS: 99 | dp_t = DpButton 100 | else: 101 | dp_t = DpAction 102 | elif parameter in CLICK_EVENTS: 103 | dp_t = DpButton 104 | else: 105 | dp_t = DpSwitch 106 | elif p_operations == Operations.WRITE: 107 | dp_t = DpAction 108 | elif p_type == ParameterType.BOOL: 109 | dp_t = DpSwitch 110 | elif p_type == ParameterType.ENUM: 111 | dp_t = DpSelect 112 | elif p_type == ParameterType.FLOAT: 113 | dp_t = DpFloat 114 | elif p_type == ParameterType.INTEGER: 115 | dp_t = DpInteger 116 | elif p_type == ParameterType.STRING: 117 | dp_t = DpText 118 | elif parameter not in CLICK_EVENTS: 119 | # Also check, if sensor could be a binary_sensor due to. 120 | if is_binary_sensor(parameter_data): 121 | parameter_data["TYPE"] = ParameterType.BOOL 122 | dp_t = DpBinarySensor 123 | else: 124 | dp_t = DpSensor 125 | 126 | return dp_t 127 | 128 | 129 | def _safe_create_data_point( 130 | dp_t: type[GenericDataPoint], 131 | channel: hmd.Channel, 132 | paramset_key: ParamsetKey, 133 | parameter: str, 134 | parameter_data: ParameterData, 135 | ) -> GenericDataPoint: 136 | """Safely create a data point and handle exceptions.""" 137 | try: 138 | return dp_t( 139 | channel=channel, 140 | paramset_key=paramset_key, 141 | parameter=parameter, 142 | parameter_data=parameter_data, 143 | ) 144 | except Exception as ex: 145 | raise HaHomematicException( 146 | f"CREATE_DATA_POINT_AND_APPEND_TO_CHANNEL: Unable to create data_point:{hms.reduce_args(args=ex.args)}" 147 | ) from ex 148 | 149 | 150 | def _check_switch_to_sensor(data_point: GenericDataPoint) -> bool: 151 | """Check if parameter of a device should be wrapped to a different category.""" 152 | if data_point.device.central.parameter_visibility.parameter_is_un_ignored( 153 | channel=data_point.channel, 154 | paramset_key=data_point.paramset_key, 155 | parameter=data_point.parameter, 156 | ): 157 | return False 158 | for devices, parameter in _SWITCH_DP_TO_SENSOR.items(): 159 | if ( 160 | hms.element_matches_key( 161 | search_elements=devices, 162 | compare_with=data_point.device.model, 163 | ) 164 | and data_point.parameter == parameter 165 | ): 166 | return True 167 | return False 168 | -------------------------------------------------------------------------------- /hahomematic/model/generic/action.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module for action data points. 3 | 4 | Actions are used to send data for write only parameters to backend. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from typing import Any 10 | 11 | from hahomematic.const import DataPointCategory 12 | from hahomematic.model.generic.data_point import GenericDataPoint 13 | from hahomematic.model.support import get_index_of_value_from_value_list 14 | 15 | 16 | class DpAction(GenericDataPoint[None, Any]): 17 | """ 18 | Implementation of an action. 19 | 20 | This is an internal default category that gets automatically generated. 21 | """ 22 | 23 | _category = DataPointCategory.ACTION 24 | _validate_state_change = False 25 | 26 | def _prepare_value_for_sending(self, value: Any, do_validate: bool = True) -> Any: 27 | """Prepare value before sending.""" 28 | if (index := get_index_of_value_from_value_list(value=value, value_list=self._values)) is not None: 29 | return index 30 | return value 31 | -------------------------------------------------------------------------------- /hahomematic/model/generic/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Module for data points implemented using the binary_sensor category.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import cast 6 | 7 | from hahomematic.const import DataPointCategory 8 | from hahomematic.model.decorators import state_property 9 | from hahomematic.model.generic.data_point import GenericDataPoint 10 | 11 | 12 | class DpBinarySensor(GenericDataPoint[bool | None, bool]): 13 | """ 14 | Implementation of a binary_sensor. 15 | 16 | This is a default data point that gets automatically generated. 17 | """ 18 | 19 | _category = DataPointCategory.BINARY_SENSOR 20 | 21 | @state_property 22 | def value(self) -> bool | None: 23 | """Return the value of the data_point.""" 24 | if self._value is not None: 25 | return cast(bool | None, self._value) 26 | return cast(bool | None, self._default) 27 | -------------------------------------------------------------------------------- /hahomematic/model/generic/button.py: -------------------------------------------------------------------------------- 1 | """Module for data points implemented using the button category.""" 2 | 3 | from __future__ import annotations 4 | 5 | from hahomematic.const import DataPointCategory 6 | from hahomematic.decorators import inspector 7 | from hahomematic.model.generic.data_point import GenericDataPoint 8 | 9 | 10 | class DpButton(GenericDataPoint[None, bool]): 11 | """ 12 | Implementation of a button. 13 | 14 | This is a default data point that gets automatically generated. 15 | """ 16 | 17 | _category = DataPointCategory.BUTTON 18 | _validate_state_change = False 19 | 20 | @inspector() 21 | async def press(self) -> None: 22 | """Handle the button press.""" 23 | await self.send_value(value=True) 24 | -------------------------------------------------------------------------------- /hahomematic/model/generic/data_point.py: -------------------------------------------------------------------------------- 1 | """Generic python representation of a CCU parameter.""" 2 | 3 | from __future__ import annotations 4 | 5 | from functools import cached_property 6 | import logging 7 | from typing import Any, Final 8 | 9 | from hahomematic.const import DP_KEY_VALUE, CallSource, DataPointUsage, EventType, Parameter, ParameterData, ParamsetKey 10 | from hahomematic.decorators import inspector 11 | from hahomematic.model import data_point as hme, device as hmd 12 | from hahomematic.model.support import DataPointNameData, GenericParameterType, get_data_point_name_data 13 | 14 | _LOGGER: Final = logging.getLogger(__name__) 15 | 16 | 17 | class GenericDataPoint[ParameterT: GenericParameterType, InputParameterT: GenericParameterType]( 18 | hme.BaseParameterDataPoint 19 | ): 20 | """Base class for generic data point.""" 21 | 22 | _validate_state_change: bool = True 23 | is_hmtype: Final = True 24 | 25 | def __init__( 26 | self, 27 | channel: hmd.Channel, 28 | paramset_key: ParamsetKey, 29 | parameter: str, 30 | parameter_data: ParameterData, 31 | ) -> None: 32 | """Init the generic data_point.""" 33 | super().__init__( 34 | channel=channel, 35 | paramset_key=paramset_key, 36 | parameter=parameter, 37 | parameter_data=parameter_data, 38 | ) 39 | 40 | @cached_property 41 | def usage(self) -> DataPointUsage: 42 | """Return the data_point usage.""" 43 | if self._is_forced_sensor or self._is_un_ignored: 44 | return DataPointUsage.DATA_POINT 45 | if (force_enabled := self._enabled_by_channel_operation_mode) is None: 46 | return self._get_data_point_usage() 47 | return DataPointUsage.DATA_POINT if force_enabled else DataPointUsage.NO_CREATE 48 | 49 | async def event(self, value: Any) -> None: 50 | """Handle event for which this data_point has subscribed.""" 51 | self._device.client.last_value_send_cache.remove_last_value_send( 52 | dpk=self.dpk, 53 | value=value, 54 | ) 55 | old_value, new_value = self.write_value(value=value) 56 | if old_value == new_value: 57 | return 58 | 59 | # reload paramset_descriptions, if value has changed 60 | if self._parameter == Parameter.CONFIG_PENDING and new_value is False and old_value is True: 61 | await self._device.reload_paramset_descriptions() 62 | 63 | for data_point in self._device.get_readable_data_points(paramset_key=ParamsetKey.MASTER): 64 | await data_point.load_data_point_value(call_source=CallSource.MANUAL_OR_SCHEDULED, direct_call=True) 65 | 66 | # send device availability events 67 | if self._parameter in ( 68 | Parameter.UN_REACH, 69 | Parameter.STICKY_UN_REACH, 70 | ): 71 | self._device.fire_device_updated_callback(self._unique_id) 72 | self._central.fire_homematic_callback( 73 | event_type=EventType.DEVICE_AVAILABILITY, 74 | event_data=self.get_event_data(new_value), 75 | ) 76 | 77 | @inspector() 78 | async def send_value( 79 | self, 80 | value: InputParameterT, 81 | collector: hme.CallParameterCollector | None = None, 82 | collector_order: int = 50, 83 | do_validate: bool = True, 84 | ) -> set[DP_KEY_VALUE]: 85 | """Send value to ccu, or use collector if set.""" 86 | if not self.is_writeable: 87 | _LOGGER.error("SEND_VALUE: writing to non-writable data_point %s is not possible", self.full_name) 88 | return set() 89 | try: 90 | prepared_value = self._prepare_value_for_sending(value=value, do_validate=do_validate) 91 | except ValueError as verr: 92 | _LOGGER.warning(verr) 93 | return set() 94 | 95 | converted_value = self._convert_value(value=prepared_value) 96 | if collector: 97 | collector.add_data_point(self, value=converted_value, collector_order=collector_order) 98 | return set() 99 | 100 | if self._validate_state_change and not self.is_state_change(value=converted_value): 101 | return set() 102 | 103 | return await self._client.set_value( 104 | channel_address=self._channel.address, 105 | paramset_key=self._paramset_key, 106 | parameter=self._parameter, 107 | value=converted_value, 108 | ) 109 | 110 | def _prepare_value_for_sending(self, value: InputParameterT, do_validate: bool = True) -> ParameterT: 111 | """Prepare value, if required, before send.""" 112 | return value # type: ignore[return-value] 113 | 114 | def _get_data_point_name(self) -> DataPointNameData: 115 | """Create the name for the data_point.""" 116 | return get_data_point_name_data( 117 | channel=self._channel, 118 | parameter=self._parameter, 119 | ) 120 | 121 | def _get_data_point_usage(self) -> DataPointUsage: 122 | """Generate the usage for the data_point.""" 123 | if self._forced_usage: 124 | return self._forced_usage 125 | if self._central.parameter_visibility.parameter_is_hidden( 126 | channel=self._channel, 127 | paramset_key=self._paramset_key, 128 | parameter=self._parameter, 129 | ): 130 | return DataPointUsage.NO_CREATE 131 | 132 | return ( 133 | DataPointUsage.NO_CREATE 134 | if (self._device.has_custom_data_point_definition and not self._device.allow_undefined_generic_data_points) 135 | else DataPointUsage.DATA_POINT 136 | ) 137 | 138 | def is_state_change(self, value: ParameterT) -> bool: 139 | """ 140 | Check if the state/value changes. 141 | 142 | If the state is uncertain, the state should also marked as changed. 143 | """ 144 | if value != self._value: 145 | return True 146 | if self.state_uncertain: 147 | return True 148 | _LOGGER.debug("NO_STATE_CHANGE: %s", self.name) 149 | return False 150 | -------------------------------------------------------------------------------- /hahomematic/model/generic/number.py: -------------------------------------------------------------------------------- 1 | """Module for data points implemented using the number category.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import cast 6 | 7 | from hahomematic.const import DataPointCategory 8 | from hahomematic.model.decorators import state_property 9 | from hahomematic.model.generic.data_point import GenericDataPoint 10 | 11 | 12 | class BaseDpNumber[NumberParameterT: int | float | None](GenericDataPoint[NumberParameterT, int | float | str]): 13 | """ 14 | Implementation of a number. 15 | 16 | This is a default data point that gets automatically generated. 17 | """ 18 | 19 | _category = DataPointCategory.NUMBER 20 | 21 | def _prepare_number_for_sending( 22 | self, value: int | float | str, type_converter: type, do_validate: bool = True 23 | ) -> NumberParameterT: 24 | """Prepare value before sending.""" 25 | if not do_validate or ( 26 | value is not None and isinstance(value, int | float) and self._min <= type_converter(value) <= self._max 27 | ): 28 | return cast(NumberParameterT, type_converter(value)) 29 | if self._special and isinstance(value, str) and value in self._special: 30 | return cast(NumberParameterT, type_converter(self._special[value])) 31 | raise ValueError( 32 | f"NUMBER failed: Invalid value: {value} (min: {self._min}, max: {self._max}, special:{self._special})" 33 | ) 34 | 35 | 36 | class DpFloat(BaseDpNumber[float | None]): 37 | """ 38 | Implementation of a Float. 39 | 40 | This is a default data point that gets automatically generated. 41 | """ 42 | 43 | def _prepare_value_for_sending(self, value: int | float | str, do_validate: bool = True) -> float | None: 44 | """Prepare value before sending.""" 45 | return self._prepare_number_for_sending(value=value, type_converter=float, do_validate=do_validate) 46 | 47 | @state_property 48 | def value(self) -> float | None: 49 | """Return the value of the data_point.""" 50 | return cast(float | None, self._value) 51 | 52 | 53 | class DpInteger(BaseDpNumber[int | None]): 54 | """ 55 | Implementation of an Integer. 56 | 57 | This is a default data point that gets automatically generated. 58 | """ 59 | 60 | def _prepare_value_for_sending(self, value: int | float | str, do_validate: bool = True) -> int | None: 61 | """Prepare value before sending.""" 62 | return self._prepare_number_for_sending(value=value, type_converter=int, do_validate=do_validate) 63 | 64 | @state_property 65 | def value(self) -> int | None: 66 | """Return the value of the data_point.""" 67 | return cast(int | None, self._value) 68 | -------------------------------------------------------------------------------- /hahomematic/model/generic/select.py: -------------------------------------------------------------------------------- 1 | """Module for data points implemented using the select category.""" 2 | 3 | from __future__ import annotations 4 | 5 | from hahomematic.const import DataPointCategory 6 | from hahomematic.model.decorators import state_property 7 | from hahomematic.model.generic.data_point import GenericDataPoint 8 | from hahomematic.model.support import get_value_from_value_list 9 | 10 | 11 | class DpSelect(GenericDataPoint[int | str, int | float | str]): 12 | """ 13 | Implementation of a select data_point. 14 | 15 | This is a default data point that gets automatically generated. 16 | """ 17 | 18 | _category = DataPointCategory.SELECT 19 | 20 | @state_property 21 | def value(self) -> str | None: 22 | """Get the value of the data_point.""" 23 | if (value := get_value_from_value_list(value=self._value, value_list=self.values)) is not None: 24 | return value 25 | return str(self._default) 26 | 27 | def _prepare_value_for_sending(self, value: int | float | str, do_validate: bool = True) -> int: 28 | """Prepare value before sending.""" 29 | # We allow setting the value via index as well, just in case. 30 | if isinstance(value, int | float) and self._values and 0 <= value < len(self._values): 31 | return int(value) 32 | if self._values and value in self._values: 33 | return self._values.index(value) 34 | raise ValueError(f"Value not in value_list for {self.name}/{self.unique_id}") 35 | -------------------------------------------------------------------------------- /hahomematic/model/generic/sensor.py: -------------------------------------------------------------------------------- 1 | """Module for data points implemented using the sensor category.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Mapping 6 | import logging 7 | from typing import Any, Final, cast 8 | 9 | from hahomematic.const import DataPointCategory, Parameter, ParameterType 10 | from hahomematic.model.decorators import state_property 11 | from hahomematic.model.generic.data_point import GenericDataPoint 12 | from hahomematic.model.support import check_length_and_log, get_value_from_value_list 13 | 14 | _LOGGER: Final = logging.getLogger(__name__) 15 | 16 | 17 | class DpSensor[SensorT: float | int | str | None](GenericDataPoint[SensorT, None]): 18 | """ 19 | Implementation of a sensor. 20 | 21 | This is a default data point that gets automatically generated. 22 | """ 23 | 24 | _category = DataPointCategory.SENSOR 25 | 26 | @state_property 27 | def value(self) -> SensorT: 28 | """Return the value.""" 29 | if (value := get_value_from_value_list(value=self._value, value_list=self.values)) is not None: 30 | return cast(SensorT, value) 31 | if convert_func := self._get_converter_func(): 32 | return cast(SensorT, convert_func(self._value)) 33 | return cast( 34 | SensorT, 35 | check_length_and_log(name=self.name, value=self._value) 36 | if self._type == ParameterType.STRING 37 | else self._value, 38 | ) 39 | 40 | def _get_converter_func(self) -> Any: 41 | """Return a converter based on sensor.""" 42 | if convert_func := _VALUE_CONVERTERS_BY_PARAM.get(self.parameter): 43 | return convert_func 44 | return None 45 | 46 | 47 | def _fix_rssi(value: Any) -> int | None: 48 | """ 49 | Fix rssi value. 50 | 51 | See https://github.com/sukramj/hahomematic/blob/devel/docs/rssi_fix.md. 52 | """ 53 | if value is None: 54 | return None 55 | if isinstance(value, int): 56 | if -127 < value < 0: 57 | return value 58 | if 1 < value < 127: 59 | return value * -1 60 | if -256 < value < -129: 61 | return (value * -1) - 256 62 | if 129 < value < 256: 63 | return value - 256 64 | return None 65 | 66 | 67 | _VALUE_CONVERTERS_BY_PARAM: Mapping[str, Any] = { 68 | Parameter.RSSI_PEER: _fix_rssi, 69 | Parameter.RSSI_DEVICE: _fix_rssi, 70 | } 71 | -------------------------------------------------------------------------------- /hahomematic/model/generic/switch.py: -------------------------------------------------------------------------------- 1 | """Module for data points implemented using the switch category.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Final, cast 6 | 7 | from hahomematic.const import DataPointCategory, ParameterType 8 | from hahomematic.decorators import inspector 9 | from hahomematic.model.data_point import CallParameterCollector 10 | from hahomematic.model.decorators import state_property 11 | from hahomematic.model.generic.data_point import GenericDataPoint 12 | 13 | _PARAM_ON_TIME: Final = "ON_TIME" 14 | 15 | 16 | class DpSwitch(GenericDataPoint[bool | None, bool]): 17 | """ 18 | Implementation of a switch. 19 | 20 | This is a default data point that gets automatically generated. 21 | """ 22 | 23 | _category = DataPointCategory.SWITCH 24 | 25 | @state_property 26 | def value(self) -> bool | None: 27 | """Get the value of the data_point.""" 28 | if self._type == ParameterType.ACTION: 29 | return False 30 | return cast(bool | None, self._value) 31 | 32 | @inspector() 33 | async def turn_on(self, collector: CallParameterCollector | None = None, on_time: float | None = None) -> None: 34 | """Turn the switch on.""" 35 | if on_time is not None: 36 | await self.set_on_time(on_time=on_time) 37 | await self.send_value(value=True, collector=collector) 38 | 39 | @inspector() 40 | async def turn_off(self, collector: CallParameterCollector | None = None) -> None: 41 | """Turn the switch off.""" 42 | await self.send_value(value=False, collector=collector) 43 | 44 | @inspector() 45 | async def set_on_time(self, on_time: float) -> None: 46 | """Set the on time value in seconds.""" 47 | await self._client.set_value( 48 | channel_address=self._channel.address, 49 | paramset_key=self._paramset_key, 50 | parameter=_PARAM_ON_TIME, 51 | value=float(on_time), 52 | ) 53 | -------------------------------------------------------------------------------- /hahomematic/model/generic/text.py: -------------------------------------------------------------------------------- 1 | """Module for data points implemented using the text category.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import cast 6 | 7 | from hahomematic.const import DataPointCategory 8 | from hahomematic.model.decorators import state_property 9 | from hahomematic.model.generic.data_point import GenericDataPoint 10 | from hahomematic.model.support import check_length_and_log 11 | 12 | 13 | class DpText(GenericDataPoint[str, str]): 14 | """ 15 | Implementation of a text. 16 | 17 | This is a default data point that gets automatically generated. 18 | """ 19 | 20 | _category = DataPointCategory.TEXT 21 | 22 | @state_property 23 | def value(self) -> str | None: 24 | """Get the value of the data_point.""" 25 | return cast(str | None, check_length_and_log(name=self.name, value=self._value)) 26 | -------------------------------------------------------------------------------- /hahomematic/model/hub/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Module for hub data points implemented using the binary_sensor category.""" 2 | 3 | from __future__ import annotations 4 | 5 | from hahomematic.const import DataPointCategory 6 | from hahomematic.model.decorators import state_property 7 | from hahomematic.model.hub.data_point import GenericSysvarDataPoint 8 | 9 | 10 | class SysvarDpBinarySensor(GenericSysvarDataPoint): 11 | """Implementation of a sysvar binary_sensor.""" 12 | 13 | _category = DataPointCategory.HUB_BINARY_SENSOR 14 | 15 | @state_property 16 | def value(self) -> bool | None: 17 | """Return the value of the data_point.""" 18 | if self._value is not None: 19 | return bool(self._value) 20 | return None 21 | -------------------------------------------------------------------------------- /hahomematic/model/hub/button.py: -------------------------------------------------------------------------------- 1 | """Module for hub data points implemented using the button category.""" 2 | 3 | from __future__ import annotations 4 | 5 | from hahomematic.const import DataPointCategory 6 | from hahomematic.decorators import inspector 7 | from hahomematic.model.decorators import state_property 8 | from hahomematic.model.hub.data_point import GenericProgramDataPoint 9 | 10 | 11 | class ProgramDpButton(GenericProgramDataPoint): 12 | """Class for a HomeMatic program button.""" 13 | 14 | _category = DataPointCategory.HUB_BUTTON 15 | 16 | @state_property 17 | def available(self) -> bool: 18 | """Return the availability of the device.""" 19 | return self._is_active and self._central.available 20 | 21 | @inspector() 22 | async def press(self) -> None: 23 | """Handle the button press.""" 24 | await self.central.execute_program(pid=self.pid) 25 | -------------------------------------------------------------------------------- /hahomematic/model/hub/number.py: -------------------------------------------------------------------------------- 1 | """Module for data points implemented using the number category.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Final 7 | 8 | from hahomematic.const import DataPointCategory 9 | from hahomematic.decorators import inspector 10 | from hahomematic.model.hub.data_point import GenericSysvarDataPoint 11 | 12 | _LOGGER: Final = logging.getLogger(__name__) 13 | 14 | 15 | class SysvarDpNumber(GenericSysvarDataPoint): 16 | """Implementation of a sysvar number.""" 17 | 18 | _category = DataPointCategory.HUB_NUMBER 19 | _is_extended = True 20 | 21 | @inspector() 22 | async def send_variable(self, value: float) -> None: 23 | """Set the value of the data_point.""" 24 | if value is not None and self.max is not None and self.min is not None: 25 | if self.min <= float(value) <= self.max: 26 | await super().send_variable(value) 27 | else: 28 | _LOGGER.warning( 29 | "SYSVAR.NUMBER failed: Invalid value: %s (min: %s, max: %s)", 30 | value, 31 | self.min, 32 | self.max, 33 | ) 34 | return 35 | await super().send_variable(value) 36 | -------------------------------------------------------------------------------- /hahomematic/model/hub/select.py: -------------------------------------------------------------------------------- 1 | """Module for hub data points implemented using the select category.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Final 7 | 8 | from hahomematic.const import DataPointCategory 9 | from hahomematic.decorators import inspector 10 | from hahomematic.model.decorators import state_property 11 | from hahomematic.model.hub.data_point import GenericSysvarDataPoint 12 | from hahomematic.model.support import get_value_from_value_list 13 | 14 | _LOGGER: Final = logging.getLogger(__name__) 15 | 16 | 17 | class SysvarDpSelect(GenericSysvarDataPoint): 18 | """Implementation of a sysvar select data_point.""" 19 | 20 | _category = DataPointCategory.HUB_SELECT 21 | _is_extended = True 22 | 23 | @state_property 24 | def value(self) -> str | None: 25 | """Get the value of the data_point.""" 26 | if (value := get_value_from_value_list(value=self._value, value_list=self.values)) is not None: 27 | return value 28 | return None 29 | 30 | @inspector() 31 | async def send_variable(self, value: int | str) -> None: 32 | """Set the value of the data_point.""" 33 | # We allow setting the value via index as well, just in case. 34 | if isinstance(value, int) and self._values: 35 | if 0 <= value < len(self._values): 36 | await super().send_variable(value) 37 | elif self._values: 38 | if value in self._values: 39 | await super().send_variable(self._values.index(value)) 40 | else: 41 | _LOGGER.warning( 42 | "Value not in value_list for %s/%s", 43 | self.name, 44 | self.unique_id, 45 | ) 46 | -------------------------------------------------------------------------------- /hahomematic/model/hub/sensor.py: -------------------------------------------------------------------------------- 1 | """Module for hub data points implemented using the sensor category.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any, Final 7 | 8 | from hahomematic.const import DataPointCategory, SysvarType 9 | from hahomematic.model.decorators import state_property 10 | from hahomematic.model.hub.data_point import GenericSysvarDataPoint 11 | from hahomematic.model.support import check_length_and_log, get_value_from_value_list 12 | 13 | _LOGGER: Final = logging.getLogger(__name__) 14 | 15 | 16 | class SysvarDpSensor(GenericSysvarDataPoint): 17 | """Implementation of a sysvar sensor.""" 18 | 19 | _category = DataPointCategory.HUB_SENSOR 20 | 21 | @state_property 22 | def value(self) -> Any | None: 23 | """Return the value.""" 24 | if ( 25 | self._data_type == SysvarType.LIST 26 | and (value := get_value_from_value_list(value=self._value, value_list=self.values)) is not None 27 | ): 28 | return value 29 | return ( 30 | check_length_and_log(name=self._legacy_name, value=self._value) 31 | if self._data_type == SysvarType.STRING 32 | else self._value 33 | ) 34 | -------------------------------------------------------------------------------- /hahomematic/model/hub/switch.py: -------------------------------------------------------------------------------- 1 | """Module for hub data points implemented using the switch category.""" 2 | 3 | from __future__ import annotations 4 | 5 | from hahomematic.const import DataPointCategory 6 | from hahomematic.decorators import inspector 7 | from hahomematic.model.decorators import state_property 8 | from hahomematic.model.hub.data_point import GenericProgramDataPoint, GenericSysvarDataPoint 9 | 10 | 11 | class SysvarDpSwitch(GenericSysvarDataPoint): 12 | """Implementation of a sysvar switch data_point.""" 13 | 14 | _category = DataPointCategory.HUB_SWITCH 15 | _is_extended = True 16 | 17 | 18 | class ProgramDpSwitch(GenericProgramDataPoint): 19 | """Implementation of a program switch data_point.""" 20 | 21 | _category = DataPointCategory.HUB_SWITCH 22 | 23 | @state_property 24 | def value(self) -> bool | None: 25 | """Get the value of the data_point.""" 26 | return self._is_active 27 | 28 | @inspector() 29 | async def turn_on(self) -> None: 30 | """Turn the program on.""" 31 | await self.central.set_program_state(pid=self._pid, state=True) 32 | await self._central.fetch_program_data(scheduled=False) 33 | 34 | @inspector() 35 | async def turn_off(self) -> None: 36 | """Turn the program off.""" 37 | await self.central.set_program_state(pid=self._pid, state=False) 38 | await self._central.fetch_program_data(scheduled=False) 39 | -------------------------------------------------------------------------------- /hahomematic/model/hub/text.py: -------------------------------------------------------------------------------- 1 | """Module for hub data points implemented using the text category.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import cast 6 | 7 | from hahomematic.const import DataPointCategory 8 | from hahomematic.model.decorators import state_property 9 | from hahomematic.model.hub.data_point import GenericSysvarDataPoint 10 | from hahomematic.model.support import check_length_and_log 11 | 12 | 13 | class SysvarDpText(GenericSysvarDataPoint): 14 | """Implementation of a sysvar text data_point.""" 15 | 16 | _category = DataPointCategory.HUB_TEXT 17 | _is_extended = True 18 | 19 | @state_property 20 | def value(self) -> str | None: 21 | """Get the value of the data_point.""" 22 | return cast(str | None, check_length_and_log(name=self._legacy_name, value=self._value)) 23 | 24 | async def send_variable(self, value: str | None) -> None: 25 | """Set the value of the data_point.""" 26 | await super().send_variable(value) 27 | -------------------------------------------------------------------------------- /hahomematic/model/update.py: -------------------------------------------------------------------------------- 1 | """Module for data points implemented using the update category.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Callable 6 | from functools import partial 7 | from typing import Final 8 | 9 | from hahomematic.const import ( 10 | CALLBACK_TYPE, 11 | DEFAULT_CUSTOM_ID, 12 | HMIP_FIRMWARE_UPDATE_IN_PROGRESS_STATES, 13 | HMIP_FIRMWARE_UPDATE_READY_STATES, 14 | DataPointCategory, 15 | Interface, 16 | ) 17 | from hahomematic.decorators import inspector 18 | from hahomematic.exceptions import HaHomematicException 19 | from hahomematic.model import device as hmd 20 | from hahomematic.model.data_point import CallbackDataPoint 21 | from hahomematic.model.decorators import config_property, state_property 22 | from hahomematic.model.support import DataPointPathData, PayloadMixin, generate_unique_id 23 | 24 | __all__ = ["DpUpdate"] 25 | 26 | 27 | class DpUpdate(CallbackDataPoint, PayloadMixin): 28 | """ 29 | Implementation of a update. 30 | 31 | This is a default data point that gets automatically generated. 32 | """ 33 | 34 | _category = DataPointCategory.UPDATE 35 | 36 | def __init__(self, device: hmd.Device) -> None: 37 | """Init the callback data_point.""" 38 | PayloadMixin.__init__(self) 39 | self._device: Final = device 40 | super().__init__( 41 | central=device.central, 42 | unique_id=generate_unique_id(central=device.central, address=device.address, parameter="Update"), 43 | ) 44 | self._set_modified_at() 45 | 46 | @state_property 47 | def available(self) -> bool: 48 | """Return the availability of the device.""" 49 | return self._device.available 50 | 51 | @property 52 | def device(self) -> hmd.Device: 53 | """Return the device of the data_point.""" 54 | return self._device 55 | 56 | @property 57 | def full_name(self) -> str: 58 | """Return the full name of the data_point.""" 59 | return f"{self._device.name} Update" 60 | 61 | @config_property 62 | def name(self) -> str: 63 | """Return the name of the data_point.""" 64 | return "Update" 65 | 66 | @state_property 67 | def firmware(self) -> str | None: 68 | """Version installed and in use.""" 69 | return self._device.firmware 70 | 71 | @state_property 72 | def firmware_update_state(self) -> str | None: 73 | """Latest version available for install.""" 74 | return self._device.firmware_update_state 75 | 76 | @state_property 77 | def in_progress(self) -> bool: 78 | """Update installation progress.""" 79 | if self._device.interface == Interface.HMIP_RF: 80 | return self._device.firmware_update_state in HMIP_FIRMWARE_UPDATE_IN_PROGRESS_STATES 81 | return False 82 | 83 | @state_property 84 | def latest_firmware(self) -> str | None: 85 | """Latest firmware available for install.""" 86 | if self._device.available_firmware and ( 87 | ( 88 | self._device.interface == Interface.HMIP_RF 89 | and self._device.firmware_update_state in HMIP_FIRMWARE_UPDATE_READY_STATES 90 | ) 91 | or self._device.interface in (Interface.BIDCOS_RF, Interface.BIDCOS_WIRED) 92 | ): 93 | return self._device.available_firmware 94 | return self._device.firmware 95 | 96 | def _get_path_data(self) -> DataPointPathData: 97 | """Return the path data of the data_point.""" 98 | return DataPointPathData( 99 | interface=None, 100 | address=self._device.address, 101 | channel_no=None, 102 | kind=DataPointCategory.UPDATE, 103 | ) 104 | 105 | def register_data_point_updated_callback(self, cb: Callable, custom_id: str) -> CALLBACK_TYPE: 106 | """Register update callback.""" 107 | if custom_id != DEFAULT_CUSTOM_ID: 108 | if self._custom_id is not None: 109 | raise HaHomematicException( 110 | f"REGISTER_UPDATE_CALLBACK failed: hm_data_point: {self.full_name} is already registered by {self._custom_id}" 111 | ) 112 | self._custom_id = custom_id 113 | 114 | if self._device.register_firmware_update_callback(cb) is not None: 115 | return partial(self._unregister_data_point_updated_callback, cb=cb, custom_id=custom_id) 116 | return None 117 | 118 | def _unregister_data_point_updated_callback(self, cb: Callable, custom_id: str) -> None: 119 | """Unregister update callback.""" 120 | if custom_id is not None: 121 | self._custom_id = None 122 | self._device.unregister_firmware_update_callback(cb) 123 | 124 | @inspector() 125 | async def update_firmware(self, refresh_after_update_intervals: tuple[int, ...]) -> bool: 126 | """Turn the update on.""" 127 | return await self._device.update_firmware(refresh_after_update_intervals=refresh_after_update_intervals) 128 | 129 | @inspector() 130 | async def refresh_firmware_data(self) -> None: 131 | """Refresh device firmware data.""" 132 | await self._device.central.refresh_firmware_data(device_address=self._device.address) 133 | self._set_modified_at() 134 | -------------------------------------------------------------------------------- /hahomematic/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SukramJ/hahomematic/cf3da7b1fc4109e6253cf48b28556fdbd036d301/hahomematic/py.typed -------------------------------------------------------------------------------- /hahomematic/rega_scripts/fetch_all_device_data.fn: -------------------------------------------------------------------------------- 1 | !# fetch_all_device_data.fn v2.2 2 | !# This script fetches all device data required to initialize the entities without affecting the duty cycle. 3 | !# 4 | !# Original script: https://github.com/ioBroker/ioBroker.hm-rega/blob/master/regascripts/datapoints.fn 5 | !# datapoints.fn 1.9 6 | !# 3'2013-9'2014 hobbyquaker https://github.com/hobbyquaker 7 | !# 8 | !# Dieses Homematic-Script gibt eine Liste aller Datenpunkte, die zur Laufzeit einen validen Zeitstempel haben, als JSON String aus. 9 | !# 10 | !# modified by: SukramJ https://github.com/SukramJ && Baxxy13 https://github.com/Baxxy13 11 | !# v2.2 - 09/2023 12 | !# 13 | !# Das Interface wird durch die Integration an 'sUse_Interface' übergeben. 14 | !# Nutzbare Interfaces: BidCos-RF, BidCos-Wired, HmIP-RF, VirtualDevices 15 | !# Zum Testen direkt auf der Homematic-Zentrale muss das Interface wie folgt eingetragen werden: sUse_Interface = "HmIP-RF"; 16 | 17 | string sUse_Interface = "##interface##"; 18 | string sDevId; 19 | string sChnId; 20 | string sDPId; 21 | string sDPId; 22 | var vDPValue; 23 | boolean bDPFirst = true; 24 | object oInterface = interfaces.Get(sUse_Interface); 25 | 26 | Write('{'); 27 | if (oInterface) { 28 | integer iInterface_ID = interfaces.Get(sUse_Interface).ID(); 29 | string sAllDevices = dom.GetObject(ID_DEVICES).EnumUsedIDs(); 30 | foreach (sDevId, sAllDevices) { 31 | object oDevice = dom.GetObject(sDevId); 32 | if ((oDevice) && (oDevice.ReadyConfig()) && (oDevice.Interface() == iInterface_ID)) { 33 | foreach (sChnId, oDevice.Channels()) { 34 | object oChannel = dom.GetObject(sChnId); 35 | foreach(sDPId, oChannel.DPs().EnumUsedIDs()) { 36 | object oDP = dom.GetObject(sDPId); 37 | if (oDP && oDP.Timestamp()) { 38 | if (oDP.TypeName() != "VARDP") { 39 | if (bDPFirst) { 40 | bDPFirst = false; 41 | } else { 42 | WriteLine(','); 43 | } 44 | integer sValueType = oDP.ValueType(); 45 | Write('"'); 46 | WriteURL(oDP.Name()); 47 | Write('":'); 48 | if (sValueType == 20) { 49 | Write('"'); 50 | WriteURL(oDP.Value()); 51 | Write('"'); 52 | } else { 53 | vDPValue = oDP.Value(); 54 | if (sValueType == 2) { 55 | if (vDPValue) { 56 | Write("true"); 57 | } else { 58 | Write("false"); 59 | } 60 | } else { 61 | if (vDPValue == "") { 62 | Write("0"); 63 | } else { 64 | Write(vDPValue); 65 | } 66 | } 67 | } 68 | } 69 | } 70 | } 71 | } 72 | } 73 | } 74 | } 75 | Write('}'); 76 | -------------------------------------------------------------------------------- /hahomematic/rega_scripts/get_program_descriptions.fn: -------------------------------------------------------------------------------- 1 | !# get_program_descriptions.fn 2 | !# Erstellt in Ergänzung zu https://github.com/eq-3/occu/blob/45b38865f6b60f16f825b75f0bdc8a9738831ee0/WebUI/www/api/methods/sysvar/getall.tcl 3 | !# Erweitert das Script um "description" 4 | !# 5 | 6 | string id; 7 | boolean dpFirst = true; 8 | Write("["); 9 | foreach(id, dom.GetObject(ID_PROGRAMS).EnumIDs()) { 10 | object prg = dom.GetObject(id); 11 | string description = ""; 12 | if (prg) { 13 | ! use UriEncode() to ensure special characters " and \ 14 | ! and others are properly encoded using URI/URL percentage 15 | ! encoding 16 | description = prg.PrgInfo().UriEncode(); 17 | 18 | if (dpFirst) { 19 | dpFirst = false; 20 | } else { 21 | WriteLine(','); 22 | } 23 | 24 | Write("{"); 25 | Write("\"id\": \"" # id # "\","); 26 | Write("\"description\": \"" # description # "\""); 27 | Write("}"); 28 | } 29 | } 30 | Write("]"); 31 | -------------------------------------------------------------------------------- /hahomematic/rega_scripts/get_serial.fn: -------------------------------------------------------------------------------- 1 | !# get_serial 2 | !# 3 | !# Erstellt durch @baxxy13 2022-04-09 4 | !# 5 | !# Dieses Script liefert die Seriennummer des Funkmoduls in folgender Priorisierung zurück: 6 | !# 1. /var/board_sgtin 7 | !# 2. /var/board_serial 8 | !# 3. /sys/module/plat_eq3ccu2/parameters/board_serial 9 | !# 10 | !# Dieses Script wird als Ersatz für JsonRPC CCU.getSerial verwendet. 11 | !# 12 | 13 | string serial; 14 | boolean find = false; 15 | string cmd_a = "/bin/sh -c 'cat /var/board_sgtin'"; 16 | string cmd_b = "/bin/sh -c 'cat /var/board_serial'"; 17 | string cmd_c = "/bin/sh -c 'cat /sys/module/plat_eq3ccu2/parameters/board_serial'"; 18 | 19 | !# Try uses /var/board_sgtin 20 | system.Exec(cmd_a, &serial, &error); 21 | if (serial) { 22 | serial = serial.Trim(); 23 | find = true; 24 | } 25 | !# Try uses /var/board_serial 26 | if (!find) { 27 | system.Exec(cmd_b, &serial, &error); 28 | if (serial) { 29 | serial = serial.Trim(); 30 | find = true; 31 | } 32 | } 33 | !# Try uses /sys/module/plat_eq3ccu2/parameters/board_serial 34 | if (!find) { 35 | system.Exec(cmd_c, &serial, &error); 36 | if (serial) { 37 | serial = serial.Trim(); 38 | } 39 | } 40 | 41 | if (!serial) { 42 | serial = "unknown"; 43 | } 44 | WriteLine('{"serial": "'# serial #'"}'); 45 | -------------------------------------------------------------------------------- /hahomematic/rega_scripts/get_system_variable_descriptions.fn: -------------------------------------------------------------------------------- 1 | !# get_system_variable_descriptions.fn 2 | !# Erstellt in Ergänzung zu https://github.com/eq-3/occu/blob/45b38865f6b60f16f825b75f0bdc8a9738831ee0/WebUI/www/api/methods/sysvar/getall.tcl 3 | !# Erweitert das Script um "description" 4 | !# 5 | 6 | string id; 7 | boolean dpFirst = true; 8 | Write("["); 9 | foreach(id, dom.GetObject(ID_SYSTEM_VARIABLES).EnumIDs()) { 10 | object sv = dom.GetObject(id); 11 | string description = ""; 12 | if (sv) { 13 | ! use UriEncode() to ensure special characters " and \ 14 | ! and others are properly encoded using URI/URL percentage 15 | ! encoding 16 | description = sv.DPInfo().UriEncode(); 17 | 18 | if (dpFirst) { 19 | dpFirst = false; 20 | } else { 21 | WriteLine(','); 22 | } 23 | 24 | Write("{"); 25 | Write("\"id\": \"" # id # "\","); 26 | Write("\"description\": \"" # description # "\""); 27 | Write("}"); 28 | } 29 | } 30 | Write("]"); 31 | -------------------------------------------------------------------------------- /hahomematic/rega_scripts/set_program_state.fn: -------------------------------------------------------------------------------- 1 | !# set_program_state.fn 2 | !# 3 | !# Dieses Script setzt den Zustand eines Programmes auf der CCU. 4 | !# 5 | string p_id = "##id##"; 6 | integer p_state = ##state##; 7 | 8 | object program = dom.GetObject(ID_PROGRAMS).Get(p_id); 9 | if (program) { 10 | program.Active(p_state); 11 | Write(program.Active()) 12 | } 13 | -------------------------------------------------------------------------------- /hahomematic/rega_scripts/set_system_variable.fn: -------------------------------------------------------------------------------- 1 | !# set_system_variable 2 | !# 3 | !# Erstellt durch @baxxy13 2022-04-11 4 | !# 5 | !# Dieses Script schreibt eine Systemvariable vom Typ Zeichenkette. 6 | !# 7 | 8 | string sv_name = "##name##"; 9 | string sv_value = "##value##"; 10 | object target_sv = dom.GetObject(ID_SYSTEM_VARIABLES).Get(sv_name); 11 | if (target_sv) { 12 | if (target_sv.ValueTypeStr() == "String") { 13 | Write(target_sv.State(sv_value)); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /hahomematic/validator.py: -------------------------------------------------------------------------------- 1 | """Validator functions used within hahomematic.""" 2 | 3 | from __future__ import annotations 4 | 5 | import voluptuous as vol 6 | 7 | from hahomematic.const import MAX_WAIT_FOR_CALLBACK 8 | from hahomematic.support import ( 9 | check_password, 10 | is_channel_address, 11 | is_device_address, 12 | is_hostname, 13 | is_ipv4_address, 14 | is_paramset_key, 15 | ) 16 | 17 | channel_no = vol.All(vol.Coerce(int), vol.Range(min=0, max=999)) 18 | positive_int = vol.All(vol.Coerce(int), vol.Range(min=0)) 19 | wait_for = vol.All(vol.Coerce(int), vol.Range(min=1, max=MAX_WAIT_FOR_CALLBACK)) 20 | 21 | 22 | def channel_address(value: str) -> str: 23 | """Validate channel_address.""" 24 | if is_channel_address(address=value): 25 | return value 26 | raise vol.Invalid("channel_address is invalid") 27 | 28 | 29 | def device_address(value: str) -> str: 30 | """Validate channel_address.""" 31 | if is_device_address(address=value): 32 | return value 33 | raise vol.Invalid("device_address is invalid") 34 | 35 | 36 | def hostname(value: str) -> str: 37 | """Validate hostname.""" 38 | if is_hostname(hostname=value): 39 | return value 40 | raise vol.Invalid("hostname is invalid") 41 | 42 | 43 | def ipv4_address(value: str) -> str: 44 | """Validate ipv4_address.""" 45 | if is_ipv4_address(address=value): 46 | return value 47 | raise vol.Invalid("ipv4_address is invalid") 48 | 49 | 50 | def password(value: str) -> str: 51 | """Validate password.""" 52 | if check_password(password=value): 53 | return value 54 | raise vol.Invalid("password is invalid") 55 | 56 | 57 | def paramset_key(value: str) -> str: 58 | """Validate paramset_key.""" 59 | if is_paramset_key(paramset_key=value): 60 | return value 61 | raise vol.Invalid("paramset_key is invalid") 62 | 63 | 64 | address = vol.All(vol.Coerce(str), vol.Any(device_address, channel_address)) 65 | host = vol.All(vol.Coerce(str), vol.Any(hostname, ipv4_address)) 66 | -------------------------------------------------------------------------------- /hahomematic_support/__init__.py: -------------------------------------------------------------------------------- 1 | """Module to support hahomematic testing with a local client.""" 2 | -------------------------------------------------------------------------------- /hahomematic_support/ruff.toml: -------------------------------------------------------------------------------- 1 | # This extend our general Ruff rules specifically for hahomematic_support 2 | extend = "../pyproject.toml" 3 | 4 | [lint.isort] 5 | known-first-party = [ 6 | "hahomematic", 7 | "hahomematic_support" 8 | ] 9 | 10 | known-third-party = [ 11 | "orjson", 12 | "voluptuous", 13 | ] -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.13 3 | platform = linux 4 | show_error_codes = true 5 | follow_imports = normal 6 | local_partial_types = true 7 | strict_equality = true 8 | no_implicit_optional = true 9 | warn_incomplete_stub = true 10 | warn_redundant_casts = true 11 | warn_unused_configs = true 12 | warn_unused_ignores = true 13 | enable_error_code = ignore-without-code, redundant-self, truthy-iterable 14 | disable_error_code = annotation-unchecked, import-not-found, import-untyped 15 | extra_checks = false 16 | check_untyped_defs = true 17 | disallow_incomplete_defs = true 18 | disallow_subclassing_any = true 19 | disallow_untyped_calls = true 20 | disallow_untyped_decorators = true 21 | disallow_untyped_defs = true 22 | warn_return_any = true 23 | warn_unreachable = true 24 | 25 | [mypy-hahomematic.*] 26 | check_untyped_defs = true 27 | disallow_incomplete_defs = true 28 | disallow_subclassing_any = true 29 | disallow_untyped_calls = true 30 | disallow_untyped_decorators = true 31 | disallow_untyped_defs = true 32 | warn_return_any = true 33 | warn_unreachable = true 34 | -------------------------------------------------------------------------------- /pylint/ruff.toml: -------------------------------------------------------------------------------- 1 | # This extend our general Ruff rules specifically for tests 2 | extend = "../pyproject.toml" 3 | 4 | [isort] 5 | known-third-party = [ 6 | "pylint", 7 | ] -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp>=3.12.6 2 | orjson>=3.10.18 3 | python-slugify>=8.0.4 4 | voluptuous>=0.15.2 5 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | -r requirements_test_pre_commit.txt 3 | 4 | coverage==7.8.2 5 | freezegun==1.5.2 6 | mypy==1.16.0 7 | pip==25.1.1 8 | pre-commit==4.2.0 9 | pur==7.3.3 10 | pydevccu==0.1.11 11 | pylint-per-file-ignores==1.4.0 12 | pylint-strict-informational==0.1 13 | pylint==3.3.7 14 | pytest-aiohttp==1.1.0 15 | pytest-asyncio==1.0.0 16 | pytest-cov==6.1.1 17 | pytest-rerunfailures==15.1 18 | pytest-socket==0.7.0 19 | pytest-timeout==2.4.0 20 | pytest==8.3.5 21 | types-python-slugify==8.0.2.20240310 22 | uv==0.7.9 23 | -------------------------------------------------------------------------------- /requirements_test_pre_commit.txt: -------------------------------------------------------------------------------- 1 | bandit==1.8.3 2 | codespell==2.4.1 3 | pre-commit-hooks==v5.0.0 4 | python-typing-update==v0.7.1 5 | ruff==0.11.12 6 | yamllint==1.37.1 7 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Resolve all dependencies that the application requires to run. 3 | 4 | # Stop on errors 5 | set -e 6 | 7 | cd "$(dirname "$0")/.." 8 | 9 | echo "Installing development dependencies..." 10 | pip install colorlog pre-commit uv 11 | uv pip install --prerelease=allow -r requirements_test.txt 12 | -------------------------------------------------------------------------------- /script/ruff.toml: -------------------------------------------------------------------------------- 1 | # This extend our general Ruff rules specifically for tests 2 | extend = "../pyproject.toml" 3 | 4 | [isort] 5 | forced-separate = [ 6 | "tests", 7 | ] -------------------------------------------------------------------------------- /script/run-in-env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -eu 3 | 4 | export "OSTYPE"=@OSTYPE 5 | 6 | # Activate pyenv and virtualenv if present, then run the specified command 7 | 8 | # pyenv, pyenv-virtualenv 9 | if [ -s .python-version ]; then 10 | PYENV_VERSION=$(head -n 1 .python-version) 11 | export PYENV_VERSION 12 | fi 13 | 14 | # other common virtualenvs 15 | my_path=$(git rev-parse --show-toplevel) 16 | 17 | for venv in venv .venv .; do 18 | if [ -f "${my_path}/${venv}/bin/activate" ]; then 19 | . "${my_path}/${venv}/bin/activate" 20 | break 21 | fi 22 | done 23 | 24 | exec "$@" 25 | -------------------------------------------------------------------------------- /script/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Setups the repository. 3 | 4 | # Stop on errors 5 | set -e 6 | 7 | cd "$(dirname "$0")/.." 8 | script/bootstrap 9 | pre-commit install 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | url = https://github.com/sukramj/hahomematic 3 | -------------------------------------------------------------------------------- /tests/bandit.yaml: -------------------------------------------------------------------------------- 1 | # https://bandit.readthedocs.io/en/latest/config.html 2 | 3 | tests: 4 | - B103 5 | - B108 6 | - B306 7 | - B307 8 | - B313 9 | - B314 10 | - B315 11 | - B316 12 | - B317 13 | - B318 14 | - B319 15 | - B601 16 | - B602 17 | - B604 18 | - B608 19 | - B609 20 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Test support for hahomematic.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from unittest.mock import Mock, patch 7 | 8 | import pydevccu 9 | import pytest 10 | 11 | from hahomematic.central import CentralUnit 12 | from hahomematic.client import Client 13 | 14 | from tests import const, helper 15 | 16 | logging.basicConfig(level=logging.INFO) 17 | 18 | # pylint: disable=protected-access, redefined-outer-name 19 | 20 | 21 | @pytest.fixture(autouse=True) 22 | def teardown(): 23 | """Clean up.""" 24 | patch.stopall() 25 | 26 | 27 | @pytest.fixture 28 | def pydev_ccu_full() -> pydevccu.Server: 29 | """Create the virtual ccu.""" 30 | ccu = pydevccu.Server(addr=(const.CCU_HOST, const.CCU_PORT)) 31 | ccu.start() 32 | yield ccu 33 | ccu.stop() 34 | 35 | 36 | @pytest.fixture 37 | def pydev_ccu_mini() -> pydevccu.Server: 38 | """Create the virtual ccu.""" 39 | ccu = pydevccu.Server(addr=(const.CCU_HOST, const.CCU_PORT), devices=["HmIP-BWTH", "HmIP-eTRV-2"]) 40 | ccu.start() 41 | yield ccu 42 | ccu.stop() 43 | 44 | 45 | @pytest.fixture 46 | async def central_unit_mini(pydev_ccu_mini: pydevccu.Server) -> CentralUnit: 47 | """Create and yield central.""" 48 | central = await helper.get_pydev_ccu_central_unit_full() 49 | yield central 50 | await central.stop() 51 | await central.clear_caches() 52 | 53 | 54 | @pytest.fixture 55 | async def central_unit_full(pydev_ccu_full: pydevccu.Server) -> CentralUnit: 56 | """Create and yield central.""" 57 | 58 | def homematic_callback(*args, **kwargs): 59 | """Do dummy homematic_callback.""" 60 | 61 | def backend_system_callback(*args, **kwargs): 62 | """Do dummy backend_system_callback.""" 63 | 64 | central = await helper.get_pydev_ccu_central_unit_full() 65 | 66 | unregister_homematic_callback = central.register_homematic_callback(homematic_callback) 67 | unregister_backend_system_callback = central.register_backend_system_callback(backend_system_callback) 68 | 69 | yield central 70 | 71 | unregister_homematic_callback() 72 | unregister_backend_system_callback() 73 | await central.stop() 74 | await central.clear_caches() 75 | 76 | 77 | @pytest.fixture 78 | async def factory() -> helper.Factory: 79 | """Return central factory.""" 80 | return helper.Factory() 81 | 82 | 83 | @pytest.fixture 84 | async def central_client_factory( 85 | address_device_translation: dict[str, str], 86 | do_mock_client: bool, 87 | add_sysvars: bool, 88 | add_programs: bool, 89 | ignore_devices_on_create: list[str] | None, 90 | un_ignore_list: list[str] | None, 91 | ) -> tuple[CentralUnit, Client | Mock, helper.Factory]: 92 | """Return central factory.""" 93 | factory = helper.Factory() 94 | central_client = await factory.get_default_central( 95 | address_device_translation=address_device_translation, 96 | do_mock_client=do_mock_client, 97 | add_sysvars=add_sysvars, 98 | add_programs=add_programs, 99 | ignore_devices_on_create=ignore_devices_on_create, 100 | un_ignore_list=un_ignore_list, 101 | ) 102 | central, client = central_client 103 | yield central, client, factory 104 | await central.stop() 105 | await central.clear_caches() 106 | -------------------------------------------------------------------------------- /tests/ruff.toml: -------------------------------------------------------------------------------- 1 | # This extend our general Ruff rules specifically for tests 2 | extend = "../pyproject.toml" 3 | 4 | lint.extend-select = [ 5 | "PT001", # Use @pytest.fixture without parentheses 6 | "PT002", # Configuration for fixture specified via positional args, use kwargs 7 | "PT003", # The scope='function' is implied in @pytest.fixture() 8 | "PT006", # Single parameter in parameterize is a string, multiple a tuple 9 | "PT013", # Found incorrect pytest import, use simple import pytest instead 10 | "PT015", # Assertion always fails, replace with pytest.fail() 11 | "PT021", # use yield instead of request.addfinalizer 12 | "PT022", # No teardown in fixture, replace useless yield with return 13 | ] 14 | 15 | lint.extend-ignore = [ 16 | "ASYNC", 17 | "PLC", # pylint 18 | "PLE", # pylint 19 | "PLR", # pylint 20 | "PLW", # pylint 21 | "N815", # Variable {name} in class scope should not be mixedCase 22 | ] 23 | 24 | [lint.isort] 25 | known-first-party = [ 26 | "hahomematic", 27 | "hahomematic_support", 28 | "tests", 29 | "script", 30 | ] 31 | known-third-party = [ 32 | "orjson", 33 | "syrupy", 34 | "pytest", 35 | "voluptuous", 36 | "pylint", 37 | ] 38 | forced-separate = [ 39 | "tests", 40 | ] -------------------------------------------------------------------------------- /tests/test_action.py: -------------------------------------------------------------------------------- 1 | """Tests for action data points of hahomematic.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import cast 6 | from unittest.mock import Mock, call 7 | 8 | import pytest 9 | 10 | from hahomematic.central import CentralUnit 11 | from hahomematic.client import Client 12 | from hahomematic.const import DataPointUsage, ParamsetKey 13 | from hahomematic.model.generic import DpAction 14 | 15 | from tests import helper 16 | 17 | TEST_DEVICES: dict[str, str] = { 18 | "VCU9724704": "HmIP-DLD.json", 19 | } 20 | 21 | # pylint: disable=protected-access 22 | 23 | 24 | @pytest.mark.asyncio 25 | @pytest.mark.parametrize( 26 | ( 27 | "address_device_translation", 28 | "do_mock_client", 29 | "add_sysvars", 30 | "add_programs", 31 | "ignore_devices_on_create", 32 | "un_ignore_list", 33 | ), 34 | [ 35 | (TEST_DEVICES, True, False, False, None, None), 36 | ], 37 | ) 38 | async def test_hmaction( 39 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 40 | ) -> None: 41 | """Test HmAction.""" 42 | central, mock_client, _ = central_client_factory 43 | action: DpAction = cast( 44 | DpAction, 45 | central.get_generic_data_point("VCU9724704:1", "LOCK_TARGET_LEVEL"), 46 | ) 47 | assert action.usage == DataPointUsage.NO_CREATE 48 | assert action.is_readable is False 49 | assert action.value is None 50 | assert action.values == ("LOCKED", "UNLOCKED", "OPEN") 51 | assert action.hmtype == "ENUM" 52 | await action.send_value("OPEN") 53 | assert mock_client.method_calls[-1] == call.set_value( 54 | channel_address="VCU9724704:1", 55 | paramset_key=ParamsetKey.VALUES, 56 | parameter="LOCK_TARGET_LEVEL", 57 | value=2, 58 | ) 59 | await action.send_value(1) 60 | assert mock_client.method_calls[-1] == call.set_value( 61 | channel_address="VCU9724704:1", 62 | paramset_key=ParamsetKey.VALUES, 63 | parameter="LOCK_TARGET_LEVEL", 64 | value=1, 65 | ) 66 | 67 | call_count = len(mock_client.method_calls) 68 | await action.send_value(1) 69 | assert (call_count + 1) == len(mock_client.method_calls) 70 | -------------------------------------------------------------------------------- /tests/test_binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Tests for binary_sensor data points of hahomematic.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import cast 6 | from unittest.mock import Mock 7 | 8 | import pytest 9 | 10 | from hahomematic.central import CentralUnit 11 | from hahomematic.client import Client 12 | from hahomematic.const import DataPointUsage 13 | from hahomematic.model.generic import DpBinarySensor 14 | from hahomematic.model.hub import SysvarDpBinarySensor 15 | 16 | from tests import const, helper 17 | 18 | TEST_DEVICES: dict[str, str] = { 19 | "VCU5864966": "HmIP-SWDO-I.json", 20 | } 21 | 22 | # pylint: disable=protected-access 23 | 24 | 25 | @pytest.mark.asyncio 26 | @pytest.mark.parametrize( 27 | ( 28 | "address_device_translation", 29 | "do_mock_client", 30 | "add_sysvars", 31 | "add_programs", 32 | "ignore_devices_on_create", 33 | "un_ignore_list", 34 | ), 35 | [ 36 | (TEST_DEVICES, True, False, False, None, None), 37 | ], 38 | ) 39 | async def test_hmbinarysensor( 40 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 41 | ) -> None: 42 | """Test HmBinarySensor.""" 43 | central, mock_client, _ = central_client_factory 44 | binary_sensor: DpBinarySensor = cast( 45 | DpBinarySensor, 46 | central.get_generic_data_point("VCU5864966:1", "STATE"), 47 | ) 48 | assert binary_sensor.usage == DataPointUsage.DATA_POINT 49 | assert binary_sensor.value is False 50 | assert binary_sensor.is_writeable is False 51 | assert binary_sensor.visible is True 52 | await central.data_point_event(const.INTERFACE_ID, "VCU5864966:1", "STATE", 1) 53 | assert binary_sensor.value is True 54 | await central.data_point_event(const.INTERFACE_ID, "VCU5864966:1", "STATE", 0) 55 | assert binary_sensor.value is False 56 | await central.data_point_event(const.INTERFACE_ID, "VCU5864966:1", "STATE", None) 57 | assert binary_sensor.value is False 58 | 59 | call_count = len(mock_client.method_calls) 60 | await binary_sensor.send_value(True) 61 | assert call_count == len(mock_client.method_calls) 62 | 63 | 64 | @pytest.mark.asyncio 65 | @pytest.mark.parametrize( 66 | ( 67 | "address_device_translation", 68 | "do_mock_client", 69 | "add_sysvars", 70 | "add_programs", 71 | "ignore_devices_on_create", 72 | "un_ignore_list", 73 | ), 74 | [ 75 | ({}, True, True, False, None, None), 76 | ], 77 | ) 78 | async def test_hmsysvarbinarysensor( 79 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 80 | ) -> None: 81 | """Test HmSysvarBinarySensor.""" 82 | central, _, _ = central_client_factory 83 | binary_sensor: SysvarDpBinarySensor = cast( 84 | SysvarDpBinarySensor, 85 | central.get_sysvar_data_point(legacy_name="logic"), 86 | ) 87 | assert binary_sensor.name == "logic" 88 | assert binary_sensor.full_name == "CentralTest logic" 89 | assert binary_sensor.value is False 90 | assert binary_sensor.is_extended is False 91 | assert binary_sensor._data_type == "LOGIC" 92 | assert binary_sensor.value is False 93 | binary_sensor.write_value(True) 94 | assert binary_sensor.value is True 95 | -------------------------------------------------------------------------------- /tests/test_button.py: -------------------------------------------------------------------------------- 1 | """Tests for button data points of hahomematic.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import cast 6 | from unittest.mock import Mock, call 7 | 8 | import pytest 9 | 10 | from hahomematic.central import CentralUnit 11 | from hahomematic.client import Client 12 | from hahomematic.const import DataPointUsage, ParamsetKey, ProgramData 13 | from hahomematic.model.generic import DpButton 14 | from hahomematic.model.hub import ProgramDpButton 15 | 16 | from tests import helper 17 | 18 | TEST_DEVICES: dict[str, str] = { 19 | "VCU1437294": "HmIP-SMI.json", 20 | } 21 | 22 | # pylint: disable=protected-access 23 | 24 | 25 | @pytest.mark.asyncio 26 | @pytest.mark.parametrize( 27 | ( 28 | "address_device_translation", 29 | "do_mock_client", 30 | "add_sysvars", 31 | "add_programs", 32 | "ignore_devices_on_create", 33 | "un_ignore_list", 34 | ), 35 | [ 36 | (TEST_DEVICES, True, False, False, None, None), 37 | ], 38 | ) 39 | async def test_hmbutton( 40 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 41 | ) -> None: 42 | """Test HmButton.""" 43 | central, mock_client, _ = central_client_factory 44 | button: DpButton = cast( 45 | DpButton, 46 | central.get_generic_data_point("VCU1437294:1", "RESET_MOTION"), 47 | ) 48 | assert button.usage == DataPointUsage.DATA_POINT 49 | assert button.available is True 50 | assert button.is_readable is False 51 | assert button.value is None 52 | assert button.values is None 53 | assert button.hmtype == "ACTION" 54 | await button.press() 55 | assert mock_client.method_calls[-1] == call.set_value( 56 | channel_address="VCU1437294:1", 57 | paramset_key=ParamsetKey.VALUES, 58 | parameter="RESET_MOTION", 59 | value=True, 60 | ) 61 | 62 | call_count = len(mock_client.method_calls) 63 | await button.press() 64 | assert (call_count + 1) == len(mock_client.method_calls) 65 | 66 | 67 | @pytest.mark.asyncio 68 | @pytest.mark.parametrize( 69 | ( 70 | "address_device_translation", 71 | "do_mock_client", 72 | "add_sysvars", 73 | "add_programs", 74 | "ignore_devices_on_create", 75 | "un_ignore_list", 76 | ), 77 | [ 78 | ({}, True, False, True, None, None), 79 | ], 80 | ) 81 | async def test_hmprogrambutton( 82 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 83 | ) -> None: 84 | """Test HmProgramButton.""" 85 | central, mock_client, _ = central_client_factory 86 | button: ProgramDpButton = cast(ProgramDpButton, central.get_program_data_point("pid1").button) 87 | assert button.usage == DataPointUsage.DATA_POINT 88 | assert button.available is True 89 | assert button.is_active is True 90 | assert button.is_internal is False 91 | assert button.name == "p1" 92 | await button.press() 93 | assert mock_client.method_calls[-1] == call.execute_program(pid="pid1") 94 | updated_program = ProgramData( 95 | legacy_name="p1", 96 | description="", 97 | pid="pid1", 98 | is_active=False, 99 | is_internal=True, 100 | last_execute_time="1900-1-1", 101 | ) 102 | button.update_data(updated_program) 103 | assert button.is_active is False 104 | assert button.is_internal is True 105 | 106 | button2: ProgramDpButton = cast(ProgramDpButton, central.get_program_data_point("pid2").button) 107 | assert button2.usage == DataPointUsage.DATA_POINT 108 | assert button2.is_active is False 109 | assert button2.is_internal is False 110 | assert button2.name == "p_2" 111 | -------------------------------------------------------------------------------- /tests/test_decorator.py: -------------------------------------------------------------------------------- 1 | """Tests for switch data points of hahomematic.""" 2 | 3 | from __future__ import annotations 4 | 5 | from hahomematic.model.decorators import ( 6 | config_property, 7 | get_public_attributes_for_config_property, 8 | get_public_attributes_for_state_property, 9 | state_property, 10 | ) 11 | 12 | # pylint: disable=protected-access 13 | 14 | 15 | def test_generic_property() -> None: 16 | """Test CustomDpSwitch.""" 17 | test_class = PropertyTestClazz() 18 | assert test_class.value == "test_value" 19 | assert test_class.config == "test_config" 20 | test_class.value = "new_value" 21 | test_class.config = "new_config" 22 | assert test_class.value == "new_value" 23 | assert test_class.config == "new_config" 24 | del test_class.value 25 | del test_class.config 26 | assert test_class.value == "" 27 | assert test_class.config == "" 28 | 29 | 30 | def test_generic_property_read() -> None: 31 | """Test CustomDpSwitch.""" 32 | test_class = PropertyTestClazz() 33 | config_attributes = get_public_attributes_for_config_property(data_object=test_class) 34 | assert config_attributes == {"config": "test_config"} 35 | value_attributes = get_public_attributes_for_state_property(data_object=test_class) 36 | assert value_attributes == {"value": "test_value"} 37 | 38 | 39 | class PropertyTestClazz: 40 | """test class for generic_properties.""" 41 | 42 | def __init__(self): 43 | """Init PropertyTestClazz.""" 44 | self._value: str = "test_value" 45 | self._config: str = "test_config" 46 | 47 | @state_property 48 | def value(self) -> str: 49 | """Return value.""" 50 | return self._value 51 | 52 | @value.setter 53 | def value(self, value: str) -> None: 54 | """Set value.""" 55 | self._value = value 56 | 57 | @value.deleter 58 | def value(self) -> None: 59 | """Delete value.""" 60 | self._value = "" 61 | 62 | @config_property 63 | def config(self) -> str: 64 | """Return config.""" 65 | return self._config 66 | 67 | @config.setter 68 | def config(self, config: str) -> None: 69 | """Set config.""" 70 | self._config = config 71 | 72 | @config.deleter 73 | def config(self) -> None: 74 | """Delete config.""" 75 | self._config = "" 76 | -------------------------------------------------------------------------------- /tests/test_device.py: -------------------------------------------------------------------------------- 1 | """Tests for devices of hahomematic.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | from unittest.mock import Mock 7 | 8 | import pytest 9 | 10 | from hahomematic.central import CentralUnit 11 | from hahomematic.client import Client 12 | 13 | from tests import const, helper 14 | 15 | TEST_DEVICES: dict[str, str] = { 16 | "VCU2128127": "HmIP-BSM.json", 17 | "VCU6354483": "HmIP-STHD.json", 18 | } 19 | 20 | # pylint: disable=protected-access 21 | 22 | 23 | @pytest.mark.asyncio 24 | @pytest.mark.parametrize( 25 | ( 26 | "address_device_translation", 27 | "do_mock_client", 28 | "add_sysvars", 29 | "add_programs", 30 | "ignore_devices_on_create", 31 | "un_ignore_list", 32 | ), 33 | [ 34 | (TEST_DEVICES, True, False, False, None, None), 35 | ], 36 | ) 37 | async def test_device_general( 38 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 39 | ) -> None: 40 | """Test device availability.""" 41 | central, _, _ = central_client_factory 42 | device = central.get_device(address="VCU2128127") 43 | assert device.address == "VCU2128127" 44 | assert device.name == "HmIP-BSM_VCU2128127" 45 | assert ( 46 | str(device) == "address: VCU2128127, " 47 | "model: 8, " 48 | "name: HmIP-BSM_VCU2128127, " 49 | "generic_data_points: 27, " 50 | "custom_data_points: 3, " 51 | "events: 6" 52 | ) 53 | assert device.model == "HmIP-BSM" 54 | assert device.interface == "BidCos-RF" 55 | assert device.interface_id == const.INTERFACE_ID 56 | assert device.has_custom_data_point_definition is True 57 | assert len(device.custom_data_points) == 3 58 | assert len(device.channels) == 11 59 | 60 | 61 | @pytest.mark.asyncio 62 | @pytest.mark.parametrize( 63 | ( 64 | "address_device_translation", 65 | "do_mock_client", 66 | "add_sysvars", 67 | "add_programs", 68 | "ignore_devices_on_create", 69 | "un_ignore_list", 70 | ), 71 | [ 72 | (TEST_DEVICES, True, False, False, None, None), 73 | ], 74 | ) 75 | async def test_device_availability( 76 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 77 | ) -> None: 78 | """Test device availability.""" 79 | central, _, _ = central_client_factory 80 | device = central.get_device(address="VCU6354483") 81 | assert device.available is True 82 | for generic_data_point in device.generic_data_points: 83 | assert generic_data_point.available is True 84 | for custom_data_point in device.custom_data_points: 85 | assert custom_data_point.available is True 86 | 87 | await central.data_point_event(const.INTERFACE_ID, "VCU6354483:0", "UNREACH", 1) 88 | assert device.available is False 89 | for generic_data_point in device.generic_data_points: 90 | assert generic_data_point.available is False 91 | for custom_data_point in device.custom_data_points: 92 | assert custom_data_point.available is False 93 | 94 | await central.data_point_event(const.INTERFACE_ID, "VCU6354483:0", "UNREACH", 0) 95 | assert device.available is True 96 | for generic_data_point in device.generic_data_points: 97 | assert generic_data_point.available is True 98 | for custom_data_point in device.custom_data_points: 99 | assert custom_data_point.available is True 100 | 101 | 102 | @pytest.mark.asyncio 103 | @pytest.mark.parametrize( 104 | ( 105 | "address_device_translation", 106 | "do_mock_client", 107 | "add_sysvars", 108 | "add_programs", 109 | "ignore_devices_on_create", 110 | "un_ignore_list", 111 | ), 112 | [ 113 | (TEST_DEVICES, True, False, False, None, None), 114 | ], 115 | ) 116 | async def test_device_config_pending( 117 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 118 | ) -> None: 119 | """Test device availability.""" 120 | central, _, _ = central_client_factory 121 | device = central.get_device(address="VCU2128127") 122 | assert device._dp_config_pending.value is False 123 | cache_hash = central.paramset_descriptions.cache_hash 124 | last_save_triggered = central.paramset_descriptions.last_save_triggered 125 | await central.data_point_event(const.INTERFACE_ID, "VCU2128127:0", "CONFIG_PENDING", True) 126 | assert device._dp_config_pending.value is True 127 | assert cache_hash == central.paramset_descriptions.cache_hash 128 | assert last_save_triggered == central.paramset_descriptions.last_save_triggered 129 | await central.data_point_event(const.INTERFACE_ID, "VCU2128127:0", "CONFIG_PENDING", False) 130 | assert device._dp_config_pending.value is False 131 | await asyncio.sleep(2) 132 | # Save triggered, but data not changed 133 | assert cache_hash == central.paramset_descriptions.cache_hash 134 | assert last_save_triggered != central.paramset_descriptions.last_save_triggered 135 | -------------------------------------------------------------------------------- /tests/test_event.py: -------------------------------------------------------------------------------- 1 | """Tests for switch data points of hahomematic.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import cast 6 | from unittest.mock import Mock, call 7 | 8 | import pytest 9 | 10 | from hahomematic.central import CentralUnit 11 | from hahomematic.client import Client 12 | from hahomematic.const import DataPointUsage, EventType 13 | from hahomematic.model.event import ClickEvent, DeviceErrorEvent, ImpulseEvent 14 | 15 | from tests import const, helper 16 | 17 | TEST_DEVICES: dict[str, str] = { 18 | "VCU2128127": "HmIP-BSM.json", 19 | "VCU0000263": "HM-Sen-EP.json", 20 | } 21 | 22 | # pylint: disable=protected-access 23 | 24 | 25 | @pytest.mark.asyncio 26 | @pytest.mark.parametrize( 27 | ( 28 | "address_device_translation", 29 | "do_mock_client", 30 | "add_sysvars", 31 | "add_programs", 32 | "ignore_devices_on_create", 33 | "un_ignore_list", 34 | ), 35 | [ 36 | (TEST_DEVICES, True, False, False, None, None), 37 | ], 38 | ) 39 | async def test_clickevent( 40 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 41 | ) -> None: 42 | """Test ClickEvent.""" 43 | central, _, factory = central_client_factory 44 | event: ClickEvent = cast(ClickEvent, central.get_event("VCU2128127:1", "PRESS_SHORT")) 45 | assert event.usage == DataPointUsage.EVENT 46 | assert event.event_type == EventType.KEYPRESS 47 | await central.data_point_event(const.INTERFACE_ID, "VCU2128127:1", "PRESS_SHORT", True) 48 | assert factory.ha_event_mock.call_args_list[-1] == call( 49 | "homematic.keypress", 50 | { 51 | "interface_id": const.INTERFACE_ID, 52 | "address": "VCU2128127", 53 | "channel_no": 1, 54 | "model": "HmIP-BSM", 55 | "parameter": "PRESS_SHORT", 56 | "value": True, 57 | }, 58 | ) 59 | 60 | 61 | @pytest.mark.asyncio 62 | @pytest.mark.parametrize( 63 | ( 64 | "address_device_translation", 65 | "do_mock_client", 66 | "add_sysvars", 67 | "add_programs", 68 | "ignore_devices_on_create", 69 | "un_ignore_list", 70 | ), 71 | [ 72 | (TEST_DEVICES, True, False, False, None, None), 73 | ], 74 | ) 75 | async def test_impulseevent( 76 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 77 | ) -> None: 78 | """Test ImpulseEvent.""" 79 | central, _, factory = central_client_factory 80 | event: ImpulseEvent = cast(ImpulseEvent, central.get_event("VCU0000263:1", "SEQUENCE_OK")) 81 | assert event.usage == DataPointUsage.EVENT 82 | assert event.event_type == EventType.IMPULSE 83 | await central.data_point_event(const.INTERFACE_ID, "VCU0000263:1", "SEQUENCE_OK", True) 84 | assert factory.ha_event_mock.call_args_list[-1] == call( 85 | "homematic.impulse", 86 | { 87 | "interface_id": const.INTERFACE_ID, 88 | "address": "VCU0000263", 89 | "channel_no": 1, 90 | "model": "HM-Sen-EP", 91 | "parameter": "SEQUENCE_OK", 92 | "value": True, 93 | }, 94 | ) 95 | 96 | 97 | @pytest.mark.asyncio 98 | @pytest.mark.parametrize( 99 | ( 100 | "address_device_translation", 101 | "do_mock_client", 102 | "add_sysvars", 103 | "add_programs", 104 | "ignore_devices_on_create", 105 | "un_ignore_list", 106 | ), 107 | [ 108 | (TEST_DEVICES, True, False, False, None, None), 109 | ], 110 | ) 111 | async def test_deviceerrorevent( 112 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 113 | ) -> None: 114 | """Test DeviceErrorEvent.""" 115 | central, _, factory = central_client_factory 116 | event: DeviceErrorEvent = cast( 117 | DeviceErrorEvent, 118 | central.get_event("VCU2128127:0", "ERROR_OVERHEAT"), 119 | ) 120 | assert event.usage == DataPointUsage.EVENT 121 | assert event.event_type == EventType.DEVICE_ERROR 122 | await central.data_point_event(const.INTERFACE_ID, "VCU2128127:0", "ERROR_OVERHEAT", True) 123 | assert factory.ha_event_mock.call_args_list[-1] == call( 124 | "homematic.device_error", 125 | { 126 | "interface_id": const.INTERFACE_ID, 127 | "address": "VCU2128127", 128 | "channel_no": 0, 129 | "model": "HmIP-BSM", 130 | "parameter": "ERROR_OVERHEAT", 131 | "value": True, 132 | }, 133 | ) 134 | -------------------------------------------------------------------------------- /tests/test_json_rpc.py: -------------------------------------------------------------------------------- 1 | """Tests for json rpc client of hahomematic.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | 7 | import orjson 8 | import pytest 9 | 10 | from hahomematic.support import cleanup_text_from_html_tags 11 | 12 | SUCCESS = '{"HmIP-RF.0001D3C99C3C93%3A0.CONFIG_PENDING":false,\r\n"VirtualDevices.INT0000001%3A1.SET_POINT_TEMPERATURE":4.500000,\r\n"VirtualDevices.INT0000001%3A1.SWITCH_POINT_OCCURED":false,\r\n"VirtualDevices.INT0000001%3A1.VALVE_STATE":4,\r\n"VirtualDevices.INT0000001%3A1.WINDOW_STATE":0,\r\n"HmIP-RF.001F9A49942EC2%3A0.CARRIER_SENSE_LEVEL":10.000000,\r\n"HmIP-RF.0003D7098F5176%3A0.UNREACH":false,\r\n"BidCos-RF.OEQ1860891%3A0.UNREACH":true,\r\n"BidCos-RF.OEQ1860891%3A0.STICKY_UNREACH":true,\r\n"BidCos-RF.OEQ1860891%3A1.INHIBIT":false,\r\n"HmIP-RF.000A570998B3FB%3A0.CONFIG_PENDING":false,\r\n"HmIP-RF.000A570998B3FB%3A0.UPDATE_PENDING":false,\r\n"HmIP-RF.000A5A4991BDDC%3A0.CONFIG_PENDING":false,\r\n"HmIP-RF.000A5A4991BDDC%3A0.UPDATE_PENDING":false,\r\n"BidCos-RF.NEQ1636407%3A1.STATE":0,\r\n"BidCos-RF.NEQ1636407%3A2.STATE":false,\r\n"BidCos-RF.NEQ1636407%3A2.INHIBIT":false,\r\n"CUxD.CUX2800001%3A12.TS":"0"}' 13 | FAILURE = '{"HmIP-RF.0001D3C99C3C93%3A0.CONFIG_PENDING":false,\r\n"VirtualDevices.INT0000001%3A1.SET_POINT_TEMPERATURE":4.500000,\r\n"VirtualDevices.INT0000001%3A1.SWITCH_POINT_OCCURED":false,\r\n"VirtualDevices.INT0000001%3A1.VALVE_STATE":4,\r\n"VirtualDevices.INT0000001%3A1.WINDOW_STATE":0,\r\n"HmIP-RF.001F9A49942EC2%3A0.CARRIER_SENSE_LEVEL":10.000000,\r\n"HmIP-RF.0003D7098F5176%3A0.UNREACH":false,\r\n,\r\n,\r\n"BidCos-RF.OEQ1860891%3A0.UNREACH":true,\r\n"BidCos-RF.OEQ1860891%3A0.STICKY_UNREACH":true,\r\n"BidCos-RF.OEQ1860891%3A1.INHIBIT":false,\r\n"HmIP-RF.000A570998B3FB%3A0.CONFIG_PENDING":false,\r\n"HmIP-RF.000A570998B3FB%3A0.UPDATE_PENDING":false,\r\n"HmIP-RF.000A5A4991BDDC%3A0.CONFIG_PENDING":false,\r\n"HmIP-RF.000A5A4991BDDC%3A0.UPDATE_PENDING":false,\r\n"BidCos-RF.NEQ1636407%3A1.STATE":0,\r\n"BidCos-RF.NEQ1636407%3A2.STATE":false,\r\n"BidCos-RF.NEQ1636407%3A2.INHIBIT":false,\r\n"CUxD.CUX2800001%3A12.TS":"0"}' 14 | 15 | 16 | def test_convert_to_json_success() -> None: 17 | """Test if convert to json is successful.""" 18 | assert orjson.loads(SUCCESS) 19 | 20 | 21 | def test_convert_to_json_fails() -> None: 22 | """Test if convert to json is successful.""" 23 | with pytest.raises(json.JSONDecodeError): 24 | orjson.loads(FAILURE) 25 | 26 | 27 | def test_defect_json() -> None: 28 | """Check if json with special characters can be parsed.""" 29 | accepted_chars = ("a", "<", ">", "'", "&", "$", "[", "]", "{", "}") 30 | faulthy_chars = ('"', "\\", " ") 31 | for sc in accepted_chars: 32 | json = "{" + '"name": "Text mit Wert ' + sc + '"' + "}" 33 | assert orjson.loads(json) 34 | 35 | for sc in faulthy_chars: 36 | json = "{" + '"name": "Text mit Wert ' + sc + '"' + "}" 37 | with pytest.raises(orjson.JSONDecodeError): 38 | orjson.loads(json) 39 | 40 | 41 | @pytest.mark.parametrize( 42 | ( 43 | "test_tag", 44 | "expected_result", 45 | ), 46 | [ 47 | (" <>", " "), 48 | ("Test1", "Test1"), 49 | ], 50 | ) 51 | def test_cleanup_html_tags(test_tag: str, expected_result: str) -> None: 52 | """Test cleanup html tags.""" 53 | assert cleanup_text_from_html_tags(text=test_tag) == expected_result 54 | -------------------------------------------------------------------------------- /tests/test_lock.py: -------------------------------------------------------------------------------- 1 | """Tests for button data points of hahomematic.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import cast 6 | from unittest.mock import Mock, call 7 | 8 | import pytest 9 | 10 | from hahomematic.central import CentralUnit 11 | from hahomematic.client import Client 12 | from hahomematic.const import WAIT_FOR_CALLBACK, DataPointUsage, ParamsetKey 13 | from hahomematic.model.custom import CustomDpIpLock, CustomDpRfLock 14 | 15 | from tests import const, helper 16 | 17 | TEST_DEVICES: dict[str, str] = { 18 | "VCU9724704": "HmIP-DLD.json", 19 | "VCU0000146": "HM-Sec-Key.json", 20 | "VCU3609622": "HmIP-eTRV-2.json", 21 | "VCU0000341": "HM-TC-IT-WM-W-EU.json", 22 | } 23 | 24 | # pylint: disable=protected-access 25 | 26 | 27 | @pytest.mark.asyncio 28 | @pytest.mark.parametrize( 29 | ( 30 | "address_device_translation", 31 | "do_mock_client", 32 | "add_sysvars", 33 | "add_programs", 34 | "ignore_devices_on_create", 35 | "un_ignore_list", 36 | ), 37 | [ 38 | (TEST_DEVICES, True, False, False, None, None), 39 | ], 40 | ) 41 | async def test_cerflock( 42 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 43 | ) -> None: 44 | """Test CustomDpRfLock.""" 45 | central, mock_client, _ = central_client_factory 46 | lock: CustomDpRfLock = cast(CustomDpRfLock, helper.get_prepared_custom_data_point(central, "VCU0000146", 1)) 47 | assert lock.usage == DataPointUsage.CDP_PRIMARY 48 | 49 | assert lock.is_locked is True 50 | await lock.unlock() 51 | assert mock_client.method_calls[-1] == call.set_value( 52 | channel_address="VCU0000146:1", 53 | paramset_key=ParamsetKey.VALUES, 54 | parameter="STATE", 55 | value=True, 56 | wait_for_callback=WAIT_FOR_CALLBACK, 57 | ) 58 | assert lock.is_locked is False 59 | await lock.lock() 60 | assert mock_client.method_calls[-1] == call.set_value( 61 | channel_address="VCU0000146:1", 62 | paramset_key=ParamsetKey.VALUES, 63 | parameter="STATE", 64 | value=False, 65 | wait_for_callback=WAIT_FOR_CALLBACK, 66 | ) 67 | assert lock.is_locked is True 68 | await lock.open() 69 | assert mock_client.method_calls[-1] == call.set_value( 70 | channel_address="VCU0000146:1", 71 | paramset_key=ParamsetKey.VALUES, 72 | parameter="OPEN", 73 | value=True, 74 | wait_for_callback=WAIT_FOR_CALLBACK, 75 | ) 76 | 77 | assert lock.is_locking is None 78 | await central.data_point_event(const.INTERFACE_ID, "VCU0000146:1", "DIRECTION", 2) 79 | assert lock.is_locking is True 80 | await central.data_point_event(const.INTERFACE_ID, "VCU0000146:1", "DIRECTION", 0) 81 | assert lock.is_locking is False 82 | 83 | assert lock.is_unlocking is False 84 | await central.data_point_event(const.INTERFACE_ID, "VCU0000146:1", "DIRECTION", 1) 85 | assert lock.is_unlocking is True 86 | await central.data_point_event(const.INTERFACE_ID, "VCU0000146:1", "DIRECTION", 0) 87 | assert lock.is_unlocking is False 88 | 89 | assert lock.is_jammed is False 90 | await central.data_point_event(const.INTERFACE_ID, "VCU0000146:1", "ERROR", 2) 91 | assert lock.is_jammed is True 92 | 93 | await central.data_point_event(const.INTERFACE_ID, "VCU0000146:1", "ERROR", 0) 94 | 95 | await lock.open() 96 | call_count = len(mock_client.method_calls) 97 | await lock.open() 98 | assert (call_count + 1) == len(mock_client.method_calls) 99 | 100 | 101 | @pytest.mark.asyncio 102 | @pytest.mark.parametrize( 103 | ( 104 | "address_device_translation", 105 | "do_mock_client", 106 | "add_sysvars", 107 | "add_programs", 108 | "ignore_devices_on_create", 109 | "un_ignore_list", 110 | ), 111 | [ 112 | (TEST_DEVICES, True, False, False, None, None), 113 | ], 114 | ) 115 | async def test_ceiplock( 116 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 117 | ) -> None: 118 | """Test CustomDpIpLock.""" 119 | central, mock_client, _ = central_client_factory 120 | lock: CustomDpIpLock = cast(CustomDpIpLock, helper.get_prepared_custom_data_point(central, "VCU9724704", 1)) 121 | assert lock.usage == DataPointUsage.CDP_PRIMARY 122 | assert lock.service_method_names == ("lock", "open", "unlock") 123 | 124 | assert lock.is_locked is False 125 | await lock.lock() 126 | assert mock_client.method_calls[-1] == call.set_value( 127 | channel_address="VCU9724704:1", 128 | paramset_key=ParamsetKey.VALUES, 129 | parameter="LOCK_TARGET_LEVEL", 130 | value=0, 131 | wait_for_callback=WAIT_FOR_CALLBACK, 132 | ) 133 | await central.data_point_event(const.INTERFACE_ID, "VCU9724704:1", "LOCK_STATE", 1) 134 | assert lock.is_locked is True 135 | await lock.unlock() 136 | assert mock_client.method_calls[-1] == call.set_value( 137 | channel_address="VCU9724704:1", 138 | paramset_key=ParamsetKey.VALUES, 139 | parameter="LOCK_TARGET_LEVEL", 140 | value=1, 141 | wait_for_callback=WAIT_FOR_CALLBACK, 142 | ) 143 | await central.data_point_event(const.INTERFACE_ID, "VCU9724704:1", "LOCK_STATE", 2) 144 | assert lock.is_locked is False 145 | await lock.open() 146 | assert mock_client.method_calls[-1] == call.set_value( 147 | channel_address="VCU9724704:1", 148 | paramset_key=ParamsetKey.VALUES, 149 | parameter="LOCK_TARGET_LEVEL", 150 | value=2, 151 | wait_for_callback=WAIT_FOR_CALLBACK, 152 | ) 153 | 154 | assert lock.is_locking is None 155 | await central.data_point_event(const.INTERFACE_ID, "VCU9724704:1", "ACTIVITY_STATE", 2) 156 | assert lock.is_locking is True 157 | await central.data_point_event(const.INTERFACE_ID, "VCU9724704:1", "ACTIVITY_STATE", 0) 158 | assert lock.is_locking is False 159 | 160 | assert lock.is_unlocking is False 161 | await central.data_point_event(const.INTERFACE_ID, "VCU9724704:1", "ACTIVITY_STATE", 1) 162 | assert lock.is_unlocking is True 163 | await central.data_point_event(const.INTERFACE_ID, "VCU9724704:1", "ACTIVITY_STATE", 0) 164 | assert lock.is_unlocking is False 165 | 166 | assert lock.is_jammed is False 167 | 168 | await lock.open() 169 | call_count = len(mock_client.method_calls) 170 | await lock.open() 171 | assert (call_count + 1) == len(mock_client.method_calls) 172 | -------------------------------------------------------------------------------- /tests/test_select.py: -------------------------------------------------------------------------------- 1 | """Tests for select data points of hahomematic.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import cast 6 | from unittest.mock import Mock, call 7 | 8 | import pytest 9 | 10 | from hahomematic.central import CentralUnit 11 | from hahomematic.client import Client 12 | from hahomematic.const import DataPointUsage, ParamsetKey 13 | from hahomematic.model.generic import DpSelect 14 | from hahomematic.model.hub import SysvarDpSelect 15 | 16 | from tests import const, helper 17 | 18 | TEST_DEVICES: dict[str, str] = { 19 | "VCU6354483": "HmIP-STHD.json", 20 | } 21 | 22 | # pylint: disable=protected-access 23 | 24 | 25 | @pytest.mark.asyncio 26 | @pytest.mark.parametrize( 27 | ( 28 | "address_device_translation", 29 | "do_mock_client", 30 | "add_sysvars", 31 | "add_programs", 32 | "ignore_devices_on_create", 33 | "un_ignore_list", 34 | ), 35 | [ 36 | (TEST_DEVICES, True, False, False, None, None), 37 | ], 38 | ) 39 | async def test_hmselect( 40 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 41 | ) -> None: 42 | """Test HmSelect.""" 43 | central, mock_client, _ = central_client_factory 44 | select: DpSelect = cast( 45 | DpSelect, 46 | central.get_generic_data_point("VCU6354483:1", "WINDOW_STATE"), 47 | ) 48 | assert select.usage == DataPointUsage.NO_CREATE 49 | assert select.unit is None 50 | assert select.min == "CLOSED" 51 | assert select.max == "OPEN" 52 | assert select.values == ("CLOSED", "OPEN") 53 | assert select.value == "CLOSED" 54 | await select.send_value("OPEN") 55 | assert mock_client.method_calls[-1] == call.set_value( 56 | channel_address="VCU6354483:1", 57 | paramset_key=ParamsetKey.VALUES, 58 | parameter="WINDOW_STATE", 59 | value=1, 60 | ) 61 | assert select.value == "OPEN" 62 | await central.data_point_event(const.INTERFACE_ID, "VCU6354483:1", "WINDOW_STATE", 0) 63 | assert select.value == "CLOSED" 64 | 65 | await select.send_value(3) 66 | # do not write. value above max 67 | assert select.value == "CLOSED" 68 | 69 | await select.send_value(1) 70 | assert mock_client.method_calls[-1] == call.set_value( 71 | channel_address="VCU6354483:1", 72 | paramset_key=ParamsetKey.VALUES, 73 | parameter="WINDOW_STATE", 74 | value=1, 75 | ) 76 | # do not write. value above max 77 | assert select.value == "OPEN" 78 | 79 | call_count = len(mock_client.method_calls) 80 | await select.send_value(1) 81 | assert call_count == len(mock_client.method_calls) 82 | 83 | 84 | @pytest.mark.asyncio 85 | @pytest.mark.parametrize( 86 | ( 87 | "address_device_translation", 88 | "do_mock_client", 89 | "add_sysvars", 90 | "add_programs", 91 | "ignore_devices_on_create", 92 | "un_ignore_list", 93 | ), 94 | [ 95 | (TEST_DEVICES, True, True, False, None, None), 96 | ], 97 | ) 98 | async def test_hmsysvarselect( 99 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 100 | ) -> None: 101 | """Test HmSysvarSelect.""" 102 | central, mock_client, _ = central_client_factory 103 | select: SysvarDpSelect = cast(SysvarDpSelect, central.get_sysvar_data_point(legacy_name="list_ext")) 104 | assert select.usage == DataPointUsage.DATA_POINT 105 | assert select.unit is None 106 | assert select.min is None 107 | assert select.max is None 108 | assert select.values == ("v1", "v2", "v3") 109 | assert select.value == "v1" 110 | await select.send_variable("v2") 111 | assert mock_client.method_calls[-1] == call.set_system_variable(legacy_name="list_ext", value=1) 112 | assert select.value == "v2" 113 | await select.send_variable(3) 114 | # do not write. value above max 115 | assert select.value == "v2" 116 | -------------------------------------------------------------------------------- /tests/test_sensor.py: -------------------------------------------------------------------------------- 1 | """Tests for sensor data points of hahomematic.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import cast 6 | from unittest.mock import Mock 7 | 8 | import pytest 9 | 10 | from hahomematic.central import CentralUnit 11 | from hahomematic.client import Client 12 | from hahomematic.const import DataPointUsage 13 | from hahomematic.model.generic import DpSensor 14 | from hahomematic.model.hub import SysvarDpSensor 15 | 16 | from tests import const, helper 17 | 18 | TEST_DEVICES: dict[str, str] = { 19 | "VCU7981740": "HmIP-SRH.json", 20 | "VCU3941846": "HMIP-PSM.json", 21 | "VCU8205532": "HmIP-SCTH230.json", 22 | } 23 | 24 | # pylint: disable=protected-access 25 | 26 | 27 | @pytest.mark.asyncio 28 | @pytest.mark.parametrize( 29 | ( 30 | "address_device_translation", 31 | "do_mock_client", 32 | "add_sysvars", 33 | "add_programs", 34 | "ignore_devices_on_create", 35 | "un_ignore_list", 36 | ), 37 | [ 38 | (TEST_DEVICES, True, False, False, None, None), 39 | ], 40 | ) 41 | async def test_hmsensor_psm( 42 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 43 | ) -> None: 44 | """Test HmSensor.""" 45 | central, _, _ = central_client_factory 46 | sensor: DpSensor = cast(DpSensor, central.get_generic_data_point("VCU3941846:6", "VOLTAGE")) 47 | assert sensor.usage == DataPointUsage.DATA_POINT 48 | assert sensor.unit == "V" 49 | assert sensor.values is None 50 | assert sensor.value is None 51 | await central.data_point_event(const.INTERFACE_ID, "VCU3941846:6", "VOLTAGE", 120) 52 | assert sensor.value == 120.0 53 | await central.data_point_event(const.INTERFACE_ID, "VCU3941846:6", "VOLTAGE", 234.00) 54 | assert sensor.value == 234.00 55 | 56 | sensor2: DpSensor = cast( 57 | DpSensor, 58 | central.get_generic_data_point("VCU3941846:0", "RSSI_DEVICE"), 59 | ) 60 | assert sensor2.usage == DataPointUsage.DATA_POINT 61 | assert sensor2.unit == "dBm" 62 | assert sensor2.values is None 63 | assert sensor2.value is None 64 | await central.data_point_event(const.INTERFACE_ID, "VCU3941846:0", "RSSI_DEVICE", 24) 65 | assert sensor2.value == -24 66 | await central.data_point_event(const.INTERFACE_ID, "VCU3941846:0", "RSSI_DEVICE", -40) 67 | assert sensor2.value == -40 68 | await central.data_point_event(const.INTERFACE_ID, "VCU3941846:0", "RSSI_DEVICE", -160) 69 | assert sensor2.value == -96 70 | await central.data_point_event(const.INTERFACE_ID, "VCU3941846:0", "RSSI_DEVICE", 160) 71 | assert sensor2.value == -96 72 | await central.data_point_event(const.INTERFACE_ID, "VCU3941846:0", "RSSI_DEVICE", 400) 73 | assert sensor2.value is None 74 | 75 | sensor3: DpSensor = cast( 76 | DpSensor, 77 | central.get_generic_data_point("VCU8205532:1", "CONCENTRATION"), 78 | ) 79 | assert sensor3.usage == DataPointUsage.DATA_POINT 80 | assert sensor3.unit == "ppm" 81 | assert sensor3.values is None 82 | assert sensor3.value is None 83 | 84 | 85 | @pytest.mark.asyncio 86 | @pytest.mark.parametrize( 87 | ( 88 | "address_device_translation", 89 | "do_mock_client", 90 | "add_sysvars", 91 | "add_programs", 92 | "ignore_devices_on_create", 93 | "un_ignore_list", 94 | ), 95 | [ 96 | (TEST_DEVICES, True, False, False, None, None), 97 | ], 98 | ) 99 | async def test_hmsensor_srh( 100 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 101 | ) -> None: 102 | """Test HmSensor.""" 103 | central, _, _ = central_client_factory 104 | sensor: DpSensor = cast(DpSensor, central.get_generic_data_point("VCU7981740:1", "STATE")) 105 | assert sensor.usage == DataPointUsage.DATA_POINT 106 | assert sensor.unit is None 107 | assert sensor.values == ("CLOSED", "TILTED", "OPEN") 108 | assert sensor.value is None 109 | await central.data_point_event(const.INTERFACE_ID, "VCU7981740:1", "STATE", 0) 110 | assert sensor.value == "CLOSED" 111 | await central.data_point_event(const.INTERFACE_ID, "VCU7981740:1", "STATE", 2) 112 | assert sensor.value == "OPEN" 113 | 114 | 115 | @pytest.mark.asyncio 116 | @pytest.mark.parametrize( 117 | ( 118 | "address_device_translation", 119 | "do_mock_client", 120 | "add_sysvars", 121 | "add_programs", 122 | "ignore_devices_on_create", 123 | "un_ignore_list", 124 | ), 125 | [ 126 | ({}, True, True, False, None, None), 127 | ], 128 | ) 129 | async def test_hmsysvarsensor( 130 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 131 | ) -> None: 132 | """Test HmSysvarSensor.""" 133 | central, _, _ = central_client_factory 134 | sensor: SysvarDpSensor = cast(SysvarDpSensor, central.get_sysvar_data_point(legacy_name="list")) 135 | assert sensor.usage == DataPointUsage.DATA_POINT 136 | assert sensor.available is True 137 | assert sensor.unit is None 138 | assert sensor.values == ("v1", "v2", "v3") 139 | assert sensor.value == "v1" 140 | 141 | sensor2: SysvarDpSensor = cast(SysvarDpSensor, central.get_sysvar_data_point(legacy_name="float")) 142 | assert sensor2.usage == DataPointUsage.DATA_POINT 143 | assert sensor2.unit is None 144 | assert sensor2.values is None 145 | assert sensor2.value == 23.2 146 | -------------------------------------------------------------------------------- /tests/test_siren.py: -------------------------------------------------------------------------------- 1 | """Tests for siren data points of hahomematic.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import cast 6 | from unittest.mock import Mock, call 7 | 8 | import pytest 9 | 10 | from hahomematic.central import CentralUnit 11 | from hahomematic.client import Client 12 | from hahomematic.const import WAIT_FOR_CALLBACK, DataPointUsage, ParamsetKey 13 | from hahomematic.model.custom import CustomDpIpSiren, CustomDpIpSirenSmoke 14 | 15 | from tests import const, helper 16 | 17 | TEST_DEVICES: dict[str, str] = { 18 | "VCU8249617": "HmIP-ASIR-2.json", 19 | "VCU2822385": "HmIP-SWSD.json", 20 | } 21 | 22 | # pylint: disable=protected-access 23 | 24 | 25 | @pytest.mark.asyncio 26 | @pytest.mark.parametrize( 27 | ( 28 | "address_device_translation", 29 | "do_mock_client", 30 | "add_sysvars", 31 | "add_programs", 32 | "ignore_devices_on_create", 33 | "un_ignore_list", 34 | ), 35 | [ 36 | (TEST_DEVICES, True, False, False, None, None), 37 | ], 38 | ) 39 | async def test_ceipsiren( 40 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 41 | ) -> None: 42 | """Test CustomDpIpSiren.""" 43 | central, mock_client, _ = central_client_factory 44 | siren: CustomDpIpSiren = cast(CustomDpIpSiren, helper.get_prepared_custom_data_point(central, "VCU8249617", 3)) 45 | assert siren.usage == DataPointUsage.CDP_PRIMARY 46 | assert siren.service_method_names == ("turn_off", "turn_on") 47 | 48 | assert siren.is_on is False 49 | await central.data_point_event(const.INTERFACE_ID, "VCU8249617:3", "ACOUSTIC_ALARM_ACTIVE", 1) 50 | assert siren.is_on is True 51 | await central.data_point_event(const.INTERFACE_ID, "VCU8249617:3", "ACOUSTIC_ALARM_ACTIVE", 0) 52 | assert siren.is_on is False 53 | await central.data_point_event(const.INTERFACE_ID, "VCU8249617:3", "OPTICAL_ALARM_ACTIVE", 1) 54 | assert siren.is_on is True 55 | await central.data_point_event(const.INTERFACE_ID, "VCU8249617:3", "OPTICAL_ALARM_ACTIVE", 0) 56 | assert siren.is_on is False 57 | 58 | await siren.turn_on( 59 | acoustic_alarm="FREQUENCY_RISING_AND_FALLING", 60 | optical_alarm="BLINKING_ALTERNATELY_REPEATING", 61 | duration=30, 62 | ) 63 | assert mock_client.method_calls[-1] == call.put_paramset( 64 | channel_address="VCU8249617:3", 65 | paramset_key_or_link_address=ParamsetKey.VALUES, 66 | values={ 67 | "ACOUSTIC_ALARM_SELECTION": 3, 68 | "OPTICAL_ALARM_SELECTION": 1, 69 | "DURATION_UNIT": 0, 70 | "DURATION_VALUE": 30, 71 | }, 72 | wait_for_callback=WAIT_FOR_CALLBACK, 73 | ) 74 | 75 | await siren.turn_on( 76 | acoustic_alarm="FREQUENCY_RISING_AND_FALLING", 77 | optical_alarm="BLINKING_ALTERNATELY_REPEATING", 78 | duration=30, 79 | ) 80 | assert mock_client.method_calls[-2] == call.put_paramset( 81 | channel_address="VCU8249617:3", 82 | paramset_key_or_link_address=ParamsetKey.VALUES, 83 | values={ 84 | "ACOUSTIC_ALARM_SELECTION": 3, 85 | "OPTICAL_ALARM_SELECTION": 1, 86 | "DURATION_UNIT": 0, 87 | "DURATION_VALUE": 30, 88 | }, 89 | wait_for_callback=WAIT_FOR_CALLBACK, 90 | ) 91 | 92 | with pytest.raises(ValueError): 93 | await siren.turn_on( 94 | acoustic_alarm="not_in_list", 95 | optical_alarm="BLINKING_ALTERNATELY_REPEATING", 96 | duration=30, 97 | ) 98 | 99 | with pytest.raises(ValueError): 100 | await siren.turn_on( 101 | acoustic_alarm="FREQUENCY_RISING_AND_FALLING", 102 | optical_alarm="not_in_list", 103 | duration=30, 104 | ) 105 | 106 | await siren.turn_off() 107 | assert mock_client.method_calls[-1] == call.put_paramset( 108 | channel_address="VCU8249617:3", 109 | paramset_key_or_link_address=ParamsetKey.VALUES, 110 | values={ 111 | "ACOUSTIC_ALARM_SELECTION": 0, 112 | "OPTICAL_ALARM_SELECTION": 0, 113 | "DURATION_UNIT": 0, 114 | "DURATION_VALUE": 0, 115 | }, 116 | wait_for_callback=WAIT_FOR_CALLBACK, 117 | ) 118 | 119 | await siren.turn_off() 120 | call_count = len(mock_client.method_calls) 121 | await siren.turn_off() 122 | assert (call_count + 1) == len(mock_client.method_calls) 123 | 124 | 125 | @pytest.mark.asyncio 126 | @pytest.mark.parametrize( 127 | ( 128 | "address_device_translation", 129 | "do_mock_client", 130 | "add_sysvars", 131 | "add_programs", 132 | "ignore_devices_on_create", 133 | "un_ignore_list", 134 | ), 135 | [ 136 | (TEST_DEVICES, True, False, False, None, None), 137 | ], 138 | ) 139 | async def test_ceipsirensmoke( 140 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 141 | ) -> None: 142 | """Test CustomDpIpSirenSmoke.""" 143 | central, mock_client, _ = central_client_factory 144 | siren: CustomDpIpSirenSmoke = cast( 145 | CustomDpIpSirenSmoke, helper.get_prepared_custom_data_point(central, "VCU2822385", 1) 146 | ) 147 | assert siren.usage == DataPointUsage.CDP_PRIMARY 148 | 149 | assert siren.is_on is False 150 | await central.data_point_event(const.INTERFACE_ID, "VCU2822385:1", "SMOKE_DETECTOR_ALARM_STATUS", 1) 151 | assert siren.is_on is True 152 | await central.data_point_event(const.INTERFACE_ID, "VCU2822385:1", "SMOKE_DETECTOR_ALARM_STATUS", 2) 153 | assert siren.is_on is True 154 | await central.data_point_event(const.INTERFACE_ID, "VCU2822385:1", "SMOKE_DETECTOR_ALARM_STATUS", 3) 155 | assert siren.is_on is True 156 | await central.data_point_event(const.INTERFACE_ID, "VCU2822385:1", "SMOKE_DETECTOR_ALARM_STATUS", 0) 157 | assert siren.is_on is False 158 | 159 | await siren.turn_on() 160 | assert mock_client.method_calls[-1] == call.set_value( 161 | channel_address="VCU2822385:1", 162 | paramset_key=ParamsetKey.VALUES, 163 | parameter="SMOKE_DETECTOR_COMMAND", 164 | value=2, 165 | wait_for_callback=WAIT_FOR_CALLBACK, 166 | ) 167 | 168 | await siren.turn_off() 169 | assert mock_client.method_calls[-1] == call.set_value( 170 | channel_address="VCU2822385:1", 171 | paramset_key=ParamsetKey.VALUES, 172 | parameter="SMOKE_DETECTOR_COMMAND", 173 | value=1, 174 | wait_for_callback=WAIT_FOR_CALLBACK, 175 | ) 176 | 177 | call_count = len(mock_client.method_calls) 178 | await siren.turn_off() 179 | assert (call_count + 1) == len(mock_client.method_calls) 180 | -------------------------------------------------------------------------------- /tests/test_text.py: -------------------------------------------------------------------------------- 1 | """Tests for text data points of hahomematic.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import cast 6 | from unittest.mock import Mock, call 7 | 8 | import pytest 9 | 10 | from hahomematic.central import CentralUnit 11 | from hahomematic.client import Client 12 | from hahomematic.const import DataPointUsage 13 | from hahomematic.model.generic import DpText 14 | from hahomematic.model.hub import SysvarDpText 15 | 16 | from tests import helper 17 | 18 | TEST_DEVICES: dict[str, str] = {} 19 | 20 | # pylint: disable=protected-access 21 | 22 | 23 | @pytest.mark.asyncio 24 | @pytest.mark.parametrize( 25 | ( 26 | "address_device_translation", 27 | "do_mock_client", 28 | "add_sysvars", 29 | "add_programs", 30 | "ignore_devices_on_create", 31 | "un_ignore_list", 32 | ), 33 | [ 34 | (TEST_DEVICES, True, False, False, None, None), 35 | ], 36 | ) 37 | async def no_test_hmtext(central_client: tuple[CentralUnit, Client | Mock]) -> None: 38 | """Test DpText. There are currently no text data points.""" 39 | central, _ = central_client 40 | text: DpText = cast(DpText, central.get_generic_data_point("VCU7981740:1", "STATE")) 41 | assert text.usage == DataPointUsage.DATA_POINT 42 | 43 | 44 | @pytest.mark.asyncio 45 | @pytest.mark.parametrize( 46 | ( 47 | "address_device_translation", 48 | "do_mock_client", 49 | "add_sysvars", 50 | "add_programs", 51 | "ignore_devices_on_create", 52 | "un_ignore_list", 53 | ), 54 | [ 55 | ({}, True, True, False, None, None), 56 | ], 57 | ) 58 | async def test_sysvardptext( 59 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 60 | ) -> None: 61 | """Test SysvarDpText. There are currently no text data points.""" 62 | central, mock_client, _ = central_client_factory 63 | text: SysvarDpText = cast(SysvarDpText, central.get_sysvar_data_point(legacy_name="string_ext")) 64 | assert text.usage == DataPointUsage.DATA_POINT 65 | 66 | assert text.unit is None 67 | assert text.values is None 68 | assert text.value == "test1" 69 | await text.send_variable("test23") 70 | assert mock_client.method_calls[-1] == call.set_system_variable(legacy_name="string_ext", value="test23") 71 | assert text.value == "test23" 72 | -------------------------------------------------------------------------------- /tests/test_valve.py: -------------------------------------------------------------------------------- 1 | """Tests for valve data points of hahomematic.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import cast 6 | from unittest.mock import Mock, call 7 | 8 | import pytest 9 | 10 | from hahomematic.central import CentralUnit 11 | from hahomematic.client import Client 12 | from hahomematic.const import WAIT_FOR_CALLBACK, DataPointUsage, ParamsetKey 13 | from hahomematic.model.custom import CustomDpIpIrrigationValve 14 | 15 | from tests import helper 16 | 17 | TEST_DEVICES: dict[str, str] = { 18 | "VCU8976407": "ELV-SH-WSM.json", 19 | } 20 | 21 | # pylint: disable=protected-access 22 | 23 | 24 | @pytest.mark.asyncio 25 | @pytest.mark.parametrize( 26 | ( 27 | "address_device_translation", 28 | "do_mock_client", 29 | "add_sysvars", 30 | "add_programs", 31 | "ignore_devices_on_create", 32 | "un_ignore_list", 33 | ), 34 | [ 35 | (TEST_DEVICES, True, False, False, None, None), 36 | ], 37 | ) 38 | async def test_ceipirrigationvalve( 39 | central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], 40 | ) -> None: 41 | """Test CustomDpValve.""" 42 | central, mock_client, _ = central_client_factory 43 | valve: CustomDpIpIrrigationValve = cast( 44 | CustomDpIpIrrigationValve, helper.get_prepared_custom_data_point(central, "VCU8976407", 4) 45 | ) 46 | assert valve.usage == DataPointUsage.CDP_PRIMARY 47 | assert valve.service_method_names == ("close", "open") 48 | 49 | await valve.close() 50 | assert valve.value is False 51 | assert valve.channel_value is False 52 | await valve.open() 53 | assert mock_client.method_calls[-1] == call.set_value( 54 | channel_address="VCU8976407:4", 55 | paramset_key=ParamsetKey.VALUES, 56 | parameter="STATE", 57 | value=True, 58 | wait_for_callback=None, 59 | ) 60 | assert valve.value is True 61 | await valve.close() 62 | assert mock_client.method_calls[-1] == call.set_value( 63 | channel_address="VCU8976407:4", 64 | paramset_key=ParamsetKey.VALUES, 65 | parameter="STATE", 66 | value=False, 67 | wait_for_callback=WAIT_FOR_CALLBACK, 68 | ) 69 | assert valve.value is False 70 | await valve.open(on_time=60) 71 | assert mock_client.method_calls[-1] == call.put_paramset( 72 | channel_address="VCU8976407:4", 73 | paramset_key_or_link_address=ParamsetKey.VALUES, 74 | values={"ON_TIME": 60.0, "STATE": True}, 75 | wait_for_callback=WAIT_FOR_CALLBACK, 76 | ) 77 | assert valve.value is True 78 | 79 | await valve.close() 80 | valve.set_timer_on_time(35.4) 81 | await valve.open() 82 | assert mock_client.method_calls[-1] == call.put_paramset( 83 | channel_address="VCU8976407:4", 84 | paramset_key_or_link_address=ParamsetKey.VALUES, 85 | values={"ON_TIME": 35.4, "STATE": True}, 86 | wait_for_callback=WAIT_FOR_CALLBACK, 87 | ) 88 | 89 | await valve.open() 90 | call_count = len(mock_client.method_calls) 91 | await valve.open() 92 | assert call_count == len(mock_client.method_calls) 93 | 94 | await valve.close() 95 | call_count = len(mock_client.method_calls) 96 | await valve.close() 97 | assert call_count == len(mock_client.method_calls) 98 | --------------------------------------------------------------------------------