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.
@@ -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" }]