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/main.py
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
"""CLI entry point for quilt-hp-python."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Coroutine, Sequence
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from typing import Any, Protocol, cast
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
except ImportError:
|
|
16
|
+
print("CLI dependencies not found. Install with: pip install 'quilt-hp-python[cli]'")
|
|
17
|
+
sys.exit(1)
|
|
18
|
+
|
|
19
|
+
from quilt_hp import __version__
|
|
20
|
+
from quilt_hp.cli.settings import SettingsStore
|
|
21
|
+
from quilt_hp.cli.store import FileStore
|
|
22
|
+
from quilt_hp.client import QuiltClient
|
|
23
|
+
from quilt_hp.models.enums import HVACMode
|
|
24
|
+
from quilt_hp.models.system import SystemSnapshot
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(help="Quilt HVAC command-line interface.")
|
|
27
|
+
console = Console()
|
|
28
|
+
_store = FileStore()
|
|
29
|
+
_settings = SettingsStore()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OutputMode(StrEnum):
|
|
33
|
+
"""Script output format."""
|
|
34
|
+
|
|
35
|
+
SUMMARY = "summary"
|
|
36
|
+
JSON = "json"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _EntityWithId(Protocol):
|
|
40
|
+
id: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _version_callback(value: bool) -> None:
|
|
44
|
+
if value:
|
|
45
|
+
console.print(__version__)
|
|
46
|
+
raise typer.Exit()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.callback()
|
|
50
|
+
def _app_callback(
|
|
51
|
+
version: bool = typer.Option(
|
|
52
|
+
False,
|
|
53
|
+
"--version",
|
|
54
|
+
help="Show package version and exit.",
|
|
55
|
+
callback=_version_callback,
|
|
56
|
+
is_eager=True,
|
|
57
|
+
),
|
|
58
|
+
) -> None:
|
|
59
|
+
_ = version
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _run[T](coro: Coroutine[Any, Any, T]) -> T:
|
|
63
|
+
return asyncio.run(coro)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _resolve(email: str | None, home: str | None) -> tuple[str, str | None]:
|
|
67
|
+
"""Return (email, home) from args, saved settings, or token cache.
|
|
68
|
+
|
|
69
|
+
Saves any newly supplied values back so future invocations can omit them.
|
|
70
|
+
Exits with an error message if email is unavailable.
|
|
71
|
+
"""
|
|
72
|
+
settings = _settings.load()
|
|
73
|
+
resolved_email = email or settings.email
|
|
74
|
+
resolved_home = home or settings.home
|
|
75
|
+
|
|
76
|
+
# Fall back to token cache: if exactly one account has tokens, use it.
|
|
77
|
+
if not resolved_email:
|
|
78
|
+
cached = _store.list_emails()
|
|
79
|
+
if len(cached) == 1:
|
|
80
|
+
resolved_email = cached[0]
|
|
81
|
+
|
|
82
|
+
if not resolved_email:
|
|
83
|
+
console.print(
|
|
84
|
+
"[red]Error:[/red] --email is required on first use.\n"
|
|
85
|
+
"It will be saved automatically and is optional on subsequent runs."
|
|
86
|
+
)
|
|
87
|
+
raise typer.Exit(1)
|
|
88
|
+
|
|
89
|
+
# Persist newly supplied values.
|
|
90
|
+
next_email = email if email and email != settings.email else None
|
|
91
|
+
next_home = home if home and home != settings.home else None
|
|
92
|
+
if next_email is not None or next_home is not None:
|
|
93
|
+
_settings.update(email=next_email, home=next_home)
|
|
94
|
+
|
|
95
|
+
return resolved_email, resolved_home
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _space_name_by_id(snap: SystemSnapshot) -> dict[str, str]:
|
|
99
|
+
return {space.id: space.name for space in snap.spaces}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _snapshot_payload(snap: SystemSnapshot) -> dict[str, Any]:
|
|
103
|
+
space_names = _space_name_by_id(snap)
|
|
104
|
+
update_refs: dict[str, list[str]] = {}
|
|
105
|
+
update_links: list[tuple[str, str, Sequence[_EntityWithId]]] = [
|
|
106
|
+
("indoor_unit", "software_update_info_id", snap.indoor_units),
|
|
107
|
+
("indoor_unit", "firmware_update_info_id", snap.indoor_units),
|
|
108
|
+
("outdoor_unit", "firmware_update_info_id", snap.outdoor_units),
|
|
109
|
+
("controller", "software_update_info_id", snap.controllers),
|
|
110
|
+
("controller", "firmware_update_info_id", snap.controllers),
|
|
111
|
+
("qsm", "software_update_info_id", snap.quilt_smart_modules),
|
|
112
|
+
("qsm", "firmware_update_info_id", snap.quilt_smart_modules),
|
|
113
|
+
]
|
|
114
|
+
for entity_type, field_name, entities in update_links:
|
|
115
|
+
for entity in entities:
|
|
116
|
+
update_id = getattr(entity, field_name, None)
|
|
117
|
+
if not update_id:
|
|
118
|
+
continue
|
|
119
|
+
update_refs.setdefault(update_id, []).append(f"{entity_type}:{entity.id}")
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
"timezone": snap.timezone,
|
|
123
|
+
"spaces": [
|
|
124
|
+
{
|
|
125
|
+
"id": s.id,
|
|
126
|
+
"name": s.name,
|
|
127
|
+
"parent_space_id": s.parent_space_id,
|
|
128
|
+
"is_room": s.is_room,
|
|
129
|
+
"controls": {
|
|
130
|
+
"hvac_mode": s.controls.hvac_mode.name,
|
|
131
|
+
"temperature_setpoint_c": s.controls.temperature_setpoint_c,
|
|
132
|
+
"cooling_setpoint_c": s.controls.cooling_setpoint_c,
|
|
133
|
+
"heating_setpoint_c": s.controls.heating_setpoint_c,
|
|
134
|
+
"display_setpoint": s.controls.display_setpoint,
|
|
135
|
+
"comfort_setting_id": s.controls.comfort_setting_id,
|
|
136
|
+
},
|
|
137
|
+
"state": {
|
|
138
|
+
"ambient_temperature_c": s.state.ambient_temperature_c,
|
|
139
|
+
"setpoint_c": s.state.setpoint_c,
|
|
140
|
+
"hvac_state": s.state.hvac_state.name,
|
|
141
|
+
"comfort_setting_id": s.state.comfort_setting_id,
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
for s in snap.spaces
|
|
145
|
+
],
|
|
146
|
+
"indoor_units": [
|
|
147
|
+
{
|
|
148
|
+
"id": idu.id,
|
|
149
|
+
"space_id": idu.space_id,
|
|
150
|
+
"space_name": space_names.get(idu.space_id),
|
|
151
|
+
"outdoor_unit_id": idu.outdoor_unit_id,
|
|
152
|
+
"qsm_id": idu.qsm_id,
|
|
153
|
+
"hardware_id": idu.hardware_id,
|
|
154
|
+
"firmware_update_info_id": idu.firmware_update_info_id,
|
|
155
|
+
"controls": {
|
|
156
|
+
"fan_speed": idu.controls.fan_speed.name,
|
|
157
|
+
"louver_mode": idu.controls.louver_mode.name,
|
|
158
|
+
"led_on": idu.led_on,
|
|
159
|
+
"led_brightness": idu.controls.led_brightness,
|
|
160
|
+
},
|
|
161
|
+
"state": {
|
|
162
|
+
"hvac_mode": idu.state.hvac_mode.name,
|
|
163
|
+
"hvac_state": idu.state.hvac_state.name,
|
|
164
|
+
"ambient_temperature_c": idu.state.ambient_temperature_c,
|
|
165
|
+
"ambient_humidity_percent": idu.state.ambient_humidity_percent,
|
|
166
|
+
"temperature_setpoint_c": idu.state.temperature_setpoint_c,
|
|
167
|
+
},
|
|
168
|
+
"performance_data": (
|
|
169
|
+
{
|
|
170
|
+
"coil_temperature_c": idu.performance_data.coil_temperature_c,
|
|
171
|
+
"actual_fan_speed_rpm": idu.performance_data.actual_fan_speed_rpm,
|
|
172
|
+
}
|
|
173
|
+
if idu.performance_data
|
|
174
|
+
else None
|
|
175
|
+
),
|
|
176
|
+
"occupancy_state": idu.effective_occupancy_state,
|
|
177
|
+
}
|
|
178
|
+
for idu in snap.indoor_units
|
|
179
|
+
],
|
|
180
|
+
"outdoor_units": [
|
|
181
|
+
{
|
|
182
|
+
"id": odu.id,
|
|
183
|
+
"space_id": odu.space_id,
|
|
184
|
+
"space_name": space_names.get(odu.space_id),
|
|
185
|
+
"model_sku": odu.model_sku,
|
|
186
|
+
"serial_number": odu.serial_number,
|
|
187
|
+
"firmware_version": odu.firmware_version,
|
|
188
|
+
"firmware_update_info_id": odu.firmware_update_info_id,
|
|
189
|
+
"performance_data": (
|
|
190
|
+
{
|
|
191
|
+
"compressor_frequency_hz": odu.performance_data.compressor_frequency_hz,
|
|
192
|
+
"ambient_temperature_c": odu.performance_data.ambient_temperature_c,
|
|
193
|
+
"coil_temperature_c": odu.performance_data.coil_temperature_c,
|
|
194
|
+
}
|
|
195
|
+
if odu.performance_data
|
|
196
|
+
else None
|
|
197
|
+
),
|
|
198
|
+
}
|
|
199
|
+
for odu in snap.outdoor_units
|
|
200
|
+
],
|
|
201
|
+
"controllers": [
|
|
202
|
+
{
|
|
203
|
+
"id": ctrl.id,
|
|
204
|
+
"space_id": ctrl.space_id,
|
|
205
|
+
"space_name": space_names.get(ctrl.space_id),
|
|
206
|
+
"name": ctrl.name,
|
|
207
|
+
"ambient_temperature_c": ctrl.ambient_temperature_c,
|
|
208
|
+
"raw_thermistor_c": ctrl.raw_thermistor_c,
|
|
209
|
+
"remote_sensor_mode": ctrl.remote_sensor_mode.name,
|
|
210
|
+
"software_update_info_id": ctrl.software_update_info_id,
|
|
211
|
+
"firmware_update_info_id": ctrl.firmware_update_info_id,
|
|
212
|
+
"serial_number": ctrl.serial_number,
|
|
213
|
+
"model_sku": ctrl.model_sku,
|
|
214
|
+
}
|
|
215
|
+
for ctrl in snap.controllers
|
|
216
|
+
],
|
|
217
|
+
"remote_sensors": [
|
|
218
|
+
{
|
|
219
|
+
"id": rs.id,
|
|
220
|
+
"indoor_unit_id": rs.indoor_unit_id,
|
|
221
|
+
"ambient_temperature_c": rs.ambient_temperature_c,
|
|
222
|
+
"humidity_percent": rs.humidity_percent,
|
|
223
|
+
"battery_level_percent": rs.battery_level_percent,
|
|
224
|
+
"signal_level_dbm": rs.signal_level_dbm,
|
|
225
|
+
"control_mode": rs.control_mode.name,
|
|
226
|
+
}
|
|
227
|
+
for rs in snap.remote_sensors
|
|
228
|
+
],
|
|
229
|
+
"controller_remote_sensors": [
|
|
230
|
+
{
|
|
231
|
+
"id": rs.id,
|
|
232
|
+
"controller_id": rs.controller_id,
|
|
233
|
+
"ambient_temperature_c": rs.ambient_temperature_c,
|
|
234
|
+
"humidity_percent": rs.humidity_percent,
|
|
235
|
+
"battery_level_percent": rs.battery_level_percent,
|
|
236
|
+
"signal_level_dbm": rs.signal_level_dbm,
|
|
237
|
+
"control_mode": rs.control_mode.name,
|
|
238
|
+
}
|
|
239
|
+
for rs in snap.controller_remote_sensors
|
|
240
|
+
],
|
|
241
|
+
"quilt_smart_modules": [
|
|
242
|
+
{
|
|
243
|
+
"id": qsm.id,
|
|
244
|
+
"software_update_info_id": qsm.software_update_info_id,
|
|
245
|
+
"firmware_update_info_id": qsm.firmware_update_info_id,
|
|
246
|
+
"sensors": (
|
|
247
|
+
{
|
|
248
|
+
"phase_detected_raw": qsm.sensors.phase_detected_raw,
|
|
249
|
+
"target_detected_raw": qsm.sensors.target_detected_raw,
|
|
250
|
+
"als_illuminance_raw": qsm.sensors.als_illuminance_raw,
|
|
251
|
+
"accel_x_raw": qsm.sensors.accel_x_raw,
|
|
252
|
+
"accel_y_raw": qsm.sensors.accel_y_raw,
|
|
253
|
+
"accel_z_raw": qsm.sensors.accel_z_raw,
|
|
254
|
+
}
|
|
255
|
+
if qsm.sensors
|
|
256
|
+
else None
|
|
257
|
+
),
|
|
258
|
+
}
|
|
259
|
+
for qsm in snap.quilt_smart_modules
|
|
260
|
+
],
|
|
261
|
+
"software_update_infos": [
|
|
262
|
+
{
|
|
263
|
+
"id": sui.id,
|
|
264
|
+
"state": sui.state,
|
|
265
|
+
"status": sui.status,
|
|
266
|
+
"current_version": sui.current_version,
|
|
267
|
+
"target_version": sui.target_version,
|
|
268
|
+
"current_progress": sui.current_progress,
|
|
269
|
+
"total_progress": sui.total_progress,
|
|
270
|
+
"progress_unit": sui.progress_unit,
|
|
271
|
+
"linked_entities": update_refs.get(sui.id, []),
|
|
272
|
+
}
|
|
273
|
+
for sui in snap.software_update_infos
|
|
274
|
+
],
|
|
275
|
+
"update_entities": [
|
|
276
|
+
{"topic": topic, "entity_type": topic.split("/")[1], "id": topic.split("/")[2]}
|
|
277
|
+
for topic in snap.stream_topics()
|
|
278
|
+
],
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _print_snapshot_summary(data: dict[str, Any]) -> None:
|
|
283
|
+
console.print("[bold]Spaces[/bold]")
|
|
284
|
+
for space in data["spaces"]:
|
|
285
|
+
controls = space["controls"]
|
|
286
|
+
state = space["state"]
|
|
287
|
+
console.print(
|
|
288
|
+
f" {space['name']} ({space['id']}) "
|
|
289
|
+
f"mode={controls['hvac_mode']} "
|
|
290
|
+
f"setpoint={controls['display_setpoint']} "
|
|
291
|
+
f"ambient={state['ambient_temperature_c']}°C"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
console.print("\n[bold]Indoor Units[/bold]")
|
|
295
|
+
for idu in data["indoor_units"]:
|
|
296
|
+
st = idu["state"]
|
|
297
|
+
console.print(
|
|
298
|
+
f" {idu['id']} space={idu['space_name'] or idu['space_id']} "
|
|
299
|
+
f"mode={st['hvac_mode']}/{st['hvac_state']} "
|
|
300
|
+
f"ambient={st['ambient_temperature_c']}°C "
|
|
301
|
+
f"humidity={st['ambient_humidity_percent']}%"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
console.print("\n[bold]Outdoor Units[/bold]")
|
|
305
|
+
for odu in data["outdoor_units"]:
|
|
306
|
+
console.print(
|
|
307
|
+
f" {odu['id']} model={odu['model_sku'] or '--'} serial={odu['serial_number'] or '--'}"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
console.print("\n[bold]Controllers[/bold]")
|
|
311
|
+
for ctrl in data["controllers"]:
|
|
312
|
+
console.print(
|
|
313
|
+
f" {ctrl['name']} ({ctrl['id']}) space={ctrl['space_name'] or ctrl['space_id']} "
|
|
314
|
+
f"ambient={ctrl['ambient_temperature_c']}°C"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
console.print("\n[bold]Remote Sensors[/bold]")
|
|
318
|
+
for rs in data["remote_sensors"]:
|
|
319
|
+
console.print(
|
|
320
|
+
f" {rs['id']} idu={rs['indoor_unit_id']} "
|
|
321
|
+
f"temp={rs['ambient_temperature_c']}°C humidity={rs['humidity_percent']}%"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
console.print("\n[bold]Controller Remote Sensors[/bold]")
|
|
325
|
+
for rs in data["controller_remote_sensors"]:
|
|
326
|
+
console.print(
|
|
327
|
+
f" {rs['id']} controller={rs['controller_id']} "
|
|
328
|
+
f"temp={rs['ambient_temperature_c']}°C humidity={rs['humidity_percent']}%"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
console.print("\n[bold]QSMs[/bold]")
|
|
332
|
+
for qsm in data["quilt_smart_modules"]:
|
|
333
|
+
console.print(f" {qsm['id']}")
|
|
334
|
+
|
|
335
|
+
console.print("\n[bold]Software Update Entities[/bold]")
|
|
336
|
+
for sui in data["software_update_infos"]:
|
|
337
|
+
links = ",".join(sui["linked_entities"]) or "--"
|
|
338
|
+
console.print(f" {sui['id']} linked={links}")
|
|
339
|
+
|
|
340
|
+
console.print("\n[bold]Update Topics[/bold]")
|
|
341
|
+
for topic in data["update_entities"]:
|
|
342
|
+
console.print(f" {topic['topic']}")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _emit_output(mode: OutputMode, payload: dict[str, Any]) -> None:
|
|
346
|
+
if mode == OutputMode.JSON:
|
|
347
|
+
console.print(json.dumps(payload, indent=2, sort_keys=True))
|
|
348
|
+
return
|
|
349
|
+
_print_snapshot_summary(payload)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@app.command()
|
|
353
|
+
def login(
|
|
354
|
+
email: str | None = typer.Option(None, envvar="QUILT_EMAIL", help="Quilt account email"),
|
|
355
|
+
home: str | None = typer.Option(None, help="Specific home name to connect to"),
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Authenticate with Quilt."""
|
|
358
|
+
email, home = _resolve(email, home)
|
|
359
|
+
|
|
360
|
+
async def _login() -> None:
|
|
361
|
+
async with QuiltClient(email, home=home, token_store=_store) as client:
|
|
362
|
+
# Try silent login first (uses cached/refreshed token, no OTP).
|
|
363
|
+
try:
|
|
364
|
+
await client.login()
|
|
365
|
+
console.print(f"[green]✓ Already logged in as {email}[/green]")
|
|
366
|
+
return
|
|
367
|
+
except Exception:
|
|
368
|
+
pass
|
|
369
|
+
|
|
370
|
+
# Cached tokens absent/expired — trigger OTP flow and prompt.
|
|
371
|
+
async def _prompt_for_otp(challenge_email: str) -> str:
|
|
372
|
+
console.print(
|
|
373
|
+
f"[yellow]✉ OTP sent to {challenge_email} — check your email.[/yellow]"
|
|
374
|
+
)
|
|
375
|
+
return cast("str", typer.prompt("Enter OTP code")).strip()
|
|
376
|
+
|
|
377
|
+
await client.login(otp_callback=_prompt_for_otp)
|
|
378
|
+
console.print("[green]✓ Successfully logged in![/green]")
|
|
379
|
+
|
|
380
|
+
_run(_login())
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@app.command()
|
|
384
|
+
def info(
|
|
385
|
+
email: str | None = typer.Option(None, envvar="QUILT_EMAIL", help="Quilt account email"),
|
|
386
|
+
home: str | None = typer.Option(None, help="Specific home name to connect to"),
|
|
387
|
+
output: OutputMode = typer.Option( # noqa: B008
|
|
388
|
+
OutputMode.SUMMARY,
|
|
389
|
+
"--output",
|
|
390
|
+
"-o",
|
|
391
|
+
help="Output mode: summary or json",
|
|
392
|
+
),
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Display complete system inventory + telemetry."""
|
|
395
|
+
email, home = _resolve(email, home)
|
|
396
|
+
|
|
397
|
+
async def _info() -> None:
|
|
398
|
+
async with QuiltClient(email, home=home, token_store=_store) as client:
|
|
399
|
+
await client.login()
|
|
400
|
+
snap = await client.get_snapshot()
|
|
401
|
+
_emit_output(output, _snapshot_payload(snap))
|
|
402
|
+
|
|
403
|
+
_run(_info())
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@app.command()
|
|
407
|
+
def devices(
|
|
408
|
+
email: str | None = typer.Option(None, envvar="QUILT_EMAIL", help="Quilt account email"),
|
|
409
|
+
home: str | None = typer.Option(None, help="Specific home name to connect to"),
|
|
410
|
+
output: OutputMode = typer.Option( # noqa: B008
|
|
411
|
+
OutputMode.SUMMARY,
|
|
412
|
+
"--output",
|
|
413
|
+
"-o",
|
|
414
|
+
help="Output mode: summary or json",
|
|
415
|
+
),
|
|
416
|
+
) -> None:
|
|
417
|
+
"""List all device/entity IDs, including update entities."""
|
|
418
|
+
email, home = _resolve(email, home)
|
|
419
|
+
|
|
420
|
+
async def _devices() -> None:
|
|
421
|
+
async with QuiltClient(email, home=home, token_store=_store) as client:
|
|
422
|
+
await client.login()
|
|
423
|
+
payload = _snapshot_payload(await client.get_snapshot())
|
|
424
|
+
device_payload = {
|
|
425
|
+
"spaces": [{"id": s["id"], "name": s["name"]} for s in payload["spaces"]],
|
|
426
|
+
"indoor_units": [
|
|
427
|
+
{"id": i["id"], "space_id": i["space_id"]} for i in payload["indoor_units"]
|
|
428
|
+
],
|
|
429
|
+
"outdoor_units": [
|
|
430
|
+
{"id": o["id"], "space_id": o["space_id"]} for o in payload["outdoor_units"]
|
|
431
|
+
],
|
|
432
|
+
"controllers": [
|
|
433
|
+
{"id": c["id"], "space_id": c["space_id"]} for c in payload["controllers"]
|
|
434
|
+
],
|
|
435
|
+
"remote_sensors": [
|
|
436
|
+
{"id": r["id"], "indoor_unit_id": r["indoor_unit_id"]}
|
|
437
|
+
for r in payload["remote_sensors"]
|
|
438
|
+
],
|
|
439
|
+
"controller_remote_sensors": [
|
|
440
|
+
{"id": r["id"], "controller_id": r["controller_id"]}
|
|
441
|
+
for r in payload["controller_remote_sensors"]
|
|
442
|
+
],
|
|
443
|
+
"quilt_smart_modules": [{"id": q["id"]} for q in payload["quilt_smart_modules"]],
|
|
444
|
+
"software_update_infos": [
|
|
445
|
+
{"id": u["id"]} for u in payload["software_update_infos"]
|
|
446
|
+
],
|
|
447
|
+
"update_entities": payload["update_entities"],
|
|
448
|
+
}
|
|
449
|
+
if output == OutputMode.JSON:
|
|
450
|
+
console.print(json.dumps(device_payload, indent=2, sort_keys=True))
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
for key, items in device_payload.items():
|
|
454
|
+
console.print(f"[bold]{key.replace('_', ' ').title()}[/bold]")
|
|
455
|
+
for item in items:
|
|
456
|
+
left = item["id"]
|
|
457
|
+
refs = ", ".join(
|
|
458
|
+
f"{k}={v}" for k, v in item.items() if k != "id" and v is not None
|
|
459
|
+
)
|
|
460
|
+
console.print(f" {left}{f' ({refs})' if refs else ''}")
|
|
461
|
+
|
|
462
|
+
_run(_devices())
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@app.command()
|
|
466
|
+
def values(
|
|
467
|
+
email: str | None = typer.Option(None, envvar="QUILT_EMAIL", help="Quilt account email"),
|
|
468
|
+
home: str | None = typer.Option(None, help="Specific home name to connect to"),
|
|
469
|
+
output: OutputMode = typer.Option( # noqa: B008
|
|
470
|
+
OutputMode.SUMMARY,
|
|
471
|
+
"--output",
|
|
472
|
+
"-o",
|
|
473
|
+
help="Output mode: summary or json",
|
|
474
|
+
),
|
|
475
|
+
) -> None:
|
|
476
|
+
"""Show current sensor values and HVAC control setpoints."""
|
|
477
|
+
email, home = _resolve(email, home)
|
|
478
|
+
|
|
479
|
+
async def _values() -> None:
|
|
480
|
+
async with QuiltClient(email, home=home, token_store=_store) as client:
|
|
481
|
+
await client.login()
|
|
482
|
+
payload = _snapshot_payload(await client.get_snapshot())
|
|
483
|
+
value_payload = {
|
|
484
|
+
"spaces": [
|
|
485
|
+
{
|
|
486
|
+
"id": s["id"],
|
|
487
|
+
"name": s["name"],
|
|
488
|
+
"ambient_temperature_c": s["state"]["ambient_temperature_c"],
|
|
489
|
+
"hvac_mode": s["controls"]["hvac_mode"],
|
|
490
|
+
"hvac_state": s["state"]["hvac_state"],
|
|
491
|
+
"display_setpoint": s["controls"]["display_setpoint"],
|
|
492
|
+
"temperature_setpoint_c": s["controls"]["temperature_setpoint_c"],
|
|
493
|
+
"cooling_setpoint_c": s["controls"]["cooling_setpoint_c"],
|
|
494
|
+
"heating_setpoint_c": s["controls"]["heating_setpoint_c"],
|
|
495
|
+
}
|
|
496
|
+
for s in payload["spaces"]
|
|
497
|
+
],
|
|
498
|
+
"indoor_units": [
|
|
499
|
+
{
|
|
500
|
+
"id": i["id"],
|
|
501
|
+
"space_id": i["space_id"],
|
|
502
|
+
"ambient_temperature_c": i["state"]["ambient_temperature_c"],
|
|
503
|
+
"ambient_humidity_percent": i["state"]["ambient_humidity_percent"],
|
|
504
|
+
"temperature_setpoint_c": i["state"]["temperature_setpoint_c"],
|
|
505
|
+
"fan_speed": i["controls"]["fan_speed"],
|
|
506
|
+
}
|
|
507
|
+
for i in payload["indoor_units"]
|
|
508
|
+
],
|
|
509
|
+
"outdoor_units": [
|
|
510
|
+
{"id": o["id"], "performance_data": o["performance_data"]}
|
|
511
|
+
for o in payload["outdoor_units"]
|
|
512
|
+
],
|
|
513
|
+
"controllers": [
|
|
514
|
+
{
|
|
515
|
+
"id": c["id"],
|
|
516
|
+
"name": c["name"],
|
|
517
|
+
"ambient_temperature_c": c["ambient_temperature_c"],
|
|
518
|
+
"raw_thermistor_c": c["raw_thermistor_c"],
|
|
519
|
+
}
|
|
520
|
+
for c in payload["controllers"]
|
|
521
|
+
],
|
|
522
|
+
"remote_sensors": payload["remote_sensors"],
|
|
523
|
+
"controller_remote_sensors": payload["controller_remote_sensors"],
|
|
524
|
+
"quilt_smart_modules": [
|
|
525
|
+
{"id": q["id"], "sensors": q["sensors"]}
|
|
526
|
+
for q in payload["quilt_smart_modules"]
|
|
527
|
+
],
|
|
528
|
+
}
|
|
529
|
+
if output == OutputMode.JSON:
|
|
530
|
+
console.print(json.dumps(value_payload, indent=2, sort_keys=True))
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
console.print("[bold]Spaces[/bold]")
|
|
534
|
+
for space in value_payload["spaces"]:
|
|
535
|
+
console.print(
|
|
536
|
+
f" {space['name']} ({space['id']}) "
|
|
537
|
+
f"ambient={space['ambient_temperature_c']}°C "
|
|
538
|
+
f"setpoint={space['display_setpoint']} "
|
|
539
|
+
f"mode/state={space['hvac_mode']}/{space['hvac_state']}"
|
|
540
|
+
)
|
|
541
|
+
console.print("\n[bold]Indoor Units[/bold]")
|
|
542
|
+
for idu in value_payload["indoor_units"]:
|
|
543
|
+
console.print(
|
|
544
|
+
f" {idu['id']} space={idu['space_id']} "
|
|
545
|
+
f"ambient={idu['ambient_temperature_c']}°C "
|
|
546
|
+
f"humidity={idu['ambient_humidity_percent']}% "
|
|
547
|
+
f"setpoint={idu['temperature_setpoint_c']}°C "
|
|
548
|
+
f"fan={idu['fan_speed']}"
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
console.print("\n[bold]Outdoor Units[/bold]")
|
|
552
|
+
for odu in value_payload["outdoor_units"]:
|
|
553
|
+
perf = odu["performance_data"] or {}
|
|
554
|
+
console.print(
|
|
555
|
+
f" {odu['id']} compressor={perf.get('compressor_frequency_hz')}Hz "
|
|
556
|
+
f"ambient={perf.get('ambient_temperature_c')}°C "
|
|
557
|
+
f"coil={perf.get('coil_temperature_c')}°C"
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
console.print("\n[bold]Controllers[/bold]")
|
|
561
|
+
for ctrl in value_payload["controllers"]:
|
|
562
|
+
console.print(
|
|
563
|
+
f" {ctrl['name']} ({ctrl['id']}) ambient={ctrl['ambient_temperature_c']}°C "
|
|
564
|
+
f"thermistor={ctrl['raw_thermistor_c']}°C"
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
console.print("\n[bold]Remote Sensors[/bold]")
|
|
568
|
+
for rs in value_payload["remote_sensors"]:
|
|
569
|
+
console.print(
|
|
570
|
+
f" {rs['id']} idu={rs['indoor_unit_id']} "
|
|
571
|
+
f"temp={rs['ambient_temperature_c']}°C humidity={rs['humidity_percent']}%"
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
console.print("\n[bold]Controller Remote Sensors[/bold]")
|
|
575
|
+
for rs in value_payload["controller_remote_sensors"]:
|
|
576
|
+
console.print(
|
|
577
|
+
f" {rs['id']} controller={rs['controller_id']} "
|
|
578
|
+
f"temp={rs['ambient_temperature_c']}°C humidity={rs['humidity_percent']}%"
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
console.print("\n[bold]QSM Sensors[/bold]")
|
|
582
|
+
for qsm in value_payload["quilt_smart_modules"]:
|
|
583
|
+
sensors = qsm["sensors"] or {}
|
|
584
|
+
console.print(
|
|
585
|
+
f" {qsm['id']} phase={sensors.get('phase_detected_raw')} "
|
|
586
|
+
f"target={sensors.get('target_detected_raw')} "
|
|
587
|
+
f"illuminance={sensors.get('als_illuminance_raw')}"
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
_run(_values())
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@app.command()
|
|
594
|
+
def presets(
|
|
595
|
+
email: str | None = typer.Option(None, envvar="QUILT_EMAIL", help="Quilt account email"),
|
|
596
|
+
home: str | None = typer.Option(None, help="Specific home name to connect to"),
|
|
597
|
+
) -> None:
|
|
598
|
+
"""List all comfort setting presets."""
|
|
599
|
+
email, home = _resolve(email, home)
|
|
600
|
+
|
|
601
|
+
async def _presets() -> None:
|
|
602
|
+
async with QuiltClient(email, home=home, token_store=_store) as client:
|
|
603
|
+
await client.login()
|
|
604
|
+
settings = await client.list_comfort_settings()
|
|
605
|
+
if not settings:
|
|
606
|
+
console.print("No comfort settings found.")
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
console.print("\n[bold]═══ Comfort Settings ═══[/bold]")
|
|
610
|
+
for cs in settings:
|
|
611
|
+
mode = cs.hvac_mode.name
|
|
612
|
+
heat = f"{cs.heating_setpoint_c:.1f}°C" if cs.heating_setpoint_c else "--"
|
|
613
|
+
cool = f"{cs.cooling_setpoint_c:.1f}°C" if cs.cooling_setpoint_c else "--"
|
|
614
|
+
fan = cs.fan_speed.name
|
|
615
|
+
console.print(f"\n [cyan]{cs.name}[/cyan] ({cs.type.name})")
|
|
616
|
+
console.print(f" Mode: {mode} Heat: {heat} Cool: {cool} Fan: {fan}")
|
|
617
|
+
console.print()
|
|
618
|
+
|
|
619
|
+
_run(_presets())
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
@app.command()
|
|
623
|
+
def schedules(
|
|
624
|
+
email: str | None = typer.Option(None, envvar="QUILT_EMAIL", help="Quilt account email"),
|
|
625
|
+
home: str | None = typer.Option(None, help="Specific home name to connect to"),
|
|
626
|
+
) -> None:
|
|
627
|
+
"""List schedules configured for each space."""
|
|
628
|
+
email, home = _resolve(email, home)
|
|
629
|
+
|
|
630
|
+
async def _schedules() -> None:
|
|
631
|
+
async with QuiltClient(email, home=home, token_store=_store) as client:
|
|
632
|
+
await client.login()
|
|
633
|
+
snapshot = await client.get_snapshot()
|
|
634
|
+
|
|
635
|
+
cs_by_id = {cs.id: cs for cs in snapshot.comfort_settings}
|
|
636
|
+
day_by_id = {d.id: d for d in snapshot.schedule_days}
|
|
637
|
+
|
|
638
|
+
console.print("\n[bold]═══ Schedules ═══[/bold]")
|
|
639
|
+
for week in snapshot.schedule_weeks:
|
|
640
|
+
space = next((s for s in snapshot.spaces if s.id == week.space_id), None)
|
|
641
|
+
space_name = space.name if space else "Unknown Space"
|
|
642
|
+
console.print(f"\n [green][{space_name}][/green]")
|
|
643
|
+
|
|
644
|
+
seen_days = set()
|
|
645
|
+
for wd in week.days:
|
|
646
|
+
if wd.day_id in seen_days:
|
|
647
|
+
continue
|
|
648
|
+
seen_days.add(wd.day_id)
|
|
649
|
+
day = day_by_id.get(wd.day_id)
|
|
650
|
+
if not day:
|
|
651
|
+
continue
|
|
652
|
+
|
|
653
|
+
wdays = [w.weekday_name for w in week.days if w.day_id == day.id]
|
|
654
|
+
console.print(f" [yellow]{', '.join(wdays)}[/yellow]: {day.name}")
|
|
655
|
+
|
|
656
|
+
for ev in day.events:
|
|
657
|
+
cs = cs_by_id.get(ev.comfort_setting_id)
|
|
658
|
+
name = cs.name if cs else "Unknown"
|
|
659
|
+
console.print(f" {ev.start_time} → [cyan]{name}[/cyan]")
|
|
660
|
+
console.print()
|
|
661
|
+
|
|
662
|
+
_run(_schedules())
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@app.command()
|
|
666
|
+
def energy(
|
|
667
|
+
email: str | None = typer.Option(None, envvar="QUILT_EMAIL", help="Quilt account email"),
|
|
668
|
+
home: str | None = typer.Option(None, help="Specific home name to connect to"),
|
|
669
|
+
period: str = typer.Option("day", help="Time period: day, week, month"),
|
|
670
|
+
) -> None:
|
|
671
|
+
"""Show energy consumption metrics."""
|
|
672
|
+
email, home = _resolve(email, home)
|
|
673
|
+
|
|
674
|
+
async def _energy() -> None:
|
|
675
|
+
import zoneinfo
|
|
676
|
+
from datetime import datetime, timedelta
|
|
677
|
+
|
|
678
|
+
async with QuiltClient(email, home=home, token_store=_store) as client:
|
|
679
|
+
await client.login()
|
|
680
|
+
snapshot = await client.get_snapshot()
|
|
681
|
+
name_by_id = {s.id: s.name for s in snapshot.spaces}
|
|
682
|
+
|
|
683
|
+
now = datetime.now(tz=zoneinfo.ZoneInfo(snapshot.timezone or "UTC"))
|
|
684
|
+
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
685
|
+
|
|
686
|
+
if period == "day":
|
|
687
|
+
end = start + timedelta(days=1) - timedelta(seconds=1)
|
|
688
|
+
elif period == "week":
|
|
689
|
+
start = start - timedelta(days=start.weekday())
|
|
690
|
+
end = start + timedelta(weeks=1) - timedelta(seconds=1)
|
|
691
|
+
else: # month
|
|
692
|
+
start = start.replace(day=1)
|
|
693
|
+
if start.month == 12:
|
|
694
|
+
end = start.replace(year=start.year + 1, month=1) - timedelta(seconds=1)
|
|
695
|
+
else:
|
|
696
|
+
end = start.replace(month=start.month + 1) - timedelta(seconds=1)
|
|
697
|
+
|
|
698
|
+
metrics = await client.get_energy(start, end)
|
|
699
|
+
header = f"{start.strftime('%b %d')} - {end.strftime('%b %d %Y')}"
|
|
700
|
+
console.print(f"\n [bold][{period.upper()}][/bold] {header}\n")
|
|
701
|
+
for sm in metrics:
|
|
702
|
+
name = name_by_id.get(sm.space_id, sm.space_id[:8])
|
|
703
|
+
total = getattr(sm, "total_kwh", 0)
|
|
704
|
+
if total == 0:
|
|
705
|
+
continue
|
|
706
|
+
console.print(f" {name:<22} total={total:.3f} kWh")
|
|
707
|
+
|
|
708
|
+
_run(_energy())
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
@app.command(name="set")
|
|
712
|
+
def set_space(
|
|
713
|
+
space_name: str = typer.Argument(..., help="Exact name of the room to update"),
|
|
714
|
+
mode: str | None = typer.Option(None, help="HVAC mode: COOL, HEAT, AUTO, STANDBY"),
|
|
715
|
+
heat: float | None = typer.Option(None, help="Heating setpoint in °C"),
|
|
716
|
+
cool: float | None = typer.Option(None, help="Cooling setpoint in °C"),
|
|
717
|
+
email: str | None = typer.Option(None, envvar="QUILT_EMAIL", help="Quilt account email"),
|
|
718
|
+
home: str | None = typer.Option(None, help="Specific home name to connect to"),
|
|
719
|
+
) -> None:
|
|
720
|
+
"""Update HVAC mode and setpoints for a room."""
|
|
721
|
+
email, home = _resolve(email, home)
|
|
722
|
+
|
|
723
|
+
async def _set() -> None:
|
|
724
|
+
async with QuiltClient(email, home=home, token_store=_store) as client:
|
|
725
|
+
await client.login()
|
|
726
|
+
snap = await client.get_snapshot()
|
|
727
|
+
|
|
728
|
+
space = next(
|
|
729
|
+
(s for s in snap.rooms if s.name.lower() == space_name.lower()),
|
|
730
|
+
None,
|
|
731
|
+
)
|
|
732
|
+
if not space:
|
|
733
|
+
console.print(f"[red]Room {space_name!r} not found.[/red]")
|
|
734
|
+
raise typer.Exit(1)
|
|
735
|
+
|
|
736
|
+
hvac_mode = HVACMode[mode.upper()] if mode else None
|
|
737
|
+
|
|
738
|
+
await client.set_space(
|
|
739
|
+
space.id,
|
|
740
|
+
mode=hvac_mode,
|
|
741
|
+
heat_setpoint_c=heat,
|
|
742
|
+
cool_setpoint_c=cool,
|
|
743
|
+
)
|
|
744
|
+
console.print(f"[green]✓ Updated {space.name}[/green]")
|
|
745
|
+
|
|
746
|
+
_run(_set())
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
@app.command()
|
|
750
|
+
def tui(
|
|
751
|
+
email: str | None = typer.Option(None, envvar="QUILT_EMAIL", help="Quilt account email"),
|
|
752
|
+
home: str | None = typer.Option(None, help="Specific home name to connect to"),
|
|
753
|
+
) -> None:
|
|
754
|
+
"""Launch the interactive terminal UI for live streaming."""
|
|
755
|
+
email, home = _resolve(email, home)
|
|
756
|
+
|
|
757
|
+
try:
|
|
758
|
+
from quilt_hp.cli.tui import QuiltApp
|
|
759
|
+
except ImportError:
|
|
760
|
+
console.print(
|
|
761
|
+
"[red]Textual not installed. Install with `pip install 'quilt-hp-python[cli]'`[/red]"
|
|
762
|
+
)
|
|
763
|
+
sys.exit(1)
|
|
764
|
+
|
|
765
|
+
tui_app = QuiltApp(email=email, home=home)
|
|
766
|
+
tui_app.run()
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
if __name__ == "__main__":
|
|
770
|
+
app()
|