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
quilt_hp/cli/tui.py ADDED
@@ -0,0 +1,2677 @@
1
+ """Textual TUI for Quilt HVAC — feature-complete, keyboard-only.
2
+
3
+ Screen flow:
4
+ LoadingScreen ──→ DashboardScreen ──→
5
+ RoomScreen (Status|Performance|Schedule tabs)
6
+ └──────────→ SystemScreen
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import datetime
13
+ from typing import TYPE_CHECKING, ClassVar
14
+
15
+ from rich.text import Text
16
+ from textual import on, work
17
+ from textual.app import App, ComposeResult
18
+ from textual.binding import Binding
19
+ from textual.containers import (
20
+ Container,
21
+ Horizontal,
22
+ ScrollableContainer,
23
+ Vertical,
24
+ )
25
+ from textual.css.query import NoMatches
26
+ from textual.reactive import reactive
27
+ from textual.screen import Screen
28
+ from textual.widgets import (
29
+ DataTable,
30
+ Footer,
31
+ Header,
32
+ Label,
33
+ ListItem,
34
+ ListView,
35
+ LoadingIndicator,
36
+ Rule,
37
+ Static,
38
+ TabbedContent,
39
+ TabPane,
40
+ )
41
+
42
+ from quilt_hp.cli.settings import SettingsStore
43
+ from quilt_hp.cli.store import FileStore
44
+ from quilt_hp.client import QuiltClient
45
+ from quilt_hp.models.controller import Controller
46
+ from quilt_hp.models.enums import (
47
+ FanSpeed,
48
+ HVACMode,
49
+ HVACState,
50
+ LedAnimation,
51
+ LightPreset,
52
+ LouverMode,
53
+ OccupancyMode,
54
+ OccupancyState,
55
+ )
56
+ from quilt_hp.models.indoor_unit import IndoorUnit
57
+ from quilt_hp.models.outdoor_unit import OutdoorUnit
58
+ from quilt_hp.models.qsm import QuiltSmartModule
59
+ from quilt_hp.models.sensor import RemoteSensor, RemoteSensorControlMode
60
+
61
+ if TYPE_CHECKING:
62
+ from quilt_hp.models.space import Space
63
+ from quilt_hp.models.system import SystemSnapshot
64
+
65
+ # ──────────────────────────────────────────────────────────────────
66
+ # Persistent settings (delegates to quilt_hp.cli.settings)
67
+ # ──────────────────────────────────────────────────────────────────
68
+
69
+ # Persistent stores (tokens separate from non-secret settings)
70
+ _token_store = FileStore()
71
+ _settings_store = SettingsStore()
72
+
73
+
74
+ # ──────────────────────────────────────────────────────────────────
75
+ # Helpers
76
+ # ──────────────────────────────────────────────────────────────────
77
+
78
+ _MODE_STYLE: dict[HVACMode, str] = {
79
+ HVACMode.HEAT: "bold red",
80
+ HVACMode.COOL: "bold cyan",
81
+ HVACMode.AUTO: "bold yellow",
82
+ HVACMode.FAN: "bold green",
83
+ HVACMode.STANDBY: "dim",
84
+ HVACMode.FALLBACK_AUTO: "bold yellow",
85
+ HVACMode.FALLBACK_OFF: "dim",
86
+ HVACMode.UNSPECIFIED: "dim",
87
+ }
88
+
89
+ _STATE_STYLE: dict[HVACState, str] = {
90
+ HVACState.HEAT: "red",
91
+ HVACState.COOL: "cyan",
92
+ HVACState.DRIFT: "yellow",
93
+ HVACState.FAN: "green",
94
+ HVACState.COOL_DEFERRED: "cyan",
95
+ HVACState.HEAT_DEFERRED: "red",
96
+ HVACState.FAN_DEFERRED: "green",
97
+ HVACState.COOL_PREPARING: "cyan",
98
+ HVACState.HEAT_PREPARING: "red",
99
+ HVACState.STANDBY: "dim",
100
+ HVACState.UNSPECIFIED: "dim",
101
+ }
102
+
103
+ _MODE_LABELS: dict[HVACMode, str] = {
104
+ HVACMode.HEAT: "HEAT",
105
+ HVACMode.COOL: "COOL",
106
+ HVACMode.AUTO: "AUTO",
107
+ HVACMode.FAN: " FAN",
108
+ HVACMode.STANDBY: "STBY",
109
+ HVACMode.FALLBACK_AUTO: "FAUTO",
110
+ HVACMode.FALLBACK_OFF: "FOFF",
111
+ HVACMode.UNSPECIFIED: " -- ",
112
+ }
113
+
114
+ _STATE_SYMBOLS: dict[HVACState, str] = {
115
+ HVACState.HEAT: "◉ Heating",
116
+ HVACState.COOL: "◉ Cooling",
117
+ HVACState.DRIFT: "~ Drift",
118
+ HVACState.FAN: "~ Fan",
119
+ HVACState.COOL_DEFERRED: "○ Cool (deferred)",
120
+ HVACState.HEAT_DEFERRED: "○ Heat (deferred)",
121
+ HVACState.FAN_DEFERRED: "○ Fan (deferred)",
122
+ HVACState.COOL_PREPARING: "⋯ Preparing to Cool",
123
+ HVACState.HEAT_PREPARING: "⋯ Preparing to Heat",
124
+ HVACState.STANDBY: "◌ Standby",
125
+ HVACState.UNSPECIFIED: "--",
126
+ }
127
+
128
+ _FAN_CYCLE = [
129
+ FanSpeed.AUTO,
130
+ FanSpeed.QUIET,
131
+ FanSpeed.LOW,
132
+ FanSpeed.MEDIUM,
133
+ FanSpeed.HIGH,
134
+ FanSpeed.BLAST,
135
+ ]
136
+ _MODE_CYCLE = [
137
+ HVACMode.HEAT,
138
+ HVACMode.COOL,
139
+ HVACMode.AUTO,
140
+ HVACMode.FAN,
141
+ HVACMode.STANDBY,
142
+ ]
143
+ _LOUVER_CYCLE = [
144
+ LouverMode.SWEEP,
145
+ LouverMode.AUTO,
146
+ LouverMode.FIXED,
147
+ LouverMode.CLOSED,
148
+ ]
149
+ _OCC_CYCLE = [OccupancyMode.DISABLED, OccupancyMode.ENABLED]
150
+
151
+
152
+ def _tc(val_c: float | None, use_f: bool) -> str:
153
+ """Format a temperature value in °C or °F."""
154
+ if val_c is None:
155
+ return "--"
156
+ if use_f:
157
+ return f"{val_c * 9 / 5 + 32:.1f}°F"
158
+ return f"{val_c:.1f}°C"
159
+
160
+
161
+ def _fmt_timeout(seconds: float) -> str:
162
+ """Format timeout as readable text (for example, '20 min')."""
163
+ if seconds <= 0:
164
+ return "0 s"
165
+ total_m = int(seconds) // 60
166
+ rem_s = int(seconds) % 60
167
+ if total_m == 0:
168
+ return f"{rem_s} s"
169
+ if rem_s == 0:
170
+ return f"{total_m} min"
171
+ return f"{total_m} min {rem_s} s"
172
+
173
+
174
+ def _tu(use_f: bool) -> str:
175
+ return "°F" if use_f else "°C"
176
+
177
+
178
+ def _led_color_str(color_code: int) -> str:
179
+ """Return a human-readable LED color label from a packed RGBW uint32.
180
+
181
+ Matches against known LightPreset values first; falls back to hex notation.
182
+ """
183
+ if color_code == 0:
184
+ return "Black"
185
+ try:
186
+ return LightPreset(color_code).name.capitalize()
187
+ except ValueError:
188
+ r = (color_code >> 24) & 0xFF
189
+ g = (color_code >> 16) & 0xFF
190
+ b = (color_code >> 8) & 0xFF
191
+ w = color_code & 0xFF
192
+ return f"#{r:02X}{g:02X}{b:02X}w{w:02X}"
193
+
194
+
195
+ def _bar(level: float, width: int = 10) -> str:
196
+ """Render a simple block-character progress bar."""
197
+ filled = max(0, min(width, round(level * width)))
198
+ return "█" * filled + "░" * (width - filled)
199
+
200
+
201
+ def _occ_glyph(occ: OccupancyState | int | None) -> str:
202
+ if occ is None:
203
+ return "?"
204
+ state = OccupancyState(occ) if isinstance(occ, int) else occ
205
+ if state == OccupancyState.DETECTED:
206
+ return "[green]●[/green]"
207
+ if state == OccupancyState.UNDETECTED:
208
+ return "[dim]○[/dim]"
209
+ return "[dim]?[/dim]"
210
+
211
+
212
+ def _fmt_mode(mode: HVACMode) -> Text:
213
+ label = _MODE_LABELS.get(mode, mode.name)
214
+ style = _MODE_STYLE.get(mode, "")
215
+ return Text(label, style=style)
216
+
217
+
218
+ def _space_mode_badge(space: Space) -> Text:
219
+ """Mode badge using Space.is_away / Space.is_off from the core model."""
220
+ if space.is_away:
221
+ return Text("AWAY", style="yellow dim")
222
+ if space.is_off:
223
+ return Text(" OFF", style="dim")
224
+ return _fmt_mode(space.controls.hvac_mode)
225
+
226
+
227
+ def _fmt_state(state: HVACState) -> Text:
228
+ label = _STATE_SYMBOLS.get(state, state.name)
229
+ style = _STATE_STYLE.get(state, "")
230
+ return Text(label, style=style)
231
+
232
+
233
+ def _cycle_next(current: object, cycle: list) -> object:
234
+ try:
235
+ return cycle[(cycle.index(current) + 1) % len(cycle)]
236
+ except ValueError:
237
+ return cycle[0]
238
+
239
+
240
+ # ──────────────────────────────────────────────────────────────────
241
+ # CSS
242
+ # ──────────────────────────────────────────────────────────────────
243
+
244
+ _APP_CSS = """
245
+ Screen {
246
+ background: $surface;
247
+ }
248
+
249
+ /* Loading */
250
+ #loading-container {
251
+ align: center middle;
252
+ height: 100%;
253
+ }
254
+ #loading-label {
255
+ margin-top: 2;
256
+ text-align: center;
257
+ color: $text-muted;
258
+ }
259
+
260
+ /* Dashboard */
261
+ #dashboard-list {
262
+ height: 1fr;
263
+ border: round $primary-darken-2;
264
+ margin: 1 2;
265
+ }
266
+ #dashboard-statusbar {
267
+ height: 1;
268
+ padding: 0 2;
269
+ background: $primary-darken-3;
270
+ color: $text-muted;
271
+ dock: bottom;
272
+ }
273
+
274
+ /* Room panels */
275
+ .panel {
276
+ border: round $primary-darken-2;
277
+ border-title-color: $accent;
278
+ border-title-align: left;
279
+ margin: 0 1;
280
+ padding: 1 2;
281
+ height: auto;
282
+ }
283
+ .section-label {
284
+ text-style: bold;
285
+ color: $accent;
286
+ margin-top: 1;
287
+ }
288
+ .kv-key {
289
+ color: $text-muted;
290
+ width: 22;
291
+ }
292
+ .kv-val {
293
+ color: $text;
294
+ }
295
+ .section-rule {
296
+ margin: 1 0;
297
+ }
298
+ #room-tabs {
299
+ height: 1fr;
300
+ }
301
+ #tab-status {
302
+ overflow-y: auto;
303
+ }
304
+ #tab-perf {
305
+ padding: 0;
306
+ }
307
+ #tab-schedule {
308
+ padding: 0;
309
+ }
310
+ .sched-row {
311
+ height: 1fr;
312
+ }
313
+ .sched-days-panel {
314
+ width: 22;
315
+ height: 1fr;
316
+ border: round $primary-darken-2;
317
+ border-title-color: $accent;
318
+ border-title-align: left;
319
+ margin: 0 0 0 1;
320
+ padding: 0 1;
321
+ }
322
+ .sched-events-panel {
323
+ width: 1fr;
324
+ height: 1fr;
325
+ border: round $primary-darken-2;
326
+ border-title-color: $accent;
327
+ border-title-align: left;
328
+ margin: 0 1 0 0;
329
+ padding: 0 1;
330
+ }
331
+ #sched-status {
332
+ height: 1;
333
+ padding: 0 2;
334
+ background: $surface-darken-1;
335
+ color: $text-muted;
336
+ }
337
+ #tab-energy {
338
+ overflow-y: auto;
339
+ }
340
+ .energy-summary {
341
+ height: auto;
342
+ padding: 1 2;
343
+ margin: 0 1;
344
+ border: round $primary-darken-2;
345
+ border-title-color: $accent;
346
+ border-title-align: left;
347
+ }
348
+ .energy-chart {
349
+ height: auto;
350
+ padding: 1 2;
351
+ margin: 0 1;
352
+ border: round $primary-darken-2;
353
+ border-title-color: $accent;
354
+ border-title-align: left;
355
+ }
356
+ #energy-status {
357
+ padding: 0 2;
358
+ color: $text-muted;
359
+ }
360
+ .controls-sensors-row {
361
+ height: auto;
362
+ }
363
+ .controls-panel {
364
+ width: 1fr;
365
+ }
366
+ .sensors-panel {
367
+ width: 1fr;
368
+ }
369
+ .dial-panel {
370
+ width: 1fr;
371
+ height: auto;
372
+ }
373
+ .qsm-panel {
374
+ width: 1fr;
375
+ height: auto;
376
+ }
377
+ .perf-row {
378
+ height: 1fr;
379
+ }
380
+ .perf-left {
381
+ width: 1fr;
382
+ height: 1fr;
383
+ }
384
+ .perf-right {
385
+ width: 1fr;
386
+ height: 1fr;
387
+ }
388
+
389
+ /* System screen */
390
+ #system-container {
391
+ overflow-y: auto;
392
+ padding: 1 2;
393
+ }
394
+ .odu-panel {
395
+ border: round $primary-darken-2;
396
+ border-title-color: $accent;
397
+ border-title-align: left;
398
+ padding: 1 2;
399
+ margin-bottom: 1;
400
+ height: auto;
401
+ }
402
+ #odu-row {
403
+ height: auto;
404
+ margin-bottom: 1;
405
+ }
406
+ #odu-row .odu-panel {
407
+ width: 1fr;
408
+ margin-bottom: 0;
409
+ margin-right: 1;
410
+ }
411
+ """
412
+
413
+
414
+ # ──────────────────────────────────────────────────────────────────
415
+ # LoadingScreen
416
+ # ──────────────────────────────────────────────────────────────────
417
+
418
+
419
+ class LoadingScreen(Screen):
420
+ """Spinner shown while logging in and fetching the initial snapshot."""
421
+
422
+ def compose(self) -> ComposeResult:
423
+ with Container(id="loading-container"):
424
+ yield LoadingIndicator()
425
+ yield Label("Connecting to Quilt Cloud…", id="loading-label")
426
+
427
+ def set_status(self, msg: str) -> None:
428
+ with contextlib.suppress(NoMatches):
429
+ self.query_one("#loading-label", Label).update(msg)
430
+
431
+
432
+ # ──────────────────────────────────────────────────────────────────
433
+ # DashboardScreen
434
+ # ──────────────────────────────────────────────────────────────────
435
+
436
+
437
+ class RoomListItem(ListItem):
438
+ """A ListView row representing one room."""
439
+
440
+ def __init__(self, space: Space, idu: IndoorUnit | None = None, use_f: bool = False) -> None:
441
+ super().__init__()
442
+ self._space_id = space.id
443
+ self._space_name = space.name
444
+ self._idu = idu
445
+ self.update_space(space, idu, use_f)
446
+
447
+ @property
448
+ def space_id(self) -> str:
449
+ return self._space_id
450
+
451
+ def _build_row(self, space: Space, idu: IndoorUnit | None, use_f: bool) -> Text:
452
+ c = space.controls
453
+ s = space.state
454
+ mode = _space_mode_badge(space) if c else Text("--", style="dim")
455
+ state = _fmt_state(s.hvac_state) if s else Text("--", style="dim")
456
+ ambient = _tc(s.ambient_temperature_c, use_f) if s else "--"
457
+ setpt = c.display_setpoint_str(use_f) if c else "--"
458
+ occ_state = (
459
+ OccupancyState(idu.effective_occupancy_state)
460
+ if idu
461
+ and idu.effective_occupancy_state is not None
462
+ and space.settings.occupancy_mode == OccupancyMode.ENABLED
463
+ else None
464
+ )
465
+ occ = Text.from_markup(_occ_glyph(occ_state))
466
+ name_w = 20
467
+ name_part = self._space_name[:name_w].ljust(name_w)
468
+ return Text.assemble(
469
+ Text(name_part, style="bold"),
470
+ " ",
471
+ mode,
472
+ " ",
473
+ occ,
474
+ " ",
475
+ Text(f"{ambient:>8}", style="green"),
476
+ Text(" → "),
477
+ Text(f"{setpt:<12}", style="yellow"),
478
+ Text(" "),
479
+ state,
480
+ )
481
+
482
+ def update_space(
483
+ self, space: Space, idu: IndoorUnit | None = None, use_f: bool = False
484
+ ) -> None:
485
+ self._space = space
486
+ if idu is not None:
487
+ self._idu = idu
488
+ with contextlib.suppress(NoMatches):
489
+ self.query_one(Static).update(self._build_row(space, self._idu, use_f))
490
+
491
+ def compose(self) -> ComposeResult:
492
+ yield Static(
493
+ self._build_row(self._space, self._idu, False),
494
+ id=f"room-row-{self._space_id}",
495
+ )
496
+
497
+
498
+ class DashboardScreen(Screen):
499
+ """Main screen — scrollable room list with live updates."""
500
+
501
+ BINDINGS: ClassVar = [
502
+ Binding("s", "system", "System"),
503
+ Binding("r", "refresh", "Refresh"),
504
+ Binding("u", "toggle_units", "°C/°F"),
505
+ Binding("enter", "select_room", "Room Detail"),
506
+ ]
507
+
508
+ use_f: reactive[bool] = reactive(False)
509
+
510
+ def __init__(
511
+ self,
512
+ snapshot: SystemSnapshot,
513
+ client: QuiltClient,
514
+ ) -> None:
515
+ super().__init__()
516
+ self._snapshot = snapshot
517
+ self._client = client
518
+ self._items: dict[str, RoomListItem] = {} # space_id → ListItem
519
+
520
+ def compose(self) -> ComposeResult:
521
+ yield Header(show_clock=True)
522
+ yield ListView(id="dashboard-list")
523
+ yield Static("", id="dashboard-statusbar")
524
+ yield Footer()
525
+
526
+ def _idu_for(self, space_id: str) -> IndoorUnit | None:
527
+ return next(
528
+ (u for u in self._snapshot.indoor_units if u.space_id == space_id),
529
+ None,
530
+ )
531
+
532
+ def on_mount(self) -> None:
533
+ lv = self.query_one(ListView)
534
+ for space in self._snapshot.rooms:
535
+ item = RoomListItem(space, self._idu_for(space.id), self.use_f)
536
+ self._items[space.id] = item
537
+ lv.append(item)
538
+ self._refresh_statusbar()
539
+ self.set_interval(60, self._auto_refresh)
540
+
541
+ async def _apply_snapshot(self, snap: SystemSnapshot) -> None:
542
+ """Replace the current snapshot and rebuild the room list in-place."""
543
+ self._snapshot = snap
544
+ # Keep app-level snapshot in sync for stream dispatcher comfort maps.
545
+ self.app._snapshot = snap # type: ignore[attr-defined]
546
+ self._items.clear()
547
+ lv = self.query_one(ListView)
548
+ await lv.clear()
549
+ for space in snap.rooms:
550
+ item = RoomListItem(space, self._idu_for(space.id), self.use_f)
551
+ self._items[space.id] = item
552
+ lv.append(item)
553
+ self._refresh_statusbar()
554
+
555
+ @work
556
+ async def _auto_refresh(self) -> None:
557
+ """Periodic silent re-sync with server state (called every 60 s)."""
558
+ with contextlib.suppress(Exception):
559
+ snap = await self._client.get_snapshot()
560
+ await self._apply_snapshot(snap)
561
+
562
+ @work
563
+ async def action_refresh(self) -> None:
564
+ try:
565
+ snap = await self._client.get_snapshot()
566
+ await self._apply_snapshot(snap)
567
+ self.notify("Refreshed", timeout=2)
568
+ except Exception as exc:
569
+ self.notify(f"Refresh failed: {exc}", severity="error")
570
+
571
+ def _refresh_statusbar(self) -> None:
572
+ snap = self._snapshot
573
+ tz = snap.timezone or "?"
574
+ loc = snap.primary_location
575
+ sched = "⏸ PAUSED" if (loc and loc.schedule_paused) else "▶ RUNNING"
576
+ odu_state = "--"
577
+ if snap.outdoor_units:
578
+ odu = snap.outdoor_units[0]
579
+ odu_state = HVACState(odu.hvac_state).name if odu.hvac_state else "--"
580
+ with contextlib.suppress(NoMatches):
581
+ self.query_one("#dashboard-statusbar", Static).update(
582
+ f" System: {tz} · Schedule: {sched} · ODU: {odu_state}"
583
+ )
584
+
585
+ def update_space(self, space: Space) -> None:
586
+ """Called from stream callbacks to update this room row."""
587
+ item = self._items.get(space.id)
588
+ if item:
589
+ idu = self._idu_for(space.id)
590
+ item.update_space(space, idu, self.use_f)
591
+ item.refresh()
592
+
593
+ def update_odu(self, odu: OutdoorUnit) -> None:
594
+ """Called when an ODU stream event arrives — refresh the statusbar."""
595
+ self._refresh_statusbar()
596
+
597
+ def watch_use_f(self, use_f: bool) -> None:
598
+ for space_id, item in self._items.items():
599
+ space = next((s for s in self._snapshot.spaces if s.id == space_id), None)
600
+ if space:
601
+ item.update_space(space, None, use_f)
602
+ item.refresh()
603
+
604
+ def action_toggle_units(self) -> None:
605
+ self.use_f = not self.use_f
606
+ self.app._persist()
607
+
608
+ def action_system(self) -> None:
609
+ self.app.push_screen(SystemScreen(self._snapshot, self._client))
610
+
611
+ def action_select_room(self) -> None:
612
+ lv = self.query_one(ListView)
613
+ if lv.highlighted_child is None:
614
+ return
615
+ item = lv.highlighted_child
616
+ if isinstance(item, RoomListItem):
617
+ self._open_room(item.space_id)
618
+
619
+ @on(ListView.Selected)
620
+ def on_room_selected(self, event: ListView.Selected) -> None:
621
+ if isinstance(event.item, RoomListItem):
622
+ self._open_room(event.item.space_id)
623
+
624
+ def _open_room(self, space_id: str) -> None:
625
+ space = next((s for s in self._snapshot.rooms if s.id == space_id), None)
626
+ if space is None:
627
+ return
628
+ idu = next(
629
+ (u for u in self._snapshot.indoor_units if u.space_id == space_id),
630
+ None,
631
+ )
632
+ ctrl = next(
633
+ (c for c in self._snapshot.controllers if c.space_id == space_id),
634
+ None,
635
+ )
636
+ odu = self._snapshot.outdoor_units[0] if self._snapshot.outdoor_units else None
637
+ qsm = self._snapshot.qsm_for_idu(idu) if idu else None
638
+ self.app.push_screen(
639
+ RoomScreen(
640
+ space=space,
641
+ idu=idu,
642
+ controller=ctrl,
643
+ odu=odu,
644
+ qsm=qsm,
645
+ snapshot=self._snapshot,
646
+ client=self._client,
647
+ use_f=self.use_f,
648
+ )
649
+ )
650
+
651
+
652
+ # ──────────────────────────────────────────────────────────────────
653
+ # RoomScreen
654
+ # ──────────────────────────────────────────────────────────────────
655
+
656
+
657
+ class _KVStatic(Static):
658
+ """A key: value line as Rich markup."""
659
+
660
+ def set_kv(self, key: str, value: str, val_style: str = "") -> None:
661
+ if val_style:
662
+ val = Text(value, style=val_style)
663
+ else:
664
+ val = Text.from_markup(value)
665
+ self.update(Text.assemble(Text(f"{key:<22}", style="dim"), val))
666
+
667
+
668
+ class RoomScreen(Screen):
669
+ """Room detail screen with Status / Performance / Schedule tabs."""
670
+
671
+ BINDINGS: ClassVar = [
672
+ Binding("escape,b", "back", "Back"),
673
+ Binding("u", "toggle_units", "°C/°F"),
674
+ # Status tab mutations
675
+ Binding("m", "cycle_mode", "Mode"),
676
+ Binding("H", "heat_up", "Heat+"),
677
+ Binding("h", "heat_down", "Heat-"),
678
+ Binding("C", "cool_up", "Cool+"),
679
+ Binding("c", "cool_down", "Cool-"),
680
+ Binding("f", "cycle_fan", "Fan"),
681
+ Binding("l", "cycle_louver", "Louver"),
682
+ Binding("L", "toggle_led", "LED"),
683
+ Binding("o", "cycle_occupancy", "Occ"),
684
+ Binding("p", "toggle_schedule", "Pause Sched"),
685
+ Binding("e", "refresh_energy", "Energy ↻"),
686
+ Binding("[", "away_timeout_dec", "Away-5m", show=False),
687
+ Binding("]", "away_timeout_inc", "Away+5m", show=False),
688
+ Binding("{", "return_timeout_dec", "Return-1m", show=False),
689
+ Binding("}", "return_timeout_inc", "Return+1m", show=False),
690
+ # Presence fence adjustment (Ctrl+arrow keys or letters on Status tab)
691
+ Binding("F", "fence_fwd_inc", "Fence Depth+", show=False),
692
+ Binding("G", "fence_fwd_dec", "Fence Depth-", show=False),
693
+ Binding("X", "fence_lr_inc", "Fence L/R+", show=False),
694
+ Binding("Z", "fence_lr_dec", "Fence L/R-", show=False),
695
+ Binding("R", "radar_height_inc", "Radar H+", show=False),
696
+ Binding("T", "radar_height_dec", "Radar H-", show=False),
697
+ ]
698
+
699
+ use_f: reactive[bool] = reactive(False)
700
+
701
+ def __init__(
702
+ self,
703
+ space: Space,
704
+ idu: IndoorUnit | None,
705
+ controller: Controller | None,
706
+ odu: OutdoorUnit | None,
707
+ qsm: QuiltSmartModule | None,
708
+ snapshot: SystemSnapshot,
709
+ client: QuiltClient,
710
+ use_f: bool = False,
711
+ ) -> None:
712
+ super().__init__()
713
+ self._space = space
714
+ self._idu = idu
715
+ self._controller = controller
716
+ self._odu = odu
717
+ self._qsm = qsm
718
+ self._snapshot = snapshot
719
+ self._client = client
720
+ self.use_f = use_f
721
+ self.title = space.name
722
+ self.sub_title = "Room"
723
+
724
+ # ── Layout ──────────────────────────────────────────────────
725
+
726
+ def compose(self) -> ComposeResult:
727
+ yield Header(show_clock=True)
728
+ with TabbedContent(id="room-tabs"):
729
+ with TabPane("Status", id="tab-status"):
730
+ yield from self._compose_status()
731
+ with TabPane("Performance", id="tab-perf"):
732
+ yield from self._compose_perf()
733
+ with TabPane("Schedule", id="tab-schedule"):
734
+ yield from self._compose_schedule()
735
+ with TabPane("Energy", id="tab-energy"):
736
+ yield from self._compose_energy()
737
+ yield Footer()
738
+
739
+ def _compose_status(self) -> ComposeResult:
740
+ with Horizontal(classes="controls-sensors-row"):
741
+ # Controls panel
742
+ with Vertical(classes="panel controls-panel") as v:
743
+ v.border_title = "Controls"
744
+ yield _KVStatic(id="ctl-mode")
745
+ yield _KVStatic(id="ctl-heat")
746
+ yield _KVStatic(id="ctl-cool")
747
+ yield _KVStatic(id="ctl-fan")
748
+ yield _KVStatic(id="ctl-louver")
749
+ yield _KVStatic(id="ctl-louver-pos")
750
+ yield _KVStatic(id="ctl-boost")
751
+ yield _KVStatic(id="ctl-led")
752
+ yield _KVStatic(id="ctl-led-color")
753
+ yield _KVStatic(id="ctl-led-anim")
754
+ yield _KVStatic(id="ctl-preset")
755
+ yield _KVStatic(id="ctl-preset-override")
756
+ yield _KVStatic(id="ctl-state-preset")
757
+ yield _KVStatic(id="ctl-occ-mode")
758
+ yield _KVStatic(id="ctl-safety")
759
+ yield _KVStatic(id="ctl-away-after")
760
+ yield _KVStatic(id="ctl-return-after")
761
+ yield _KVStatic(id="ctl-away-temps")
762
+ # Sensors panel
763
+ with Vertical(classes="panel sensors-panel", id="sensors-panel") as v:
764
+ v.border_title = "Sensors"
765
+ yield _KVStatic(id="sen-ambient")
766
+ yield _KVStatic(id="sen-calc-ambient")
767
+ yield _KVStatic(id="sen-humidity")
768
+ yield _KVStatic(id="sen-fan-rpm")
769
+ yield _KVStatic(id="sen-fan-setpoint-rpm")
770
+ yield _KVStatic(id="sen-setpoint")
771
+ yield _KVStatic(id="sen-inlet")
772
+ yield _KVStatic(id="sen-outlet")
773
+ yield _KVStatic(id="sen-louver-angle")
774
+ yield _KVStatic(id="sen-state")
775
+ yield _KVStatic(id="sen-occ-state")
776
+ yield _KVStatic(id="sen-presence-l")
777
+ yield _KVStatic(id="sen-presence-r")
778
+ yield _KVStatic(id="sen-presence-level")
779
+ yield _KVStatic(id="sen-fence-lr")
780
+ yield _KVStatic(id="sen-fence-fwd")
781
+ yield _KVStatic(id="sen-radar-height")
782
+ yield _KVStatic(id="sen-idu-mode")
783
+ yield _KVStatic(id="sen-idu-name")
784
+ yield _KVStatic(id="sen-idu-light-default")
785
+ yield Rule(classes="section-rule")
786
+ # Dial and QSM panels side by side
787
+ with Horizontal(classes="controls-sensors-row"):
788
+ # Dial panel
789
+ with Vertical(classes="panel dial-panel", id="dial-panel") as v:
790
+ v.border_title = "Dial (Thermostat)"
791
+ yield _KVStatic(id="dial-serial")
792
+ yield _KVStatic(id="dial-ambient")
793
+ yield _KVStatic(id="dial-calib")
794
+ yield _KVStatic(id="dial-pcb")
795
+ yield _KVStatic(id="dial-wifi")
796
+ yield _KVStatic(id="dial-wifi-ip")
797
+ yield _KVStatic(id="dial-wifi-last")
798
+ yield _KVStatic(id="dial-wifi-ap")
799
+ yield _KVStatic(id="dial-wifi-p2p")
800
+ yield _KVStatic(id="dial-remote-sensor")
801
+ yield _KVStatic(id="dial-crs-temp")
802
+ yield _KVStatic(id="dial-crs-humidity")
803
+ yield _KVStatic(id="dial-crs-battery")
804
+ yield _KVStatic(id="dial-crs-signal")
805
+ # QSM panel
806
+ with Vertical(classes="panel qsm-panel") as v:
807
+ v.border_title = "QSM (Smart Module)"
808
+ yield _KVStatic(id="qsm-wifi-hosted")
809
+ yield _KVStatic(id="qsm-wifi-ap")
810
+ yield _KVStatic(id="qsm-wifi-p2p")
811
+ yield _KVStatic(id="qsm-presence")
812
+ yield _KVStatic(id="qsm-als")
813
+ yield _KVStatic(id="qsm-accel")
814
+
815
+ def _compose_perf(self) -> ComposeResult:
816
+ with Horizontal(classes="perf-row"):
817
+ with ScrollableContainer(classes="panel perf-left") as v:
818
+ v.border_title = "IDU / ODU"
819
+ yield Label("IDU Temperatures", classes="section-label")
820
+ yield _KVStatic(id="p-coil")
821
+ yield _KVStatic(id="p-outlet")
822
+ yield _KVStatic(id="p-inlet")
823
+ yield _KVStatic(id="p-gas")
824
+ yield _KVStatic(id="p-liquid")
825
+ yield Rule()
826
+ yield Label("HVAC Inputs (Controller→IDU)", classes="section-label")
827
+ yield _KVStatic(id="p-hi-ext-ambient")
828
+ yield _KVStatic(id="p-hi-setpoint")
829
+ yield _KVStatic(id="p-hi-mode")
830
+ yield _KVStatic(id="p-hi-state")
831
+ yield _KVStatic(id="p-hi-source")
832
+ yield _KVStatic(id="p-hi-ctrl-type")
833
+ yield Rule()
834
+ yield Label("ODU Compressor", classes="section-label")
835
+ yield _KVStatic(id="p-odu-state")
836
+ yield _KVStatic(id="p-odu-freq")
837
+ yield _KVStatic(id="p-odu-coil")
838
+ yield _KVStatic(id="p-odu-exhaust")
839
+ yield _KVStatic(id="p-odu-hi")
840
+ yield _KVStatic(id="p-odu-lo")
841
+ yield _KVStatic(id="p-odu-ambient")
842
+ with ScrollableContainer(classes="panel perf-right") as v:
843
+ v.border_title = "Energy / Efficiency"
844
+ yield Label("IDU Energy", classes="section-label")
845
+ yield _KVStatic(id="p-interval")
846
+ yield _KVStatic(id="p-energy-j")
847
+ yield _KVStatic(id="p-energy-kwh")
848
+ yield _KVStatic(id="p-fan-actual")
849
+ yield _KVStatic(id="p-pd-mode")
850
+ yield _KVStatic(id="p-pd-state")
851
+ yield Rule()
852
+ yield Label("Efficiency", classes="section-label")
853
+ yield _KVStatic(id="p-capacity")
854
+ yield _KVStatic(id="p-cop")
855
+ yield _KVStatic(id="p-hvac-power")
856
+ yield _KVStatic(id="p-led-power")
857
+ yield _KVStatic(id="p-pm-mode")
858
+ yield _KVStatic(id="p-pm-state")
859
+ yield _KVStatic(id="p-pm-duration")
860
+ yield _KVStatic(id="p-pm-energy-total")
861
+ yield _KVStatic(id="p-pm-hvac-energy")
862
+ yield _KVStatic(id="p-pm-led-energy")
863
+ yield Rule()
864
+ yield Label("IDU Conditions", classes="section-label")
865
+ yield _KVStatic(id="p-cond-defrost")
866
+ yield _KVStatic(id="p-cond-oilreturn")
867
+ yield _KVStatic(id="p-cond-coilpreheat")
868
+ yield _KVStatic(id="p-cond-safetyheat")
869
+ yield _KVStatic(id="p-cond-anticold")
870
+ yield _KVStatic(id="p-cond-modeswitch")
871
+ yield _KVStatic(id="p-cond-modeconflict")
872
+ yield _KVStatic(id="p-cond-modeconflictavoid")
873
+ yield _KVStatic(id="p-cond-abnormal-odu-air")
874
+ yield _KVStatic(id="p-cond-odu-comm")
875
+ yield _KVStatic(id="p-cond-modbus")
876
+ yield Rule()
877
+ yield Label("ODU Hardware", classes="section-label")
878
+ yield _KVStatic(id="p-odu-model")
879
+ yield _KVStatic(id="p-odu-serial")
880
+ yield _KVStatic(id="p-odu-fw")
881
+ yield Rule()
882
+ yield Label("IDU Commands", classes="section-label")
883
+ yield _KVStatic(id="p-cmd-fallback")
884
+
885
+ def _compose_schedule(self) -> ComposeResult:
886
+ yield Static("", id="sched-status")
887
+ with Horizontal(classes="sched-row"):
888
+ with ScrollableContainer(classes="sched-days-panel") as v:
889
+ v.border_title = "Schedule"
890
+ yield DataTable(id="sched-week", show_cursor=True, cursor_type="row")
891
+ with ScrollableContainer(classes="sched-events-panel", id="sched-events-panel") as v:
892
+ v.border_title = "Events"
893
+ yield DataTable(id="sched-day", show_cursor=False)
894
+
895
+ def _compose_energy(self) -> ComposeResult:
896
+ yield Static("", id="energy-status")
897
+ with Vertical(classes="energy-summary") as v:
898
+ v.border_title = "Energy Summary"
899
+ yield _KVStatic(id="e-today")
900
+ yield _KVStatic(id="e-yesterday")
901
+ yield _KVStatic(id="e-7day")
902
+ yield _KVStatic(id="e-30day")
903
+ with Vertical(classes="energy-chart") as v:
904
+ v.border_title = "Last 24 Hours — Hourly (kWh)"
905
+ yield Static("", id="e-sparkline")
906
+ yield DataTable(id="e-table")
907
+
908
+ # ── Mount: populate all panels ───────────────────────────────
909
+
910
+ def on_mount(self) -> None:
911
+ self._populate_status()
912
+ self._populate_perf()
913
+ self._populate_schedule()
914
+ self._fetch_energy()
915
+
916
+ def _populate_status(self) -> None:
917
+ space = self._space
918
+ idu = self._idu
919
+ ctrl = self._controller
920
+ use_f = self.use_f
921
+
922
+ c = space.controls
923
+ s = space.state
924
+ sets = space.settings
925
+
926
+ # Look up comfort preset name
927
+ preset_name = "--"
928
+ if c.comfort_setting_id:
929
+ cs = next(
930
+ (x for x in self._snapshot.comfort_settings if x.id == c.comfort_setting_id),
931
+ None,
932
+ )
933
+ if cs:
934
+ preset_name = f"{cs.name} ({cs.type.name})"
935
+
936
+ if space.is_away:
937
+ mode_label, mode_style = "AWAY", "yellow dim"
938
+ elif space.is_off:
939
+ mode_label, mode_style = "OFF", "dim"
940
+ else:
941
+ mode_label = space.controls.hvac_mode.name
942
+ mode_style = _MODE_STYLE.get(space.controls.hvac_mode, "")
943
+ self._kv("ctl-mode", "Mode", mode_label, mode_style)
944
+ self._kv("ctl-heat", "Heat Setpoint", _tc(c.heating_setpoint_c, use_f), "red")
945
+ self._kv(
946
+ "ctl-cool",
947
+ "Cool Setpoint",
948
+ _tc(c.cooling_setpoint_c, use_f),
949
+ "cyan",
950
+ )
951
+ self._kv("ctl-fan", "Fan Speed", idu.controls.fan_speed.name if idu else "--")
952
+ self._kv(
953
+ "ctl-louver",
954
+ "Louver",
955
+ idu.controls.louver_mode.name if idu else "--",
956
+ )
957
+ if idu and idu.controls.louver_mode.name == "FIXED" and idu.controls.louver_fixed_position:
958
+ self._kv(
959
+ "ctl-louver-pos",
960
+ " Fixed Pos",
961
+ f"{idu.controls.louver_fixed_position:.1f}°",
962
+ )
963
+ else:
964
+ self._kv("ctl-louver-pos", " Fixed Pos", "--")
965
+ boost_str = "--"
966
+ if idu and c.boost_mode.name not in ("UNSPECIFIED",):
967
+ boost_str = "ON" if c.boost_mode.name == "ON" else "Off"
968
+ elif idu:
969
+ boost_str = "Off"
970
+ self._kv(
971
+ "ctl-boost",
972
+ "Boost Mode",
973
+ boost_str,
974
+ "bold yellow" if boost_str == "ON" else "",
975
+ )
976
+ led_str = "--"
977
+ led_color_str = "--"
978
+ led_anim_str = "--"
979
+ if idu:
980
+ if not idu.is_online:
981
+ led_str = "OFF (offline)"
982
+ led_color_str = "--"
983
+ led_anim_str = "--"
984
+ elif idu.led_on:
985
+ led_str = f"ON {idu.controls.led_brightness * 100:.0f}%"
986
+ led_color_str = _led_color_str(idu.controls.led_color_code)
987
+ anim = idu.controls.led_animation
988
+ led_anim_str = (
989
+ anim.name.replace("_", " ").title()
990
+ if anim not in (LedAnimation.UNSPECIFIED, LedAnimation.NONE)
991
+ else "None"
992
+ )
993
+ else:
994
+ led_str = "OFF"
995
+ led_color_str = "--"
996
+ led_anim_str = "--"
997
+ self._kv("ctl-led", "LED", led_str)
998
+ self._kv("ctl-led-color", " Color", led_color_str)
999
+ self._kv("ctl-led-anim", " Effect", led_anim_str)
1000
+ self._kv("ctl-preset", "Comfort Preset", preset_name, "yellow")
1001
+ # Comfort setting override: why the current preset was applied
1002
+ override = c.comfort_setting_override
1003
+ from quilt_hp.models.enums import ComfortSettingOverride
1004
+
1005
+ override_labels = {
1006
+ ComfortSettingOverride.NONE: ("Schedule", "dim"),
1007
+ ComfortSettingOverride.UNTIL_NEXT_SCHEDULE: (
1008
+ "Manual (until next event)",
1009
+ "yellow",
1010
+ ),
1011
+ ComfortSettingOverride.INDEFINITE: (
1012
+ "Manual (indefinite)",
1013
+ "yellow",
1014
+ ),
1015
+ ComfortSettingOverride.SCHEDULE: ("Schedule", "dim"),
1016
+ ComfortSettingOverride.UNOCCUPIED: ("Auto-Away", "yellow dim"),
1017
+ ComfortSettingOverride.OCCUPIED: ("Auto-Return", "green dim"),
1018
+ }
1019
+ ov_str, ov_style = override_labels.get(
1020
+ override, (override.name.replace("_", " ").title(), "")
1021
+ )
1022
+ self._kv("ctl-preset-override", " Applied Via", ov_str, ov_style)
1023
+ # State-reported active comfort setting may differ from controls preset.
1024
+ state_preset_name = "--"
1025
+ if s.comfort_setting_id:
1026
+ cs_state = next(
1027
+ (x for x in self._snapshot.comfort_settings if x.id == s.comfort_setting_id),
1028
+ None,
1029
+ )
1030
+ if cs_state:
1031
+ state_preset_name = cs_state.name
1032
+ elif s.comfort_setting_id != c.comfort_setting_id:
1033
+ state_preset_name = f"…{s.comfort_setting_id[-8:]}"
1034
+ self._kv(
1035
+ "ctl-state-preset",
1036
+ " Active (state)",
1037
+ state_preset_name,
1038
+ "yellow dim",
1039
+ )
1040
+ occ_mode_label = sets.occupancy_mode.name.capitalize()
1041
+ self._kv("ctl-occ-mode", "Occupancy Mode", occ_mode_label)
1042
+ self._kv(
1043
+ "ctl-safety",
1044
+ "Safety Heating",
1045
+ sets.safety_heating.name.capitalize(),
1046
+ )
1047
+
1048
+ # Auto-away / auto-return timeouts (editable with [ ] { })
1049
+ away_style = "" if sets.occupancy_mode == OccupancyMode.ENABLED else "dim"
1050
+ self._kv(
1051
+ "ctl-away-after",
1052
+ "Auto-Away After",
1053
+ _fmt_timeout(sets.unoccupied_timeout_s),
1054
+ away_style,
1055
+ )
1056
+ self._kv(
1057
+ "ctl-return-after",
1058
+ "Auto-Return After",
1059
+ _fmt_timeout(sets.occupied_timeout_s),
1060
+ away_style,
1061
+ )
1062
+
1063
+ # Away temperatures — from the space's AWAY comfort setting
1064
+ away_cs = next(
1065
+ (
1066
+ cs
1067
+ for cs in self._snapshot.comfort_settings
1068
+ if cs.space_id == space.id and cs.type.name == "AWAY"
1069
+ ),
1070
+ None,
1071
+ )
1072
+ if away_cs:
1073
+ away_temps = (
1074
+ f"Heat {_tc(away_cs.heating_setpoint_c, use_f)} / "
1075
+ f"Cool {_tc(away_cs.cooling_setpoint_c, use_f)}"
1076
+ )
1077
+ self._kv("ctl-away-temps", "Away Temps", away_temps, "yellow dim")
1078
+ else:
1079
+ self._kv("ctl-away-temps", "Away Temps", "not configured", "dim")
1080
+
1081
+ # Update panel titles with offline status
1082
+ idu_title = "Sensors [dim]F/G depth X/Z L/R R/T height[/dim]"
1083
+ if idu and not idu.is_online:
1084
+ idu_title = "Sensors [bold red]⚠ IDU OFFLINE[/]"
1085
+ with contextlib.suppress(Exception):
1086
+ self.query_one("#sensors-panel").border_title = idu_title
1087
+
1088
+ dial_title = "Dial (Thermostat)"
1089
+ if ctrl and not ctrl.is_online:
1090
+ dial_title = "Dial (Thermostat) [bold red]⚠ OFFLINE[/]"
1091
+ with contextlib.suppress(Exception):
1092
+ self.query_one("#dial-panel").border_title = dial_title
1093
+
1094
+ self._kv(
1095
+ "sen-ambient",
1096
+ "Ambient Temp",
1097
+ _tc(s.ambient_temperature_c, use_f),
1098
+ "green",
1099
+ )
1100
+ if idu and idu.state.calculated_ambient_temperature_c:
1101
+ self._kv(
1102
+ "sen-calc-ambient",
1103
+ "Ambient (calc)",
1104
+ _tc(idu.state.calculated_ambient_temperature_c, use_f),
1105
+ )
1106
+ else:
1107
+ self._kv("sen-calc-ambient", "Ambient (calc)", "--")
1108
+ self._kv(
1109
+ "sen-humidity",
1110
+ "Humidity",
1111
+ f"{idu.state.ambient_humidity_percent:.0f}%"
1112
+ if idu and idu.state.ambient_humidity_percent
1113
+ else "--",
1114
+ )
1115
+ fan_rpm = idu.state.fan_speed_rpm if idu and idu.state else None
1116
+ self._kv(
1117
+ "sen-fan-rpm",
1118
+ "Fan Speed (actual)",
1119
+ f"{fan_rpm:.0f} RPM" if fan_rpm else "Off",
1120
+ )
1121
+ fan_sp_rpm = idu.state.fan_speed_setpoint_rpm if idu and idu.state else None
1122
+ self._kv(
1123
+ "sen-fan-setpoint-rpm",
1124
+ "Fan Speed (setpoint)",
1125
+ f"{fan_sp_rpm:.0f} RPM" if fan_sp_rpm else "--",
1126
+ )
1127
+ self._kv("sen-setpoint", "Active Setpoint", _tc(s.setpoint_c, use_f))
1128
+ if idu and idu.state.inlet_temperature_c:
1129
+ self._kv(
1130
+ "sen-inlet",
1131
+ "Inlet Temp",
1132
+ _tc(idu.state.inlet_temperature_c, use_f),
1133
+ )
1134
+ else:
1135
+ self._kv("sen-inlet", "Inlet Temp", "--")
1136
+ if idu and idu.state.outlet_temperature_c:
1137
+ self._kv(
1138
+ "sen-outlet",
1139
+ "Outlet Temp",
1140
+ _tc(idu.state.outlet_temperature_c, use_f),
1141
+ )
1142
+ else:
1143
+ self._kv("sen-outlet", "Outlet Temp", "--")
1144
+ if idu and idu.state.louver_angle_up_down_degrees:
1145
+ self._kv(
1146
+ "sen-louver-angle",
1147
+ "Louver Angle",
1148
+ f"{idu.state.louver_angle_up_down_degrees:.1f}°",
1149
+ )
1150
+ else:
1151
+ self._kv("sen-louver-angle", "Louver Angle", "--")
1152
+ state_fmt = _fmt_state(s.hvac_state)
1153
+ self._kv("sen-state", "HVAC State", state_fmt.plain, str(state_fmt.style))
1154
+ raw_occ = idu.effective_occupancy_state if idu else None
1155
+ occ_state = OccupancyState(raw_occ) if raw_occ is not None else None
1156
+ if occ_state == OccupancyState.DETECTED:
1157
+ occ_str, occ_style = "Occupied", "green"
1158
+ elif occ_state == OccupancyState.UNDETECTED:
1159
+ occ_str, occ_style = "Vacant", "dim"
1160
+ elif idu and not idu.is_online:
1161
+ occ_str, occ_style = "offline", "dim italic"
1162
+ else:
1163
+ occ_str, occ_style = "--", "dim italic"
1164
+ # occupancy_state is the auto-away engine decision (lags real presence
1165
+ # unoccupied_timeout_s). It controls HVAC setback, not live radar.
1166
+ self._kv("sen-occ-state", "Occupancy", occ_str, occ_style)
1167
+
1168
+ # Presence sensors — binary DETECTED / UNDETECTED per radar sensor.
1169
+ # KMP uses sensor0Presence / sensor1Presence as Presence enum
1170
+ # (DETECTED/UNDETECTED); these are NOT analog values.
1171
+ if idu and idu.presence:
1172
+ from quilt_hp.models.enums import Presence
1173
+
1174
+ def _presence_str(p: Presence) -> tuple[str, str]:
1175
+ if p == Presence.DETECTED:
1176
+ return "Detected", "green bold"
1177
+ if p == Presence.UNDETECTED:
1178
+ return "Not Detected", "dim"
1179
+ return "--", "dim italic"
1180
+
1181
+ l_str, l_style = _presence_str(idu.presence.sensor0_presence)
1182
+ r_str, r_style = _presence_str(idu.presence.sensor1_presence)
1183
+ self._kv("sen-presence-l", "Radar L", l_str, l_style)
1184
+ self._kv("sen-presence-r", "Radar R", r_str, r_style)
1185
+ else:
1186
+ self._kv("sen-presence-l", "Radar L", "--")
1187
+ self._kv("sen-presence-r", "Radar R", "--")
1188
+
1189
+ # Presence detection level and fence geometry (from IDU settings)
1190
+ if idu:
1191
+ pdl = idu.state.presence_detection_level
1192
+ pdl_str = f"{pdl:.2f}" if pdl is not None else "--"
1193
+ self._kv("sen-presence-level", "Detection Level", pdl_str)
1194
+ st = idu.settings
1195
+ if st.presence_fence_left_m or st.presence_fence_right_m:
1196
+ lr_str = (
1197
+ f"L {st.presence_fence_left_m:.2f} m / R {st.presence_fence_right_m:.2f} m"
1198
+ )
1199
+ else:
1200
+ lr_str = "[dim]unconfigured (max range)[/dim]"
1201
+ if st.presence_fence_forward_m:
1202
+ fwd_str = f"{st.presence_fence_forward_m:.2f} m"
1203
+ else:
1204
+ fwd_str = "[dim]unconfigured (max range)[/dim]"
1205
+ h_str = (
1206
+ f"{st.radar_sensor_distance_from_floor_m:.2f} m"
1207
+ if st.radar_sensor_distance_from_floor_m
1208
+ else "[dim]unconfigured[/dim]"
1209
+ )
1210
+ self._kv("sen-fence-lr", "Fence L/R", lr_str)
1211
+ self._kv("sen-fence-fwd", "Fence Depth", fwd_str)
1212
+ self._kv("sen-radar-height", "Radar Height", h_str)
1213
+ else:
1214
+ self._kv("sen-presence-level", "Detection Level", "--")
1215
+ self._kv("sen-fence-lr", "Fence L/R", "--")
1216
+ self._kv("sen-fence-fwd", "Fence Depth", "--")
1217
+ self._kv("sen-radar-height", "Radar Height", "--")
1218
+
1219
+ idu_mode_str = idu.state.hvac_mode.name if idu and idu.state else "--"
1220
+ idu_mode_style = _MODE_STYLE.get(idu.state.hvac_mode, "") if idu and idu.state else ""
1221
+ self._kv("sen-idu-mode", "IDU Mode", idu_mode_str, idu_mode_style)
1222
+ if idu and idu.settings:
1223
+ self._kv("sen-idu-name", "IDU Name", idu.settings.name or "--")
1224
+ light_pct = idu.settings.light_brightness_default_percent
1225
+ self._kv(
1226
+ "sen-idu-light-default",
1227
+ "Default Brightness",
1228
+ f"{light_pct * 100:.0f}%" if light_pct else "--",
1229
+ )
1230
+ else:
1231
+ self._kv("sen-idu-name", "IDU Name", "--")
1232
+ self._kv("sen-idu-light-default", "Default Brightness", "--")
1233
+
1234
+ # Dial / Controller
1235
+ def _wifi_str(w: object | None) -> str:
1236
+ if not w:
1237
+ return "--"
1238
+ parts = []
1239
+ if w.ssid:
1240
+ parts.append(w.ssid)
1241
+ if w.ip:
1242
+ parts.append(w.ip)
1243
+ if w.signal_dbm:
1244
+ parts.append(f"{w.signal_dbm} dBm")
1245
+ return " · ".join(parts) if parts else "--"
1246
+
1247
+ if ctrl:
1248
+ self._kv("dial-serial", "Serial", ctrl.serial_number or "--")
1249
+ self._kv(
1250
+ "dial-ambient",
1251
+ "Ambient",
1252
+ _tc(ctrl.calibrated_ambient_c, use_f),
1253
+ "green",
1254
+ )
1255
+ self._kv(
1256
+ "dial-calib",
1257
+ "Raw Thermistor",
1258
+ _tc(ctrl.raw_thermistor_c, use_f),
1259
+ )
1260
+ self._kv(
1261
+ "dial-pcb",
1262
+ "PCB A / B",
1263
+ f"{_tc(ctrl.pcb_temperature_a_c, use_f)} / "
1264
+ f"{_tc(ctrl.pcb_temperature_b_c, use_f)}",
1265
+ )
1266
+ # Wi-Fi status: SSID, band, signal
1267
+ wifi_parts = []
1268
+ if ctrl.wifi_ssid:
1269
+ wifi_parts.append(ctrl.wifi_ssid)
1270
+ if ctrl.wifi_band:
1271
+ wifi_parts.append(ctrl.wifi_band)
1272
+ if ctrl.wifi_signal_dbm:
1273
+ wifi_parts.append(f"{ctrl.wifi_signal_dbm} dBm")
1274
+ self._kv("dial-wifi", "WiFi", " · ".join(wifi_parts) or "--")
1275
+ self._kv("dial-wifi-ip", " IP", ctrl.wifi_ip or "--")
1276
+ # Last seen: format as local time if available
1277
+ if ctrl.wifi_last_seen:
1278
+ local_ts = ctrl.wifi_last_seen.astimezone()
1279
+ last_seen_str = local_ts.strftime("%Y-%m-%d %H:%M:%S")
1280
+ else:
1281
+ last_seen_str = "--"
1282
+ self._kv("dial-wifi-last", " Last Seen", last_seen_str)
1283
+ self._kv("dial-wifi-ap", "WiFi (AP)", _wifi_str(ctrl.ap_wifi))
1284
+ self._kv("dial-wifi-p2p", "WiFi (P2P)", _wifi_str(ctrl.p2p_wifi))
1285
+ rsm = ctrl.remote_sensor_mode
1286
+ rsm_str = (
1287
+ "Enabled"
1288
+ if rsm.name == "ENABLED"
1289
+ else ("Disabled" if rsm.name == "DISABLED" else "--")
1290
+ )
1291
+ rsm_style = "green" if rsm.name == "ENABLED" else "dim"
1292
+ self._kv("dial-remote-sensor", "Zone Sensor", rsm_str, rsm_style)
1293
+ # ControllerRemoteSensor — Dial acting as zone sensor
1294
+ crs = next(
1295
+ (
1296
+ r
1297
+ for r in self._snapshot.controller_remote_sensors
1298
+ if r.controller_id == ctrl.id
1299
+ ),
1300
+ None,
1301
+ )
1302
+ if crs:
1303
+ self._kv(
1304
+ "dial-crs-temp",
1305
+ " Zone Temp",
1306
+ _tc(crs.ambient_temperature_c, use_f),
1307
+ "green",
1308
+ )
1309
+ self._kv(
1310
+ "dial-crs-humidity",
1311
+ " Zone Humidity",
1312
+ f"{crs.humidity_percent:.0f}%" if crs.humidity_percent else "--",
1313
+ )
1314
+ self._kv(
1315
+ "dial-crs-battery",
1316
+ " Battery",
1317
+ f"{crs.battery_level_percent:.0f}%" if crs.battery_level_percent else "--",
1318
+ )
1319
+ self._kv(
1320
+ "dial-crs-signal",
1321
+ " Signal",
1322
+ f"{crs.signal_level_dbm} dBm" if crs.signal_level_dbm else "--",
1323
+ )
1324
+ else:
1325
+ self._kv("dial-crs-temp", " Zone Temp", "--")
1326
+ self._kv("dial-crs-humidity", " Zone Humidity", "--")
1327
+ self._kv("dial-crs-battery", " Battery", "--")
1328
+ self._kv("dial-crs-signal", " Signal", "--")
1329
+ else:
1330
+ for nid in (
1331
+ "dial-serial",
1332
+ "dial-ambient",
1333
+ "dial-calib",
1334
+ "dial-pcb",
1335
+ "dial-wifi",
1336
+ "dial-wifi-ip",
1337
+ "dial-wifi-last",
1338
+ "dial-wifi-ap",
1339
+ "dial-wifi-p2p",
1340
+ "dial-remote-sensor",
1341
+ "dial-crs-temp",
1342
+ "dial-crs-humidity",
1343
+ "dial-crs-battery",
1344
+ "dial-crs-signal",
1345
+ ):
1346
+ self._kv(
1347
+ nid,
1348
+ nid.replace("dial-", "").replace("-", " ").title(),
1349
+ "--",
1350
+ )
1351
+
1352
+ # QSM / Smart Module
1353
+ qsm = self._qsm
1354
+
1355
+ if qsm:
1356
+ self._kv("qsm-wifi-hosted", "WiFi (hosted)", _wifi_str(qsm.hosted_wifi))
1357
+ self._kv("qsm-wifi-ap", "WiFi (AP)", _wifi_str(qsm.ap_wifi))
1358
+ self._kv("qsm-wifi-p2p", "WiFi (P2P)", _wifi_str(qsm.p2p_wifi))
1359
+ if qsm.sensors:
1360
+ s = qsm.sensors
1361
+ self._kv(
1362
+ "qsm-presence",
1363
+ "Presence",
1364
+ f"phase {s.phase_detected_raw:.3f} target {s.target_detected_raw:.3f}",
1365
+ )
1366
+ self._kv(
1367
+ "qsm-als",
1368
+ "Light (ALS)",
1369
+ f"illum {s.als_illuminance_raw} IR {s.als_ir_raw} both {s.als_both_raw}",
1370
+ )
1371
+ self._kv(
1372
+ "qsm-accel",
1373
+ "Accel X/Y/Z",
1374
+ f"{s.accel_x_raw} / {s.accel_y_raw} / {s.accel_z_raw}",
1375
+ )
1376
+ else:
1377
+ for nid, lbl in [
1378
+ ("qsm-presence", "Presence"),
1379
+ ("qsm-als", "ALS"),
1380
+ ("qsm-accel", "Accel"),
1381
+ ]:
1382
+ self._kv(nid, lbl, "--")
1383
+ else:
1384
+ for nid, lbl in [
1385
+ ("qsm-wifi-hosted", "WiFi (hosted)"),
1386
+ ("qsm-wifi-ap", "WiFi (AP)"),
1387
+ ("qsm-wifi-p2p", "WiFi (P2P)"),
1388
+ ("qsm-presence", "Presence"),
1389
+ ("qsm-als", "ALS"),
1390
+ ("qsm-accel", "Accel"),
1391
+ ]:
1392
+ self._kv(nid, lbl, "no QSM")
1393
+
1394
+ def _populate_perf(self) -> None:
1395
+ idu = self._idu
1396
+ odu = self._odu
1397
+ use_f = self.use_f
1398
+
1399
+ _COND_STATE_LABELS = {0: "—", 1: "inactive", 2: "ACTIVE"}
1400
+ _COND_ACTIVE_STYLE = "bold red"
1401
+
1402
+ if idu and idu.performance_data:
1403
+ pd = idu.performance_data
1404
+ self._kv("p-coil", "Coil Temp", _tc(pd.coil_temperature_c, use_f))
1405
+ self._kv("p-outlet", "Outlet Temp", _tc(pd.outlet_temperature_c, use_f))
1406
+ self._kv("p-inlet", "Inlet Temp", _tc(pd.inlet_temperature_c, use_f))
1407
+ self._kv("p-gas", "Gas Pipe Temp", _tc(pd.gas_pipe_temperature_c, use_f))
1408
+ self._kv(
1409
+ "p-liquid",
1410
+ "Liquid Pipe Temp",
1411
+ _tc(pd.liquid_pipe_temperature_c, use_f),
1412
+ )
1413
+ self._kv(
1414
+ "p-interval",
1415
+ "Sample Interval",
1416
+ f"{pd.measurement_interval_s:.1f} s",
1417
+ )
1418
+ # energy_measurement_j is IDU electronics (QSM + fan board),
1419
+ # not HVAC/compressor energy.
1420
+ # Actual HVAC power is in performance_metrics below.
1421
+ pwr = (
1422
+ pd.energy_measurement_j / pd.measurement_interval_s
1423
+ if pd.measurement_interval_s > 0
1424
+ else 0
1425
+ )
1426
+ self._kv("p-energy-j", "IDU Module Power", f"{pwr:.1f} W")
1427
+ self._kv(
1428
+ "p-energy-kwh",
1429
+ "IDU Module Energy",
1430
+ f"{pd.energy_measurement_j:.1f} J",
1431
+ )
1432
+ self._kv(
1433
+ "p-fan-actual",
1434
+ "Fan (actual)",
1435
+ f"{pd.actual_fan_speed_rpm:.0f} RPM",
1436
+ )
1437
+ self._kv(
1438
+ "p-pd-mode",
1439
+ "Mode (perf)",
1440
+ pd.hvac_mode.name,
1441
+ _MODE_STYLE.get(pd.hvac_mode, ""),
1442
+ )
1443
+ pd_state_fmt = _fmt_state(pd.hvac_state)
1444
+ self._kv(
1445
+ "p-pd-state",
1446
+ "State (perf)",
1447
+ pd_state_fmt.plain,
1448
+ str(pd_state_fmt.style),
1449
+ )
1450
+ else:
1451
+ for nid, label in [
1452
+ ("p-coil", "Coil"),
1453
+ ("p-outlet", "Outlet"),
1454
+ ("p-inlet", "Inlet"),
1455
+ ("p-gas", "Gas Pipe"),
1456
+ ("p-liquid", "Liquid Pipe"),
1457
+ ("p-interval", "Interval"),
1458
+ ("p-energy-j", "Energy J"),
1459
+ ("p-energy-kwh", "Energy kWh"),
1460
+ ("p-fan-actual", "Fan actual"),
1461
+ ("p-pd-mode", "Mode (perf)"),
1462
+ ("p-pd-state", "State (perf)"),
1463
+ ]:
1464
+ self._kv(nid, label, "no data")
1465
+
1466
+ if idu and idu.performance_metrics:
1467
+ pm = idu.performance_metrics
1468
+ self._kv("p-capacity", "Capacity", f"{pm.capacity_w:.0f} W")
1469
+ self._kv("p-cop", "COP", f"{pm.coefficient_of_performance:.2f}")
1470
+ self._kv("p-hvac-power", "HVAC Power", f"{pm.hvac_power_w:.0f} W")
1471
+ self._kv("p-led-power", "LED Power", f"{pm.led_power_w:.1f} W")
1472
+ self._kv(
1473
+ "p-pm-mode",
1474
+ "Mode (metrics)",
1475
+ pm.hvac_mode.name,
1476
+ _MODE_STYLE.get(pm.hvac_mode, ""),
1477
+ )
1478
+ pm_state_fmt = _fmt_state(pm.hvac_state)
1479
+ self._kv(
1480
+ "p-pm-state",
1481
+ "State (metrics)",
1482
+ pm_state_fmt.plain,
1483
+ str(pm_state_fmt.style),
1484
+ )
1485
+ self._kv("p-pm-duration", "Window", f"{pm.measurement_duration_s:.1f} s")
1486
+ self._kv(
1487
+ "p-pm-energy-total",
1488
+ "Energy (total)",
1489
+ f"{pm.energy_total_j:.1f} J",
1490
+ )
1491
+ self._kv("p-pm-hvac-energy", "Energy (HVAC)", f"{pm.hvac_energy_j:.1f} J")
1492
+ self._kv("p-pm-led-energy", "Energy (LED)", f"{pm.led_energy_j:.1f} J")
1493
+ else:
1494
+ for nid, label in [
1495
+ ("p-capacity", "Capacity"),
1496
+ ("p-cop", "COP"),
1497
+ ("p-hvac-power", "HVAC Power"),
1498
+ ("p-led-power", "LED Power"),
1499
+ ("p-pm-mode", "Mode (metrics)"),
1500
+ ("p-pm-state", "State (metrics)"),
1501
+ ("p-pm-duration", "Window"),
1502
+ ("p-pm-energy-total", "Energy (total)"),
1503
+ ("p-pm-hvac-energy", "Energy (HVAC)"),
1504
+ ("p-pm-led-energy", "Energy (LED)"),
1505
+ ]:
1506
+ self._kv(nid, label, "no data")
1507
+
1508
+ if idu and idu.hvac_inputs:
1509
+ hi = idu.hvac_inputs
1510
+ self._kv(
1511
+ "p-hi-ext-ambient",
1512
+ "Ext. Ambient",
1513
+ _tc(hi.external_ambient_temperature_c, use_f),
1514
+ )
1515
+ self._kv(
1516
+ "p-hi-setpoint",
1517
+ "Setpoint (ctrl)",
1518
+ _tc(hi.temperature_setpoint_c, use_f),
1519
+ )
1520
+ self._kv(
1521
+ "p-hi-mode",
1522
+ "Mode (ctrl)",
1523
+ hi.hvac_mode.name,
1524
+ _MODE_STYLE.get(hi.hvac_mode, ""),
1525
+ )
1526
+ hi_state_fmt = _fmt_state(hi.hvac_state)
1527
+ self._kv(
1528
+ "p-hi-state",
1529
+ "State (ctrl)",
1530
+ hi_state_fmt.plain,
1531
+ str(hi_state_fmt.style),
1532
+ )
1533
+ self._kv(
1534
+ "p-hi-source",
1535
+ "Ambient Source",
1536
+ str(hi.ambient_temperature_source),
1537
+ )
1538
+ ctrl_type = hi.hvac_controller_type
1539
+ ctrl_type_short = (
1540
+ ctrl_type.name.replace("HVAC_CONTROLLER_TYPE_", "").replace("_", " ").title()
1541
+ )
1542
+ self._kv("p-hi-ctrl-type", "Controller Type", ctrl_type_short)
1543
+ else:
1544
+ for nid, label in [
1545
+ ("p-hi-ext-ambient", "Ext. Ambient"),
1546
+ ("p-hi-setpoint", "Setpoint (ctrl)"),
1547
+ ("p-hi-mode", "Mode (ctrl)"),
1548
+ ("p-hi-state", "State (ctrl)"),
1549
+ ("p-hi-source", "Ambient Source"),
1550
+ ("p-hi-ctrl-type", "Controller Type"),
1551
+ ]:
1552
+ self._kv(nid, label, "no data")
1553
+
1554
+ if idu and idu.conditions:
1555
+ co = idu.conditions
1556
+
1557
+ def _cs(val: int) -> tuple[str, str]:
1558
+ return _COND_STATE_LABELS.get(val, str(val)), (
1559
+ _COND_ACTIVE_STYLE if val == 2 else ""
1560
+ )
1561
+
1562
+ for nid, label, val in [
1563
+ ("p-cond-defrost", "Defrost Cycle", co.defrost_cycle),
1564
+ ("p-cond-oilreturn", "Oil Return", co.oil_return),
1565
+ ("p-cond-coilpreheat", "Coil Preheat", co.coil_preheat),
1566
+ ("p-cond-safetyheat", "Safety Heating", co.safety_heating),
1567
+ ("p-cond-anticold", "Anti-Cold Wind", co.anti_cold_wind),
1568
+ (
1569
+ "p-cond-modeswitch",
1570
+ "Mode Switch Delay",
1571
+ co.hvac_mode_switching_delay,
1572
+ ),
1573
+ ("p-cond-modeconflict", "Mode Conflict", co.mode_conflict),
1574
+ (
1575
+ "p-cond-modeconflictavoid",
1576
+ "Mode Conflict Avoid",
1577
+ co.mode_conflict_avoidance,
1578
+ ),
1579
+ (
1580
+ "p-cond-abnormal-odu-air",
1581
+ "Abnormal ODU Air",
1582
+ co.abnormal_outdoor_air_temperature,
1583
+ ),
1584
+ (
1585
+ "p-cond-odu-comm",
1586
+ "ODU Comm Error",
1587
+ co.outdoor_unit_communication_error,
1588
+ ),
1589
+ (
1590
+ "p-cond-modbus",
1591
+ "Modbus Comm Error",
1592
+ co.modbus_communication_error,
1593
+ ),
1594
+ ]:
1595
+ text, style = _cs(val)
1596
+ self._kv(nid, label, text, style)
1597
+ else:
1598
+ for nid, label in [
1599
+ ("p-cond-defrost", "Defrost Cycle"),
1600
+ ("p-cond-oilreturn", "Oil Return"),
1601
+ ("p-cond-coilpreheat", "Coil Preheat"),
1602
+ ("p-cond-safetyheat", "Safety Heating"),
1603
+ ("p-cond-anticold", "Anti-Cold Wind"),
1604
+ ("p-cond-modeswitch", "Mode Switch Delay"),
1605
+ ("p-cond-modeconflict", "Mode Conflict"),
1606
+ ("p-cond-modeconflictavoid", "Mode Conflict Avoid"),
1607
+ ("p-cond-abnormal-odu-air", "Abnormal ODU Air"),
1608
+ ("p-cond-odu-comm", "ODU Comm Error"),
1609
+ ("p-cond-modbus", "Modbus Comm Error"),
1610
+ ]:
1611
+ self._kv(nid, label, "no data")
1612
+
1613
+ if odu:
1614
+ hs = HVACState(odu.hvac_state)
1615
+ odu_state_str = hs.name if odu.hvac_state else "—"
1616
+ odu_state_style = _STATE_STYLE.get(hs, "dim") if odu.hvac_state else "dim"
1617
+ self._kv("p-odu-state", "ODU State", odu_state_str, odu_state_style)
1618
+ model = odu.model_sku if odu.model_sku and odu.model_sku != "N/A" else None
1619
+ self._kv("p-odu-model", "Model", model or "--")
1620
+ self._kv("p-odu-serial", "Serial", odu.serial_number or "--")
1621
+ self._kv("p-odu-fw", "Firmware", odu.firmware_version or "--")
1622
+ if odu.performance_data:
1623
+ pd = odu.performance_data
1624
+ self._kv(
1625
+ "p-odu-freq",
1626
+ "Compressor Freq",
1627
+ f"{pd.compressor_frequency_hz:.1f} Hz",
1628
+ )
1629
+ self._kv(
1630
+ "p-odu-coil",
1631
+ "ODU Coil Temp",
1632
+ _tc(pd.coil_temperature_c, use_f),
1633
+ )
1634
+ self._kv(
1635
+ "p-odu-exhaust",
1636
+ "Exhaust Temp",
1637
+ _tc(pd.exhaust_temperature_c, use_f),
1638
+ )
1639
+ self._kv(
1640
+ "p-odu-hi",
1641
+ "High Pressure",
1642
+ f"{pd.high_pressure_kpa:.1f} kPa",
1643
+ )
1644
+ self._kv("p-odu-lo", "Low Pressure", f"{pd.low_pressure_kpa:.1f} kPa")
1645
+ self._kv(
1646
+ "p-odu-ambient",
1647
+ "ODU Ambient",
1648
+ _tc(pd.ambient_temperature_c, use_f),
1649
+ )
1650
+ else:
1651
+ for nid in (
1652
+ "p-odu-freq",
1653
+ "p-odu-coil",
1654
+ "p-odu-exhaust",
1655
+ "p-odu-hi",
1656
+ "p-odu-lo",
1657
+ "p-odu-ambient",
1658
+ ):
1659
+ self._kv(nid, nid, "no data")
1660
+ else:
1661
+ for nid in (
1662
+ "p-odu-state",
1663
+ "p-odu-freq",
1664
+ "p-odu-coil",
1665
+ "p-odu-exhaust",
1666
+ "p-odu-hi",
1667
+ "p-odu-lo",
1668
+ "p-odu-ambient",
1669
+ "p-odu-model",
1670
+ "p-odu-serial",
1671
+ "p-odu-fw",
1672
+ ):
1673
+ self._kv(nid, nid, "no ODU")
1674
+
1675
+ # IDU Commands (fallback control on connectivity loss)
1676
+ if idu and idu.commands:
1677
+ fc = idu.commands.fallback_control_command
1678
+ fc_str = fc.name.replace("FALLBACK_CONTROL_COMMAND_", "").replace("_", " ").title()
1679
+ self._kv("p-cmd-fallback", "Fallback Command", fc_str)
1680
+ else:
1681
+ self._kv("p-cmd-fallback", "Fallback Command", "--")
1682
+
1683
+ def _populate_schedule(self) -> None:
1684
+ space_id = self._space.id
1685
+ snap = self._snapshot
1686
+
1687
+ week = next((w for w in snap.schedule_weeks if w.space_id == space_id), None)
1688
+ self._sched_day_by_id = {d.id: d for d in snap.schedule_days}
1689
+ self._sched_cs_by_id = {cs.id: cs for cs in snap.comfort_settings}
1690
+
1691
+ _DAYS = [
1692
+ "Monday",
1693
+ "Tuesday",
1694
+ "Wednesday",
1695
+ "Thursday",
1696
+ "Friday",
1697
+ "Saturday",
1698
+ "Sunday",
1699
+ ]
1700
+
1701
+ week_table: DataTable = self.query_one("#sched-week", DataTable)
1702
+ if not week_table.columns:
1703
+ week_table.add_columns("Day")
1704
+
1705
+ week_table.clear()
1706
+ # Each weekday can map to multiple day programs (one event each).
1707
+ # _sched_row_day_ids[i] is a list of day_ids for weekday i+1.
1708
+ self._sched_row_day_ids: list[list[str]] = [[] for _ in _DAYS]
1709
+
1710
+ if week:
1711
+ for wd in week.days:
1712
+ idx = wd.weekday - 1 # weekday 1=Mon … 7=Sun → 0-based
1713
+ if 0 <= idx < 7:
1714
+ self._sched_row_day_ids[idx].append(wd.day_id)
1715
+ for day_name in _DAYS:
1716
+ week_table.add_row(day_name)
1717
+ # Show Monday's events by default
1718
+ self._populate_day_events(
1719
+ [
1720
+ self._sched_day_by_id[did]
1721
+ for did in self._sched_row_day_ids[0]
1722
+ if did in self._sched_day_by_id
1723
+ ],
1724
+ self._sched_cs_by_id,
1725
+ label="Monday",
1726
+ )
1727
+ else:
1728
+ for day_name in _DAYS:
1729
+ week_table.add_row(day_name)
1730
+ self._populate_day_events([], {}, label="Monday")
1731
+
1732
+ loc = snap.primary_location
1733
+ self._update_schedule_status(loc.schedule_paused if loc else False)
1734
+
1735
+ @on(DataTable.RowHighlighted, "#sched-week")
1736
+ def _on_sched_week_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
1737
+ _DAYS = [
1738
+ "Monday",
1739
+ "Tuesday",
1740
+ "Wednesday",
1741
+ "Thursday",
1742
+ "Friday",
1743
+ "Saturday",
1744
+ "Sunday",
1745
+ ]
1746
+ idx = event.cursor_row
1747
+ row_ids = getattr(self, "_sched_row_day_ids", [[] for _ in _DAYS])
1748
+ cs_by_id = getattr(self, "_sched_cs_by_id", {})
1749
+ day_by_id = getattr(self, "_sched_day_by_id", {})
1750
+ day_ids = row_ids[idx] if idx < len(row_ids) else []
1751
+ days = [day_by_id[did] for did in day_ids if did in day_by_id]
1752
+ self._populate_day_events(days, cs_by_id, label=_DAYS[idx] if idx < 7 else "")
1753
+
1754
+ def _populate_day_events(self, days: list, cs_by_id: dict, label: str = "") -> None:
1755
+ from quilt_hp.models.enums import HVACMode as _HM
1756
+ from quilt_hp.models.enums import LouverMode as _LM
1757
+ from quilt_hp.models.schedule import ScheduleDay
1758
+
1759
+ if label:
1760
+ with contextlib.suppress(Exception):
1761
+ self.query_one("#sched-events-panel").border_title = label
1762
+
1763
+ day_table: DataTable = self.query_one("#sched-day", DataTable)
1764
+ if not day_table.columns:
1765
+ day_table.add_columns("Time", "Mode", "Heat", "Cool", "Fan", "Preset")
1766
+ day_table.clear()
1767
+
1768
+ # Gather events across day programs for this weekday, sorted by time.
1769
+ all_events = sorted(
1770
+ (ev for day in days if isinstance(day, ScheduleDay) for ev in day.events),
1771
+ key=lambda e: e.start_s,
1772
+ )
1773
+
1774
+ if not all_events:
1775
+ day_table.add_row("--", "[dim]no events[/dim]", "--", "--", "--", "--")
1776
+ return
1777
+
1778
+ for ev in all_events:
1779
+ ev_mode = _HM(ev.hvac_mode) if ev.hvac_mode else _HM.UNSPECIFIED
1780
+ preset_name = ""
1781
+ fan_str = "--"
1782
+ heat = ev.heating_setpoint_c
1783
+ cool = ev.cooling_setpoint_c
1784
+
1785
+ if ev.comfort_setting_id:
1786
+ cs = cs_by_id.get(ev.comfort_setting_id)
1787
+ if cs:
1788
+ preset_name = cs.name
1789
+ ev_mode = cs.hvac_mode
1790
+ heat = cs.heating_setpoint_c
1791
+ cool = cs.cooling_setpoint_c
1792
+ fan_str = cs.fan_speed.name.replace("FAN_SPEED_", "").title()
1793
+ lm = cs.louver_mode
1794
+ if lm not in (_LM.UNSPECIFIED, _LM.AUTO):
1795
+ louver = (
1796
+ f"FIXED {cs.louver_fixed_position:.0f}°"
1797
+ if lm == _LM.FIXED and cs.louver_fixed_position
1798
+ else lm.name
1799
+ )
1800
+ fan_str = f"{fan_str} / {louver}"
1801
+
1802
+ mode_str = ev_mode.name.replace("HVAC_MODE_", "").replace("_", " ").title()
1803
+ day_table.add_row(
1804
+ ev.start_time or "--",
1805
+ mode_str,
1806
+ _tc(heat, self.use_f) if heat else "--",
1807
+ _tc(cool, self.use_f) if cool else "--",
1808
+ fan_str,
1809
+ preset_name or "--",
1810
+ )
1811
+
1812
+ def _update_schedule_status(self, paused: bool) -> None:
1813
+ try:
1814
+ status = (
1815
+ "[yellow]⏸ PAUSED[/yellow] [dim](p to resume)[/dim]"
1816
+ if paused
1817
+ else "[green]▶ RUNNING[/green]"
1818
+ )
1819
+ self.query_one("#sched-status", Static).update(status)
1820
+ except NoMatches:
1821
+ pass
1822
+
1823
+ # ── Energy ──────────────────────────────────────────────────
1824
+
1825
+ @work
1826
+ async def _fetch_energy(self) -> None:
1827
+ """Fetch 30 days of hourly room energy data for summary totals."""
1828
+ try:
1829
+ self._set_energy_status("⟳ Loading energy data…")
1830
+ tz = datetime.UTC
1831
+ snap_tz = self._snapshot.timezone
1832
+ if snap_tz:
1833
+ try:
1834
+ import zoneinfo
1835
+
1836
+ tz = zoneinfo.ZoneInfo(snap_tz)
1837
+ except Exception:
1838
+ pass
1839
+ now = datetime.datetime.now(tz)
1840
+ start = (now - datetime.timedelta(days=30)).replace(
1841
+ hour=0, minute=0, second=0, microsecond=0
1842
+ )
1843
+ metrics = await self._client.get_energy(start=start, end=now)
1844
+ space_metrics = next((m for m in metrics if m.space_id == self._space.id), None)
1845
+ self._populate_energy(space_metrics, tz)
1846
+ self._set_energy_status("")
1847
+ except Exception as exc:
1848
+ self._set_energy_status(f"[red]Energy fetch failed: {exc}[/red]")
1849
+
1850
+ def _set_energy_status(self, msg: str) -> None:
1851
+ try:
1852
+ self.query_one("#energy-status", Static).update(msg)
1853
+ except NoMatches:
1854
+ pass
1855
+
1856
+ def _populate_energy(self, metrics: object | None, tz: datetime.timezone) -> None:
1857
+ from quilt_hp.models.energy import SpaceEnergyMetrics
1858
+
1859
+ table: DataTable = self.query_one("#e-table", DataTable)
1860
+ if not table.columns:
1861
+ table.add_columns("Date", "Hour", "kWh", "Status")
1862
+
1863
+ if metrics is None or not isinstance(metrics, SpaceEnergyMetrics) or not metrics.buckets:
1864
+ self._kv("e-today", "Today", "no data")
1865
+ self._kv("e-yesterday", "Yesterday", "no data")
1866
+ self._kv("e-7day", "Last 7 days", "no data")
1867
+ self._kv("e-30day", "Last 30 days", "no data")
1868
+ try:
1869
+ self.query_one("#e-sparkline", Static).update("no energy data")
1870
+ except NoMatches:
1871
+ pass
1872
+ return
1873
+
1874
+ now = datetime.datetime.now(tz)
1875
+ today = now.date()
1876
+ yesterday = today - datetime.timedelta(days=1)
1877
+
1878
+ # Group buckets by local date.
1879
+ # Buckets are UTC-aware from the service; astimezone converts them.
1880
+ by_date: dict[datetime.date, list] = {}
1881
+ for b in metrics.buckets:
1882
+ bt = b.start_time
1883
+ if bt.tzinfo is None:
1884
+ # Defensive: treat naive datetimes as UTC.
1885
+ bt = bt.replace(tzinfo=datetime.UTC)
1886
+ bt_local = bt.astimezone(tz)
1887
+ d = bt_local.date()
1888
+ by_date.setdefault(d, []).append((bt_local, b.energy_kwh, b.status))
1889
+
1890
+ def _day_total(d: datetime.date) -> float:
1891
+ return sum(kwh for _, kwh, _ in by_date.get(d, []))
1892
+
1893
+ today_kwh = _day_total(today)
1894
+ yest_kwh = _day_total(yesterday)
1895
+ week_kwh = sum(_day_total(today - datetime.timedelta(days=i)) for i in range(7))
1896
+ month_kwh = sum(_day_total(today - datetime.timedelta(days=i)) for i in range(30))
1897
+
1898
+ self._kv("e-today", "Today", f"{today_kwh:.3f} kWh", "cyan")
1899
+ self._kv("e-yesterday", "Yesterday", f"{yest_kwh:.3f} kWh")
1900
+ self._kv("e-7day", "Last 7 days", f"{week_kwh:.3f} kWh")
1901
+ self._kv("e-30day", "Last 30 days", f"{month_kwh:.3f} kWh")
1902
+
1903
+ # Sparkline — today so far, 24 fixed hourly slots (00–23 local time)
1904
+ cutoff = now - datetime.timedelta(hours=24)
1905
+ today_hours = by_date.get(today, [])
1906
+ blocks = " ▁▂▃▄▅▆▇█"
1907
+ if today_hours:
1908
+ max_kwh = max(kwh for _, kwh, _ in today_hours) or 1.0
1909
+ hour_map = {bt.hour: kwh for bt, kwh, _ in today_hours}
1910
+ bar_chars = []
1911
+ for h in range(24):
1912
+ kwh = hour_map.get(h, 0.0)
1913
+ idx = min(int(kwh / max_kwh * 8), 8)
1914
+ bar_chars.append(blocks[idx])
1915
+ sparkline = "".join(bar_chars)
1916
+ labels = "00 03 06 09 12 15 18 21 23"
1917
+ spark_str = f"{sparkline}\n[dim]{labels}[/dim]"
1918
+ else:
1919
+ spark_str = "[dim]no energy data for today[/dim]"
1920
+
1921
+ try:
1922
+ self.query_one("#e-sparkline", Static).update(spark_str)
1923
+ except NoMatches:
1924
+ pass
1925
+
1926
+ # Populate hourly table — last 24 hours only (most recent first)
1927
+ table.clear()
1928
+ status_labels = {0: "—", 1: "✓", 2: "~"}
1929
+ recent_buckets = [
1930
+ (bt, kwh, s) for buckets in by_date.values() for bt, kwh, s in buckets if bt >= cutoff
1931
+ ]
1932
+ for bt, kwh, status in sorted(recent_buckets, reverse=True):
1933
+ table.add_row(
1934
+ bt.strftime("%Y-%m-%d"),
1935
+ bt.strftime("%H:00"),
1936
+ f"{kwh:.4f}",
1937
+ status_labels.get(status, str(status)),
1938
+ )
1939
+
1940
+ def _kv(self, widget_id: str, key: str, value: str, val_style: str = "") -> None:
1941
+ try:
1942
+ w = self.query_one(f"#{widget_id}", _KVStatic)
1943
+ w.set_kv(key, value, val_style)
1944
+ except NoMatches:
1945
+ pass
1946
+
1947
+ # ── Live update entry points ─────────────────────────────────
1948
+
1949
+ def update_space(self, space: Space) -> None:
1950
+ self._space = space
1951
+ self._populate_status()
1952
+
1953
+ def update_idu(self, idu: IndoorUnit) -> None:
1954
+ self._idu = idu
1955
+ self._populate_status()
1956
+ self._populate_perf()
1957
+
1958
+ def update_odu(self, odu: OutdoorUnit) -> None:
1959
+ self._odu = odu
1960
+ self._populate_status()
1961
+ self._populate_perf()
1962
+
1963
+ def update_ctrl(self, ctrl: Controller) -> None:
1964
+ self._controller = ctrl
1965
+ self._populate_status()
1966
+
1967
+ def update_qsm(self, qsm: QuiltSmartModule) -> None:
1968
+ self._qsm = qsm
1969
+ self._populate_status()
1970
+
1971
+ # ── Actions ─────────────────────────────────────────────────
1972
+
1973
+ def action_back(self) -> None:
1974
+ self.app.pop_screen()
1975
+
1976
+ def action_refresh_energy(self) -> None:
1977
+ self._fetch_energy()
1978
+
1979
+ def action_toggle_units(self) -> None:
1980
+ self.use_f = not self.use_f
1981
+ self.app._persist()
1982
+ self._populate_status()
1983
+ self._populate_perf()
1984
+
1985
+ def action_cycle_mode(self) -> None:
1986
+ if not self._space or not self._space.controls:
1987
+ return
1988
+ # If the room is currently AWAY (STANDBY + comfort setting), the next
1989
+ # meaningful step is plain STANDBY (OFF), not skipping ahead to HEAT.
1990
+ if self._space.is_away:
1991
+ self._mutate_space(mode=HVACMode.STANDBY)
1992
+ else:
1993
+ nxt = _cycle_next(self._space.controls.hvac_mode, _MODE_CYCLE)
1994
+ self._mutate_space(mode=nxt)
1995
+
1996
+ def action_heat_up(self) -> None:
1997
+ self._delta_setpoint("heat", +0.5)
1998
+
1999
+ def action_heat_down(self) -> None:
2000
+ self._delta_setpoint("heat", -0.5)
2001
+
2002
+ def action_cool_up(self) -> None:
2003
+ self._delta_setpoint("cool", +0.5)
2004
+
2005
+ def action_cool_down(self) -> None:
2006
+ self._delta_setpoint("cool", -0.5)
2007
+
2008
+ def _delta_setpoint(self, which: str, delta: float) -> None:
2009
+ if not self._space or not self._space.controls:
2010
+ return
2011
+ c = self._space.controls
2012
+ if which == "heat":
2013
+ val = (c.heating_setpoint_c or 20.0) + delta
2014
+ self._mutate_space(heat_setpoint_c=val)
2015
+ else:
2016
+ val = (c.cooling_setpoint_c or 26.0) + delta
2017
+ self._mutate_space(cool_setpoint_c=val)
2018
+
2019
+ def action_cycle_fan(self) -> None:
2020
+ if not self._idu:
2021
+ return
2022
+ nxt = _cycle_next(self._idu.controls.fan_speed, _FAN_CYCLE)
2023
+ self._mutate_idu(fan_speed=nxt)
2024
+
2025
+ def action_cycle_louver(self) -> None:
2026
+ if not self._idu:
2027
+ return
2028
+ nxt = _cycle_next(self._idu.controls.louver_mode, _LOUVER_CYCLE)
2029
+ self._mutate_idu(louver_mode=nxt)
2030
+
2031
+ def action_toggle_led(self) -> None:
2032
+ if not self._idu:
2033
+ return
2034
+ new_brightness = 0.0 if self._idu.controls.light_on else 1.0
2035
+ self._mutate_idu(led_brightness=new_brightness)
2036
+
2037
+ def action_cycle_occupancy(self) -> None:
2038
+ if not self._space:
2039
+ return
2040
+ nxt = _cycle_next(self._space.settings.occupancy_mode, _OCC_CYCLE)
2041
+ # occupancy_mode is a settings field; mutate via a future API if added.
2042
+ # For now notify the user it's read-only in this version.
2043
+ self.notify(f"Occupancy mode would → {nxt.name} (not yet wired)", timeout=3)
2044
+
2045
+ _AWAY_TIMEOUT_STEP_S: float = 300.0 # 5 minutes
2046
+ _RETURN_TIMEOUT_STEP_S: float = 60.0 # 1 minute
2047
+ _TIMEOUT_MIN_S: float = 60.0 # 1 minute minimum
2048
+
2049
+ def action_away_timeout_dec(self) -> None:
2050
+ if not self._space:
2051
+ return
2052
+ cur = self._space.settings.unoccupied_timeout_s
2053
+ self._mutate_settings(
2054
+ unoccupied_timeout_s=max(self._TIMEOUT_MIN_S, cur - self._AWAY_TIMEOUT_STEP_S)
2055
+ )
2056
+
2057
+ def action_away_timeout_inc(self) -> None:
2058
+ if not self._space:
2059
+ return
2060
+ cur = self._space.settings.unoccupied_timeout_s
2061
+ self._mutate_settings(unoccupied_timeout_s=cur + self._AWAY_TIMEOUT_STEP_S)
2062
+
2063
+ def action_return_timeout_dec(self) -> None:
2064
+ if not self._space:
2065
+ return
2066
+ cur = self._space.settings.occupied_timeout_s
2067
+ self._mutate_settings(
2068
+ occupied_timeout_s=max(self._TIMEOUT_MIN_S, cur - self._RETURN_TIMEOUT_STEP_S)
2069
+ )
2070
+
2071
+ def action_return_timeout_inc(self) -> None:
2072
+ if not self._space:
2073
+ return
2074
+ cur = self._space.settings.occupied_timeout_s
2075
+ self._mutate_settings(occupied_timeout_s=cur + self._RETURN_TIMEOUT_STEP_S)
2076
+
2077
+ def action_toggle_schedule(self) -> None:
2078
+ loc = self._snapshot.primary_location
2079
+ if loc is None:
2080
+ self.notify("No location found", severity="error")
2081
+ return
2082
+ self._do_toggle_schedule(not loc.schedule_paused)
2083
+
2084
+ @work
2085
+ async def _mutate_space(
2086
+ self,
2087
+ mode: HVACMode | None = None,
2088
+ heat_setpoint_c: float | None = None,
2089
+ cool_setpoint_c: float | None = None,
2090
+ ) -> None:
2091
+ try:
2092
+ updated = await self._client.set_space(
2093
+ self._space,
2094
+ mode=mode,
2095
+ heat_setpoint_c=heat_setpoint_c,
2096
+ cool_setpoint_c=cool_setpoint_c,
2097
+ )
2098
+ self._space = updated
2099
+ self._populate_status()
2100
+ except Exception as exc:
2101
+ self.notify(f"Error: {exc}", severity="error")
2102
+
2103
+ @work
2104
+ async def _mutate_settings(
2105
+ self,
2106
+ unoccupied_timeout_s: float | None = None,
2107
+ occupied_timeout_s: float | None = None,
2108
+ ) -> None:
2109
+ """Update space auto-away / auto-return timeouts."""
2110
+ try:
2111
+ updated = await self._client.set_space_settings(
2112
+ self._space,
2113
+ unoccupied_timeout_s=unoccupied_timeout_s,
2114
+ occupied_timeout_s=occupied_timeout_s,
2115
+ )
2116
+ self._space = updated
2117
+ self._populate_status()
2118
+ except Exception as exc:
2119
+ self.notify(f"Error: {exc}", severity="error")
2120
+
2121
+ @work
2122
+ async def _mutate_idu(
2123
+ self,
2124
+ fan_speed: FanSpeed | None = None,
2125
+ louver_mode: LouverMode | None = None,
2126
+ led_brightness: float | None = None,
2127
+ ) -> None:
2128
+ if not self._idu:
2129
+ return
2130
+ try:
2131
+ updated = await self._client.set_indoor_unit(
2132
+ self._idu,
2133
+ fan_speed=fan_speed,
2134
+ louver_mode=louver_mode,
2135
+ led_brightness=led_brightness,
2136
+ )
2137
+ self._idu = updated
2138
+ self._populate_status()
2139
+ except Exception as exc:
2140
+ self.notify(f"Error: {exc}", severity="error")
2141
+
2142
+ _FENCE_STEP_M = 0.5
2143
+
2144
+ def action_fence_fwd_inc(self) -> None:
2145
+ if self._idu:
2146
+ cur = self._idu.settings.presence_fence_forward_m
2147
+ self._mutate_idu_settings(fence_forward_m=round(cur + self._FENCE_STEP_M, 2))
2148
+
2149
+ def action_fence_fwd_dec(self) -> None:
2150
+ if self._idu:
2151
+ cur = self._idu.settings.presence_fence_forward_m
2152
+ self._mutate_idu_settings(fence_forward_m=max(0.0, round(cur - self._FENCE_STEP_M, 2)))
2153
+
2154
+ def action_fence_lr_inc(self) -> None:
2155
+ if self._idu:
2156
+ st = self._idu.settings
2157
+ step = self._FENCE_STEP_M
2158
+ self._mutate_idu_settings(
2159
+ fence_left_m=round(st.presence_fence_left_m + step, 2),
2160
+ fence_right_m=round(st.presence_fence_right_m + step, 2),
2161
+ )
2162
+
2163
+ def action_fence_lr_dec(self) -> None:
2164
+ if self._idu:
2165
+ st = self._idu.settings
2166
+ step = self._FENCE_STEP_M
2167
+ self._mutate_idu_settings(
2168
+ fence_left_m=max(0.0, round(st.presence_fence_left_m - step, 2)),
2169
+ fence_right_m=max(0.0, round(st.presence_fence_right_m - step, 2)),
2170
+ )
2171
+
2172
+ def action_radar_height_inc(self) -> None:
2173
+ if self._idu:
2174
+ cur = self._idu.settings.radar_sensor_distance_from_floor_m
2175
+ self._mutate_idu_settings(radar_height_m=round(cur + self._FENCE_STEP_M, 2))
2176
+
2177
+ def action_radar_height_dec(self) -> None:
2178
+ if self._idu:
2179
+ cur = self._idu.settings.radar_sensor_distance_from_floor_m
2180
+ self._mutate_idu_settings(radar_height_m=max(0.0, round(cur - self._FENCE_STEP_M, 2)))
2181
+
2182
+ @work
2183
+ async def _mutate_idu_settings(
2184
+ self,
2185
+ fence_left_m: float | None = None,
2186
+ fence_right_m: float | None = None,
2187
+ fence_forward_m: float | None = None,
2188
+ radar_height_m: float | None = None,
2189
+ ) -> None:
2190
+ if not self._idu:
2191
+ return
2192
+ try:
2193
+ updated = await self._client.set_indoor_unit_settings(
2194
+ self._idu,
2195
+ fence_left_m=fence_left_m,
2196
+ fence_right_m=fence_right_m,
2197
+ fence_forward_m=fence_forward_m,
2198
+ radar_height_m=radar_height_m,
2199
+ )
2200
+ self._idu = updated
2201
+ self._populate_status()
2202
+ except Exception as exc:
2203
+ self.notify(f"Fence update error: {exc}", severity="error")
2204
+
2205
+ @work
2206
+ async def _do_toggle_schedule(self, paused: bool) -> None:
2207
+ try:
2208
+ await self._client.set_schedule_execution(paused)
2209
+ loc = self._snapshot.primary_location
2210
+ if loc:
2211
+ # patch local cache
2212
+ from dataclasses import replace
2213
+
2214
+ patched = replace(loc, schedule_paused=paused)
2215
+ self._snapshot.locations[0] = patched
2216
+ self._update_schedule_status(paused)
2217
+ self.notify("Schedules " + ("paused" if paused else "resumed"), timeout=2)
2218
+ except Exception as exc:
2219
+ self.notify(f"Error: {exc}", severity="error")
2220
+
2221
+
2222
+ # ──────────────────────────────────────────────────────────────────
2223
+ # SystemScreen
2224
+ # ──────────────────────────────────────────────────────────────────
2225
+
2226
+
2227
+ class SystemScreen(Screen):
2228
+ """System-wide overview: ODU, controllers, remote sensors."""
2229
+
2230
+ BINDINGS: ClassVar = [
2231
+ Binding("escape,b", "back", "Back"),
2232
+ Binding("u", "toggle_units", "°C/°F"),
2233
+ Binding("p", "toggle_schedule", "Pause Sched"),
2234
+ ]
2235
+
2236
+ use_f: reactive[bool] = reactive(False)
2237
+
2238
+ def __init__(
2239
+ self,
2240
+ snapshot: SystemSnapshot,
2241
+ client: QuiltClient,
2242
+ ) -> None:
2243
+ super().__init__()
2244
+ self._snapshot = snapshot
2245
+ self._client = client
2246
+
2247
+ def compose(self) -> ComposeResult:
2248
+ yield Header(show_clock=True)
2249
+ with Vertical(id="system-container"):
2250
+ # System header
2251
+ with Vertical(classes="odu-panel") as v:
2252
+ v.border_title = "System"
2253
+ yield Static(id="sys-header")
2254
+
2255
+ # ODU row — one panel per outdoor unit
2256
+ with Horizontal(id="odu-row"):
2257
+ if self._snapshot.outdoor_units:
2258
+ for i in range(len(self._snapshot.outdoor_units)):
2259
+ with Vertical(classes="odu-panel") as v:
2260
+ v.border_title = (
2261
+ f"Outdoor Unit {i + 1}"
2262
+ if len(self._snapshot.outdoor_units) > 1
2263
+ else "Outdoor Unit"
2264
+ )
2265
+ yield Static(id=f"sys-odu-{i}")
2266
+ else:
2267
+ yield Static("[dim]No outdoor unit data[/dim]", id="sys-odu-0")
2268
+
2269
+ # Controllers
2270
+ with Vertical(classes="odu-panel") as v:
2271
+ v.border_title = "Controllers (Dials)"
2272
+ yield DataTable(id="sys-ctrls")
2273
+
2274
+ # Remote sensors
2275
+ with Vertical(classes="odu-panel") as v:
2276
+ v.border_title = "Remote Sensors"
2277
+ yield DataTable(id="sys-sensors")
2278
+
2279
+ # Firmware / software update status
2280
+ with Vertical(classes="odu-panel") as v:
2281
+ v.border_title = "Firmware / Software Updates"
2282
+ yield DataTable(id="sys-firmware")
2283
+ yield Footer()
2284
+
2285
+ def on_mount(self) -> None:
2286
+ self.use_f = False
2287
+ self._populate()
2288
+
2289
+ def _populate(self) -> None:
2290
+ snap = self._snapshot
2291
+ use_f = self.use_f
2292
+
2293
+ # Header
2294
+ loc = snap.primary_location
2295
+ tz = snap.timezone or "?"
2296
+ sched = (
2297
+ "[yellow]⏸ PAUSED[/yellow]"
2298
+ if (loc and loc.schedule_paused)
2299
+ else "[green]▶ RUNNING[/green]"
2300
+ )
2301
+ loc_name = loc.name if loc and loc.name else ""
2302
+ header_parts = []
2303
+ if loc_name:
2304
+ header_parts.append(f"[bold]{loc_name}[/bold]")
2305
+ header_parts.append(f"[bold]Timezone:[/bold] {tz}")
2306
+ header_parts.append(f"[bold]Schedule:[/bold] {sched}")
2307
+ self.query_one("#sys-header", Static).update(" ".join(header_parts))
2308
+
2309
+ # ODU panels — one per unit
2310
+ for i, odu in enumerate(snap.outdoor_units):
2311
+ odu_lines: list[str] = []
2312
+ hs = HVACState(odu.hvac_state)
2313
+ state = hs.name if odu.hvac_state else "—"
2314
+ state_style = _STATE_STYLE.get(hs, "dim") if odu.hvac_state else "dim"
2315
+ odu_lines.append(f"[{state_style}]State: {state}[/{state_style}]")
2316
+ model = odu.model_sku if odu.model_sku and odu.model_sku != "N/A" else None
2317
+ if model:
2318
+ odu_lines.append(f"Model: {model}")
2319
+ if odu.serial_number:
2320
+ odu_lines.append(f"Serial: {odu.serial_number}")
2321
+ if odu.firmware_version:
2322
+ odu_lines.append(f"Firmware: {odu.firmware_version}")
2323
+ if odu.performance_data:
2324
+ pd = odu.performance_data
2325
+ odu_lines.append(f"Compressor: {pd.compressor_frequency_hz:.1f} Hz")
2326
+ odu_lines.append(f"ODU Coil: {_tc(pd.coil_temperature_c, use_f)}")
2327
+ odu_lines.append(f"Exhaust: {_tc(pd.exhaust_temperature_c, use_f)}")
2328
+ odu_lines.append(f"Hi Pressure: {pd.high_pressure_kpa:.1f} kPa")
2329
+ odu_lines.append(f"Lo Pressure: {pd.low_pressure_kpa:.1f} kPa")
2330
+ odu_lines.append(f"ODU Ambient: {_tc(pd.ambient_temperature_c, use_f)}")
2331
+ self.query_one(f"#sys-odu-{i}", Static).update("\n".join(odu_lines))
2332
+
2333
+ # Controllers table
2334
+ ctrl_table: DataTable = self.query_one("#sys-ctrls", DataTable)
2335
+ if not ctrl_table.columns:
2336
+ ctrl_table.add_columns(
2337
+ "Name",
2338
+ "Ambient",
2339
+ "Raw Thermistor",
2340
+ "PCB-A",
2341
+ "PCB-B",
2342
+ "WiFi SSID",
2343
+ "IP",
2344
+ "Signal",
2345
+ )
2346
+ ctrl_table.clear()
2347
+ for ctrl in snap.controllers:
2348
+ ctrl_table.add_row(
2349
+ ctrl.name or ctrl.id[:8],
2350
+ _tc(ctrl.calibrated_ambient_c, use_f),
2351
+ _tc(ctrl.raw_thermistor_c, use_f),
2352
+ _tc(ctrl.pcb_temperature_a_c, use_f),
2353
+ _tc(ctrl.pcb_temperature_b_c, use_f),
2354
+ ctrl.wifi_ssid or "--",
2355
+ ctrl.wifi_ip or "--",
2356
+ f"{ctrl.wifi_signal_dbm} dBm" if ctrl.wifi_signal_dbm else "--",
2357
+ )
2358
+
2359
+ # Remote sensors table
2360
+ sensor_table: DataTable = self.query_one("#sys-sensors", DataTable)
2361
+ if not sensor_table.columns:
2362
+ sensor_table.add_columns(
2363
+ "Sensor",
2364
+ "Room",
2365
+ "Mode",
2366
+ "Temp",
2367
+ "Humidity",
2368
+ "Battery",
2369
+ "Signal",
2370
+ )
2371
+ sensor_table.clear()
2372
+ # Build IDU→room name map for display
2373
+ idu_to_room: dict[str, str] = {}
2374
+ for room in snap.rooms:
2375
+ for idu in snap.indoor_units:
2376
+ if idu.space_id == room.id:
2377
+ idu_to_room[idu.id] = room.name or room.id[:8]
2378
+ for rs in sorted(
2379
+ snap.remote_sensors,
2380
+ key=lambda r: idu_to_room.get(r.indoor_unit_id, ""),
2381
+ ):
2382
+ mode_str = "EN" if rs.control_mode == RemoteSensorControlMode.ENABLED else "DIS"
2383
+ mode_style = "green" if rs.control_mode == RemoteSensorControlMode.ENABLED else "dim"
2384
+ sensor_table.add_row(
2385
+ rs.mac or rs.id[:8],
2386
+ idu_to_room.get(rs.indoor_unit_id, rs.indoor_unit_id[:8]),
2387
+ Text(mode_str, style=mode_style),
2388
+ _tc(rs.ambient_temperature_c, use_f),
2389
+ f"{rs.humidity_percent:.0f}%" if rs.humidity_percent else "--",
2390
+ f"{rs.battery_level_percent:.0f}%" if rs.battery_level_percent else "--",
2391
+ f"{rs.signal_level_dbm} dBm" if rs.signal_level_dbm else "--",
2392
+ )
2393
+ for crs in snap.controller_remote_sensors:
2394
+ ctrl = next((c for c in snap.controllers if c.id == crs.controller_id), None)
2395
+ label = (
2396
+ f"Dial {ctrl.serial_number or ctrl.name or crs.controller_id[:8]}"
2397
+ if ctrl
2398
+ else crs.id[:8]
2399
+ )
2400
+ room = next(
2401
+ (c.space_id for c in snap.controllers if c.id == crs.controller_id),
2402
+ None,
2403
+ )
2404
+ room_name = (
2405
+ next(
2406
+ (s.name for s in snap.rooms if s.id == room),
2407
+ room[:8] if room else "--",
2408
+ )
2409
+ if room
2410
+ else "--"
2411
+ )
2412
+ mode_str = "EN" if crs.control_mode == RemoteSensorControlMode.ENABLED else "DIS"
2413
+ mode_style = "green" if crs.control_mode == RemoteSensorControlMode.ENABLED else "dim"
2414
+ sensor_table.add_row(
2415
+ label,
2416
+ room_name,
2417
+ Text(mode_str, style=mode_style),
2418
+ _tc(crs.ambient_temperature_c, use_f),
2419
+ f"{crs.humidity_percent:.0f}%" if crs.humidity_percent else "--",
2420
+ f"{crs.battery_level_percent:.0f}%" if crs.battery_level_percent else "--",
2421
+ f"{crs.signal_level_dbm} dBm" if crs.signal_level_dbm else "--",
2422
+ )
2423
+
2424
+ # Firmware / software update table
2425
+ fw_table: DataTable = self.query_one("#sys-firmware", DataTable)
2426
+ if not fw_table.columns:
2427
+ fw_table.add_columns(
2428
+ "Device",
2429
+ "Type",
2430
+ "Current Version",
2431
+ "Target Version",
2432
+ "Progress",
2433
+ "State",
2434
+ )
2435
+ fw_table.clear()
2436
+ sui_by_id = {s.id: s for s in snap.software_update_infos}
2437
+
2438
+ def _fw_row(device_name: str, sw_id: str | None, fw_id: str | None) -> None:
2439
+ for label, uid in [("SW", sw_id), ("FW", fw_id)]:
2440
+ if not uid:
2441
+ continue
2442
+ sui = sui_by_id.get(uid)
2443
+ if not sui:
2444
+ continue
2445
+ ver = sui.current_version or "--"
2446
+ target = sui.target_version or "--"
2447
+ prog = (
2448
+ f"{sui.current_progress:.0f}/{sui.total_progress:.0f}"
2449
+ if sui.total_progress
2450
+ else "--"
2451
+ )
2452
+ state = str(sui.state) if sui.state else "--"
2453
+ fw_table.add_row(device_name, label, ver, target, prog, state)
2454
+
2455
+ for idu in snap.indoor_units:
2456
+ room = next((s.name for s in snap.rooms if s.id == idu.space_id), idu.id[:8])
2457
+ _fw_row(f"IDU {room}", None, idu.firmware_update_info_id)
2458
+ for odu in snap.outdoor_units:
2459
+ _fw_row(
2460
+ f"ODU {odu.serial_number or odu.id[:8]}",
2461
+ None,
2462
+ odu.firmware_update_info_id,
2463
+ )
2464
+ for ctrl in snap.controllers:
2465
+ _fw_row(
2466
+ f"Dial {ctrl.serial_number or ctrl.name or ctrl.id[:8]}",
2467
+ ctrl.software_update_info_id,
2468
+ ctrl.firmware_update_info_id,
2469
+ )
2470
+ for qsm in snap.quilt_smart_modules:
2471
+ _fw_row(
2472
+ f"QSM {qsm.id[:8]}",
2473
+ qsm.software_update_info_id,
2474
+ qsm.firmware_update_info_id,
2475
+ )
2476
+
2477
+ def action_back(self) -> None:
2478
+ self.app.pop_screen()
2479
+
2480
+ def update_odu(self, odu: OutdoorUnit) -> None:
2481
+ """Called by QuiltApp stream dispatcher when an ODU update arrives."""
2482
+ self._populate()
2483
+
2484
+ def update_remote_sensor(self, rs: RemoteSensor) -> None:
2485
+ """Called by QuiltApp stream dispatcher on RemoteSensor updates."""
2486
+ self._populate()
2487
+
2488
+ def action_toggle_units(self) -> None:
2489
+ loc = self._snapshot.primary_location
2490
+ if loc is None:
2491
+ self.notify("No location found", severity="error")
2492
+ return
2493
+ self._do_toggle_schedule(not loc.schedule_paused)
2494
+
2495
+ @work
2496
+ async def _do_toggle_schedule(self, paused: bool) -> None:
2497
+ try:
2498
+ await self._client.set_schedule_execution(paused)
2499
+ loc = self._snapshot.primary_location
2500
+ if loc:
2501
+ from dataclasses import replace
2502
+
2503
+ patched = replace(loc, schedule_paused=paused)
2504
+ self._snapshot.locations[0] = patched
2505
+ self._populate()
2506
+ self.notify("Schedules " + ("paused" if paused else "resumed"), timeout=2)
2507
+ except Exception as exc:
2508
+ self.notify(f"Error: {exc}", severity="error")
2509
+
2510
+
2511
+ # ──────────────────────────────────────────────────────────────────
2512
+ # QuiltApp
2513
+ # ──────────────────────────────────────────────────────────────────
2514
+
2515
+
2516
+ class QuiltApp(App[None]):
2517
+ """Quilt HVAC TUI application."""
2518
+
2519
+ CSS = _APP_CSS
2520
+ TITLE = "Quilt HVAC"
2521
+ BINDINGS: ClassVar = [
2522
+ Binding("q", "quit", "Quit", priority=True),
2523
+ Binding("d", "toggle_dark", "Dark/Light", priority=True),
2524
+ ]
2525
+
2526
+ def __init__(self, email: str, home: str | None = None) -> None:
2527
+ super().__init__()
2528
+ self._email = email
2529
+ self._home = home
2530
+ self._client = QuiltClient(email, home=home, snapshot_ttl_s=30, token_store=_token_store)
2531
+ self._stream = None
2532
+ self._snapshot = None
2533
+ self._settings = _settings_store.load()
2534
+ # Apply persisted dark/light before first render
2535
+ if self._settings.dark is not None:
2536
+ self.theme = "textual-dark" if self._settings.dark else "textual-light"
2537
+
2538
+ @property
2539
+ def _is_dark(self) -> bool:
2540
+ return self.theme != "textual-light"
2541
+
2542
+ def _persist(self) -> None:
2543
+ """Save current toggleable settings to disk."""
2544
+ screen = self.screen
2545
+ use_f = getattr(screen, "use_f", self._settings.use_fahrenheit)
2546
+ self._settings = _settings_store.update(use_fahrenheit=use_f, dark=self._is_dark)
2547
+
2548
+ def action_toggle_dark(self) -> None:
2549
+ self.theme = "textual-light" if self._is_dark else "textual-dark"
2550
+ self._persist()
2551
+
2552
+ def on_mount(self) -> None:
2553
+ self._loading_screen = LoadingScreen()
2554
+ self.push_screen(self._loading_screen)
2555
+ self._boot()
2556
+
2557
+ @work
2558
+ async def _boot(self) -> None:
2559
+ """Log in, fetch snapshot, and replace LoadingScreen."""
2560
+ loading = self._loading_screen
2561
+
2562
+ # _boot is an async @work — it runs on the main event loop, so UI
2563
+ # methods can be called directly (no call_from_thread needed).
2564
+ def _set_status(msg: str) -> None:
2565
+ if isinstance(loading, LoadingScreen):
2566
+ loading.set_status(msg)
2567
+
2568
+ try:
2569
+ _set_status("Authenticating…")
2570
+ await self._client.login()
2571
+ _set_status("Loading system snapshot…")
2572
+ snap = await self._client.get_snapshot()
2573
+ self._snapshot = snap
2574
+
2575
+ # Auto-save home name to settings so future runs don't need --home
2576
+ if self._client.system_name and not self._settings.home:
2577
+ self._settings = _settings_store.update(home=self._client.system_name)
2578
+
2579
+ # Set app title to the home name once resolved
2580
+ if self._client.system_name:
2581
+ self.title = self._client.system_name
2582
+
2583
+ dashboard = DashboardScreen(snap, self._client)
2584
+ await self.switch_screen(dashboard)
2585
+
2586
+ # Restore persisted use_fahrenheit
2587
+ # (dark mode already applied in __init__)
2588
+ if self._settings.use_fahrenheit:
2589
+ dashboard.use_f = True
2590
+
2591
+ # Start the shared stream
2592
+ self._start_stream(snap)
2593
+
2594
+ except Exception as exc:
2595
+ self.notify(f"Boot failed: {exc}", severity="error")
2596
+
2597
+ @work(exclusive=True)
2598
+ async def _start_stream(self, snap: SystemSnapshot) -> None:
2599
+ """Open shared NotifierStream, dispatch events to the active screen."""
2600
+ stream = self._client.stream(snap.stream_topics())
2601
+
2602
+ # Stream callbacks are invoked from within async code on the same event
2603
+ # loop — call UI dispatch methods directly (no call_from_thread).
2604
+ stream.on_space_update(self._dispatch_space)
2605
+ stream.on_indoor_unit_update(self._dispatch_idu)
2606
+ stream.on_outdoor_unit_update(self._dispatch_odu)
2607
+ stream.on_controller_update(self._dispatch_ctrl)
2608
+ stream.on_qsm_update(self._dispatch_qsm)
2609
+ stream.on_remote_sensor_update(self._dispatch_remote_sensor)
2610
+
2611
+ with contextlib.suppress(Exception):
2612
+ await stream.run_forever()
2613
+
2614
+ def _dispatch_space(self, space: Space) -> None:
2615
+ if self._snapshot:
2616
+ space = self._snapshot.apply_space(space)
2617
+ screen = self.screen
2618
+ if isinstance(screen, DashboardScreen) or (
2619
+ isinstance(screen, RoomScreen) and screen._space.id == space.id
2620
+ ):
2621
+ screen.update_space(space)
2622
+
2623
+ def _dispatch_idu(self, idu: IndoorUnit) -> None:
2624
+ if self._snapshot:
2625
+ idu = self._snapshot.apply_indoor_unit(idu)
2626
+ screen = self.screen
2627
+ if isinstance(screen, RoomScreen) and screen._idu and screen._idu.id == idu.id:
2628
+ screen.update_idu(idu)
2629
+ elif isinstance(screen, DashboardScreen):
2630
+ space = (
2631
+ next(
2632
+ (s for s in self._snapshot.rooms if s.id == idu.space_id),
2633
+ None,
2634
+ )
2635
+ if self._snapshot
2636
+ else None
2637
+ )
2638
+ if space:
2639
+ item = screen._items.get(space.id)
2640
+ if item:
2641
+ item.update_space(space, idu, screen.use_f)
2642
+ item.refresh()
2643
+
2644
+ def _dispatch_odu(self, odu: OutdoorUnit) -> None:
2645
+ if self._snapshot:
2646
+ odu = self._snapshot.apply_outdoor_unit(odu)
2647
+ screen = self.screen
2648
+ if isinstance(screen, (DashboardScreen, RoomScreen, SystemScreen)):
2649
+ screen.update_odu(odu)
2650
+
2651
+ def _dispatch_ctrl(self, ctrl: Controller) -> None:
2652
+ if self._snapshot:
2653
+ ctrl = self._snapshot.apply_controller(ctrl)
2654
+ screen = self.screen
2655
+ if (
2656
+ isinstance(screen, RoomScreen)
2657
+ and screen._controller
2658
+ and screen._controller.id == ctrl.id
2659
+ ):
2660
+ screen.update_ctrl(ctrl)
2661
+
2662
+ def _dispatch_qsm(self, qsm: QuiltSmartModule) -> None:
2663
+ if self._snapshot:
2664
+ qsm = self._snapshot.apply_qsm(qsm)
2665
+ screen = self.screen
2666
+ if isinstance(screen, RoomScreen) and screen._qsm and screen._qsm.id == qsm.id:
2667
+ screen.update_qsm(qsm)
2668
+
2669
+ def _dispatch_remote_sensor(self, rs: RemoteSensor) -> None:
2670
+ if self._snapshot:
2671
+ rs = self._snapshot.apply_remote_sensor(rs)
2672
+ screen = self.screen
2673
+ if isinstance(screen, SystemScreen):
2674
+ screen.update_remote_sensor(rs)
2675
+
2676
+ async def on_unmount(self) -> None:
2677
+ await self._client.close()