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.
Files changed (53) hide show
  1. quilt_hp/__init__.py +22 -0
  2. quilt_hp/_paths.py +26 -0
  3. quilt_hp/_proto/__init__.py +0 -0
  4. quilt_hp/_proto/quilt_device_pairing_pb2.py +56 -0
  5. quilt_hp/_proto/quilt_device_pairing_pb2.pyi +317 -0
  6. quilt_hp/_proto/quilt_device_pairing_pb2_grpc.py +24 -0
  7. quilt_hp/_proto/quilt_hds_pb2.py +292 -0
  8. quilt_hp/_proto/quilt_hds_pb2.pyi +3947 -0
  9. quilt_hp/_proto/quilt_hds_pb2_grpc.py +1732 -0
  10. quilt_hp/_proto/quilt_notifier_pb2.py +55 -0
  11. quilt_hp/_proto/quilt_notifier_pb2.pyi +258 -0
  12. quilt_hp/_proto/quilt_notifier_pb2_grpc.py +97 -0
  13. quilt_hp/_proto/quilt_services_pb2.py +171 -0
  14. quilt_hp/_proto/quilt_services_pb2.pyi +1320 -0
  15. quilt_hp/_proto/quilt_services_pb2_grpc.py +1188 -0
  16. quilt_hp/_proto/quilt_system_pb2.py +53 -0
  17. quilt_hp/_proto/quilt_system_pb2.pyi +164 -0
  18. quilt_hp/_proto/quilt_system_pb2_grpc.py +270 -0
  19. quilt_hp/auth.py +244 -0
  20. quilt_hp/cli/__init__.py +1 -0
  21. quilt_hp/cli/main.py +770 -0
  22. quilt_hp/cli/settings.py +123 -0
  23. quilt_hp/cli/store.py +105 -0
  24. quilt_hp/cli/tui.py +2677 -0
  25. quilt_hp/client.py +616 -0
  26. quilt_hp/const.py +57 -0
  27. quilt_hp/exceptions.py +23 -0
  28. quilt_hp/models/__init__.py +85 -0
  29. quilt_hp/models/comfort.py +47 -0
  30. quilt_hp/models/controller.py +135 -0
  31. quilt_hp/models/energy.py +31 -0
  32. quilt_hp/models/enums.py +298 -0
  33. quilt_hp/models/indoor_unit.py +412 -0
  34. quilt_hp/models/outdoor_unit.py +71 -0
  35. quilt_hp/models/qsm.py +105 -0
  36. quilt_hp/models/schedule.py +98 -0
  37. quilt_hp/models/sensor.py +92 -0
  38. quilt_hp/models/software_update.py +74 -0
  39. quilt_hp/models/space.py +177 -0
  40. quilt_hp/models/system.py +451 -0
  41. quilt_hp/py.typed +1 -0
  42. quilt_hp/services/__init__.py +1 -0
  43. quilt_hp/services/hds.py +480 -0
  44. quilt_hp/services/streaming.py +561 -0
  45. quilt_hp/services/system.py +95 -0
  46. quilt_hp/services/user.py +143 -0
  47. quilt_hp/tokens.py +119 -0
  48. quilt_hp/transport.py +192 -0
  49. quilt_hp_python-0.1.1.dist-info/METADATA +172 -0
  50. quilt_hp_python-0.1.1.dist-info/RECORD +53 -0
  51. quilt_hp_python-0.1.1.dist-info/WHEEL +4 -0
  52. quilt_hp_python-0.1.1.dist-info/entry_points.txt +2 -0
  53. quilt_hp_python-0.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,412 @@
