ihcctl 0.2.0__py3-none-any.whl

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,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: ihcctl
3
+ Version: 0.2.0
4
+ Summary: Python library and CLI for local Zehnder IHC control
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: bleak>=0.22
8
+ Provides-Extra: test
9
+ Requires-Dist: pytest>=8; extra == "test"
10
+
11
+ # ihcctl
12
+
13
+ `ihcctl` is a Python library and CLI for local Zehnder IHC controller control
14
+ over Bluetooth Low Energy. Normal use does not require a cloud service.
15
+
16
+ The distribution is `ihcctl`; the Python import namespace remains
17
+ `zehnder_ihc`; the CLI command is `ihcctl`.
18
+
19
+ ## Install
20
+
21
+ For the CLI, prefer pipx:
22
+
23
+ ```bash
24
+ pipx install ihcctl
25
+ ihcctl --help
26
+ ```
27
+
28
+ Alternatively install into a Python environment:
29
+
30
+ ```bash
31
+ python -m pip install ihcctl
32
+ ```
33
+
34
+ The first public release, `ihcctl` 0.2.0, is prepared but not yet published.
35
+ Until public publication and verification, these are the intended future
36
+ commands rather than a current PyPI installation path.
37
+
38
+ ## Python library and CLI
39
+
40
+ Use `import zehnder_ihc` or import public classes directly:
41
+
42
+ ```python
43
+ from zehnder_ihc import IHCClient
44
+ ```
45
+
46
+ The CLI requires a Bluetooth address and an externally stored identity file.
47
+ Do not commit it or share it with Home Assistant.
48
+
49
+ ```bash
50
+ ihcctl --help
51
+ ihcctl register --help
52
+ ```
53
+
54
+ Registration requires physical IHC confirmation. The help commands do not
55
+ contact hardware. Do not keep the CLI connected while Home Assistant polls the
56
+ same controller.
57
+
58
+ ## Home Assistant
59
+
60
+ The custom integration uses the same `zehnder_ihc` library internally. During
61
+ this preparation phase its `manifest.json` deliberately retains the existing
62
+ local direct-wheel requirement; it has not switched to `ihcctl`.
63
+
64
+ Only after a verified public PyPI release will a separately authorized change
65
+ pin it to `ihcctl==0.2.0`. Do not manually install, uninstall, or remove
66
+ packages in a Home Assistant container.
67
+
68
+ The optional Schedule Manager resource is:
69
+
70
+ ```yaml
71
+ url: /zehnder_ihc/frontend/zehnder-ihc-schedule-card.js
72
+ type: module
73
+ ```
74
+
75
+ ## Development / fallback workflow
76
+
77
+ `tools/start-local-pypi.sh` and local pypiserver remain development/fallback
78
+ tools. They are not the normal workflow for new CLI users or the intended
79
+ production Home Assistant workflow after public PyPI migration. Existing Home
80
+ Assistant deployments still need the current local wheel until the later
81
+ manifest migration succeeds.
82
+
83
+ For repository work, use the existing test extra:
84
+
85
+ ```bash
86
+ python -m venv .venv
87
+ source .venv/bin/activate
88
+ python -m pip install -e ".[test]"
89
+ python -m pytest -q
90
+ ```
91
+
92
+ Tests use sanitized frames and fake BLE backends; they do not contact IHC
93
+ hardware or perform writes.
94
+
95
+ ## Publishing
96
+
97
+ Maintainers must read [the publishing guide](docs/publishing-pypi.md). Local
98
+ readiness never publishes:
99
+
100
+ ```bash
101
+ tools/release-pypi.sh --check
102
+ ```
103
+
104
+ TestPyPI and production PyPI require separate explicit authorization.
105
+
106
+ ## Safety
107
+
108
+ Phase-1 research is frozen. Never commit client identities, `ihc-client*.json`,
109
+ or files under `phase1-private/`. Hardware-changing tests need explicit
110
+ approval and protocol-level readback where possible.
@@ -0,0 +1,18 @@
1
+ zehnder_ihc/__init__.py,sha256=etpICo-W5voec7WEaWGJIFFjMow5kh_MY-SBh-_PEkQ,1601
2
+ zehnder_ihc/backup.py,sha256=VxpE5iNZUNUyYe8bfN4IhAkW26iUBIQ4fY1Kz7dEM7Y,13096
3
+ zehnder_ihc/bleak_backend.py,sha256=MQ2xx_wRDrc4YbUU9anlqqWE1qPh63TmxlBpQtkK84U,2700
4
+ zehnder_ihc/cli.py,sha256=s3D8_uE7HJNj28jJJDDyAcr3axS0ha9KyC-P3mDDMB8,25624
5
+ zehnder_ihc/client.py,sha256=RkvxqMnwi-9fROlieaa9TYkCUfuGy3B-erbM9sEV8TU,48930
6
+ zehnder_ihc/errors.py,sha256=aPIqDPLrLY0JwGZK_z-cbYIie9aS1Wi-3_lOMgLcxZk,3421
7
+ zehnder_ihc/framing.py,sha256=7XCywGPLycoFEJvLdG-6xcREl7L46TXTEpYLyEkX1DA,1375
8
+ zehnder_ihc/identity.py,sha256=bfJgyiRvoQqsPPSYcnSoQm-Jc6qtLNyoz_O0TRgcz7U,2086
9
+ zehnder_ihc/models.py,sha256=6Z6MnVYA4xO3kdzyzHjs_vXp2u6QjpNIZCp3khouN1M,3479
10
+ zehnder_ihc/mutations.py,sha256=_Bpe7YSeh7FT3LUu7_g1hettI-j9aSIUbkrgd-5ikGE,2561
11
+ zehnder_ihc/protocol.py,sha256=FnM-wW5fAug51tk0ASojDCmIf1FfDG5y28v6BiiUXCc,19776
12
+ zehnder_ihc/restore.py,sha256=qhp064lSA-6EVFc1GteFSZ5ENvbnkzn5qhN7NTeTauM,12147
13
+ zehnder_ihc/transport.py,sha256=WGGE2jdgcQfdROpIQNCKc61mV89slQAuFnOmcibtme0,6373
14
+ ihcctl-0.2.0.dist-info/METADATA,sha256=0lEeRTnNP1G8TYhzmMAuZ0hD6MpYXtP5qa53bV8GgvY,3054
15
+ ihcctl-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
+ ihcctl-0.2.0.dist-info/entry_points.txt,sha256=guH5eXAm0th1yOOCPSwktzImVlJi3CbZt66ZzCgnIcY,48
17
+ ihcctl-0.2.0.dist-info/top_level.txt,sha256=clCOa7CNlJ6qCf-jYWZTomtZeiKLyg9QxznpjOnCY4I,12
18
+ ihcctl-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ihcctl = zehnder_ihc.cli:main
@@ -0,0 +1 @@
1
+ zehnder_ihc
@@ -0,0 +1,66 @@
1
+ """Public Zehnder IHC client API."""
2
+
3
+ from .bleak_backend import BleakBackend
4
+ from .backup import BackupDayplanV1, BackupSourceDeviceV1, BackupWeekplanV1, ScheduleBackupV1
5
+ from .client import IHCClient
6
+ from .errors import (
7
+ ActivePlanDeletionError,
8
+ AmbiguousPlanNameError,
9
+ BackupDeviceMismatchError,
10
+ BackupValidationError,
11
+ IndeterminateRegistrationError,
12
+ PlanNotFoundError,
13
+ PlanReferencedError,
14
+ )
15
+ from .identity import Identity
16
+ from .models import (
17
+ Dayplan,
18
+ DayplanQuickHeating,
19
+ DayplanTransition,
20
+ OperatingMode,
21
+ ScheduleInventory,
22
+ ScheduleState,
23
+ Status,
24
+ Weekday,
25
+ Weekplan,
26
+ )
27
+ from .restore import (
28
+ ScheduleRestoreActionKindV1,
29
+ ScheduleRestoreActionV1,
30
+ ScheduleRestoreExecutionStepV1,
31
+ ScheduleRestoreMappingV1,
32
+ ScheduleRestorePlanV1,
33
+ )
34
+ from .transport import BLETransport
35
+
36
+ __all__ = [
37
+ "BLETransport",
38
+ "BleakBackend",
39
+ "ActivePlanDeletionError",
40
+ "AmbiguousPlanNameError",
41
+ "BackupDayplanV1",
42
+ "BackupDeviceMismatchError",
43
+ "BackupSourceDeviceV1",
44
+ "BackupValidationError",
45
+ "BackupWeekplanV1",
46
+ "Dayplan",
47
+ "DayplanQuickHeating",
48
+ "DayplanTransition",
49
+ "IHCClient",
50
+ "Identity",
51
+ "IndeterminateRegistrationError",
52
+ "OperatingMode",
53
+ "PlanNotFoundError",
54
+ "PlanReferencedError",
55
+ "ScheduleInventory",
56
+ "ScheduleBackupV1",
57
+ "ScheduleRestoreActionKindV1",
58
+ "ScheduleRestoreActionV1",
59
+ "ScheduleRestoreExecutionStepV1",
60
+ "ScheduleRestoreMappingV1",
61
+ "ScheduleRestorePlanV1",
62
+ "ScheduleState",
63
+ "Status",
64
+ "Weekday",
65
+ "Weekplan",
66
+ ]
zehnder_ihc/backup.py ADDED
@@ -0,0 +1,280 @@
1
+ """Versioned logical schedule-backup models."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+ import json
6
+ import re
7
+ from typing import ClassVar
8
+
9
+ from .errors import BackupValidationError
10
+ from .models import DayplanQuickHeating, DayplanTransition, ScheduleState, Weekday
11
+
12
+ _BLUETOOTH_ADDRESS = re.compile(r"^[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5}$")
13
+ _TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$")
14
+
15
+
16
+ def _error(reason: str) -> BackupValidationError:
17
+ return BackupValidationError(reason)
18
+
19
+
20
+ def _require_object(value: object, path: str) -> dict[str, object]:
21
+ if not isinstance(value, dict):
22
+ raise _error(f"{path} must be an object")
23
+ return value
24
+
25
+
26
+ def _require_fields(value: dict[str, object], expected: set[str], path: str) -> None:
27
+ missing = expected - value.keys()
28
+ unknown = value.keys() - expected
29
+ if missing:
30
+ raise _error(f"{path} is missing required field {sorted(missing)[0]!r}")
31
+ if unknown:
32
+ raise _error(f"{path} contains unknown field {sorted(unknown)[0]!r}")
33
+
34
+
35
+ def _require_string(value: object, path: str, *, non_empty: bool = False) -> str:
36
+ if not isinstance(value, str):
37
+ raise _error(f"{path} must be a string")
38
+ if non_empty and not value:
39
+ raise _error(f"{path} must not be empty")
40
+ return value
41
+
42
+
43
+ def _require_integer(value: object, path: str) -> int:
44
+ if not isinstance(value, int) or isinstance(value, bool):
45
+ raise _error(f"{path} must be an integer")
46
+ return value
47
+
48
+
49
+ def _format_created_at(value: datetime) -> str:
50
+ rendered = (
51
+ f"{value.year:04d}-{value.month:02d}-{value.day:02d}T"
52
+ f"{value.hour:02d}:{value.minute:02d}:{value.second:02d}"
53
+ )
54
+ if value.microsecond:
55
+ rendered += f".{value.microsecond:06d}"
56
+ return rendered + "Z"
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class BackupSourceDeviceV1:
61
+ """The normalized Bluetooth address bound to one backup."""
62
+
63
+ bluetooth_address: str
64
+
65
+ def __post_init__(self) -> None:
66
+ if not isinstance(self.bluetooth_address, str) or not _BLUETOOTH_ADDRESS.fullmatch(self.bluetooth_address):
67
+ raise _error("source_device.bluetooth_address must be a Bluetooth MAC address")
68
+ object.__setattr__(self, "bluetooth_address", self.bluetooth_address.upper())
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class BackupDayplanV1:
73
+ """One logical Dayplan without a device ID."""
74
+
75
+ key: str
76
+ name: str
77
+ transitions: tuple[DayplanTransition, ...]
78
+ quick_heating: DayplanQuickHeating | None
79
+
80
+ def __post_init__(self) -> None:
81
+ _require_string(self.key, "dayplan.key", non_empty=True)
82
+ _require_string(self.name, "dayplan.name")
83
+ if not isinstance(self.transitions, tuple):
84
+ raise _error("dayplan.transitions must be a tuple")
85
+ if not all(isinstance(item, DayplanTransition) for item in self.transitions):
86
+ raise _error("dayplan.transitions must contain DayplanTransition values")
87
+ if self.quick_heating is not None and not isinstance(self.quick_heating, DayplanQuickHeating):
88
+ raise _error("dayplan.quick_heating must be a DayplanQuickHeating or None")
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class BackupWeekplanV1:
93
+ """One logical Weekplan without a device ID."""
94
+
95
+ key: str
96
+ name: str
97
+ assignments: tuple[str, str, str, str, str, str, str]
98
+
99
+ def __post_init__(self) -> None:
100
+ _require_string(self.key, "weekplan.key", non_empty=True)
101
+ _require_string(self.name, "weekplan.name")
102
+ if not isinstance(self.assignments, tuple) or len(self.assignments) != len(Weekday):
103
+ raise _error("weekplan.assignments must contain exactly seven Dayplan keys")
104
+ for assignment in self.assignments:
105
+ _require_string(assignment, "weekplan.assignment", non_empty=True)
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class ScheduleBackupV1:
110
+ """A complete logical schedule backup in format version 1."""
111
+
112
+ FORMAT: ClassVar[str] = "zehnder-ihc-schedule-backup"
113
+ VERSION: ClassVar[int] = 1
114
+ created_at: datetime
115
+ source_device: BackupSourceDeviceV1
116
+ dayplans: tuple[BackupDayplanV1, ...]
117
+ weekplans: tuple[BackupWeekplanV1, ...]
118
+ active_weekplan: str
119
+
120
+ def __post_init__(self) -> None:
121
+ if not isinstance(self.created_at, datetime) or self.created_at.tzinfo is not timezone.utc:
122
+ raise _error("created_at must be a timezone-aware UTC timestamp")
123
+ if not isinstance(self.source_device, BackupSourceDeviceV1):
124
+ raise _error("source_device must be a BackupSourceDeviceV1")
125
+ if not isinstance(self.dayplans, tuple) or not all(isinstance(item, BackupDayplanV1) for item in self.dayplans):
126
+ raise _error("dayplans must be a tuple of BackupDayplanV1 values")
127
+ if not isinstance(self.weekplans, tuple) or not all(isinstance(item, BackupWeekplanV1) for item in self.weekplans):
128
+ raise _error("weekplans must be a tuple of BackupWeekplanV1 values")
129
+ _require_string(self.active_weekplan, "active_weekplan", non_empty=True)
130
+ dayplan_keys = [item.key for item in self.dayplans]
131
+ weekplan_keys = [item.key for item in self.weekplans]
132
+ if len(dayplan_keys) != len(set(dayplan_keys)):
133
+ raise _error("dayplan keys must be unique")
134
+ if len(weekplan_keys) != len(set(weekplan_keys)):
135
+ raise _error("weekplan keys must be unique")
136
+ dayplan_key_set = set(dayplan_keys)
137
+ for weekplan in self.weekplans:
138
+ if any(item not in dayplan_key_set for item in weekplan.assignments):
139
+ raise _error("weekplan assignments must reference declared Dayplan keys")
140
+ if self.active_weekplan not in set(weekplan_keys):
141
+ raise _error("active_weekplan must reference a declared Weekplan key")
142
+
143
+ @property
144
+ def format(self) -> str:
145
+ """Return the fixed v1 document format identifier."""
146
+ return self.FORMAT
147
+
148
+ @property
149
+ def version(self) -> int:
150
+ """Return the fixed v1 document version."""
151
+ return self.VERSION
152
+
153
+ def to_dict(self) -> dict[str, object]:
154
+ """Return the canonical JSON-compatible backup representation."""
155
+ return {
156
+ "format": self.FORMAT, "version": self.VERSION, "created_at": _format_created_at(self.created_at),
157
+ "source_device": {"bluetooth_address": self.source_device.bluetooth_address},
158
+ "dayplans": [{"key": plan.key, "name": plan.name,
159
+ "transitions": [{"minute_of_day": item.minute_of_day, "state": item.state.name} for item in plan.transitions],
160
+ "quick_heating": None if plan.quick_heating is None else {"start_minute": plan.quick_heating.start_minute, "type_code": plan.quick_heating.type_code, "duration_minutes": plan.quick_heating.duration_minutes}}
161
+ for plan in self.dayplans],
162
+ "weekplans": [{"key": plan.key, "name": plan.name, "assignments": list(plan.assignments)} for plan in self.weekplans],
163
+ "active_weekplan": self.active_weekplan,
164
+ }
165
+
166
+ @classmethod
167
+ def from_dict(cls, value: object) -> "ScheduleBackupV1":
168
+ """Validate one JSON-compatible backup representation."""
169
+ try:
170
+ document = _require_object(value, "backup")
171
+ _require_fields(document, {"format", "version", "created_at", "source_device", "dayplans", "weekplans", "active_weekplan"}, "backup")
172
+ if document["format"] != cls.FORMAT:
173
+ raise _error("backup.format is unsupported")
174
+ if _require_integer(document["version"], "backup.version") != cls.VERSION:
175
+ raise _error("backup.version is unsupported")
176
+ return cls(_parse_created_at(document["created_at"]), _parse_source_device(document["source_device"]), _parse_dayplans(document["dayplans"]), _parse_weekplans(document["weekplans"]), _require_string(document["active_weekplan"], "backup.active_weekplan", non_empty=True))
177
+ except BackupValidationError:
178
+ raise
179
+ except (TypeError, ValueError) as error:
180
+ raise _error(str(error)) from error
181
+
182
+ def to_json(self) -> str:
183
+ """Return canonical JSON for this backup."""
184
+ return json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"), allow_nan=False)
185
+
186
+ @classmethod
187
+ def from_json(cls, value: str | bytes) -> "ScheduleBackupV1":
188
+ """Parse and validate one JSON backup document."""
189
+ if not isinstance(value, (str, bytes)):
190
+ raise _error("backup JSON must be text or bytes")
191
+ try:
192
+ parsed = json.loads(value, object_pairs_hook=_reject_duplicate_keys, parse_constant=_reject_non_finite)
193
+ except (TypeError, ValueError, UnicodeDecodeError, json.JSONDecodeError) as error:
194
+ raise _error(f"invalid backup JSON: {error}") from error
195
+ return cls.from_dict(parsed)
196
+
197
+
198
+ def _parse_created_at(value: object) -> datetime:
199
+ timestamp = _require_string(value, "backup.created_at")
200
+ if not _TIMESTAMP.fullmatch(timestamp):
201
+ raise _error("backup.created_at must be a UTC ISO-8601 timestamp ending in Z")
202
+ try:
203
+ return datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
204
+ except ValueError as error:
205
+ raise _error("backup.created_at is invalid") from error
206
+
207
+
208
+ def _parse_source_device(value: object) -> BackupSourceDeviceV1:
209
+ source = _require_object(value, "backup.source_device")
210
+ _require_fields(source, {"bluetooth_address"}, "backup.source_device")
211
+ return BackupSourceDeviceV1(_require_string(source["bluetooth_address"], "backup.source_device.bluetooth_address"))
212
+
213
+
214
+ def _parse_dayplans(value: object) -> tuple[BackupDayplanV1, ...]:
215
+ if not isinstance(value, list):
216
+ raise _error("backup.dayplans must be an array")
217
+ return tuple(_parse_dayplan(item, index) for index, item in enumerate(value))
218
+
219
+
220
+ def _parse_dayplan(value: object, index: int) -> BackupDayplanV1:
221
+ path = f"backup.dayplans[{index}]"
222
+ plan = _require_object(value, path)
223
+ _require_fields(plan, {"key", "name", "transitions", "quick_heating"}, path)
224
+ transitions_value = plan["transitions"]
225
+ if not isinstance(transitions_value, list):
226
+ raise _error(f"{path}.transitions must be an array")
227
+ transitions = tuple(_parse_transition(item, path, item_index) for item_index, item in enumerate(transitions_value))
228
+ return BackupDayplanV1(_require_string(plan["key"], f"{path}.key", non_empty=True), _require_string(plan["name"], f"{path}.name"), transitions, _parse_quick_heating(plan["quick_heating"], path))
229
+
230
+
231
+ def _parse_transition(value: object, dayplan_path: str, index: int) -> DayplanTransition:
232
+ path = f"{dayplan_path}.transitions[{index}]"
233
+ transition = _require_object(value, path)
234
+ _require_fields(transition, {"minute_of_day", "state"}, path)
235
+ try:
236
+ return DayplanTransition(_require_integer(transition["minute_of_day"], f"{path}.minute_of_day"), ScheduleState[_require_string(transition["state"], f"{path}.state")])
237
+ except (KeyError, ValueError) as error:
238
+ raise _error(f"{path} is invalid: {error}") from error
239
+
240
+
241
+ def _parse_quick_heating(value: object, dayplan_path: str) -> DayplanQuickHeating | None:
242
+ if value is None:
243
+ return None
244
+ path = f"{dayplan_path}.quick_heating"
245
+ quick = _require_object(value, path)
246
+ _require_fields(quick, {"start_minute", "type_code", "duration_minutes"}, path)
247
+ try:
248
+ return DayplanQuickHeating(_require_integer(quick["start_minute"], f"{path}.start_minute"), _require_integer(quick["type_code"], f"{path}.type_code"), _require_integer(quick["duration_minutes"], f"{path}.duration_minutes"))
249
+ except ValueError as error:
250
+ raise _error(f"{path} is invalid: {error}") from error
251
+
252
+
253
+ def _parse_weekplans(value: object) -> tuple[BackupWeekplanV1, ...]:
254
+ if not isinstance(value, list):
255
+ raise _error("backup.weekplans must be an array")
256
+ return tuple(_parse_weekplan(item, index) for index, item in enumerate(value))
257
+
258
+
259
+ def _parse_weekplan(value: object, index: int) -> BackupWeekplanV1:
260
+ path = f"backup.weekplans[{index}]"
261
+ plan = _require_object(value, path)
262
+ _require_fields(plan, {"key", "name", "assignments"}, path)
263
+ assignments_value = plan["assignments"]
264
+ if not isinstance(assignments_value, list):
265
+ raise _error(f"{path}.assignments must be an array")
266
+ assignments = tuple(_require_string(item, f"{path}.assignments[{item_index}]", non_empty=True) for item_index, item in enumerate(assignments_value))
267
+ return BackupWeekplanV1(_require_string(plan["key"], f"{path}.key", non_empty=True), _require_string(plan["name"], f"{path}.name"), assignments) # type: ignore[arg-type]
268
+
269
+
270
+ def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
271
+ result: dict[str, object] = {}
272
+ for key, value in pairs:
273
+ if key in result:
274
+ raise _error(f"duplicate JSON object member {key!r}")
275
+ result[key] = value
276
+ return result
277
+
278
+
279
+ def _reject_non_finite(value: str) -> None:
280
+ raise _error(f"non-finite JSON value {value!r} is not supported")
@@ -0,0 +1,70 @@
1
+ """Bleak implementation of the IHC BLE backend boundary."""
2
+
3
+ import asyncio
4
+ from collections.abc import Callable
5
+
6
+ WRITE_UUID = "2456e1b9-26e2-8f83-e744-f34f01e9d702"
7
+ NOTIFY_UUID = "2456e1b9-26e2-8f83-e744-f34f01e9d703"
8
+
9
+
10
+ class BleakBackend:
11
+ """Connect one known IHC and expose its verified GATT characteristics."""
12
+
13
+ def __init__(self, client_factory=None, *, find_device=None, device=None) -> None:
14
+ if client_factory is None or find_device is None:
15
+ from bleak import BleakClient, BleakScanner
16
+
17
+ client_factory = BleakClient
18
+ find_device = BleakScanner.find_device_by_address
19
+ self._client_factory = client_factory
20
+ self._find_device = find_device
21
+ self._device = device
22
+ self._client = None
23
+
24
+ async def connect(self, address: str, notification_handler: Callable[[bytes], None]) -> None:
25
+ """Find, connect and subscribe to the IHC notification characteristic."""
26
+ device = self._device or await self._find_device(address)
27
+ if device is None:
28
+ raise ConnectionError(f"IHC not found: {address}")
29
+ self._client = self._client_factory(device)
30
+ try:
31
+ await self._client.connect()
32
+ await self._client.start_notify(
33
+ NOTIFY_UUID, lambda _sender, value: notification_handler(bytes(value))
34
+ )
35
+ except asyncio.CancelledError:
36
+ try:
37
+ await self.close()
38
+ except BaseException:
39
+ pass
40
+ raise
41
+ except Exception as error:
42
+ try:
43
+ await self.close()
44
+ except BaseException:
45
+ pass
46
+ raise ConnectionError("IHC Bluetooth connection failed") from error
47
+
48
+ async def close(self) -> None:
49
+ """Stop notifications and release the active BLE client."""
50
+ client = self._client
51
+ self._client = None
52
+ if client is None:
53
+ return
54
+ if client.is_connected:
55
+ try:
56
+ await client.stop_notify(NOTIFY_UUID)
57
+ finally:
58
+ await client.disconnect()
59
+
60
+ async def write(self, value: bytes, *, response: bool) -> None:
61
+ """Write one value to the verified IHC write characteristic."""
62
+ if self._client is None:
63
+ raise ConnectionError("IHC is not connected")
64
+ await self._client.write_gatt_char(WRITE_UUID, value, response=response)
65
+
66
+ async def read_notify(self) -> bytes:
67
+ """Read d703 to acknowledge notification flow control."""
68
+ if self._client is None:
69
+ raise ConnectionError("IHC is not connected")
70
+ return bytes(await self._client.read_gatt_char(NOTIFY_UUID))