majordom-homekit 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- majordom_homekit-0.1.2/LICENSE +177 -0
- majordom_homekit-0.1.2/PKG-INFO +117 -0
- majordom_homekit-0.1.2/README.md +101 -0
- majordom_homekit-0.1.2/majordom_homekit/__init__.py +9 -0
- majordom_homekit-0.1.2/majordom_homekit/characteristics_storage.py +89 -0
- majordom_homekit-0.1.2/majordom_homekit/controller.py +325 -0
- majordom_homekit-0.1.2/majordom_homekit/mapper.py +186 -0
- majordom_homekit-0.1.2/majordom_homekit/models.py +82 -0
- majordom_homekit-0.1.2/majordom_homekit/pairings_storage.py +69 -0
- majordom_homekit-0.1.2/majordom_homekit/parameters_map.py +7 -0
- majordom_homekit-0.1.2/majordom_homekit/py.typed +0 -0
- majordom_homekit-0.1.2/majordom_homekit/readme.md +48 -0
- majordom_homekit-0.1.2/pyproject.toml +106 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: majordom-homekit
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: HomeKit integration for MajorDom — bridges HomeKit accessories into the MajorDom language.
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Author: Mark Parker
|
|
7
|
+
Author-email: mark@parker-programs.com
|
|
8
|
+
Requires-Python: >=3.12,<3.14
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Requires-Dist: majordom-integration-sdk (>=0.1,<1.0)
|
|
13
|
+
Requires-Dist: parker-aiohomekit (>=4.0.1,<5.0.0)
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# integration-homekit
|
|
17
|
+
|
|
18
|
+
A [MajorDom](https://majordom.io) integration — bridges **Apple HomeKit** accessories into the
|
|
19
|
+
MajorDom language.
|
|
20
|
+
|
|
21
|
+
Built for the **MajorDom Hub**, but it doesn't need it: this is a standalone, standardized
|
|
22
|
+
library for HomeKit that you can use on its own (see **Run it standalone** below). Built on the
|
|
23
|
+
[MajorDom Integration SDK](https://github.com/MajorDom-Systems/integration-sdk). The entry point
|
|
24
|
+
is `HomeKitController` (`majordom_homekit/controller.py`), which the Hub — or the SDK's dev runner —
|
|
25
|
+
instantiates and drives through its lifecycle: discovery → pairing → commands → teardown.
|
|
26
|
+
|
|
27
|
+
- **Other protocols:** browse the [MajorDom integrations](https://github.com/orgs/MajorDom-Systems/repositories?q=integration-).
|
|
28
|
+
- **Create your own:** start from the [integration template](https://github.com/MajorDom-Systems/integration-template).
|
|
29
|
+
|
|
30
|
+
## Documentation
|
|
31
|
+
|
|
32
|
+
Full integration-author docs — the controller lifecycle, data models, storing data, discovery,
|
|
33
|
+
and a worked example — live at **[docs.majordom.io](https://docs.majordom.io/device-integration)**.
|
|
34
|
+
|
|
35
|
+
## Development
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
poetry install && poetry run poe install
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| Task | Description |
|
|
42
|
+
|------|-------------|
|
|
43
|
+
| `poe check` | Full quality pipeline (ruff, ty, pytest, poetry build/check) |
|
|
44
|
+
| `poe check --ci` | Same, plus `git diff --exit-code` |
|
|
45
|
+
|
|
46
|
+
Work lands on `develop`; `master` is protected and released via **Actions → Release**. Tests drive
|
|
47
|
+
the controller with the SDK's test doubles against an in-process fake HAP accessory server — no real
|
|
48
|
+
accessory required (see `tests/`).
|
|
49
|
+
|
|
50
|
+
## Run it standalone (without the Hub)
|
|
51
|
+
|
|
52
|
+
HomeKit discovers IP accessories over mDNS on the local network (BLE accessories need a BLE
|
|
53
|
+
adapter). No external service is required. Then:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
# run.py
|
|
57
|
+
import asyncio
|
|
58
|
+
|
|
59
|
+
from majordom_integration_sdk.dev import run_controller
|
|
60
|
+
from majordom_homekit import HomeKitController
|
|
61
|
+
|
|
62
|
+
# Watches for HomeKit accessories, logs discoveries/events. Ctrl-C to stop.
|
|
63
|
+
asyncio.run(run_controller(HomeKitController, db_path="devices.db"))
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
poetry run python run.py
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## About this integration
|
|
71
|
+
|
|
72
|
+
- **Protocol / platform:** Apple HomeKit Accessory Protocol (HAP) via the
|
|
73
|
+
[`parker-aiohomekit`](https://pypi.org/project/parker-aiohomekit/) fork of `aiohomekit`.
|
|
74
|
+
- **Transport(s):** IP (Wi-Fi / Ethernet, discovered via mDNS); BLE for BLE accessories.
|
|
75
|
+
- **Supported devices:** HomeKit-compatible accessories — lights, plugs, switches, sensors, locks.
|
|
76
|
+
- **Credentials needed to pair:** `code` (the accessory's 8-digit HomeKit setup code).
|
|
77
|
+
|
|
78
|
+
### Required harness
|
|
79
|
+
|
|
80
|
+
- **Hardware adapters:** a BLE adapter only if pairing BLE accessories; IP accessories need none.
|
|
81
|
+
- **Third-party software services:** none — `aiohomekit` speaks HAP directly.
|
|
82
|
+
- **OS / permissions:** mDNS on the LAN for discovery; BLE access for BLE accessories.
|
|
83
|
+
|
|
84
|
+
### Protocol stack (OSI)
|
|
85
|
+
|
|
86
|
+
| OSI layer | Protocol | Implemented by |
|
|
87
|
+
|-----------|----------|----------------|
|
|
88
|
+
| Application (7) | HAP characteristics / services | **this integration** (via `aiohomekit`) |
|
|
89
|
+
| Session (5) | Pair-Setup / Pair-Verify (SRP, Ed25519) | library (`aiohomekit`) |
|
|
90
|
+
| Transport (4) | TCP (IP) / GATT (BLE) | OS |
|
|
91
|
+
| Network + below (1–3) | IP over Wi-Fi / Ethernet (or BLE) | OS |
|
|
92
|
+
|
|
93
|
+
### Progress
|
|
94
|
+
|
|
95
|
+
- [x] Discovery services registered (mDNS via `zeroconf_discovery_service`; BLE where applicable); cancel closures called in `stop`
|
|
96
|
+
- [x] Discovery listeners fire and call `controller_did_receive_discovery`
|
|
97
|
+
- [x] Re-discovery of already-paired accessories on reconnect (`controller_did_connect_device`)
|
|
98
|
+
- [x] Device pairing (HAP pair-setup with the setup code)
|
|
99
|
+
- [x] Device schema mapped: services/characteristics → parameter list with per-parameter metadata
|
|
100
|
+
- [x] Hub → Device control (`send_command`)
|
|
101
|
+
- [x] Device → Hub event subscription (`controller_did_receive_events`)
|
|
102
|
+
- [x] `identify`
|
|
103
|
+
- [x] `unpair`
|
|
104
|
+
- [x] `fetch`
|
|
105
|
+
- [x] Availability tracking while running (`controller_did_lose_device` / `last_error`)
|
|
106
|
+
- [x] Graceful shutdown in `stop`
|
|
107
|
+
- [x] Tests pass against a fake HAP accessory server (`tests/`)
|
|
108
|
+
|
|
109
|
+
### Notes
|
|
110
|
+
|
|
111
|
+
Uses the `parker-aiohomekit` fork of `aiohomekit`; note its Python upper bound (`<3.14`).
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
See [LICENSE](LICENSE). For commercial licensing or partnership inquiries regarding MajorDom,
|
|
116
|
+
contact us via [parker-industries.org/partnership](https://parker-industries.org/partnership).
|
|
117
|
+
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# integration-homekit
|
|
2
|
+
|
|
3
|
+
A [MajorDom](https://majordom.io) integration — bridges **Apple HomeKit** accessories into the
|
|
4
|
+
MajorDom language.
|
|
5
|
+
|
|
6
|
+
Built for the **MajorDom Hub**, but it doesn't need it: this is a standalone, standardized
|
|
7
|
+
library for HomeKit that you can use on its own (see **Run it standalone** below). Built on the
|
|
8
|
+
[MajorDom Integration SDK](https://github.com/MajorDom-Systems/integration-sdk). The entry point
|
|
9
|
+
is `HomeKitController` (`majordom_homekit/controller.py`), which the Hub — or the SDK's dev runner —
|
|
10
|
+
instantiates and drives through its lifecycle: discovery → pairing → commands → teardown.
|
|
11
|
+
|
|
12
|
+
- **Other protocols:** browse the [MajorDom integrations](https://github.com/orgs/MajorDom-Systems/repositories?q=integration-).
|
|
13
|
+
- **Create your own:** start from the [integration template](https://github.com/MajorDom-Systems/integration-template).
|
|
14
|
+
|
|
15
|
+
## Documentation
|
|
16
|
+
|
|
17
|
+
Full integration-author docs — the controller lifecycle, data models, storing data, discovery,
|
|
18
|
+
and a worked example — live at **[docs.majordom.io](https://docs.majordom.io/device-integration)**.
|
|
19
|
+
|
|
20
|
+
## Development
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
poetry install && poetry run poe install
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
| Task | Description |
|
|
27
|
+
|------|-------------|
|
|
28
|
+
| `poe check` | Full quality pipeline (ruff, ty, pytest, poetry build/check) |
|
|
29
|
+
| `poe check --ci` | Same, plus `git diff --exit-code` |
|
|
30
|
+
|
|
31
|
+
Work lands on `develop`; `master` is protected and released via **Actions → Release**. Tests drive
|
|
32
|
+
the controller with the SDK's test doubles against an in-process fake HAP accessory server — no real
|
|
33
|
+
accessory required (see `tests/`).
|
|
34
|
+
|
|
35
|
+
## Run it standalone (without the Hub)
|
|
36
|
+
|
|
37
|
+
HomeKit discovers IP accessories over mDNS on the local network (BLE accessories need a BLE
|
|
38
|
+
adapter). No external service is required. Then:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
# run.py
|
|
42
|
+
import asyncio
|
|
43
|
+
|
|
44
|
+
from majordom_integration_sdk.dev import run_controller
|
|
45
|
+
from majordom_homekit import HomeKitController
|
|
46
|
+
|
|
47
|
+
# Watches for HomeKit accessories, logs discoveries/events. Ctrl-C to stop.
|
|
48
|
+
asyncio.run(run_controller(HomeKitController, db_path="devices.db"))
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
poetry run python run.py
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## About this integration
|
|
56
|
+
|
|
57
|
+
- **Protocol / platform:** Apple HomeKit Accessory Protocol (HAP) via the
|
|
58
|
+
[`parker-aiohomekit`](https://pypi.org/project/parker-aiohomekit/) fork of `aiohomekit`.
|
|
59
|
+
- **Transport(s):** IP (Wi-Fi / Ethernet, discovered via mDNS); BLE for BLE accessories.
|
|
60
|
+
- **Supported devices:** HomeKit-compatible accessories — lights, plugs, switches, sensors, locks.
|
|
61
|
+
- **Credentials needed to pair:** `code` (the accessory's 8-digit HomeKit setup code).
|
|
62
|
+
|
|
63
|
+
### Required harness
|
|
64
|
+
|
|
65
|
+
- **Hardware adapters:** a BLE adapter only if pairing BLE accessories; IP accessories need none.
|
|
66
|
+
- **Third-party software services:** none — `aiohomekit` speaks HAP directly.
|
|
67
|
+
- **OS / permissions:** mDNS on the LAN for discovery; BLE access for BLE accessories.
|
|
68
|
+
|
|
69
|
+
### Protocol stack (OSI)
|
|
70
|
+
|
|
71
|
+
| OSI layer | Protocol | Implemented by |
|
|
72
|
+
|-----------|----------|----------------|
|
|
73
|
+
| Application (7) | HAP characteristics / services | **this integration** (via `aiohomekit`) |
|
|
74
|
+
| Session (5) | Pair-Setup / Pair-Verify (SRP, Ed25519) | library (`aiohomekit`) |
|
|
75
|
+
| Transport (4) | TCP (IP) / GATT (BLE) | OS |
|
|
76
|
+
| Network + below (1–3) | IP over Wi-Fi / Ethernet (or BLE) | OS |
|
|
77
|
+
|
|
78
|
+
### Progress
|
|
79
|
+
|
|
80
|
+
- [x] Discovery services registered (mDNS via `zeroconf_discovery_service`; BLE where applicable); cancel closures called in `stop`
|
|
81
|
+
- [x] Discovery listeners fire and call `controller_did_receive_discovery`
|
|
82
|
+
- [x] Re-discovery of already-paired accessories on reconnect (`controller_did_connect_device`)
|
|
83
|
+
- [x] Device pairing (HAP pair-setup with the setup code)
|
|
84
|
+
- [x] Device schema mapped: services/characteristics → parameter list with per-parameter metadata
|
|
85
|
+
- [x] Hub → Device control (`send_command`)
|
|
86
|
+
- [x] Device → Hub event subscription (`controller_did_receive_events`)
|
|
87
|
+
- [x] `identify`
|
|
88
|
+
- [x] `unpair`
|
|
89
|
+
- [x] `fetch`
|
|
90
|
+
- [x] Availability tracking while running (`controller_did_lose_device` / `last_error`)
|
|
91
|
+
- [x] Graceful shutdown in `stop`
|
|
92
|
+
- [x] Tests pass against a fake HAP accessory server (`tests/`)
|
|
93
|
+
|
|
94
|
+
### Notes
|
|
95
|
+
|
|
96
|
+
Uses the `parker-aiohomekit` fork of `aiohomekit`; note its Python upper bound (`<3.14`).
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
See [LICENSE](LICENSE). For commercial licensing or partnership inquiries regarding MajorDom,
|
|
101
|
+
contact us via [parker-industries.org/partnership](https://parker-industries.org/partnership).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""HomeKit integration for MajorDom.
|
|
2
|
+
|
|
3
|
+
Bridges HomeKit accessories into the MajorDom language. `HomeKitController` is the entry
|
|
4
|
+
point the Hub (or the SDK's standalone dev runner) instantiates and drives.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from majordom_homekit.controller import HomeKitController
|
|
8
|
+
|
|
9
|
+
__all__ = ["HomeKitController"]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HomeKit-specific storage adapter.
|
|
3
|
+
TLDR;
|
|
4
|
+
aiohomekit manages pairing and characteristics data internally, but delegates persistence to a
|
|
5
|
+
pluggable storage interface.
|
|
6
|
+
This class implements that interface on top of MajorDom's DeviceRepository, so HomeKit state is
|
|
7
|
+
stored in the Hub's database.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from contextlib import AbstractAsyncContextManager
|
|
12
|
+
|
|
13
|
+
from aiohomekit.model.accessories import AccessoriesState
|
|
14
|
+
from aiohomekit.model.typed_dicts import HKDeviceID
|
|
15
|
+
from aiohomekit.storage.characteristics_storage import CharacteristicsStorageProtocol
|
|
16
|
+
from majordom_integration_sdk.repository import DeviceRepositoryProtocol
|
|
17
|
+
|
|
18
|
+
from .mapper import HKMajorDomMapper
|
|
19
|
+
from .models import HKDevice, HKDeviceIntegrationData, HKDeviceState
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HKCharacteristicsStorageMajorDom(CharacteristicsStorageProtocol):
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
make_device_repository: Callable[[], AbstractAsyncContextManager[DeviceRepositoryProtocol]],
|
|
26
|
+
mapper: HKMajorDomMapper,
|
|
27
|
+
):
|
|
28
|
+
self.make_device_repository = make_device_repository
|
|
29
|
+
self.mapper = mapper
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def _integration_name(self):
|
|
33
|
+
return "HomeKit"
|
|
34
|
+
|
|
35
|
+
# CharacteristicsStorageProtocol Implementation
|
|
36
|
+
|
|
37
|
+
async def get_all(self) -> dict[HKDeviceID, AccessoriesState]:
|
|
38
|
+
all = {}
|
|
39
|
+
async with self.make_device_repository() as device_repository:
|
|
40
|
+
for device in await device_repository.get_all(as_=HKDevice):
|
|
41
|
+
if accessory := device.integration_data.characteristics_cache:
|
|
42
|
+
all[device.hk_id] = accessory
|
|
43
|
+
return all
|
|
44
|
+
|
|
45
|
+
async def get(self, id: HKDeviceID) -> AccessoriesState | None:
|
|
46
|
+
uuid = self.mapper.hap_id_to_uuid(id)
|
|
47
|
+
async with self.make_device_repository() as device_repository:
|
|
48
|
+
if (device := await device_repository.get(uuid, as_=HKDevice)) and (
|
|
49
|
+
accessory := device.integration_data.characteristics_cache
|
|
50
|
+
):
|
|
51
|
+
return accessory
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
async def save(self, id: HKDeviceID, item: AccessoriesState):
|
|
55
|
+
# NOTE: this method is called only when data model (characteristics) is updated
|
|
56
|
+
# which is detected by a change of config_num
|
|
57
|
+
# usually after the accessory's software update
|
|
58
|
+
async with self.make_device_repository() as device_repository:
|
|
59
|
+
device_id = self.mapper.hap_id_to_uuid(id)
|
|
60
|
+
device = await device_repository.state(device_id, as_=HKDeviceState)
|
|
61
|
+
assert device
|
|
62
|
+
if not device.integration_data:
|
|
63
|
+
device.integration_data = HKDeviceIntegrationData()
|
|
64
|
+
|
|
65
|
+
# fill only the unique data from AccessoryState that isn't already passed from
|
|
66
|
+
# Discovery or DeviceCreate by the core
|
|
67
|
+
# TODO: check these values with real devices
|
|
68
|
+
|
|
69
|
+
accessory = item.accessories[0] # TODO: add later support for multiple accessories
|
|
70
|
+
device.manufacturer = accessory.manufacturer
|
|
71
|
+
device.integration_data.characteristics_cache = item
|
|
72
|
+
|
|
73
|
+
# map all homekit characteristics to majordom parameters
|
|
74
|
+
|
|
75
|
+
for accessory in item.accessories:
|
|
76
|
+
for service in accessory.services:
|
|
77
|
+
for characteristic in service.characteristics:
|
|
78
|
+
parameter = self.mapper.hap_char_to_majordom_parameter(device.id, accessory.aid, characteristic)
|
|
79
|
+
device.parameters.append(parameter)
|
|
80
|
+
|
|
81
|
+
await device_repository.save(device)
|
|
82
|
+
|
|
83
|
+
async def delete(self, id: HKDeviceID):
|
|
84
|
+
# TODO: check usage, remove vs unpair, allow fast re-pairing
|
|
85
|
+
async with self.make_device_repository() as device_repository:
|
|
86
|
+
uuid = self.mapper.hap_id_to_uuid(id)
|
|
87
|
+
if device := await device_repository.get(uuid, as_=HKDevice):
|
|
88
|
+
device.integration_data.characteristics_cache = None # TODO: empty collection?
|
|
89
|
+
await device_repository.save(device)
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import override
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from aiohomekit.controller.abstract import (
|
|
8
|
+
AbstractController,
|
|
9
|
+
AbstractDiscovery,
|
|
10
|
+
AbstractPairing,
|
|
11
|
+
)
|
|
12
|
+
from aiohomekit.controller.relay import Controller as AioHomeKitController
|
|
13
|
+
from aiohomekit.controller.relay import controller as controller_module
|
|
14
|
+
from aiohomekit.model.characteristics import CharacteristicKey
|
|
15
|
+
from aiohomekit.model.characteristics.characteristic_key import CharacteristicKeyValue
|
|
16
|
+
from aiohomekit.model.characteristics.permissions import CharacteristicPermissions
|
|
17
|
+
from aiohomekit.model.typed_dicts import HKDeviceID, Response
|
|
18
|
+
from majordom_integration_sdk.controller import (
|
|
19
|
+
AbstractController as MajorDomController, # avoid collision with aiohomekit
|
|
20
|
+
)
|
|
21
|
+
from majordom_integration_sdk.schemas.base import NonEmptyStr
|
|
22
|
+
from majordom_integration_sdk.schemas.command import DeviceCommand
|
|
23
|
+
from majordom_integration_sdk.schemas.device import (
|
|
24
|
+
CredentialsType,
|
|
25
|
+
Discovery,
|
|
26
|
+
ProvidedCredentials,
|
|
27
|
+
)
|
|
28
|
+
from majordom_integration_sdk.schemas.event import DeviceParameterChange
|
|
29
|
+
|
|
30
|
+
from .characteristics_storage import HKCharacteristicsStorageMajorDom
|
|
31
|
+
from .mapper import HKMajorDomMapper
|
|
32
|
+
from .models import HKDevice, HKParameter
|
|
33
|
+
from .pairings_storage import HKPairingsStorageMajorDom
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# TODO: make a better way to pass this
|
|
38
|
+
controller_module.BLE_TRANSPORT_SUPPORTED = False
|
|
39
|
+
controller_module.COAP_TRANSPORT_SUPPORTED = False
|
|
40
|
+
controller_module.IP_TRANSPORT_SUPPORTED = True
|
|
41
|
+
|
|
42
|
+
# TODO: what are Accessory.needs_polling chars? handle them
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class HomeKitController(MajorDomController):
|
|
46
|
+
"""Bridges the Hub to HomeKit (HAP) accessories over IP via aiohomekit.
|
|
47
|
+
|
|
48
|
+
aiohomekit owns the HAP protocol, discovery, pairing crypto, and the live connection;
|
|
49
|
+
this controller adapts it to the Hub's AbstractController contract and persists HomeKit
|
|
50
|
+
state in the Hub DB through the two storage adapters. See readme.md.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
mapper: HKMajorDomMapper
|
|
54
|
+
|
|
55
|
+
# -------------------------------------------------------------------------
|
|
56
|
+
# AbstractController interface
|
|
57
|
+
# -------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
name = "HomeKit"
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def discoveries(self) -> dict[UUID, Discovery]:
|
|
63
|
+
return self._majordom_discoveries
|
|
64
|
+
|
|
65
|
+
# device_type / parameter_type let the Hub deserialize into our typed subclasses.
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
@override
|
|
69
|
+
def device_type(self) -> type[HKDevice]:
|
|
70
|
+
return HKDevice
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
@override
|
|
74
|
+
def parameter_type(self) -> type[HKParameter]:
|
|
75
|
+
return HKParameter
|
|
76
|
+
|
|
77
|
+
# -------------------------------------------------------------------------
|
|
78
|
+
# Lifecycle
|
|
79
|
+
# -------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
async def start(self):
|
|
82
|
+
# A single mapper, wired with the framework's UUID generators, is shared with both
|
|
83
|
+
# storage adapters so every HAP id maps to the same MajorDom UUID everywhere.
|
|
84
|
+
self.mapper = HKMajorDomMapper(device_uuid=self.device_uuid, parameter_uuid=self.parameter_uuid)
|
|
85
|
+
self._majordom_discoveries: dict[UUID, Discovery] = dict()
|
|
86
|
+
self._hap_discoveries: dict[UUID, AbstractDiscovery] = dict()
|
|
87
|
+
self._availability: dict[UUID, bool] = dict() # device_id -> last signalled availability
|
|
88
|
+
|
|
89
|
+
# aiohomekit already registers the `_hap._tcp/_udp` mDNS browsers itself (given our
|
|
90
|
+
# shared AsyncZeroconf below), so we don't register them on the discovery service here.
|
|
91
|
+
|
|
92
|
+
self.hk_char_storage = HKCharacteristicsStorageMajorDom(self.dependencies.make_device_repository, self.mapper)
|
|
93
|
+
self.hk_pairing_data_storage = HKPairingsStorageMajorDom(self.dependencies.make_device_repository, self.mapper)
|
|
94
|
+
|
|
95
|
+
# TODO: BLE discovery and pairing (aiohomekit supports it; the hub only wires IP for now)
|
|
96
|
+
|
|
97
|
+
self._aiohomekit_controller = AioHomeKitController(
|
|
98
|
+
zeroconf_instance=self.dependencies.zeroconf_discovery_service.async_zeroconf,
|
|
99
|
+
char_cache=self.hk_char_storage,
|
|
100
|
+
pairing_data_storage=self.hk_pairing_data_storage,
|
|
101
|
+
)
|
|
102
|
+
self._aiohomekit_controller.on_discovery(self._aiohomekit_did_discover)
|
|
103
|
+
await self._aiohomekit_controller.start()
|
|
104
|
+
|
|
105
|
+
# aiohomekit only pushes "became available"; it never pushes "became unavailable"
|
|
106
|
+
# (see AbstractPairing.add_observer_for_availability). So we poll each paired
|
|
107
|
+
# accessory's connection state to catch both directions — mid-session drops and
|
|
108
|
+
# reconnects — and reconcile it through _set_availability.
|
|
109
|
+
self._availability_task = asyncio.create_task(self._availability_loop())
|
|
110
|
+
|
|
111
|
+
async def stop(self):
|
|
112
|
+
if task := getattr(self, "_availability_task", None):
|
|
113
|
+
task.cancel()
|
|
114
|
+
await self._aiohomekit_controller.stop()
|
|
115
|
+
|
|
116
|
+
# -------------------------------------------------------------------------
|
|
117
|
+
# Public device operations (Hub -> device)
|
|
118
|
+
# -------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
async def pair_device(self, discovery: Discovery, credentials: ProvidedCredentials | None):
|
|
121
|
+
if not credentials or credentials.type not in discovery.expected_credentials_options:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"Credentials type {credentials.type if credentials else None!r} is not one of the "
|
|
124
|
+
f"types this discovery advertised: {discovery.expected_credentials_options}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
hap_discovery = self._hap_discoveries[discovery.id]
|
|
128
|
+
|
|
129
|
+
async with asyncio.timeout(1): # TODO: timeout to settings
|
|
130
|
+
finish_pairing = await hap_discovery.start_pairing()
|
|
131
|
+
|
|
132
|
+
# TODO: check if pairing steps need to be split
|
|
133
|
+
async with asyncio.timeout(1):
|
|
134
|
+
pairing_data = await finish_pairing(str(credentials.value or ""))
|
|
135
|
+
|
|
136
|
+
pairing_id = pairing_data["AccessoryPairingID"].lower()
|
|
137
|
+
# main "patch"/"create" data is saved in majordom's core
|
|
138
|
+
# aiohomekit will save pairing data and characteristics (data model) automatically using
|
|
139
|
+
# the provided storage during finish_pairing
|
|
140
|
+
# so no need to fetch or save anything manually here
|
|
141
|
+
|
|
142
|
+
# upd: looks like it isn't implemented in aiohomekit yet, so doing it manually
|
|
143
|
+
pairing = self._aiohomekit_controller.pairings[pairing_id]
|
|
144
|
+
await pairing.fetch_accessories_and_characteristics()
|
|
145
|
+
await self.hk_char_storage.save(
|
|
146
|
+
pairing_id, pairing.accessories_state
|
|
147
|
+
) # converts and saves parameters (aka characteristics)
|
|
148
|
+
await self.hk_pairing_data_storage.save(pairing_id, pairing_data) # converts and saves data for connection
|
|
149
|
+
|
|
150
|
+
await self._handle_connected_pairing(pairing_id)
|
|
151
|
+
self._hap_discoveries.pop(discovery.id)
|
|
152
|
+
self._majordom_discoveries.pop(discovery.id)
|
|
153
|
+
|
|
154
|
+
async def unpair(self, device: HKDevice):
|
|
155
|
+
await self._aiohomekit_controller.remove_pairing(device.hk_id)
|
|
156
|
+
# aiohomekit will cleanup the storage, so no need to do anything here
|
|
157
|
+
|
|
158
|
+
async def identify(self, device: HKDevice):
|
|
159
|
+
await self._aiohomekit_controller.identify(device.hk_id)
|
|
160
|
+
|
|
161
|
+
async def fetch(self, device: HKDevice):
|
|
162
|
+
pairing = self._aiohomekit_controller.pairings[device.hk_id]
|
|
163
|
+
if not pairing:
|
|
164
|
+
raise RuntimeError(
|
|
165
|
+
f"Unexpected Error: Pairing {device.hk_id} for '{device.id}' aka '{device.name}' not found"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# fetching the values only; aiohomekit will save the data model on change automatically
|
|
169
|
+
response = await pairing.get_characteristics(
|
|
170
|
+
self._get_all_characteristics_keys(pairing, {CharacteristicPermissions.paired_read})
|
|
171
|
+
)
|
|
172
|
+
await self._handle_accessory_response(device.hk_id, response)
|
|
173
|
+
|
|
174
|
+
async def send_command(self, command: DeviceCommand, device: HKDevice, parameter: HKParameter):
|
|
175
|
+
hk_value = self.mapper.mj_value_to_hap(command.value)
|
|
176
|
+
|
|
177
|
+
pairing = self._aiohomekit_controller.pairings[device.hk_id]
|
|
178
|
+
|
|
179
|
+
if not pairing:
|
|
180
|
+
raise RuntimeError(
|
|
181
|
+
f"Unexpected Error: Pairing {device.hk_id} for '{device.id}' aka '{device.name}' not found"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
response = await pairing.put_characteristics(
|
|
185
|
+
[CharacteristicKeyValue(parameter.integration_data.aid, parameter.integration_data.iid, hk_value)]
|
|
186
|
+
)
|
|
187
|
+
await self._handle_accessory_response(device.hk_id, response)
|
|
188
|
+
|
|
189
|
+
# -------------------------------------------------------------------------
|
|
190
|
+
# Device -> Hub: discovery (aiohomekit delegate)
|
|
191
|
+
# -------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def _aiohomekit_did_discover(self, controller: AbstractController, hk_discovery: AbstractDiscovery):
|
|
194
|
+
asyncio.create_task(self._async_aiohomekit_did_discover(controller, hk_discovery))
|
|
195
|
+
|
|
196
|
+
async def _async_aiohomekit_did_discover(self, controller: AbstractController, hk_discovery: AbstractDiscovery):
|
|
197
|
+
# Already paired to us -> a reconnect, not a new discovery.
|
|
198
|
+
if hk_discovery.description.id in controller.pairings:
|
|
199
|
+
logger.debug(f"{self.name} Discovered paired device...")
|
|
200
|
+
await self._handle_connected_pairing(hk_discovery.description.id)
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
# Paired to some other controller -> can't offer for pairing here.
|
|
204
|
+
if hk_discovery.paired:
|
|
205
|
+
# TODO: handle this case
|
|
206
|
+
# If device supports only one controller, show as unreachable (requires unpairing)
|
|
207
|
+
# Some protocols support multiple controllers (like Matter, some HomeKit, Zigbee green with hacks, etc.)
|
|
208
|
+
# In this case, accessory might need to be put in pairing mode using the first
|
|
209
|
+
# controller to allow a second pairing with this controller
|
|
210
|
+
logger.info(f'Device "{hk_discovery.description.name}" is paired to another controller')
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
# A new, unpaired accessory -> surface it as a discovery.
|
|
214
|
+
logger.info(f"{self.name} Discovered new device...")
|
|
215
|
+
desc = hk_discovery.description
|
|
216
|
+
discovery_uuid = self.mapper.hap_id_to_uuid(hk_discovery.description.id)
|
|
217
|
+
mj_discovery_info = Discovery(
|
|
218
|
+
# technical
|
|
219
|
+
id=discovery_uuid,
|
|
220
|
+
integration=NonEmptyStr(self.name),
|
|
221
|
+
expected_credentials_options=[CredentialsType.code.with_mask("DDD-DD-DDD")],
|
|
222
|
+
expiration=None, # TODO:
|
|
223
|
+
# UX
|
|
224
|
+
transport=NonEmptyStr("IP"),
|
|
225
|
+
device_name=desc.name,
|
|
226
|
+
device_manufacturer=None, # looks like it needs device to be paired first
|
|
227
|
+
device_category=desc.category,
|
|
228
|
+
device_icon=None, # will be implemented later
|
|
229
|
+
# device_model_id = None,
|
|
230
|
+
# ? model_name: desc.model
|
|
231
|
+
)
|
|
232
|
+
self._hap_discoveries[discovery_uuid] = hk_discovery
|
|
233
|
+
self._majordom_discoveries[discovery_uuid] = mj_discovery_info
|
|
234
|
+
await self.dependencies.output.controller_did_receive_discovery(self, mj_discovery_info)
|
|
235
|
+
|
|
236
|
+
# TODO: dismiss Discovery if discovery disapperd or expired
|
|
237
|
+
|
|
238
|
+
# -------------------------------------------------------------------------
|
|
239
|
+
# Device -> Hub: parameter events & availability
|
|
240
|
+
# -------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def run_handle_accessory_response(self, hk_device_id: HKDeviceID, responses: Response):
|
|
243
|
+
asyncio.create_task(self._handle_accessory_response(hk_device_id, responses))
|
|
244
|
+
|
|
245
|
+
async def _handle_accessory_response(self, hk_device_id: HKDeviceID, responses: Response):
|
|
246
|
+
# TODO: review, test
|
|
247
|
+
parameter_changed_events = []
|
|
248
|
+
for (aid, iid), response in responses.items():
|
|
249
|
+
if "value" in response:
|
|
250
|
+
hk_value = response["value"]
|
|
251
|
+
device_id = self.mapper.hap_id_to_uuid(hk_device_id)
|
|
252
|
+
device_parameter_id = self.mapper.hap_iid_to_param_uuid(hk_device_id, aid, iid)
|
|
253
|
+
parameter_changed_events.append(
|
|
254
|
+
DeviceParameterChange(
|
|
255
|
+
device_id=device_id,
|
|
256
|
+
parameter_id=device_parameter_id,
|
|
257
|
+
value=hk_value, # TODO: self.mapper.hap_value_to_mj(pairing.characteristic_for_key((aid, iid)))
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
if "status" in response and response["status"] != 0:
|
|
261
|
+
raise ValueError(
|
|
262
|
+
f'Something\'s wrong with characteristic "{aid}.{iid}": '
|
|
263
|
+
f'status "{response["status"]}", description: {response.get("description", "none")}'
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
await self.dependencies.output.controller_did_receive_events(self, parameter_changed_events)
|
|
267
|
+
|
|
268
|
+
async def _set_availability(self, device_id: UUID, available: bool):
|
|
269
|
+
"""Single funnel for availability transitions — dedupes so the Hub is only told on an
|
|
270
|
+
actual change, and translates it into the framework's connect / lose callbacks."""
|
|
271
|
+
if self._availability.get(device_id) == available:
|
|
272
|
+
return
|
|
273
|
+
self._availability[device_id] = available
|
|
274
|
+
if available:
|
|
275
|
+
await self.dependencies.output.controller_did_connect_device(self, device_id)
|
|
276
|
+
else:
|
|
277
|
+
await self.dependencies.output.controller_did_lose_device(self, device_id)
|
|
278
|
+
|
|
279
|
+
async def _availability_loop(self, interval: float = 30):
|
|
280
|
+
while True:
|
|
281
|
+
await asyncio.sleep(interval)
|
|
282
|
+
try:
|
|
283
|
+
for hk_id, pairing in list(self._aiohomekit_controller.pairings.items()):
|
|
284
|
+
await self._set_availability(self.mapper.hap_id_to_uuid(hk_id), pairing.is_available)
|
|
285
|
+
except Exception:
|
|
286
|
+
logger.exception(f"{self.name} availability poll failed")
|
|
287
|
+
|
|
288
|
+
# -------------------------------------------------------------------------
|
|
289
|
+
# Private helpers: connection setup
|
|
290
|
+
# -------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
async def _handle_connected_pairing(self, pairing_id: HKDeviceID):
|
|
293
|
+
pairing = self._aiohomekit_controller.pairings[pairing_id.lower()]
|
|
294
|
+
await self._set_availability(self.mapper.hap_id_to_uuid(pairing_id), True)
|
|
295
|
+
# subscribe to events
|
|
296
|
+
await self._observe_characteristics(pairing)
|
|
297
|
+
# update state
|
|
298
|
+
state = await pairing.get_characteristics(
|
|
299
|
+
self._get_all_characteristics_keys(pairing, {CharacteristicPermissions.paired_read})
|
|
300
|
+
)
|
|
301
|
+
await self._handle_accessory_response(pairing_id, state)
|
|
302
|
+
|
|
303
|
+
async def _observe_characteristics(self, pairing: AbstractPairing):
|
|
304
|
+
cleanup = pairing.add_observer_for_characteristics(self.run_handle_accessory_response)
|
|
305
|
+
|
|
306
|
+
# make controller handle cleanup for us
|
|
307
|
+
if pairing.id not in self._aiohomekit_controller._pairing_cleanups:
|
|
308
|
+
self._aiohomekit_controller._pairing_cleanups[pairing.id] = []
|
|
309
|
+
self._aiohomekit_controller._pairing_cleanups[pairing.id].append(cleanup)
|
|
310
|
+
|
|
311
|
+
# pairing.add_observer_for_availability # TODO: check and use
|
|
312
|
+
|
|
313
|
+
response = await pairing.subscribe_characteristics(
|
|
314
|
+
self._get_all_characteristics_keys(pairing, {CharacteristicPermissions.events})
|
|
315
|
+
)
|
|
316
|
+
await self._handle_accessory_response(pairing.id, response)
|
|
317
|
+
|
|
318
|
+
def _get_all_characteristics_keys(
|
|
319
|
+
self, pairing: AbstractPairing, perms: set[CharacteristicPermissions]
|
|
320
|
+
) -> Iterable[CharacteristicKey]:
|
|
321
|
+
for accessory in pairing.accessories_state.accessories:
|
|
322
|
+
for service in accessory.services:
|
|
323
|
+
for characteristic in service.characteristics:
|
|
324
|
+
if perms.issubset(characteristic.perms):
|
|
325
|
+
yield CharacteristicKey(accessory.aid, characteristic.iid)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Conversion logic between HAP (HomeKit Accessory Protocol) concepts and MajorDom's domain model.
|
|
3
|
+
Isolated here to keep the Controller free of boilerplate — formats, units, permissions, and valid
|
|
4
|
+
enum values are all mapped in one place.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import inspect
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Callable, Iterable
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Any
|
|
12
|
+
from uuid import UUID
|
|
13
|
+
|
|
14
|
+
from aiohomekit.model.characteristics import (
|
|
15
|
+
Characteristic,
|
|
16
|
+
CharacteristicFormats,
|
|
17
|
+
CharacteristicUnits,
|
|
18
|
+
)
|
|
19
|
+
from aiohomekit.model.characteristics import const as aiohomekit_consts
|
|
20
|
+
from aiohomekit.model.characteristics.characteristic_types import CharacteristicsTypes
|
|
21
|
+
from aiohomekit.model.characteristics.permissions import CharacteristicPermissions
|
|
22
|
+
from aiohomekit.model.typed_dicts import HKDeviceID
|
|
23
|
+
from majordom_integration_sdk.schemas.parameter import (
|
|
24
|
+
ParameterDataType,
|
|
25
|
+
ParameterRole,
|
|
26
|
+
ParameterUnit,
|
|
27
|
+
ParameterVisibility,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from majordom_homekit.models import (
|
|
31
|
+
HKParameterIntegrationData,
|
|
32
|
+
HKParameterState,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HKMajorDomMapper:
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
device_uuid: Callable[[str], UUID],
|
|
40
|
+
parameter_uuid: Callable[[UUID, str], UUID],
|
|
41
|
+
):
|
|
42
|
+
# The controller's provided (framework) UUID generators — see AbstractController.
|
|
43
|
+
# HAP identifiers are turned into MajorDom UUIDs exclusively through these, so device
|
|
44
|
+
# and parameter ids are namespaced under the integration consistently with every other
|
|
45
|
+
# integration.
|
|
46
|
+
self._device_uuid = device_uuid
|
|
47
|
+
self._parameter_uuid = parameter_uuid
|
|
48
|
+
|
|
49
|
+
# -------------------------------------------------------------------------
|
|
50
|
+
# Identity: HAP identifiers -> MajorDom UUIDs
|
|
51
|
+
# -------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def hap_id_to_uuid(self, hk_device_id: HKDeviceID) -> UUID:
|
|
54
|
+
return self._device_uuid(hk_device_id.lower())
|
|
55
|
+
|
|
56
|
+
def hap_iid_to_param_uuid(self, hk_device_id: HKDeviceID, aid: int, iid: int) -> UUID:
|
|
57
|
+
return self._parameter_uuid(self.hap_id_to_uuid(hk_device_id), f"{aid}.{iid}")
|
|
58
|
+
|
|
59
|
+
# -------------------------------------------------------------------------
|
|
60
|
+
# MajorDom -> HAP
|
|
61
|
+
# -------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def mj_value_to_hap(self, value: Any):
|
|
64
|
+
# TODO: implement conversion if needed
|
|
65
|
+
# aiohomekit handle a lot of processing for us
|
|
66
|
+
# use characteristic.format and characteristic.unit for correct conversion
|
|
67
|
+
# checking existing mapping inside aiohomekit might be helpful
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
# -------------------------------------------------------------------------
|
|
71
|
+
# HAP -> MajorDom
|
|
72
|
+
# -------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def hap_char_to_majordom_parameter(
|
|
75
|
+
self, device_id: UUID, aid: int, characteristic: Characteristic
|
|
76
|
+
) -> HKParameterState:
|
|
77
|
+
return HKParameterState(
|
|
78
|
+
id=self._parameter_uuid(device_id, f"{aid}.{characteristic.iid}"),
|
|
79
|
+
name=characteristic.description or "",
|
|
80
|
+
data_type=self._hap_format_to_mj_data_type(characteristic.format),
|
|
81
|
+
unit=self._hap_unit_to_mj(characteristic.unit),
|
|
82
|
+
role=self._hap_perms_to_mj_role(characteristic.perms),
|
|
83
|
+
visibility=ParameterVisibility.user,
|
|
84
|
+
min_value=characteristic.minValue,
|
|
85
|
+
max_value=characteristic.maxValue or characteristic.maxLen,
|
|
86
|
+
min_step=characteristic.minStep,
|
|
87
|
+
valid_values=self._valid_values(characteristic),
|
|
88
|
+
integration_data=HKParameterIntegrationData(
|
|
89
|
+
type=characteristic.type,
|
|
90
|
+
aid=aid,
|
|
91
|
+
iid=characteristic.iid,
|
|
92
|
+
),
|
|
93
|
+
).with_value(self.hap_value_to_mj(characteristic))
|
|
94
|
+
# UNUSED:
|
|
95
|
+
# Service.available
|
|
96
|
+
# Characteristic.available
|
|
97
|
+
# Characteristic.ev
|
|
98
|
+
# Characteristic.maxDataLen
|
|
99
|
+
# Characteristic.handle
|
|
100
|
+
# Characteristic.broadcast_events
|
|
101
|
+
# Characteristic.disconnected_events
|
|
102
|
+
# Characteristic.valid_values_range
|
|
103
|
+
|
|
104
|
+
def hap_value_to_mj(self, characteristic: Characteristic):
|
|
105
|
+
# characteristic.value should already work in most cases since aiohomekit handle a lot of processing for us
|
|
106
|
+
# otherwise use characteristic.format and characteristic.unit for correct conversion
|
|
107
|
+
# checking existing mapping inside aiohomekit might be helpful
|
|
108
|
+
return characteristic.value
|
|
109
|
+
|
|
110
|
+
def _hap_format_to_mj_data_type(self, format: str | None) -> ParameterDataType:
|
|
111
|
+
mapping: dict[str | None, ParameterDataType] = {
|
|
112
|
+
CharacteristicFormats.bool: ParameterDataType.bool,
|
|
113
|
+
CharacteristicFormats.uint8: ParameterDataType.integer,
|
|
114
|
+
CharacteristicFormats.uint16: ParameterDataType.integer,
|
|
115
|
+
CharacteristicFormats.uint32: ParameterDataType.integer,
|
|
116
|
+
CharacteristicFormats.uint64: ParameterDataType.integer,
|
|
117
|
+
CharacteristicFormats.int: ParameterDataType.integer,
|
|
118
|
+
CharacteristicFormats.int32: ParameterDataType.integer,
|
|
119
|
+
CharacteristicFormats.float: ParameterDataType.decimal,
|
|
120
|
+
CharacteristicFormats.string: ParameterDataType.string,
|
|
121
|
+
CharacteristicFormats.data: ParameterDataType.data,
|
|
122
|
+
# TODO: review
|
|
123
|
+
CharacteristicFormats.tlv8: ParameterDataType.data,
|
|
124
|
+
CharacteristicFormats.array: ParameterDataType.data,
|
|
125
|
+
CharacteristicFormats.dict: ParameterDataType.data,
|
|
126
|
+
}
|
|
127
|
+
return mapping[format]
|
|
128
|
+
|
|
129
|
+
def _hap_unit_to_mj(self, unit: str | None) -> ParameterUnit:
|
|
130
|
+
mapping: dict[str | None, ParameterUnit] = {
|
|
131
|
+
CharacteristicUnits.celsius: ParameterUnit.celsius,
|
|
132
|
+
CharacteristicUnits.percentage: ParameterUnit.percentage,
|
|
133
|
+
CharacteristicUnits.arcdegrees: ParameterUnit.arcdegree,
|
|
134
|
+
CharacteristicUnits.lux: ParameterUnit.lux,
|
|
135
|
+
CharacteristicUnits.seconds: ParameterUnit.second,
|
|
136
|
+
None: ParameterUnit.plain,
|
|
137
|
+
}
|
|
138
|
+
return mapping[unit]
|
|
139
|
+
|
|
140
|
+
def _hap_perms_to_mj_role(self, perms: Iterable[str]) -> ParameterRole:
|
|
141
|
+
# TODO: review all perms in specs
|
|
142
|
+
if CharacteristicPermissions.paired_write in perms:
|
|
143
|
+
return ParameterRole.control
|
|
144
|
+
elif CharacteristicPermissions.paired_read in perms:
|
|
145
|
+
return ParameterRole.sensor
|
|
146
|
+
else:
|
|
147
|
+
return ParameterRole.event
|
|
148
|
+
|
|
149
|
+
def _valid_values(self, characteristic: Characteristic) -> dict[int | str | float, str] | None:
|
|
150
|
+
# TODO: convert to codegen instead of runtime parsing
|
|
151
|
+
if values_enum := self._search_values_enum_for_characteristic(characteristic):
|
|
152
|
+
return {v.value: underscore_to_display_case(k) for k, v in values_enum.__members__.items()}
|
|
153
|
+
else:
|
|
154
|
+
return {key: str(key) for key in characteristic.valid_values or []}
|
|
155
|
+
|
|
156
|
+
# scrapping
|
|
157
|
+
|
|
158
|
+
def _search_values_enum_for_characteristic(self, characteristic: Characteristic) -> type[Enum] | None:
|
|
159
|
+
# try get characteristic type name by id
|
|
160
|
+
for char_type in CharacteristicsTypes:
|
|
161
|
+
if char_type == characteristic.type and (values_enum := self._search_values_enum_by_name(char_type.name)):
|
|
162
|
+
return values_enum
|
|
163
|
+
|
|
164
|
+
# try using description as a type name
|
|
165
|
+
if characteristic.description:
|
|
166
|
+
possible_enum_name = "_".join(characteristic.description.split(" ")).upper()
|
|
167
|
+
if values_enum := self._search_values_enum_by_name(possible_enum_name):
|
|
168
|
+
return values_enum
|
|
169
|
+
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
def _search_values_enum_by_name(self, char_uppercase_name: str) -> type[Enum] | None:
|
|
173
|
+
# ignore = {'values', 'target', 'current', 'state', 'status', 'capabilities', 'units'}
|
|
174
|
+
searched_char_name_set = set(word.lower() for word in char_uppercase_name.split("_"))
|
|
175
|
+
|
|
176
|
+
for name, obj in inspect.getmembers(aiohomekit_consts, inspect.isclass):
|
|
177
|
+
if not issubclass(obj, Enum):
|
|
178
|
+
continue
|
|
179
|
+
member_name_set = set(word.lower() for word in re.split(r"(?<!^)(?=[A-Z])", name))
|
|
180
|
+
# compare as sets because some enum names have different word order
|
|
181
|
+
if searched_char_name_set == member_name_set:
|
|
182
|
+
return obj
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def underscore_to_display_case(name: str) -> str:
|
|
186
|
+
return " ".join([word.title() for word in name.split("_")])
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MajorDom's Device and Parameter schemas expose an `integration_data` field for storing protocol-specific state.
|
|
3
|
+
By default it is an untyped dict persisted as JSON. Integrations can subclass Device/DeviceState
|
|
4
|
+
and Parameter/ParameterState
|
|
5
|
+
to declare a typed schema for that field — Hub will then handle (de-)serialization automatically
|
|
6
|
+
before passing Device instance to Controller's methods or when saving to the database.
|
|
7
|
+
To make the Hub instantiate these custom types, the integration's Controller must override
|
|
8
|
+
`device_type` and `parameter_type`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
from uuid import UUID
|
|
13
|
+
|
|
14
|
+
from aiohomekit.model.accessories import AccessoriesState
|
|
15
|
+
from aiohomekit.model.typed_dicts import PairingData
|
|
16
|
+
from majordom_integration_sdk.schemas.base import Base
|
|
17
|
+
from majordom_integration_sdk.schemas.device import Device, DeviceState, ParameterState
|
|
18
|
+
from majordom_integration_sdk.schemas.parameter import Parameter
|
|
19
|
+
from pydantic import BaseModel, field_serializer, field_validator
|
|
20
|
+
|
|
21
|
+
# Integration data
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HKDeviceIntegrationData(Base):
|
|
25
|
+
# NOTE: must be initializable without any arguments
|
|
26
|
+
|
|
27
|
+
pairing_data: PairingData | None = None
|
|
28
|
+
characteristics_cache: AccessoriesState | None = None
|
|
29
|
+
|
|
30
|
+
# since AccessoriesState isn't a pydantic class, we need to implement (de)serialization
|
|
31
|
+
|
|
32
|
+
model_config = {
|
|
33
|
+
"arbitrary_types_allowed": True,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@field_serializer("characteristics_cache")
|
|
37
|
+
def serialize_characteristics_cache(self, v: AccessoriesState, _info) -> dict[str, Any]:
|
|
38
|
+
return v.as_dict() if v else {}
|
|
39
|
+
|
|
40
|
+
@field_validator("characteristics_cache", mode="before")
|
|
41
|
+
@classmethod
|
|
42
|
+
def parse_characteristics_cache(cls, v: Any) -> AccessoriesState | None:
|
|
43
|
+
if not v:
|
|
44
|
+
return None
|
|
45
|
+
if isinstance(v, AccessoriesState):
|
|
46
|
+
return v
|
|
47
|
+
if isinstance(v, dict):
|
|
48
|
+
return AccessoriesState.from_dict(v)
|
|
49
|
+
raise ValueError(f"Expected dict or AccessoriesState, got {type(v)}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HKParameterIntegrationData(BaseModel):
|
|
53
|
+
type: UUID
|
|
54
|
+
aid: int
|
|
55
|
+
iid: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Overriding types for ease of use (shortcuts)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class HKParameter(Parameter):
|
|
62
|
+
integration_data: HKParameterIntegrationData
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HKParameterState(ParameterState):
|
|
66
|
+
integration_data: HKParameterIntegrationData
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class HKDevice(Device):
|
|
70
|
+
integration_data: HKDeviceIntegrationData
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def hk_id(self) -> str:
|
|
74
|
+
assert self.integration_data and self.integration_data.pairing_data
|
|
75
|
+
return self.integration_data.pairing_data["AccessoryPairingID"].lower()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# never used
|
|
79
|
+
class HKDeviceState(HKDevice, DeviceState):
|
|
80
|
+
# HKDevice + DeviceState — only used in isolation for convenient parsing, so the
|
|
81
|
+
# parameter-type narrowing is safe here.
|
|
82
|
+
parameters: list[HKParameterState]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TLDR: same purpose as characteristics_storage.py
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from contextlib import AbstractAsyncContextManager
|
|
7
|
+
|
|
8
|
+
from aiohomekit.model.typed_dicts import HKDeviceID, PairingData
|
|
9
|
+
from aiohomekit.storage.pairing_data_storage import PairingDataStorageProtocol
|
|
10
|
+
from majordom_integration_sdk.repository import DeviceRepositoryProtocol
|
|
11
|
+
|
|
12
|
+
from .mapper import HKMajorDomMapper
|
|
13
|
+
from .models import HKDevice
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HKPairingsStorageMajorDom(PairingDataStorageProtocol):
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
make_device_repository: Callable[[], AbstractAsyncContextManager[DeviceRepositoryProtocol]],
|
|
20
|
+
mapper: HKMajorDomMapper,
|
|
21
|
+
):
|
|
22
|
+
self.make_device_repository = make_device_repository
|
|
23
|
+
self.mapper = mapper
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def _integration_name(self):
|
|
27
|
+
return "HomeKit"
|
|
28
|
+
|
|
29
|
+
# aiohomekit.PairingsStorageType
|
|
30
|
+
|
|
31
|
+
async def get_all(self) -> dict[HKDeviceID, PairingData]:
|
|
32
|
+
# keyed by the HAP pairing id (HKDeviceID), matching HKCharacteristicsStorage.get_all
|
|
33
|
+
# and what aiohomekit looks pairings up by — not the MajorDom device UUID.
|
|
34
|
+
pairings: dict[HKDeviceID, PairingData] = {}
|
|
35
|
+
async with self.make_device_repository() as device_repository:
|
|
36
|
+
for device in await device_repository.get_all(as_=HKDevice):
|
|
37
|
+
if pairing_data := device.integration_data.pairing_data:
|
|
38
|
+
pairings[device.hk_id] = pairing_data
|
|
39
|
+
return pairings
|
|
40
|
+
|
|
41
|
+
async def get(self, id: str) -> PairingData | None:
|
|
42
|
+
uuid = self.mapper.hap_id_to_uuid(id)
|
|
43
|
+
async with self.make_device_repository() as device_repository:
|
|
44
|
+
if (device := await device_repository.get(uuid, as_=HKDevice)) and (
|
|
45
|
+
pairing_data := device.integration_data.pairing_data
|
|
46
|
+
):
|
|
47
|
+
return pairing_data
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
async def save(self, id: str, item: PairingData):
|
|
51
|
+
uuid = self.mapper.hap_id_to_uuid(id)
|
|
52
|
+
async with self.make_device_repository() as device_repository:
|
|
53
|
+
if device := await device_repository.get(uuid, as_=HKDevice):
|
|
54
|
+
device.integration_data.pairing_data = item
|
|
55
|
+
await device_repository.save(device)
|
|
56
|
+
|
|
57
|
+
async def delete_model(self, id: str):
|
|
58
|
+
# TODO: check usage
|
|
59
|
+
uuid = self.mapper.hap_id_to_uuid(id)
|
|
60
|
+
async with self.make_device_repository() as device_repository:
|
|
61
|
+
if device := await device_repository.get(uuid):
|
|
62
|
+
device.integration_data.pairing_data = None
|
|
63
|
+
await device_repository.save(device)
|
|
64
|
+
# TODO: check if should delete the device
|
|
65
|
+
# Discussion:
|
|
66
|
+
# this class is responsible solely for managing pairing data storage, and should
|
|
67
|
+
# not be used for any other purpose.
|
|
68
|
+
# However, we should ensure there is no case of aiohomekit disconnecting from the
|
|
69
|
+
# device without notifying us.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Supplemental metadata for parameters / characteristics that the API does not expose directly.
|
|
3
|
+
Maps characteristic types to static values or callables that provide missing information — such as
|
|
4
|
+
enum labels, value ranges, or units.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
parameters_map = {}
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# HomeKit integration
|
|
2
|
+
|
|
3
|
+
Bridges the Hub to HomeKit (HAP) accessories over IP, built on top of
|
|
4
|
+
[`aiohomekit`](https://github.com/Jc2k/aiohomekit). `aiohomekit` owns the HAP protocol,
|
|
5
|
+
discovery, pairing crypto, and the live connection; this integration adapts it to the
|
|
6
|
+
Hub's `AbstractController` contract and persists HomeKit state in the Hub's database.
|
|
7
|
+
|
|
8
|
+
## Files
|
|
9
|
+
|
|
10
|
+
| File | Responsibility |
|
|
11
|
+
|---|---|
|
|
12
|
+
| `controller.py` | `AbstractController` implementation — lifecycle, pairing, control, and the aiohomekit discovery/event delegates |
|
|
13
|
+
| `mapper.py` | HAP ↔ MajorDom conversions: UUIDs (via the framework helpers), characteristic → parameter, formats/units/permissions/valid-values |
|
|
14
|
+
| `models.py` | Typed `integration_data` schemas (`HKDevice`, `HKParameter`, …) |
|
|
15
|
+
| `characteristics_storage.py` | Adapts aiohomekit's characteristics cache onto the Hub's `DeviceRepository` |
|
|
16
|
+
| `pairings_storage.py` | Adapts aiohomekit's pairing-data storage onto the Hub's `DeviceRepository` |
|
|
17
|
+
|
|
18
|
+
The two storage classes implement aiohomekit's storage protocols so that all HomeKit
|
|
19
|
+
state lives in the Hub DB rather than aiohomekit's own files. They share the controller's
|
|
20
|
+
single `HKMajorDomMapper` instance, so every HAP id maps to the same MajorDom UUID
|
|
21
|
+
everywhere.
|
|
22
|
+
|
|
23
|
+
## Discovery & pairing
|
|
24
|
+
|
|
25
|
+
- **Discovery** uses the Hub's shared Zeroconf service: aiohomekit browses `_hap._tcp` /
|
|
26
|
+
`_hap._udp` using the `AsyncZeroconf` handed to it from
|
|
27
|
+
`dependencies.zeroconf_discovery_service`, and calls back into
|
|
28
|
+
`_aiohomekit_did_discover`. Already-paired accessories seen again trigger a reconnect;
|
|
29
|
+
accessories paired to another controller are surfaced but not offered for pairing.
|
|
30
|
+
- **Pairing** takes a HAP setup code (`CredentialsType.code`, `DDD-DD-DDD`). The credentials
|
|
31
|
+
type is validated against the discovery's `expected_credentials_options` before pairing.
|
|
32
|
+
|
|
33
|
+
## Availability
|
|
34
|
+
|
|
35
|
+
aiohomekit only pushes a *became-available* signal, never *became-unavailable*, so the
|
|
36
|
+
controller polls each paired accessory's connection state (`_availability_loop`) and
|
|
37
|
+
funnels both directions through `_set_availability`, which dedupes and emits the
|
|
38
|
+
framework's `controller_did_connect_device` / `controller_did_lose_device`.
|
|
39
|
+
|
|
40
|
+
## Tests
|
|
41
|
+
|
|
42
|
+
`tests/test_controllers/test_homekit/` runs against aiohomekit's in-process
|
|
43
|
+
`AccessoryServer` (a fake accessory) — no real hardware needed.
|
|
44
|
+
|
|
45
|
+
## Not yet implemented
|
|
46
|
+
|
|
47
|
+
- BLE discovery/pairing (aiohomekit supports it; the Hub wires IP only for now).
|
|
48
|
+
- Multi-accessory bridges save only the first accessory's manufacturer.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "majordom-homekit"
|
|
3
|
+
version = "0.1.2"
|
|
4
|
+
description = "HomeKit integration for MajorDom — bridges HomeKit accessories into the MajorDom language."
|
|
5
|
+
authors = [{ name = "Mark Parker", email = "mark@parker-programs.com" }]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
requires-python = ">=3.12,<3.14" # upper bound comes from parker-aiohomekit
|
|
8
|
+
dependencies = [
|
|
9
|
+
"majordom-integration-sdk (>=0.1,<1.0)",
|
|
10
|
+
"parker-aiohomekit (>=4.0.1,<5.0.0)",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[dependency-groups]
|
|
14
|
+
dev = [
|
|
15
|
+
"poethepoet (>=0.36.0,<1.0.0)",
|
|
16
|
+
"ty",
|
|
17
|
+
"ruff",
|
|
18
|
+
"pip-audit",
|
|
19
|
+
"pytest",
|
|
20
|
+
"pytest-asyncio",
|
|
21
|
+
"pytest-cov",
|
|
22
|
+
"pytest-timeout",
|
|
23
|
+
"pytest-repeat",
|
|
24
|
+
"pytest-benchmark",
|
|
25
|
+
"pytest-profiling",
|
|
26
|
+
"pytest-resource-usage",
|
|
27
|
+
"coverage",
|
|
28
|
+
"pympler",
|
|
29
|
+
"psutil",
|
|
30
|
+
"faker",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
35
|
+
build-backend = "poetry.core.masonry.api"
|
|
36
|
+
|
|
37
|
+
[tool.poetry]
|
|
38
|
+
requires-poetry = ">=2.0"
|
|
39
|
+
packages = [{ include = "majordom_homekit" }]
|
|
40
|
+
|
|
41
|
+
# DEV-MODE: local paths to the private SDK. Switch to the published PyPI package when going
|
|
42
|
+
# public. (Our pydantic-discriminator fork isn't on PyPI either — see the SDK's pyproject.)
|
|
43
|
+
[tool.poetry.dependencies]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
asyncio_mode = "auto"
|
|
47
|
+
pythonpath = ["."]
|
|
48
|
+
filterwarnings = ["once"]
|
|
49
|
+
|
|
50
|
+
[tool.coverage.run]
|
|
51
|
+
omit = ["tests/*"]
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
line-length = 120
|
|
55
|
+
indent-width = 4
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
select = ["E", "F", "UP", "B", "SIM", "I"]
|
|
59
|
+
fixable = ["ALL"]
|
|
60
|
+
|
|
61
|
+
[tool.ruff.format]
|
|
62
|
+
quote-style = "double"
|
|
63
|
+
indent-style = "space"
|
|
64
|
+
line-ending = "auto"
|
|
65
|
+
docstring-code-format = true
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint.per-file-ignores]
|
|
68
|
+
"__init__.py" = ["F401"]
|
|
69
|
+
"tests/*" = ["E402"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
[tool.poe.tasks.install]
|
|
73
|
+
shell = """
|
|
74
|
+
poetry install
|
|
75
|
+
echo '#!/bin/sh\npoetry run poe check' > .git/hooks/pre-commit
|
|
76
|
+
chmod +x .git/hooks/pre-commit
|
|
77
|
+
echo "Pre-commit hook installed."
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
[tool.poe.tasks.check]
|
|
81
|
+
shell = """
|
|
82
|
+
code=0
|
|
83
|
+
|
|
84
|
+
echo "\nRunning ruff lint:"
|
|
85
|
+
poetry run ruff check --fix || code=1
|
|
86
|
+
|
|
87
|
+
echo "\nRunning ruff format:"
|
|
88
|
+
poetry run ruff format || code=1
|
|
89
|
+
|
|
90
|
+
echo "\nRunning ty:"
|
|
91
|
+
poetry run ty check || code=1
|
|
92
|
+
|
|
93
|
+
echo "\nRunning pytest:"
|
|
94
|
+
poetry run pytest --cov=majordom_homekit --cov-report=xml || code=1
|
|
95
|
+
|
|
96
|
+
echo "\nRunning poetry build:"
|
|
97
|
+
poetry build || code=1
|
|
98
|
+
|
|
99
|
+
echo "\nRunning poetry check:"
|
|
100
|
+
poetry check || code=1
|
|
101
|
+
|
|
102
|
+
${ci:+git diff --exit-code}
|
|
103
|
+
|
|
104
|
+
exit $code
|
|
105
|
+
"""
|
|
106
|
+
args = [{ name = "ci", type = "boolean" }]
|