quilt-hp-python 0.1.1__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.
- quilt_hp/__init__.py +22 -0
- quilt_hp/_paths.py +26 -0
- quilt_hp/_proto/__init__.py +0 -0
- quilt_hp/_proto/quilt_device_pairing_pb2.py +56 -0
- quilt_hp/_proto/quilt_device_pairing_pb2.pyi +317 -0
- quilt_hp/_proto/quilt_device_pairing_pb2_grpc.py +24 -0
- quilt_hp/_proto/quilt_hds_pb2.py +292 -0
- quilt_hp/_proto/quilt_hds_pb2.pyi +3947 -0
- quilt_hp/_proto/quilt_hds_pb2_grpc.py +1732 -0
- quilt_hp/_proto/quilt_notifier_pb2.py +55 -0
- quilt_hp/_proto/quilt_notifier_pb2.pyi +258 -0
- quilt_hp/_proto/quilt_notifier_pb2_grpc.py +97 -0
- quilt_hp/_proto/quilt_services_pb2.py +171 -0
- quilt_hp/_proto/quilt_services_pb2.pyi +1320 -0
- quilt_hp/_proto/quilt_services_pb2_grpc.py +1188 -0
- quilt_hp/_proto/quilt_system_pb2.py +53 -0
- quilt_hp/_proto/quilt_system_pb2.pyi +164 -0
- quilt_hp/_proto/quilt_system_pb2_grpc.py +270 -0
- quilt_hp/auth.py +244 -0
- quilt_hp/cli/__init__.py +1 -0
- quilt_hp/cli/main.py +770 -0
- quilt_hp/cli/settings.py +123 -0
- quilt_hp/cli/store.py +105 -0
- quilt_hp/cli/tui.py +2677 -0
- quilt_hp/client.py +616 -0
- quilt_hp/const.py +57 -0
- quilt_hp/exceptions.py +23 -0
- quilt_hp/models/__init__.py +85 -0
- quilt_hp/models/comfort.py +47 -0
- quilt_hp/models/controller.py +135 -0
- quilt_hp/models/energy.py +31 -0
- quilt_hp/models/enums.py +298 -0
- quilt_hp/models/indoor_unit.py +412 -0
- quilt_hp/models/outdoor_unit.py +71 -0
- quilt_hp/models/qsm.py +105 -0
- quilt_hp/models/schedule.py +98 -0
- quilt_hp/models/sensor.py +92 -0
- quilt_hp/models/software_update.py +74 -0
- quilt_hp/models/space.py +177 -0
- quilt_hp/models/system.py +451 -0
- quilt_hp/py.typed +1 -0
- quilt_hp/services/__init__.py +1 -0
- quilt_hp/services/hds.py +480 -0
- quilt_hp/services/streaming.py +561 -0
- quilt_hp/services/system.py +95 -0
- quilt_hp/services/user.py +143 -0
- quilt_hp/tokens.py +119 -0
- quilt_hp/transport.py +192 -0
- quilt_hp_python-0.1.1.dist-info/METADATA +172 -0
- quilt_hp_python-0.1.1.dist-info/RECORD +53 -0
- quilt_hp_python-0.1.1.dist-info/WHEEL +4 -0
- quilt_hp_python-0.1.1.dist-info/entry_points.txt +2 -0
- quilt_hp_python-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""System-level models — system info and full snapshot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from quilt_hp.models.comfort import ComfortSetting
|
|
9
|
+
from quilt_hp.models.controller import Controller
|
|
10
|
+
from quilt_hp.models.enums import (
|
|
11
|
+
ComfortSettingType,
|
|
12
|
+
HVACMode,
|
|
13
|
+
LightState,
|
|
14
|
+
LouverMode,
|
|
15
|
+
RemoteSensorControlMode,
|
|
16
|
+
)
|
|
17
|
+
from quilt_hp.models.indoor_unit import IndoorUnit
|
|
18
|
+
from quilt_hp.models.outdoor_unit import OutdoorUnit
|
|
19
|
+
from quilt_hp.models.qsm import QuiltSmartModule
|
|
20
|
+
from quilt_hp.models.schedule import ScheduleDay, ScheduleWeek
|
|
21
|
+
from quilt_hp.models.sensor import ControllerRemoteSensor, RemoteSensor
|
|
22
|
+
from quilt_hp.models.software_update import SoftwareUpdateInfo
|
|
23
|
+
from quilt_hp.models.space import Space
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class Location:
|
|
28
|
+
"""A Quilt location with global settings like schedule execution state."""
|
|
29
|
+
|
|
30
|
+
id: str
|
|
31
|
+
name: str
|
|
32
|
+
system_id: str
|
|
33
|
+
timezone: str
|
|
34
|
+
schedule_paused: bool # True when SCHEDULE_EXECUTION_PAUSED
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_proto(cls, proto: object) -> Location:
|
|
38
|
+
"""Construct from a protobuf Location message."""
|
|
39
|
+
from quilt_hp._proto import quilt_hds_pb2 as hds
|
|
40
|
+
|
|
41
|
+
return cls(
|
|
42
|
+
id=proto.header.object_id, # type: ignore[attr-defined]
|
|
43
|
+
name=proto.attributes.name, # type: ignore[attr-defined]
|
|
44
|
+
system_id=proto.header.system_id, # type: ignore[attr-defined]
|
|
45
|
+
timezone=proto.attributes.tz_identifier, # type: ignore[attr-defined]
|
|
46
|
+
schedule_paused=(
|
|
47
|
+
proto.controls.schedule_execution # type: ignore[attr-defined]
|
|
48
|
+
== hds.SCHEDULE_EXECUTION_PAUSED
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(slots=True)
|
|
54
|
+
class SystemInfo:
|
|
55
|
+
"""Basic system metadata from ListSystems."""
|
|
56
|
+
|
|
57
|
+
id: str
|
|
58
|
+
name: str
|
|
59
|
+
timezone: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(slots=True)
|
|
63
|
+
class SystemSnapshot:
|
|
64
|
+
"""Full system state from GetHomeDatastoreSystem."""
|
|
65
|
+
|
|
66
|
+
spaces: list[Space]
|
|
67
|
+
indoor_units: list[IndoorUnit]
|
|
68
|
+
outdoor_units: list[OutdoorUnit]
|
|
69
|
+
controllers: list[Controller]
|
|
70
|
+
quilt_smart_modules: list[QuiltSmartModule]
|
|
71
|
+
comfort_settings: list[ComfortSetting]
|
|
72
|
+
schedule_weeks: list[ScheduleWeek]
|
|
73
|
+
schedule_days: list[ScheduleDay]
|
|
74
|
+
remote_sensors: list[RemoteSensor]
|
|
75
|
+
controller_remote_sensors: list[ControllerRemoteSensor]
|
|
76
|
+
software_update_infos: list[SoftwareUpdateInfo]
|
|
77
|
+
locations: list[Location]
|
|
78
|
+
timezone: str | None
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def rooms(self) -> list[Space]:
|
|
82
|
+
"""Return only leaf/room spaces (those with a parent)."""
|
|
83
|
+
return [s for s in self.spaces if s.is_room]
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def primary_location(self) -> Location | None:
|
|
87
|
+
"""The first (and typically only) Location for this system."""
|
|
88
|
+
return self.locations[0] if self.locations else None
|
|
89
|
+
|
|
90
|
+
def space_by_name(self, name: str) -> Space | None:
|
|
91
|
+
"""Find a space by name (case-insensitive)."""
|
|
92
|
+
name_lower = name.lower()
|
|
93
|
+
for s in self.spaces:
|
|
94
|
+
if s.name.lower() == name_lower:
|
|
95
|
+
return s
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
def enrich_space(self, space: Space) -> Space:
|
|
99
|
+
"""Resolve active_comfort_setting_type on a stream-updated Space.
|
|
100
|
+
|
|
101
|
+
Stream updates deliver individual Space protos without the comfort
|
|
102
|
+
settings context. Call this before using space.is_away / space.is_off
|
|
103
|
+
on a space received from the NotifierStream.
|
|
104
|
+
"""
|
|
105
|
+
cs_id = space.controls.comfort_setting_id
|
|
106
|
+
for cs in self.comfort_settings:
|
|
107
|
+
if cs.id == cs_id:
|
|
108
|
+
space.active_comfort_setting_type = cs.type
|
|
109
|
+
return space
|
|
110
|
+
space.active_comfort_setting_type = None
|
|
111
|
+
return space
|
|
112
|
+
|
|
113
|
+
def apply_space(self, space: Space) -> Space:
|
|
114
|
+
"""Enrich and patch a stream-updated Space into the snapshot.
|
|
115
|
+
|
|
116
|
+
Resolves comfort-setting type (needed for is_away / is_off) then
|
|
117
|
+
merges the diff into the existing Space, preserving sub-messages that
|
|
118
|
+
were absent from the sparse proto diff. Returns the merged space.
|
|
119
|
+
|
|
120
|
+
Proto3 stream diffs are sparse — only changed fields are sent. A
|
|
121
|
+
settings-only diff produces a Space with
|
|
122
|
+
``controls.hvac_mode=UNSPECIFIED`` and
|
|
123
|
+
``state.ambient_temperature_c=None``. Without merging, those defaults
|
|
124
|
+
would overwrite real data.
|
|
125
|
+
"""
|
|
126
|
+
self.enrich_space(space)
|
|
127
|
+
for i, s in enumerate(self.spaces):
|
|
128
|
+
if s.id == space.id:
|
|
129
|
+
from dataclasses import replace
|
|
130
|
+
|
|
131
|
+
updates: dict[str, Any] = {}
|
|
132
|
+
# state absent: ambient_temperature_c is None
|
|
133
|
+
# (set only when updated_ts present)
|
|
134
|
+
if (
|
|
135
|
+
space.state.ambient_temperature_c is None
|
|
136
|
+
and s.state.ambient_temperature_c is not None
|
|
137
|
+
):
|
|
138
|
+
updates["state"] = s.state
|
|
139
|
+
# controls absent: hvac_mode is UNSPECIFIED and existing mode
|
|
140
|
+
# is real
|
|
141
|
+
if (
|
|
142
|
+
space.controls.hvac_mode == HVACMode.UNSPECIFIED
|
|
143
|
+
and s.controls.hvac_mode != HVACMode.UNSPECIFIED
|
|
144
|
+
):
|
|
145
|
+
updates["controls"] = s.controls
|
|
146
|
+
# settings absent: name is empty (settings.name is always
|
|
147
|
+
# non-empty in a real Space)
|
|
148
|
+
if not space.settings.name and s.settings.name:
|
|
149
|
+
updates["settings"] = s.settings
|
|
150
|
+
updates["name"] = s.name
|
|
151
|
+
if updates:
|
|
152
|
+
space = replace(space, **updates)
|
|
153
|
+
self.spaces[i] = space
|
|
154
|
+
return space
|
|
155
|
+
self.spaces.append(space)
|
|
156
|
+
return space
|
|
157
|
+
|
|
158
|
+
def apply_indoor_unit(self, idu: IndoorUnit) -> IndoorUnit:
|
|
159
|
+
"""Patch a stream-updated IndoorUnit into the snapshot.
|
|
160
|
+
|
|
161
|
+
Stream protos are partial — ``qsm_id``, ``outdoor_unit_id``, the
|
|
162
|
+
``hvac_inputs``/``conditions`` sub-messages, and the ``state``
|
|
163
|
+
sub-message may all be absent in a diff. Preserve existing values so
|
|
164
|
+
stream updates don't erase them.
|
|
165
|
+
|
|
166
|
+
In particular, a controls-only diff (e.g. LED toggle) omits ``state``,
|
|
167
|
+
which would make ``state.updated_at=None`` and therefore
|
|
168
|
+
``is_online=False``, causing ``led_on`` and
|
|
169
|
+
``effective_occupancy_state`` to report stale data.
|
|
170
|
+
"""
|
|
171
|
+
for i, u in enumerate(self.indoor_units):
|
|
172
|
+
if u.id == idu.id:
|
|
173
|
+
from dataclasses import replace
|
|
174
|
+
|
|
175
|
+
updates: dict[str, Any] = {}
|
|
176
|
+
if idu.qsm_id is None and u.qsm_id:
|
|
177
|
+
updates["qsm_id"] = u.qsm_id
|
|
178
|
+
if idu.outdoor_unit_id is None and u.outdoor_unit_id:
|
|
179
|
+
updates["outdoor_unit_id"] = u.outdoor_unit_id
|
|
180
|
+
if idu.hvac_inputs is None and u.hvac_inputs is not None:
|
|
181
|
+
updates["hvac_inputs"] = u.hvac_inputs
|
|
182
|
+
if idu.conditions is None and u.conditions is not None:
|
|
183
|
+
updates["conditions"] = u.conditions
|
|
184
|
+
# state absent from diff: updated_at is None — preserve
|
|
185
|
+
# existing so is_online stays valid
|
|
186
|
+
if idu.state.updated_at is None and u.state.updated_at is not None:
|
|
187
|
+
updates["state"] = u.state
|
|
188
|
+
# controls absent detection: state IS in diff
|
|
189
|
+
# (timestamped state-only update), and all control sentinel
|
|
190
|
+
# fields are at proto3 defaults. When controls are genuinely
|
|
191
|
+
# sent: led_color_code is non-zero, OR led_state is explicit
|
|
192
|
+
# ON/OFF, OR louver_mode is set. Brightness alone is not a
|
|
193
|
+
# safe sentinel because mobile_led_scheduling_enabled preserves
|
|
194
|
+
# brightness when LED is OFF.
|
|
195
|
+
if (
|
|
196
|
+
idu.state.updated_at is not None
|
|
197
|
+
and idu.controls.fan_speed_mode_raw == 0
|
|
198
|
+
and idu.controls.louver_mode == LouverMode.UNSPECIFIED
|
|
199
|
+
and idu.controls.led_color_code == 0
|
|
200
|
+
and idu.controls.led_state == LightState.UNSPECIFIED
|
|
201
|
+
and idu.controls.led_brightness == 0.0
|
|
202
|
+
):
|
|
203
|
+
updates["controls"] = u.controls
|
|
204
|
+
# | None sub-messages: absent from diff → parsed as None;
|
|
205
|
+
# preserve existing data
|
|
206
|
+
if idu.performance_data is None and u.performance_data is not None:
|
|
207
|
+
updates["performance_data"] = u.performance_data
|
|
208
|
+
if idu.performance_metrics is None and u.performance_metrics is not None:
|
|
209
|
+
updates["performance_metrics"] = u.performance_metrics
|
|
210
|
+
if idu.presence is None and u.presence is not None:
|
|
211
|
+
updates["presence"] = u.presence
|
|
212
|
+
if idu.occupancy is None and u.occupancy is not None:
|
|
213
|
+
updates["occupancy"] = u.occupancy
|
|
214
|
+
if updates:
|
|
215
|
+
idu = replace(idu, **updates)
|
|
216
|
+
self.indoor_units[i] = idu
|
|
217
|
+
return idu
|
|
218
|
+
self.indoor_units.append(idu)
|
|
219
|
+
return idu
|
|
220
|
+
|
|
221
|
+
def apply_outdoor_unit(self, odu: OutdoorUnit) -> OutdoorUnit:
|
|
222
|
+
"""Patch a stream-updated OutdoorUnit into the snapshot.
|
|
223
|
+
|
|
224
|
+
Stream diffs may omit ``state`` (only performance_data changed) or
|
|
225
|
+
lack hardware info (no hw_map available at parse time). Preserve any
|
|
226
|
+
existing non-default values so partial updates don't erase them.
|
|
227
|
+
"""
|
|
228
|
+
from dataclasses import replace
|
|
229
|
+
|
|
230
|
+
for i, u in enumerate(self.outdoor_units):
|
|
231
|
+
if u.id == odu.id:
|
|
232
|
+
# Preserve hvac_state when stream diff has a default-zero state
|
|
233
|
+
if not odu.hvac_state and u.hvac_state:
|
|
234
|
+
odu = replace(odu, hvac_state=u.hvac_state)
|
|
235
|
+
# Preserve hardware info — stream diffs are parsed without hw_map
|
|
236
|
+
if odu.model_sku is None and u.model_sku is not None:
|
|
237
|
+
odu = replace(
|
|
238
|
+
odu,
|
|
239
|
+
model_sku=u.model_sku,
|
|
240
|
+
serial_number=u.serial_number,
|
|
241
|
+
firmware_version=u.firmware_version,
|
|
242
|
+
)
|
|
243
|
+
self.outdoor_units[i] = odu
|
|
244
|
+
return odu
|
|
245
|
+
self.outdoor_units.append(odu)
|
|
246
|
+
return odu
|
|
247
|
+
|
|
248
|
+
def apply_controller(self, ctrl: Controller) -> Controller:
|
|
249
|
+
"""Patch a stream-updated Controller into the snapshot.
|
|
250
|
+
|
|
251
|
+
Stream diffs are sparse — a temperature state diff omits settings (name)
|
|
252
|
+
and hosted_wifi_state. Preserve existing non-empty values.
|
|
253
|
+
Hardware info (serial, model_sku, firmware_version) is only populated at
|
|
254
|
+
initial snapshot load and is never in stream diffs; always preserve it.
|
|
255
|
+
"""
|
|
256
|
+
from dataclasses import replace
|
|
257
|
+
|
|
258
|
+
for i, c in enumerate(self.controllers):
|
|
259
|
+
if c.id == ctrl.id:
|
|
260
|
+
updates: dict[str, Any] = {}
|
|
261
|
+
if not ctrl.name and c.name:
|
|
262
|
+
updates["name"] = c.name
|
|
263
|
+
if ctrl.wifi_ssid is None and c.wifi_ssid is not None:
|
|
264
|
+
updates["wifi_ssid"] = c.wifi_ssid
|
|
265
|
+
updates["wifi_ip"] = c.wifi_ip
|
|
266
|
+
updates["wifi_signal_dbm"] = c.wifi_signal_dbm
|
|
267
|
+
updates["wifi_freq_mhz"] = c.wifi_freq_mhz
|
|
268
|
+
updates["wifi_last_seen"] = c.wifi_last_seen
|
|
269
|
+
if ctrl.ap_wifi is None and c.ap_wifi is not None:
|
|
270
|
+
updates["ap_wifi"] = c.ap_wifi
|
|
271
|
+
if ctrl.p2p_wifi is None and c.p2p_wifi is not None:
|
|
272
|
+
updates["p2p_wifi"] = c.p2p_wifi
|
|
273
|
+
if (
|
|
274
|
+
ctrl.remote_sensor_mode == RemoteSensorControlMode.UNSPECIFIED
|
|
275
|
+
and c.remote_sensor_mode != RemoteSensorControlMode.UNSPECIFIED
|
|
276
|
+
):
|
|
277
|
+
updates["remote_sensor_mode"] = c.remote_sensor_mode
|
|
278
|
+
# Hardware fields are never in stream diffs — always preserve
|
|
279
|
+
# from snapshot
|
|
280
|
+
if ctrl.serial_number is None and c.serial_number is not None:
|
|
281
|
+
updates["serial_number"] = c.serial_number
|
|
282
|
+
updates["model_sku"] = c.model_sku
|
|
283
|
+
updates["firmware_version"] = c.firmware_version
|
|
284
|
+
if updates:
|
|
285
|
+
ctrl = replace(ctrl, **updates)
|
|
286
|
+
self.controllers[i] = ctrl
|
|
287
|
+
return ctrl
|
|
288
|
+
self.controllers.append(ctrl)
|
|
289
|
+
return ctrl
|
|
290
|
+
|
|
291
|
+
def apply_qsm(self, qsm: QuiltSmartModule) -> QuiltSmartModule:
|
|
292
|
+
"""Patch a stream-updated QuiltSmartModule into the snapshot.
|
|
293
|
+
|
|
294
|
+
Stream diffs are sparse — a controls diff omits state (sensors) and wifi
|
|
295
|
+
state sub-messages. Preserve existing non-None values.
|
|
296
|
+
"""
|
|
297
|
+
from dataclasses import replace
|
|
298
|
+
|
|
299
|
+
for i, q in enumerate(self.quilt_smart_modules):
|
|
300
|
+
if q.id == qsm.id:
|
|
301
|
+
updates: dict[str, Any] = {}
|
|
302
|
+
if qsm.sensors is None and q.sensors is not None:
|
|
303
|
+
updates["sensors"] = q.sensors
|
|
304
|
+
if qsm.hosted_wifi is None and q.hosted_wifi is not None:
|
|
305
|
+
updates["hosted_wifi"] = q.hosted_wifi
|
|
306
|
+
if qsm.ap_wifi is None and q.ap_wifi is not None:
|
|
307
|
+
updates["ap_wifi"] = q.ap_wifi
|
|
308
|
+
if qsm.p2p_wifi is None and q.p2p_wifi is not None:
|
|
309
|
+
updates["p2p_wifi"] = q.p2p_wifi
|
|
310
|
+
if updates:
|
|
311
|
+
qsm = replace(qsm, **updates)
|
|
312
|
+
self.quilt_smart_modules[i] = qsm
|
|
313
|
+
return qsm
|
|
314
|
+
self.quilt_smart_modules.append(qsm)
|
|
315
|
+
return qsm
|
|
316
|
+
|
|
317
|
+
def apply_remote_sensor(self, rs: RemoteSensor) -> RemoteSensor:
|
|
318
|
+
"""Patch a stream-updated RemoteSensor into the snapshot.
|
|
319
|
+
|
|
320
|
+
Stream diffs are sparse — a state-only diff (temperature/humidity update)
|
|
321
|
+
omits controls, leaving control_mode=UNSPECIFIED. A controls-only diff
|
|
322
|
+
omits state, zeroing all sensor readings. Preserve existing non-None
|
|
323
|
+
values.
|
|
324
|
+
"""
|
|
325
|
+
from dataclasses import replace
|
|
326
|
+
|
|
327
|
+
for i, r in enumerate(self.remote_sensors):
|
|
328
|
+
if r.id == rs.id:
|
|
329
|
+
updates: dict[str, Any] = {}
|
|
330
|
+
# controls absent: control_mode defaults to UNSPECIFIED.
|
|
331
|
+
if (
|
|
332
|
+
rs.control_mode == RemoteSensorControlMode.UNSPECIFIED
|
|
333
|
+
and r.control_mode != RemoteSensorControlMode.UNSPECIFIED
|
|
334
|
+
):
|
|
335
|
+
updates["control_mode"] = r.control_mode
|
|
336
|
+
# state absent: all sensor readings become None
|
|
337
|
+
if rs.ambient_temperature_c is None and r.ambient_temperature_c is not None:
|
|
338
|
+
updates["ambient_temperature_c"] = r.ambient_temperature_c
|
|
339
|
+
if rs.humidity_percent is None and r.humidity_percent is not None:
|
|
340
|
+
updates["humidity_percent"] = r.humidity_percent
|
|
341
|
+
if rs.battery_level_percent is None and r.battery_level_percent is not None:
|
|
342
|
+
updates["battery_level_percent"] = r.battery_level_percent
|
|
343
|
+
if rs.signal_level_dbm is None and r.signal_level_dbm is not None:
|
|
344
|
+
updates["signal_level_dbm"] = r.signal_level_dbm
|
|
345
|
+
if updates:
|
|
346
|
+
rs = replace(rs, **updates)
|
|
347
|
+
self.remote_sensors[i] = rs
|
|
348
|
+
return rs
|
|
349
|
+
self.remote_sensors.append(rs)
|
|
350
|
+
return rs
|
|
351
|
+
|
|
352
|
+
def apply_controller_remote_sensor(
|
|
353
|
+
self, crs: ControllerRemoteSensor
|
|
354
|
+
) -> ControllerRemoteSensor:
|
|
355
|
+
"""Patch a stream-updated ControllerRemoteSensor into the snapshot."""
|
|
356
|
+
from dataclasses import replace
|
|
357
|
+
|
|
358
|
+
for i, r in enumerate(self.controller_remote_sensors):
|
|
359
|
+
if r.id == crs.id:
|
|
360
|
+
updates: dict[str, Any] = {}
|
|
361
|
+
if (
|
|
362
|
+
crs.control_mode == RemoteSensorControlMode.UNSPECIFIED
|
|
363
|
+
and r.control_mode != RemoteSensorControlMode.UNSPECIFIED
|
|
364
|
+
):
|
|
365
|
+
updates["control_mode"] = r.control_mode
|
|
366
|
+
if crs.ambient_temperature_c is None and r.ambient_temperature_c is not None:
|
|
367
|
+
updates["ambient_temperature_c"] = r.ambient_temperature_c
|
|
368
|
+
if crs.humidity_percent is None and r.humidity_percent is not None:
|
|
369
|
+
updates["humidity_percent"] = r.humidity_percent
|
|
370
|
+
if crs.battery_level_percent is None and r.battery_level_percent is not None:
|
|
371
|
+
updates["battery_level_percent"] = r.battery_level_percent
|
|
372
|
+
if crs.signal_level_dbm is None and r.signal_level_dbm is not None:
|
|
373
|
+
updates["signal_level_dbm"] = r.signal_level_dbm
|
|
374
|
+
if updates:
|
|
375
|
+
crs = replace(crs, **updates)
|
|
376
|
+
self.controller_remote_sensors[i] = crs
|
|
377
|
+
return crs
|
|
378
|
+
self.controller_remote_sensors.append(crs)
|
|
379
|
+
return crs
|
|
380
|
+
|
|
381
|
+
def qsm_for_idu(self, idu: IndoorUnit) -> QuiltSmartModule | None:
|
|
382
|
+
"""Return the QSM embedded in the given IDU, or None."""
|
|
383
|
+
if not idu.qsm_id:
|
|
384
|
+
return None
|
|
385
|
+
return next((q for q in self.quilt_smart_modules if q.id == idu.qsm_id), None)
|
|
386
|
+
|
|
387
|
+
def stream_topics(self) -> list[str]:
|
|
388
|
+
"""Return the NotifierService topic strings for this snapshot.
|
|
389
|
+
|
|
390
|
+
Pass the result directly to ``client.stream(topics)``. Covers all
|
|
391
|
+
rooms, indoor units, outdoor units, and controllers.
|
|
392
|
+
"""
|
|
393
|
+
topics: list[str] = []
|
|
394
|
+
for space in self.rooms:
|
|
395
|
+
topics.append(f"hds/space/{space.id}")
|
|
396
|
+
for idu in self.indoor_units:
|
|
397
|
+
topics.append(f"hds/indoor_unit/{idu.id}")
|
|
398
|
+
for odu in self.outdoor_units:
|
|
399
|
+
topics.append(f"hds/outdoor_unit/{odu.id}")
|
|
400
|
+
for ctrl in self.controllers:
|
|
401
|
+
topics.append(f"hds/controller/{ctrl.id}")
|
|
402
|
+
for qsm in self.quilt_smart_modules:
|
|
403
|
+
topics.append(f"hds/quilt_smart_module/{qsm.id}")
|
|
404
|
+
for rs in self.remote_sensors:
|
|
405
|
+
topics.append(f"hds/remote_sensor/{rs.id}")
|
|
406
|
+
for crs in self.controller_remote_sensors:
|
|
407
|
+
topics.append(f"hds/controller_remote_sensor/{crs.id}")
|
|
408
|
+
for sui in self.software_update_infos:
|
|
409
|
+
topics.append(f"hds/software_update_info/{sui.id}")
|
|
410
|
+
return topics
|
|
411
|
+
|
|
412
|
+
@classmethod
|
|
413
|
+
def from_proto(cls, proto: object) -> SystemSnapshot:
|
|
414
|
+
"""Construct from a protobuf HomeDatastoreSystem message."""
|
|
415
|
+
p = cast("Any", proto)
|
|
416
|
+
odu_hw_map = {h.header.object_id: h for h in p.outdoor_unit_hardware}
|
|
417
|
+
ctrl_hw_map = {h.header.object_id: h for h in p.controller_hardware}
|
|
418
|
+
|
|
419
|
+
locations = [Location.from_proto(loc) for loc in p.locations]
|
|
420
|
+
tz = locations[0].timezone if locations else None
|
|
421
|
+
|
|
422
|
+
comfort_settings = [ComfortSetting.from_proto(cs) for cs in p.comfort_settings]
|
|
423
|
+
cs_type_by_id: dict[str, ComfortSettingType] = {cs.id: cs.type for cs in comfort_settings}
|
|
424
|
+
|
|
425
|
+
spaces: list[Space] = []
|
|
426
|
+
for s in p.spaces:
|
|
427
|
+
space = Space.from_proto(s)
|
|
428
|
+
space.active_comfort_setting_type = cs_type_by_id.get(
|
|
429
|
+
space.controls.comfort_setting_id
|
|
430
|
+
)
|
|
431
|
+
spaces.append(space)
|
|
432
|
+
|
|
433
|
+
return cls(
|
|
434
|
+
spaces=spaces,
|
|
435
|
+
indoor_units=[IndoorUnit.from_proto(u) for u in p.indoor_units],
|
|
436
|
+
outdoor_units=[OutdoorUnit.from_proto(u, odu_hw_map) for u in p.outdoor_units],
|
|
437
|
+
controllers=[Controller.from_proto(c, ctrl_hw_map) for c in p.controllers],
|
|
438
|
+
quilt_smart_modules=[QuiltSmartModule.from_proto(q) for q in p.quilt_smart_modules],
|
|
439
|
+
comfort_settings=comfort_settings,
|
|
440
|
+
schedule_weeks=[ScheduleWeek.from_proto(w) for w in p.schedule_weeks],
|
|
441
|
+
schedule_days=[ScheduleDay.from_proto(d) for d in p.schedule_days],
|
|
442
|
+
remote_sensors=[RemoteSensor.from_proto(rs) for rs in p.remote_sensors],
|
|
443
|
+
controller_remote_sensors=[
|
|
444
|
+
ControllerRemoteSensor.from_proto(crs) for crs in p.controller_remote_sensors
|
|
445
|
+
],
|
|
446
|
+
software_update_infos=[
|
|
447
|
+
SoftwareUpdateInfo.from_proto(s) for s in p.software_update_infos
|
|
448
|
+
],
|
|
449
|
+
locations=locations,
|
|
450
|
+
timezone=tz,
|
|
451
|
+
)
|
quilt_hp/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Service layer — thin async wrappers around gRPC stubs."""
|