quilt-hp-python 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quilt_hp/__init__.py +22 -0
- quilt_hp/_paths.py +26 -0
- quilt_hp/_proto/__init__.py +0 -0
- quilt_hp/_proto/quilt_device_pairing_pb2.py +56 -0
- quilt_hp/_proto/quilt_device_pairing_pb2.pyi +317 -0
- quilt_hp/_proto/quilt_device_pairing_pb2_grpc.py +24 -0
- quilt_hp/_proto/quilt_hds_pb2.py +292 -0
- quilt_hp/_proto/quilt_hds_pb2.pyi +3947 -0
- quilt_hp/_proto/quilt_hds_pb2_grpc.py +1732 -0
- quilt_hp/_proto/quilt_notifier_pb2.py +55 -0
- quilt_hp/_proto/quilt_notifier_pb2.pyi +258 -0
- quilt_hp/_proto/quilt_notifier_pb2_grpc.py +97 -0
- quilt_hp/_proto/quilt_services_pb2.py +171 -0
- quilt_hp/_proto/quilt_services_pb2.pyi +1320 -0
- quilt_hp/_proto/quilt_services_pb2_grpc.py +1188 -0
- quilt_hp/_proto/quilt_system_pb2.py +53 -0
- quilt_hp/_proto/quilt_system_pb2.pyi +164 -0
- quilt_hp/_proto/quilt_system_pb2_grpc.py +270 -0
- quilt_hp/auth.py +244 -0
- quilt_hp/cli/__init__.py +1 -0
- quilt_hp/cli/main.py +770 -0
- quilt_hp/cli/settings.py +123 -0
- quilt_hp/cli/store.py +105 -0
- quilt_hp/cli/tui.py +2677 -0
- quilt_hp/client.py +616 -0
- quilt_hp/const.py +57 -0
- quilt_hp/exceptions.py +23 -0
- quilt_hp/models/__init__.py +85 -0
- quilt_hp/models/comfort.py +47 -0
- quilt_hp/models/controller.py +135 -0
- quilt_hp/models/energy.py +31 -0
- quilt_hp/models/enums.py +298 -0
- quilt_hp/models/indoor_unit.py +412 -0
- quilt_hp/models/outdoor_unit.py +71 -0
- quilt_hp/models/qsm.py +105 -0
- quilt_hp/models/schedule.py +98 -0
- quilt_hp/models/sensor.py +92 -0
- quilt_hp/models/software_update.py +74 -0
- quilt_hp/models/space.py +177 -0
- quilt_hp/models/system.py +451 -0
- quilt_hp/py.typed +1 -0
- quilt_hp/services/__init__.py +1 -0
- quilt_hp/services/hds.py +480 -0
- quilt_hp/services/streaming.py +561 -0
- quilt_hp/services/system.py +95 -0
- quilt_hp/services/user.py +143 -0
- quilt_hp/tokens.py +119 -0
- quilt_hp/transport.py +192 -0
- quilt_hp_python-0.1.1.dist-info/METADATA +172 -0
- quilt_hp_python-0.1.1.dist-info/RECORD +53 -0
- quilt_hp_python-0.1.1.dist-info/WHEEL +4 -0
- quilt_hp_python-0.1.1.dist-info/entry_points.txt +2 -0
- quilt_hp_python-0.1.1.dist-info/licenses/LICENSE +21 -0
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()
|