├── .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 | [](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 | [](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 |
--------------------------------------------------------------------------------