quilt-hp-python 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. quilt_hp/__init__.py +22 -0
  2. quilt_hp/_paths.py +26 -0
  3. quilt_hp/_proto/__init__.py +0 -0
  4. quilt_hp/_proto/quilt_device_pairing_pb2.py +56 -0
  5. quilt_hp/_proto/quilt_device_pairing_pb2.pyi +317 -0
  6. quilt_hp/_proto/quilt_device_pairing_pb2_grpc.py +24 -0
  7. quilt_hp/_proto/quilt_hds_pb2.py +292 -0
  8. quilt_hp/_proto/quilt_hds_pb2.pyi +3947 -0
  9. quilt_hp/_proto/quilt_hds_pb2_grpc.py +1732 -0
  10. quilt_hp/_proto/quilt_notifier_pb2.py +55 -0
  11. quilt_hp/_proto/quilt_notifier_pb2.pyi +258 -0
  12. quilt_hp/_proto/quilt_notifier_pb2_grpc.py +97 -0
  13. quilt_hp/_proto/quilt_services_pb2.py +171 -0
  14. quilt_hp/_proto/quilt_services_pb2.pyi +1320 -0
  15. quilt_hp/_proto/quilt_services_pb2_grpc.py +1188 -0
  16. quilt_hp/_proto/quilt_system_pb2.py +53 -0
  17. quilt_hp/_proto/quilt_system_pb2.pyi +164 -0
  18. quilt_hp/_proto/quilt_system_pb2_grpc.py +270 -0
  19. quilt_hp/auth.py +244 -0
  20. quilt_hp/cli/__init__.py +1 -0
  21. quilt_hp/cli/main.py +770 -0
  22. quilt_hp/cli/settings.py +123 -0
  23. quilt_hp/cli/store.py +105 -0
  24. quilt_hp/cli/tui.py +2677 -0
  25. quilt_hp/client.py +616 -0
  26. quilt_hp/const.py +57 -0
  27. quilt_hp/exceptions.py +23 -0
  28. quilt_hp/models/__init__.py +85 -0
  29. quilt_hp/models/comfort.py +47 -0
  30. quilt_hp/models/controller.py +135 -0
  31. quilt_hp/models/energy.py +31 -0
  32. quilt_hp/models/enums.py +298 -0
  33. quilt_hp/models/indoor_unit.py +412 -0
  34. quilt_hp/models/outdoor_unit.py +71 -0
  35. quilt_hp/models/qsm.py +105 -0
  36. quilt_hp/models/schedule.py +98 -0
  37. quilt_hp/models/sensor.py +92 -0
  38. quilt_hp/models/software_update.py +74 -0
  39. quilt_hp/models/space.py +177 -0
  40. quilt_hp/models/system.py +451 -0
  41. quilt_hp/py.typed +1 -0
  42. quilt_hp/services/__init__.py +1 -0
  43. quilt_hp/services/hds.py +480 -0
  44. quilt_hp/services/streaming.py +561 -0
  45. quilt_hp/services/system.py +95 -0
  46. quilt_hp/services/user.py +143 -0
  47. quilt_hp/tokens.py +119 -0
  48. quilt_hp/transport.py +192 -0
  49. quilt_hp_python-0.1.1.dist-info/METADATA +172 -0
  50. quilt_hp_python-0.1.1.dist-info/RECORD +53 -0
  51. quilt_hp_python-0.1.1.dist-info/WHEEL +4 -0
  52. quilt_hp_python-0.1.1.dist-info/entry_points.txt +2 -0
  53. quilt_hp_python-0.1.1.dist-info/licenses/LICENSE +21 -0
quilt_hp/cli/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()