1
+ """Indoor unit model — wall-mounted mini-split head."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+
8
+ from quilt_hp.models.enums import (
9
+ FallbackControlCommand,
10
+ FanSpeed,
11
+ HvacControllerType,
12
+ HVACMode,
13
+ HVACState,
14
+ LedAnimation,
15
+ LightState,
16
+ LouverMode,
17
+ Presence,
18
+ )
19
+
20
+ _ONLINE_THRESHOLD_MINUTES = 5
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class IndoorUnitControls:
25
+ """Controllable settings for an indoor unit."""
26
+
27
+ fan_speed: FanSpeed
28
+ louver_mode: LouverMode
29
+ louver_fixed_position: float
30
+ led_color_code: int
31
+ led_brightness: float # stored brightness 0.0-1.0; preserved when led_state=OFF
32
+ led_animation: LedAnimation
33
+ led_state: LightState = LightState.UNSPECIFIED # explicit ON/OFF (proto field 13)
34
+ # Raw wire FAN_SPEED_MODE value (0=absent/proto3-default, 1=AUTO,
35
+ # 2=SETPOINT). Needed because FanSpeed.from_wire(0, 0.0) and
36
+ # from_wire(1, 0.0) both return FanSpeed.AUTO.
37
+ fan_speed_mode_raw: int = 0
38
+
39
+ @property
40
+ def light_on(self) -> bool:
41
+ """True when the LED intent is ON.
42
+
43
+ When ``led_state`` is explicit (mobile_led_scheduling_enabled gate on):
44
+ - ON → True
45
+ - OFF → False (brightness is preserved server-side, so > 0 does not
46
+ mean on)
47
+ Fallback when UNSPECIFIED: matches KMP ``Light.isOn`` logic:
48
+ isBlack = led_color_code == 0; isOn = !isBlack && brightness > 0.
49
+ """
50
+ if self.led_state == LightState.ON:
51
+ return True
52
+ if self.led_state == LightState.OFF:
53
+ return False
54
+ # UNSPECIFIED: legacy brightness-based detection
55
+ return self.led_color_code != 0 and self.led_brightness > 0.0
56
+
57
+
58
+ @dataclass(slots=True)
59
+ class IndoorUnitSettings:
60
+ """Configuration/calibration settings for an indoor unit."""
61
+
62
+ name: str
63
+ description: str
64
+ light_brightness_default_percent: float
65
+ presence_fence_left_m: float # detection zone left boundary (0 = unconfigured/max range)
66
+ presence_fence_right_m: float # detection zone right boundary
67
+ presence_fence_forward_m: float # detection zone forward boundary (depth)
68
+ radar_sensor_distance_from_floor_m: float # mounting height calibration
69
+
70
+
71
+ @dataclass(slots=True)
72
+ class IndoorUnitState:
73
+ """Read-only state for an indoor unit."""
74
+
75
+ hvac_mode: HVACMode
76
+ hvac_state: HVACState
77
+ ambient_temperature_c: float
78
+ ambient_humidity_percent: float
79
+ fan_speed_rpm: float
80
+ fan_speed_setpoint_rpm: float
81
+ presence_detection_level: float
82
+ # Additional state fields (proto fields 2, 7, 9, 10, 13, 14)
83
+ temperature_setpoint_c: float = 0.0
84
+ light_brightness_percent: float = 0.0 # device-reported actual LED brightness (field 7)
85
+ inlet_temperature_c: float = 0.0
86
+ outlet_temperature_c: float = 0.0
87
+ calculated_ambient_temperature_c: float = 0.0
88
+ louver_angle_up_down_degrees: float = 0.0
89
+ # proto field 1: timestamp of last state update (used for online detection)
90
+ updated_at: datetime | None = None
91
+
92
+
93
+ @dataclass(slots=True)
94
+ class IndoorUnitPerformanceData:
95
+ """Raw IDU sensor measurements (updated every ~5 seconds)."""
96
+
97
+ measurement_interval_s: float
98
+ energy_measurement_j: float
99
+ hvac_mode: HVACMode
100
+ hvac_state: HVACState
101
+ actual_fan_speed_rpm: float
102
+ outlet_temperature_c: float
103
+ inlet_temperature_c: float
104
+ inlet_humidity_pct: float
105
+ coil_temperature_c: float
106
+ gas_pipe_temperature_c: float
107
+ liquid_pipe_temperature_c: float
108
+
109
+ @property
110
+ def energy_kwh(self) -> float:
111
+ """Energy for this interval in kWh."""
112
+ return self.energy_measurement_j / 3_600_000
113
+
114
+
115
+ @dataclass(slots=True)
116
+ class IndoorUnitPerformanceMetrics:
117
+ """Computed efficiency metrics (only populated when unit is running)."""
118
+
119
+ capacity_w: float
120
+ coefficient_of_performance: float
121
+ hvac_power_w: float
122
+ led_power_w: float
123
+ hvac_mode: HVACMode
124
+ hvac_state: HVACState
125
+ measurement_duration_s: float = 0.0
126
+ energy_total_j: float = 0.0
127
+ hvac_energy_j: float = 0.0
128
+ led_energy_j: float = 0.0
129
+
130
+
131
+ @dataclass(slots=True)
132
+ class IndoorUnitHvacInputs:
133
+ """HVAC controller inputs — what the controller sends to the IDU."""
134
+
135
+ external_ambient_temperature_c: float
136
+ ambient_temperature_source: int
137
+ temperature_setpoint_c: float
138
+ hvac_mode: HVACMode
139
+ hvac_state: HVACState
140
+ hvac_controller_type: HvacControllerType = HvacControllerType.UNSPECIFIED
141
+
142
+
143
+ @dataclass(slots=True)
144
+ class IndoorUnitCommands:
145
+ """IDU fallback control command (sent during cloud connectivity loss)."""
146
+
147
+ fallback_control_command: FallbackControlCommand
148
+
149
+
150
+ @dataclass(slots=True)
151
+ class IndoorUnitConditions:
152
+ """IDU diagnostic conditions (ODU-linked conditions included)."""
153
+
154
+ mode_conflict: int
155
+ anti_cold_wind: int
156
+ abnormal_outdoor_air_temperature: int
157
+ hvac_mode_switching_delay: int
158
+ defrost_cycle: int
159
+ safety_heating: int
160
+ oil_return: int
161
+ modbus_communication_error: int
162
+ coil_preheat: int
163
+ mode_conflict_avoidance: int
164
+ outdoor_unit_communication_error: int
165
+
166
+ @property
167
+ def any_active(self) -> bool:
168
+ """True if any condition is ACTIVE (value 2)."""
169
+ return any(
170
+ getattr(self, f) == 2
171
+ for f in (
172
+ "mode_conflict",
173
+ "anti_cold_wind",
174
+ "abnormal_outdoor_air_temperature",
175
+ "hvac_mode_switching_delay",
176
+ "defrost_cycle",
177
+ "safety_heating",
178
+ "oil_return",
179
+ "modbus_communication_error",
180
+ "coil_preheat",
181
+ "mode_conflict_avoidance",
182
+ "outdoor_unit_communication_error",
183
+ )
184
+ )
185
+
186
+
187
+ @dataclass(slots=True)
188
+ class IndoorUnitPresence:
189
+ """Radar presence sensor data — binary DETECTED / UNDETECTED per sensor."""
190
+
191
+ sensor0_presence: Presence
192
+ sensor1_presence: Presence
193
+
194
+
195
+ @dataclass(slots=True)
196
+ class IndoorUnitOccupancy:
197
+ """Computed room occupancy state."""
198
+
199
+ occupancy_state: int
200
+
201
+
202
+ @dataclass(slots=True)
203
+ class IndoorUnit:
204
+ """A Quilt indoor unit (wall-mounted mini-split head)."""
205
+
206
+ id: str
207
+ system_id: str
208
+ space_id: str
209
+ outdoor_unit_id: str | None # APK: IndoorUnitRelationships.outdoor_unit_id field 3
210
+ hardware_id: str
211
+ qsm_id: str | None # QuiltSmartModule embedded in this unit
212
+ settings: IndoorUnitSettings
213
+ controls: IndoorUnitControls
214
+ state: IndoorUnitState
215
+ hvac_inputs: IndoorUnitHvacInputs | None
216
+ conditions: IndoorUnitConditions | None
217
+ performance_data: IndoorUnitPerformanceData | None
218
+ performance_metrics: IndoorUnitPerformanceMetrics | None
219
+ presence: IndoorUnitPresence | None
220
+ occupancy: IndoorUnitOccupancy | None
221
+ firmware_update_info_id: str | None = None
222
+ commands: IndoorUnitCommands | None = None
223
+
224
+ @classmethod
225
+ def from_proto(cls, proto: object) -> IndoorUnit:
226
+ """Construct from a protobuf IndoorUnit message."""
227
+ return _idu_from_proto(proto)
228
+
229
+ @property
230
+ def is_online(self) -> bool:
231
+ """True if the IDU has sent a state update within the last 5 minutes.
232
+
233
+ Matches KMP SpaceViewNode.isOnlineByUpdatedTimestamp.
234
+ An offline IDU may have stale controls data — treat LED as off.
235
+ """
236
+ ts = self.state.updated_at
237
+ if ts is None:
238
+ return False
239
+ now = datetime.now(tz=UTC)
240
+ delta_minutes = (now - ts).total_seconds() / 60
241
+ return delta_minutes < _ONLINE_THRESHOLD_MINUTES
242
+
243
+ @property
244
+ def led_on(self) -> bool:
245
+ """True if the LED is currently illuminated.
246
+
247
+ Applies the online gate: KMP's ``getLight()`` calls ``filterOnline()``
248
+ and returns ``Light.OFF`` for offline IDUs. An offline IDU's controls
249
+ data is stale and must not be used for LED state.
250
+ """
251
+ return self.is_online and self.controls.light_on
252
+
253
+ @property
254
+ def effective_occupancy_state(self) -> int | None:
255
+ """Occupancy state proto value, or None if the IDU is offline.
256
+
257
+ KMP reads presence/occupancy only from online IDUs (``filterOnline()``).
258
+ An offline IDU's last-known ``occupancy_state`` is stale and must not
259
+ be displayed as current. Returns None when offline or no occupancy data.
260
+ """
261
+ if not self.is_online or self.occupancy is None:
262
+ return None
263
+ return self.occupancy.occupancy_state
264
+
265
+
266
+ def _idu_from_proto(proto: object) -> IndoorUnit:
267
+ """Internal: convert a proto IndoorUnit to our model."""
268
+ from quilt_hp.models.enums import HVACMode, HVACState
269
+
270
+ c = proto.controls # type: ignore[attr-defined]
271
+ s = proto.state # type: ignore[attr-defined]
272
+ st = proto.settings # type: ignore[attr-defined]
273
+ pd = proto.performance_data # type: ignore[attr-defined]
274
+ pm = proto.performance_metrics # type: ignore[attr-defined]
275
+
276
+ perf_data = None
277
+ if pd.updated_ts:
278
+ perf_data = IndoorUnitPerformanceData(
279
+ measurement_interval_s=pd.measurement_interval_s,
280
+ energy_measurement_j=pd.energy_measurement_j,
281
+ hvac_mode=HVACMode(pd.hvac_mode),
282
+ hvac_state=HVACState(pd.hvac_state),
283
+ actual_fan_speed_rpm=pd.actual_fan_speed_rpm,
284
+ outlet_temperature_c=pd.outlet_temperature_c,
285
+ inlet_temperature_c=pd.inlet_temperature_c,
286
+ inlet_humidity_pct=pd.inlet_humidity_pct,
287
+ coil_temperature_c=pd.coil_temperature_c,
288
+ gas_pipe_temperature_c=pd.gas_pipe_temperature_c,
289
+ liquid_pipe_temperature_c=pd.liquid_pipe_temperature_c,
290
+ )
291
+
292
+ perf_metrics = None
293
+ if pm.updated_ts:
294
+ perf_metrics = IndoorUnitPerformanceMetrics(
295
+ capacity_w=pm.capacity_w,
296
+ coefficient_of_performance=pm.coefficient_of_performance,
297
+ hvac_power_w=pm.hvac_power_w,
298
+ led_power_w=pm.led_power_w,
299
+ hvac_mode=HVACMode(pm.hvac_mode),
300
+ hvac_state=HVACState(pm.hvac_state),
301
+ measurement_duration_s=pm.measurement_duration_s,
302
+ energy_total_j=pm.energy_total_j,
303
+ hvac_energy_j=pm.hvac_energy_j,
304
+ led_energy_j=pm.led_energy_j,
305
+ )
306
+
307
+ hvac_inputs = None
308
+ hi = proto.hvac_inputs # type: ignore[attr-defined]
309
+ if hi.updated_ts:
310
+ hvac_inputs = IndoorUnitHvacInputs(
311
+ external_ambient_temperature_c=hi.external_ambient_temperature_c,
312
+ ambient_temperature_source=hi.ambient_temperature_source,
313
+ temperature_setpoint_c=hi.temperature_setpoint_c,
314
+ hvac_mode=HVACMode(hi.hvac_mode),
315
+ hvac_state=HVACState(hi.hvac_state),
316
+ hvac_controller_type=HvacControllerType(hi.hvac_controller_type),
317
+ )
318
+
319
+ commands = None
320
+ cmd = proto.commands # type: ignore[attr-defined]
321
+ if cmd.updated_ts:
322
+ commands = IndoorUnitCommands(
323
+ fallback_control_command=FallbackControlCommand(cmd.fallback_control_command),
324
+ )
325
+
326
+ conditions = None
327
+ co = proto.conditions # type: ignore[attr-defined]
328
+ if co.updated_ts:
329
+ conditions = IndoorUnitConditions(
330
+ mode_conflict=co.mode_conflict,
331
+ anti_cold_wind=co.anti_cold_wind,
332
+ abnormal_outdoor_air_temperature=co.abnormal_outdoor_air_temperature,
333
+ hvac_mode_switching_delay=co.hvac_mode_switching_delay,
334
+ defrost_cycle=co.defrost_cycle,
335
+ safety_heating=co.safety_heating,
336
+ oil_return=co.oil_return,
337
+ modbus_communication_error=co.modbus_communication_error,
338
+ coil_preheat=co.coil_preheat,
339
+ mode_conflict_avoidance=co.mode_conflict_avoidance,
340
+ outdoor_unit_communication_error=co.outdoor_unit_communication_error,
341
+ )
342
+
343
+ presence_state = None
344
+ if hasattr(proto, "presence") and proto.presence.updated_ts:
345
+ presence_state = IndoorUnitPresence(
346
+ sensor0_presence=Presence(proto.presence.sensor0_presence),
347
+ sensor1_presence=Presence(proto.presence.sensor1_presence),
348
+ )
349
+
350
+ occupancy_state = None
351
+ if hasattr(proto, "occupancy") and proto.occupancy.updated_ts:
352
+ occupancy_state = IndoorUnitOccupancy(
353
+ occupancy_state=proto.occupancy.occupancy_state,
354
+ )
355
+
356
+ return IndoorUnit(
357
+ id=proto.header.object_id, # type: ignore[attr-defined]
358
+ system_id=proto.header.system_id, # type: ignore[attr-defined]
359
+ space_id=proto.relationships.space_id, # type: ignore[attr-defined]
360
+ outdoor_unit_id=proto.relationships.outdoor_unit_id or None, # type: ignore[attr-defined]
361
+ hardware_id=proto.relationships.hardware_id, # type: ignore[attr-defined]
362
+ qsm_id=proto.relationships.quilt_smart_module_id or None, # type: ignore[attr-defined]
363
+ settings=IndoorUnitSettings(
364
+ name=st.name,
365
+ description=st.description,
366
+ light_brightness_default_percent=st.light_brightness_default_percent,
367
+ presence_fence_left_m=st.presence_fence_left_m,
368
+ presence_fence_right_m=st.presence_fence_right_m,
369
+ presence_fence_forward_m=st.presence_fence_forward_m,
370
+ radar_sensor_distance_from_floor_m=st.radar_sensor_distance_from_floor_m,
371
+ ),
372
+ controls=IndoorUnitControls(
373
+ fan_speed=FanSpeed.from_wire(c.fan_speed_mode, c.fan_speed_percent),
374
+ fan_speed_mode_raw=c.fan_speed_mode,
375
+ louver_mode=LouverMode(c.louver_mode) if c.louver_mode else LouverMode.UNSPECIFIED,
376
+ louver_fixed_position=c.louver_fixed_position,
377
+ led_color_code=c.led_color_code,
378
+ led_brightness=c.led_color_brightness_percent,
379
+ led_animation=LedAnimation(c.led_animation),
380
+ led_state=LightState(c.led_state),
381
+ ),
382
+ state=IndoorUnitState(
383
+ hvac_mode=HVACMode(s.hvac_mode),
384
+ hvac_state=HVACState(s.hvac_state),
385
+ ambient_temperature_c=s.ambient_temperature_c,
386
+ ambient_humidity_percent=s.ambient_humidity_percent,
387
+ fan_speed_rpm=s.fan_speed_rpm,
388
+ fan_speed_setpoint_rpm=s.fan_speed_setpoint_rpm,
389
+ presence_detection_level=s.presence_detection_level,
390
+ temperature_setpoint_c=s.temperature_setpoint_c,
391
+ light_brightness_percent=s.light_brightness_percent,
392
+ inlet_temperature_c=s.inlet_temperature_c,
393
+ outlet_temperature_c=s.outlet_temperature_c,
394
+ calculated_ambient_temperature_c=s.calculated_ambient_temperature_c,
395
+ louver_angle_up_down_degrees=s.louver_angle_up_down_degrees,
396
+ updated_at=(
397
+ datetime.fromtimestamp(s.updated_ts.seconds, tz=UTC)
398
+ if s.updated_ts and s.updated_ts.seconds
399
+ else None
400
+ ),
401
+ ),
402
+ hvac_inputs=hvac_inputs,
403
+ conditions=conditions,
404
+ performance_data=perf_data,
405
+ performance_metrics=perf_metrics,
406
+ presence=presence_state,
407
+ occupancy=occupancy_state,
408
+ firmware_update_info_id=(
409
+ proto.relationships.firmware_update_info_id or None # type: ignore[attr-defined]
410
+ ),
411
+ commands=commands,
412
+ )
@@ -0,0 +1,71 @@
1
+ """Outdoor unit model — compressor/condenser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class OutdoorUnitPerformanceData:
10
+ """Raw ODU compressor telemetry."""
11
+
12
+ measurement_interval_s: float
13
+ energy_measurement_j: float
14
+ compressor_frequency_hz: float
15
+ ambient_temperature_c: float
16
+ coil_temperature_c: float
17
+ exhaust_temperature_c: float
18
+ high_pressure_kpa: float
19
+ low_pressure_kpa: float
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class OutdoorUnit:
24
+ """A Quilt outdoor unit (compressor)."""
25
+
26
+ id: str
27
+ system_id: str
28
+ space_id: str
29
+ hvac_state: int
30
+ model_sku: str | None
31
+ serial_number: str | None
32
+ firmware_version: str | None
33
+ firmware_update_info_id: str | None
34
+ performance_data: OutdoorUnitPerformanceData | None
35
+
36
+ @classmethod
37
+ def from_proto(cls, proto: object, hw_map: dict[str, object] | None = None) -> OutdoorUnit:
38
+ """Construct from a protobuf OutdoorUnit message."""
39
+ hw_id = proto.relationships.hardware_id # type: ignore[attr-defined]
40
+ hw = hw_map.get(hw_id) if hw_map else None
41
+
42
+ pd = None
43
+ if hasattr(proto, "performance_data"):
44
+ p = proto.performance_data
45
+ # Server does not reliably set updated_ts on
46
+ # OutdoorUnitPerformanceData. Gate on any non-zero value instead.
47
+ if p.ambient_temperature_c or p.compressor_frequency_hz or p.energy_measurement_j:
48
+ pd = OutdoorUnitPerformanceData(
49
+ measurement_interval_s=p.measurement_interval_s,
50
+ energy_measurement_j=p.energy_measurement_j,
51
+ compressor_frequency_hz=p.compressor_frequency_hz,
52
+ ambient_temperature_c=p.ambient_temperature_c,
53
+ coil_temperature_c=p.coil_temperature_c,
54
+ exhaust_temperature_c=p.exhaust_temperature_c,
55
+ high_pressure_kpa=p.high_pressure_kpa,
56
+ low_pressure_kpa=p.low_pressure_kpa,
57
+ )
58
+
59
+ return cls(
60
+ id=proto.header.object_id, # type: ignore[attr-defined]
61
+ system_id=proto.header.system_id, # type: ignore[attr-defined]
62
+ space_id=proto.relationships.space_id, # type: ignore[attr-defined]
63
+ hvac_state=proto.state.hvac_state, # type: ignore[attr-defined]
64
+ model_sku=hw.attributes.model_sku if hw else None, # type: ignore[attr-defined]
65
+ serial_number=hw.attributes.serial_number if hw else None, # type: ignore[attr-defined]
66
+ firmware_version=hw.attributes.firmware_version if hw else None, # type: ignore[attr-defined]
67
+ firmware_update_info_id=(
68
+ proto.relationships.firmware_update_info_id or None # type: ignore[attr-defined]
69
+ ),
70
+ performance_data=pd,
71
+ )
quilt_hp/models/qsm.py ADDED
@@ -0,0 +1,105 @@
1
+ """QuiltSmartModule (QSM) model — the WiFi compute module embedded in each IDU.
2
+
3
+ Each indoor unit contains one QSM that handles:
4
+ - Cloud/local NATS connectivity (three WiFi interfaces: hosted, AP, P2P)
5
+ - Presence detection (phase + target radar channels)
6
+ - Ambient light sensing (ALS: illuminance, IR, combined)
7
+ - Accelerometer (X/Y/Z — detects unit tilt/movement)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class WifiInfo:
17
+ """WiFi interface snapshot (one of three on a QSM)."""
18
+
19
+ ssid: str | None
20
+ ip: str | None
21
+ signal_dbm: int | None
22
+
23
+ @property
24
+ def connected(self) -> bool:
25
+ return bool(self.ssid)
26
+
27
+ @classmethod
28
+ def from_proto(cls, proto: object) -> WifiInfo:
29
+ ssid = getattr(proto, "ssid", None) or None
30
+ ip = getattr(proto, "ipv4_address", None) or None
31
+ sig = getattr(proto, "signal_level_dbm", None) or None
32
+ return cls(ssid=ssid, ip=ip, signal_dbm=sig)
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class QsmSensors:
37
+ """Raw sensor data from the QSM (updated every few seconds)."""
38
+
39
+ # Radar presence sensor (mm-wave)
40
+ phase_detected_raw: float # phase-channel detection strength
41
+ target_detected_raw: float # target-channel detection strength
42
+
43
+ # Ambient light sensor
44
+ als_illuminance_raw: int # broadband illuminance
45
+ als_ir_raw: int # IR channel
46
+ als_both_raw: int # combined
47
+
48
+ # Accelerometer (detects unit orientation/tilt)
49
+ accel_x_raw: int
50
+ accel_y_raw: int
51
+ accel_z_raw: int
52
+
53
+
54
+ @dataclass(slots=True)
55
+ class QuiltSmartModule:
56
+ """A QSM — the WiFi compute module embedded in every Quilt indoor unit."""
57
+
58
+ id: str
59
+ system_id: str
60
+ led_color_code: int
61
+ sensors: QsmSensors | None
62
+ hosted_wifi: WifiInfo | None # normal station mode (connects to home network)
63
+ ap_wifi: WifiInfo | None # access-point mode (direct device provisioning)
64
+ p2p_wifi: WifiInfo | None # peer-to-peer / Wi-Fi Direct (usually empty)
65
+ software_update_info_id: str | None = None
66
+ firmware_update_info_id: str | None = None
67
+
68
+ @classmethod
69
+ def from_proto(cls, proto: object) -> QuiltSmartModule:
70
+ """Construct from a protobuf QuiltSmartModule message."""
71
+ c = proto.controls # type: ignore[attr-defined]
72
+ s = proto.state # type: ignore[attr-defined]
73
+
74
+ sensors: QsmSensors | None = None
75
+ if s.updated_ts:
76
+ sensors = QsmSensors(
77
+ phase_detected_raw=s.phase_detected_raw,
78
+ target_detected_raw=s.target_detected_raw,
79
+ als_illuminance_raw=s.als_illuminance_raw,
80
+ als_ir_raw=s.als_ir_raw,
81
+ als_both_raw=s.als_both_raw,
82
+ accel_x_raw=s.accel_x_raw,
83
+ accel_y_raw=s.accel_y_raw,
84
+ accel_z_raw=s.accel_z_raw,
85
+ )
86
+
87
+ def _wifi(w: object) -> WifiInfo | None:
88
+ info = WifiInfo.from_proto(w)
89
+ return info if info.connected else None
90
+
91
+ return cls(
92
+ id=proto.header.object_id, # type: ignore[attr-defined]
93
+ system_id=proto.header.system_id, # type: ignore[attr-defined]
94
+ led_color_code=c.led_color_code,
95
+ sensors=sensors,
96
+ hosted_wifi=_wifi(proto.hosted_wifi_state), # type: ignore[attr-defined]
97
+ ap_wifi=_wifi(proto.ap_wifi_state), # type: ignore[attr-defined]
98
+ p2p_wifi=_wifi(proto.p2p_wifi_state), # type: ignore[attr-defined]
99
+ software_update_info_id=(
100
+ proto.relationships.software_update_info_id or None # type: ignore[attr-defined]
101
+ ),
102
+ firmware_update_info_id=(
103
+ proto.relationships.firmware_update_info_id or None # type: ignore[attr-defined]
104
+ ),
105
+ )
@@ -0,0 +1,98 @@
1
+ """Schedule models — week programs, day programs, schedule events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ _WEEKDAY_NAMES = {
8
+ 0: "?",
9
+ 1: "Mon",
10
+ 2: "Tue",
11
+ 3: "Wed",
12
+ 4: "Thu",
13
+ 5: "Fri",
14
+ 6: "Sat",
15
+ 7: "Sun",
16
+ }
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class ScheduleEvent:
21
+ """A single time event within a schedule day."""
22
+
23
+ start_s: int # seconds from midnight
24
+ comfort_setting_id: str
25
+ hvac_mode: int
26
+ heating_setpoint_c: float
27
+ cooling_setpoint_c: float
28
+ precondition: bool
29
+
30
+ @property
31
+ def start_time(self) -> str:
32
+ """Format start_s as HH:MM."""
33
+ return f"{self.start_s // 3600:02d}:{(self.start_s % 3600) // 60:02d}"
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class ScheduleDay:
38
+ """A named day program with time events."""
39
+
40
+ id: str
41
+ name: str
42
+ space_id: str
43
+ events: list[ScheduleEvent]
44
+
45
+ @classmethod
46
+ def from_proto(cls, proto: object) -> ScheduleDay:
47
+ """Construct from a protobuf ScheduleDay message."""
48
+ events = [
49
+ ScheduleEvent(
50
+ start_s=ev.start_s,
51
+ comfort_setting_id=ev.comfort_setting_id,
52
+ hvac_mode=ev.hvac_mode,
53
+ heating_setpoint_c=ev.heating_temperature_setpoint_c,
54
+ cooling_setpoint_c=ev.cooling_temperature_setpoint_c,
55
+ precondition=ev.precondition,
56
+ )
57
+ for ev in sorted(proto.events, key=lambda e: e.start_s) # type: ignore[attr-defined]
58
+ ]
59
+ return cls(
60
+ id=proto.header.object_id, # type: ignore[attr-defined]
61
+ name=proto.attributes.name, # type: ignore[attr-defined]
62
+ space_id=proto.relationships.space_id, # type: ignore[attr-defined]
63
+ events=events,
64
+ )
65
+
66
+
67
+ @dataclass(slots=True)
68
+ class ScheduleWeekDay:
69
+ """A single weekday→day-program mapping."""
70
+
71
+ weekday: int
72
+ day_id: str
73
+
74
+ @property
75
+ def weekday_name(self) -> str:
76
+ return _WEEKDAY_NAMES.get(self.weekday, str(self.weekday))
77
+
78
+
79
+ @dataclass(slots=True)
80
+ class ScheduleWeek:
81
+ """A weekly schedule for a space, mapping weekdays to day programs."""
82
+
83
+ id: str
84
+ space_id: str
85
+ days: list[ScheduleWeekDay]
86
+
87
+ @classmethod
88
+ def from_proto(cls, proto: object) -> ScheduleWeek:
89
+ """Construct from a protobuf ScheduleWeek message."""
90
+ days = [
91
+ ScheduleWeekDay(weekday=wd.weekday, day_id=wd.day_id)
92
+ for wd in sorted(proto.days, key=lambda x: x.weekday) # type: ignore[attr-defined]
93
+ ]
94
+ return cls(
95
+ id=proto.header.object_id, # type: ignore[attr-defined]
96
+ space_id=proto.relationships.space_id, # type: ignore[attr-defined]
97
+ days=days,
98
+ )