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,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
|
+
)
|