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.
- ihcctl-0.2.0.dist-info/METADATA +110 -0
- ihcctl-0.2.0.dist-info/RECORD +18 -0
- ihcctl-0.2.0.dist-info/WHEEL +5 -0
- ihcctl-0.2.0.dist-info/entry_points.txt +2 -0
- ihcctl-0.2.0.dist-info/top_level.txt +1 -0
- zehnder_ihc/__init__.py +66 -0
- zehnder_ihc/backup.py +280 -0
- zehnder_ihc/bleak_backend.py +70 -0
- zehnder_ihc/cli.py +530 -0
- zehnder_ihc/client.py +1026 -0
- zehnder_ihc/errors.py +94 -0
- zehnder_ihc/framing.py +39 -0
- zehnder_ihc/identity.py +58 -0
- zehnder_ihc/models.py +128 -0
- zehnder_ihc/mutations.py +57 -0
- zehnder_ihc/protocol.py +477 -0
- zehnder_ihc/restore.py +231 -0
- zehnder_ihc/transport.py +143 -0
|
@@ -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 @@
|
|
|
1
|
+
zehnder_ihc
|
zehnder_ihc/__init__.py
ADDED
|
@@ -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))
|