quilt-hp-python 0.5.3__tar.gz → 0.5.4__tar.gz

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 (123) hide show
  1. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/CHANGELOG.md +60 -0
  2. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/PKG-INFO +10 -6
  3. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/README.md +9 -5
  4. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/explanation/streaming-protocol.md +1 -1
  5. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/reference/client.md +3 -3
  6. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/reference/hds-entities.md +2 -2
  7. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/reference/models.md +49 -20
  8. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/reference/token-management.md +5 -0
  9. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/proto/cleaned/quilt_hds.proto +2 -2
  10. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/pyproject.toml +1 -1
  11. quilt_hp_python-0.5.4/src/quilt_hp/__init__.py +46 -0
  12. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/_paths.py +6 -2
  13. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_hds_pb2.pyi +2 -2
  14. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/auth.py +20 -5
  15. quilt_hp_python-0.5.4/src/quilt_hp/cli/constants.py +25 -0
  16. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/cli/main.py +67 -20
  17. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/cli/store.py +101 -58
  18. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/cli/tui.py +470 -188
  19. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/client.py +91 -40
  20. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/models/_helpers.py +40 -0
  21. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/models/comfort.py +11 -1
  22. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/models/controller.py +56 -32
  23. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/models/indoor_unit.py +96 -40
  24. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/models/outdoor_unit.py +23 -26
  25. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/models/qsm.py +24 -14
  26. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/models/sensor.py +41 -20
  27. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/models/space.py +72 -25
  28. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/models/system.py +102 -26
  29. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/services/__init__.py +8 -1
  30. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/services/hds.py +31 -49
  31. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/services/streaming.py +176 -47
  32. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/services/user.py +17 -17
  33. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/tokens.py +9 -4
  34. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/transport.py +52 -18
  35. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_auth.py +58 -0
  36. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_auth_store_settings_edges.py +47 -4
  37. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_cli_surfaces_extra.py +35 -2
  38. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_client_cache.py +88 -0
  39. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_client_service_error_paths.py +1 -1
  40. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_hds_service_branches.py +2 -0
  41. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_models_from_proto.py +6 -14
  42. quilt_hp_python-0.5.4/tests/test_models_real_proto_merge.py +352 -0
  43. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_streaming.py +13 -1
  44. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_streaming_reconnect_dispatch_extra.py +156 -1
  45. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_tokens.py +13 -7
  46. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_transport_interceptor_extra.py +84 -8
  47. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_tui_bindings.py +52 -0
  48. quilt_hp_python-0.5.3/src/quilt_hp/__init__.py +0 -24
  49. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/.github/copilot-instructions.md +0 -0
  50. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/.github/workflows/ci.yml +0 -0
  51. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/.github/workflows/docs-deploy.yml +0 -0
  52. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/.github/workflows/release.yml +0 -0
  53. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/.gitignore +0 -0
  54. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/LICENSE +0 -0
  55. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/explanation/architecture.md +0 -0
  56. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/explanation/authentication.md +0 -0
  57. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/explanation/grpc-and-protobuf.md +0 -0
  58. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/explanation/snapshot-and-stream.md +0 -0
  59. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/how-to/authenticate.md +0 -0
  60. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/how-to/automation-daemon.md +0 -0
  61. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/how-to/cli-scripting.md +0 -0
  62. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/how-to/configure-comfort-settings.md +0 -0
  63. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/how-to/configure-schedules.md +0 -0
  64. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/how-to/contribute.md +0 -0
  65. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/how-to/control-spaces.md +0 -0
  66. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/how-to/home-assistant.md +0 -0
  67. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/how-to/regenerate-protos.md +0 -0
  68. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/how-to/stream-updates.md +0 -0
  69. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/how-to/tui-app.md +0 -0
  70. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/index.md +0 -0
  71. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/reference/api-reference.md +0 -0
  72. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/reference/documentation-standards.md +0 -0
  73. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/reference/grpc-services-matrix.md +0 -0
  74. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/docs/tutorial/get-started.md +0 -0
  75. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/mkdocs.yml +0 -0
  76. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/proto/cleaned/quilt_device_pairing.proto +0 -0
  77. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/proto/cleaned/quilt_notifier.proto +0 -0
  78. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/proto/cleaned/quilt_services.proto +0 -0
  79. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/proto/cleaned/quilt_system.proto +0 -0
  80. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/scripts/bump_version.py +0 -0
  81. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/scripts/check_docs_nav.py +0 -0
  82. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/scripts/generate_public_api_reference.py +0 -0
  83. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/scripts/regen_protos.sh +0 -0
  84. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/__init__.py +0 -0
  85. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_device_pairing_pb2.py +0 -0
  86. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_device_pairing_pb2.pyi +0 -0
  87. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_device_pairing_pb2_grpc.py +0 -0
  88. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_hds_pb2.py +0 -0
  89. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_hds_pb2_grpc.py +0 -0
  90. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_notifier_pb2.py +0 -0
  91. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_notifier_pb2.pyi +0 -0
  92. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_notifier_pb2_grpc.py +0 -0
  93. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_services_pb2.py +0 -0
  94. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_services_pb2.pyi +0 -0
  95. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_services_pb2_grpc.py +0 -0
  96. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_system_pb2.py +0 -0
  97. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_system_pb2.pyi +0 -0
  98. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_system_pb2_grpc.py +0 -0
  99. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/cli/__init__.py +0 -0
  100. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/cli/settings.py +0 -0
  101. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/const.py +0 -0
  102. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/exceptions.py +0 -0
  103. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/models/__init__.py +0 -0
  104. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/models/energy.py +0 -0
  105. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/models/enums.py +0 -0
  106. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/models/schedule.py +0 -0
  107. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/models/software_update.py +0 -0
  108. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/py.typed +0 -0
  109. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/src/quilt_hp/services/system.py +0 -0
  110. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/__init__.py +0 -0
  111. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/conftest.py +0 -0
  112. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_cli_feature_completion.py +0 -0
  113. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_cli_login.py +0 -0
  114. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_grpc_retry.py +0 -0
  115. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_hds_payloads.py +0 -0
  116. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_hds_schedule_mapping.py +0 -0
  117. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_models.py +0 -0
  118. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_models_extra.py +0 -0
  119. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_settings_store.py +0 -0
  120. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_streaming_concurrency.py +0 -0
  121. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_streaming_debounce.py +0 -0
  122. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_streaming_health.py +0 -0
  123. {quilt_hp_python-0.5.3 → quilt_hp_python-0.5.4}/tests/test_transport.py +0 -0
@@ -2,6 +2,66 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.5.4] - 2026-07-03
6
+
7
+ Fixes all findings from a full architecture/code/performance/bug-hunt evaluation.
8
+
9
+ ### Critical
10
+
11
+ - **Proto3 absence detection never fired on real protos.** Truthiness and
12
+ getattr-with-default checks always pass on generated messages (unset
13
+ sub-messages return truthy default instances), so sparse stream diffs zeroed
14
+ room state, IDU controls, sensor readings, and controller temperatures on
15
+ merge. All model parsing now uses `HasField`-based helpers, and
16
+ `SystemSnapshot.apply_*` preserves identity/relationship fields, controller
17
+ temperatures, ODU telemetry, and QSM LED state. New test suite
18
+ (`tests/test_models_real_proto_merge.py`) reproduces the notifier stream's
19
+ serialize/parse path with real generated protos.
20
+ - **Transport UNAUTHENTICATED retry was dead code.** In `grpc.aio`, awaiting the
21
+ interceptor continuation returns the call object; `AioRpcError` only surfaces
22
+ when the call is awaited — so token refresh/retry never executed and clients
23
+ broke permanently ~1h after token expiry. The interceptor now awaits the call
24
+ and retries once. Tests updated to model real `grpc.aio` semantics.
25
+
26
+ ### High
27
+
28
+ - Stream reconnect backoff/budget reset after a healthy connection (routine LB
29
+ stream recycling no longer escalates to permanent 60s delays or kills the
30
+ stream after N lifetime disconnects); non-gRPC failures reconnect and surface
31
+ via `on_error` instead of dying silently; malformed events are skipped;
32
+ jitter added; prompt reconnect after successful token refresh.
33
+ - `authenticate()` no longer returns a locally-unexpired token the server just
34
+ rejected; rotated Cognito refresh tokens are persisted; expiry measured from
35
+ token receipt.
36
+ - `grpc_call` passes `CancelledError` through untranslated (asyncio.timeout/
37
+ cancellation semantics restored).
38
+ - TUI: fatal stream death now shows a disconnect indicator and auto-recovers;
39
+ first-time users get an OTP modal; boot failures show an error screen
40
+ instead of a stuck spinner.
41
+
42
+ ### Medium / Low
43
+
44
+ - Unified error translation: all hds/user RPCs route through `grpc_call`;
45
+ `UNAVAILABLE`/`DEADLINE_EXCEEDED` → `QuiltConnectionError` and `NOT_FOUND` →
46
+ `QuiltNotFoundError` everywhere.
47
+ - Single-flight guards on token refresh and cold snapshot fetch;
48
+ `get_system_id(home=...)` no longer poisons the default-system cache;
49
+ `close()` stops tracked streams; duplicate authorization metadata
50
+ eliminated.
51
+ - Stream API: `on_connected` callback, unsubscribe handles from all `on_*`
52
+ registrations, `apply_software_update_info`, descriptor-derived wire-scan
53
+ field numbers.
54
+ - CLI/TUI: app-owned snapshot (stacked screens no longer go stale), cursor
55
+ preservation, setpoint bounds, energy period validation, app-level °C/°F,
56
+ LED brightness restoration, 0.0 readings rendered correctly, token store
57
+ corruption recovery + fsync + flock, config dir 0700, dead code removal.
58
+ - Docs: README no longer demonstrates raw-stream usage; stream/token types
59
+ re-exported at package top level; stale OutdoorUnit/Controller reference
60
+ listings corrected; contradictory proto comments fixed.
61
+
62
+ Intentionally unchanged: Python >= 3.14 floor and boto3 dependency (HA 2026.3+
63
+ runs Python 3.14).
64
+
5
65
  ## [0.5.3] - 2026-06-30
6
66
 
7
67
  ## [0.5.2] - 2026-06-30
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quilt-hp-python
3
- Version: 0.5.3
3
+ Version: 0.5.4
4
4
  Summary: Async Python client for Quilt mini-split HVAC systems
5
5
  Project-URL: Repository, https://github.com/eman/quilt-hp-python
6
6
  Project-URL: Issues, https://github.com/eman/quilt-hp-python/issues
@@ -101,11 +101,15 @@ async def main():
101
101
  spaces = await client.list_spaces()
102
102
  await client.set_space(spaces[0].id, mode=HVACMode.COOL, cool_setpoint_c=22.0)
103
103
 
104
- # Stream real-time updates
105
- spaces = await client.list_spaces()
106
- topics = [f"hds/space/{s.id}" for s in spaces]
107
- async with client.stream(topics) as stream:
108
- stream.on_space_update(lambda s: print(f"{s.name}: {s.state.ambient_temperature_c}°C"))
104
+ # Stream real-time updates — stream events are sparse diffs, so
105
+ # always merge them into a snapshot before use
106
+ snapshot = await client.get_snapshot()
107
+ async with client.stream(snapshot.stream_topics()) as stream:
108
+ def on_space(space):
109
+ merged = snapshot.apply_space(space)
110
+ print(f"{merged.name}: {merged.state.ambient_temperature_c}°C")
111
+
112
+ stream.on_space_update(on_space)
109
113
  await asyncio.sleep(60)
110
114
 
111
115
  asyncio.run(main())
@@ -36,11 +36,15 @@ async def main():
36
36
  spaces = await client.list_spaces()
37
37
  await client.set_space(spaces[0].id, mode=HVACMode.COOL, cool_setpoint_c=22.0)
38
38
 
39
- # Stream real-time updates
40
- spaces = await client.list_spaces()
41
- topics = [f"hds/space/{s.id}" for s in spaces]
42
- async with client.stream(topics) as stream:
43
- stream.on_space_update(lambda s: print(f"{s.name}: {s.state.ambient_temperature_c}°C"))
39
+ # Stream real-time updates — stream events are sparse diffs, so
40
+ # always merge them into a snapshot before use
41
+ snapshot = await client.get_snapshot()
42
+ async with client.stream(snapshot.stream_topics()) as stream:
43
+ def on_space(space):
44
+ merged = snapshot.apply_space(space)
45
+ print(f"{merged.name}: {merged.state.ambient_temperature_c}°C")
46
+
47
+ stream.on_space_update(on_space)
44
48
  await asyncio.sleep(60)
45
49
 
46
50
  asyncio.run(main())
@@ -84,7 +84,7 @@ stateDiagram-v2
84
84
  Fatal --> [*]
85
85
  ```
86
86
 
87
- The back-off starts at `reconnect_delay_s` (default 1 second), doubles on each failed attempt, and caps at 60 seconds. The request queue is reset before each reconnect so the server receives a fresh subscription request with the full topic list.
87
+ The back-off starts at `reconnect_delay_s` (default 1 second), doubles on each consecutive failed attempt with ±50% jitter, and caps at 60 seconds. Both the back-off and the `max_reconnects` budget reset once a connection stays healthy for a while, so routine server-side stream recycling never escalates reconnect latency. After a successful token refresh the stream reconnects immediately. The request queue is reset before each reconnect so the server receives a fresh subscription request with the full topic list. `on_connected` callbacks fire on every successful (re)connect — use them to re-fetch a snapshot, since events published while disconnected are lost.
88
88
 
89
89
  The `UNAUTHENTICATED` path is special: the stream refreshes the token before waiting, because reconnecting immediately with a stale token would fail again. If the refresh itself fails, the stream gives up rather than entering an infinite retry loop with an invalid token.
90
90
 
@@ -69,7 +69,7 @@ Raised when a requested resource does not exist (gRPC `NOT_FOUND`).
69
69
  ### `__version__`
70
70
 
71
71
  ```python
72
- __version__: str # e.g. "0.5.3"
72
+ __version__: str # e.g. "0.5.4"
73
73
  ```
74
74
 
75
75
  ---
@@ -112,7 +112,7 @@ async def __aenter__(self) -> QuiltClient: ...
112
112
  async def __aexit__(self, *_: object) -> None: ...
113
113
  ```
114
114
 
115
- `__aexit__` calls `close()` to close the gRPC channel. Prefer the async context manager, or call `await close()` yourself when managing lifecycle manually.
115
+ `__aexit__` calls `close()`, which stops any live `NotifierStream`s created via `stream()` and closes the gRPC channel. Prefer the async context manager, or call `await close()` yourself when managing lifecycle manually.
116
116
 
117
117
  ---
118
118
 
@@ -528,7 +528,7 @@ Creates a `NotifierStream`. It does not start the stream; call `start()`, `run_f
528
528
 
529
529
  **Parameters:**
530
530
  - `topics`: topic strings. Use `snapshot.stream_topics()` to get all topics for a system.
531
- - `max_reconnects`: maximum reconnect attempts per disconnect. `-1` = unlimited (default). `0` = no retries.
531
+ - `max_reconnects`: maximum *consecutive* reconnect attempts — the counter resets after a connection stays healthy, so this bounds retries per disconnect event. `-1` = unlimited (default). `0` = no retries.
532
532
  - `reconnect_delay_s`: initial back-off in seconds. Doubles on each attempt, capped at 60 s.
533
533
 
534
534
  **Returns:** `NotifierStream` instance.
@@ -142,8 +142,8 @@ The Quilt Dial is a compact circular thermostat (58 mm diameter) that can be wal
142
142
  | `id` | `str` | Unique object ID |
143
143
  | `space_id` | `str` | Space this Dial controls |
144
144
  | `name` | `str` | Display name |
145
- | `ambient_temperature_c` | `float` | Temperature measured by the Dial's built-in sensor |
146
- | `raw_thermistor_c` | `float` | Raw uncalibrated thermistor reading |
145
+ | `ambient_temperature_c` | `float \| None` | Temperature measured by the Dial's built-in sensor; `None` when no state reading is available |
146
+ | `raw_thermistor_c` | `float \| None` | Raw uncalibrated thermistor reading; `None` when no state reading is available |
147
147
  | `remote_sensor_mode` | `HvacControllerType` | How the Dial's temperature reading influences the space setpoint |
148
148
  | `model_sku` | `str \| None` | Hardware model identifier |
149
149
  | `serial_number` | `str \| None` | Unit serial number |
@@ -65,7 +65,7 @@ service = UserService(channel)
65
65
  ### `NotifierStream`
66
66
 
67
67
  ```python
68
- from quilt_hp.services.streaming import NotifierStream
68
+ from quilt_hp import NotifierStream # also importable from quilt_hp.services.streaming
69
69
 
70
70
  # metadata_provider returns gRPC call metadata (e.g. auth headers).
71
71
  # Obtain a token from your QuiltClient or token store.
@@ -84,10 +84,11 @@ stream = NotifierStream.create(
84
84
 
85
85
  See [Streaming protocol behavior](../explanation/streaming-protocol.md) for the full state machine, event types, and reconnect behavior.
86
86
 
87
- Callback registration methods:
87
+ Callback registration methods (each returns an *unsubscribe* callable —
88
+ call it to detach the callback without stopping the stream):
88
89
 
89
90
  ```python
90
- stream.on_space_update(callback)
91
+ unsub = stream.on_space_update(callback)
91
92
  stream.on_indoor_unit_update(callback)
92
93
  stream.on_outdoor_unit_update(callback)
93
94
  stream.on_controller_update(callback)
@@ -95,9 +96,15 @@ stream.on_qsm_update(callback)
95
96
  stream.on_remote_sensor_update(callback)
96
97
  stream.on_controller_remote_sensor_update(callback)
97
98
  stream.on_software_update_info(callback)
98
- stream.on_error(callback)
99
+ stream.on_error(callback) # fatal stream errors
100
+ stream.on_connected(callback) # fired on every successful (re)connect
101
+ unsub() # detach the space callback
99
102
  ```
100
103
 
104
+ `on_connected` fires on every successful connect and reconnect. Events
105
+ published while disconnected are lost — use it to re-fetch a snapshot and
106
+ close the gap.
107
+
101
108
  Lifecycle methods:
102
109
 
103
110
  ```python
@@ -158,6 +165,7 @@ snapshot.apply_controller(controller)
158
165
  snapshot.apply_qsm(qsm)
159
166
  snapshot.apply_remote_sensor(sensor)
160
167
  snapshot.apply_controller_remote_sensor(sensor)
168
+ snapshot.apply_software_update_info(info)
161
169
  ```
162
170
 
163
171
  ---
@@ -291,19 +299,28 @@ class IndoorUnitState:
291
299
  class OutdoorUnit:
292
300
  id: str
293
301
  system_id: str
302
+ space_id: str
303
+ hvac_state: HVACState
304
+ model_sku: str | None
294
305
  serial_number: str | None
295
- model_name: str | None
296
- state: OutdoorUnitState
306
+ firmware_version: str | None
307
+ firmware_update_info_id: str | None
308
+ performance_data: OutdoorUnitPerformanceData | None
297
309
  ```
298
310
 
299
- #### `OutdoorUnitState`
311
+ #### `OutdoorUnitPerformanceData`
300
312
 
301
313
  ```python
302
314
  @dataclass
303
- class OutdoorUnitState:
304
- updated_at: datetime | None
305
- outdoor_temp_c: float | None
306
- is_online: bool
315
+ class OutdoorUnitPerformanceData:
316
+ measurement_interval_s: float
317
+ energy_measurement_j: float
318
+ compressor_frequency_hz: float
319
+ ambient_temperature_c: float
320
+ coil_temperature_c: float
321
+ exhaust_temperature_c: float
322
+ high_pressure_kpa: float
323
+ low_pressure_kpa: float
307
324
  ```
308
325
 
309
326
  ---
@@ -315,19 +332,31 @@ class OutdoorUnitState:
315
332
  class Controller:
316
333
  id: str
317
334
  system_id: str
335
+ space_id: str
336
+ name: str
337
+ raw_thermistor_c: float | None # None when no state reading available
338
+ pcb_temperature_a_c: float | None
339
+ pcb_temperature_b_c: float | None
340
+ calibrated_ambient_c: float | None # exposed as ambient_temperature_c
341
+ wifi_ssid: str | None
342
+ wifi_ip: str | None
343
+ wifi_signal_dbm: int | None
344
+ wifi_freq_mhz: int | None
345
+ wifi_last_seen: datetime | None
346
+ ap_wifi: WifiInfo | None
347
+ p2p_wifi: WifiInfo | None
348
+ remote_sensor_mode: RemoteSensorControlMode
349
+ software_update_info_id: str | None
350
+ firmware_update_info_id: str | None
318
351
  serial_number: str | None
352
+ model_sku: str | None
319
353
  firmware_version: str | None
320
- state: ControllerState
354
+ state_updated_at: datetime | None
355
+ local_comms_health: LocalCommsHealthStatus
321
356
  ```
322
357
 
323
- #### `ControllerState`
324
-
325
- ```python
326
- @dataclass
327
- class ControllerState:
328
- updated_at: datetime | None
329
- is_online: bool
330
- ```
358
+ Useful properties: `ambient_temperature_c` (→ `calibrated_ambient_c`, `None`
359
+ when no state reading is available), `wifi_band`, `is_online`.
331
360
 
332
361
  ---
333
362
 
@@ -223,6 +223,11 @@ class TokenRefreshReason(StrEnum):
223
223
  - `TRANSPORT_UNAUTHENTICATED`: The gRPC transport interceptor received `UNAUTHENTICATED` on a unary RPC.
224
224
  - `STREAM_UNAUTHENTICATED`: The `NotifierStream` received `UNAUTHENTICATED` and is refreshing before reconnecting.
225
225
 
226
+ For the two `*_UNAUTHENTICATED` reasons, `authenticate()` does **not** trust a
227
+ locally-unexpired cached IdToken — the server just rejected it, so a Cognito
228
+ refresh is forced. If the Cognito response contains a rotated `RefreshToken`,
229
+ the new value is persisted to the token store.
230
+
226
231
  ---
227
232
 
228
233
  ## `RefreshFailureAction`
@@ -333,7 +333,7 @@ message IndoorUnitSettings {
333
333
  // f1,f2=absent; f3=ledColorCode(varint/uint32), f4=ledColorBrightnessPercent(float),
334
334
  // f5=fanSpeedMode(varint), f6=fanSpeedPercent(float), f7=updatedTs(Timestamp),
335
335
  // f8,f9=absent; f10=louverMode(varint), f11=louverFixedPosition(float),
336
- // f12=lightState(varint), f13=lightAnimation(varint).
336
+ // f12=lightAnimation(varint), f13=lightState(varint).
337
337
  // NOTE: These are FLAT fields, NOT nested sub-messages.
338
338
  message IndoorUnitControls {
339
339
  // f1, f2 absent from wire captures
@@ -344,7 +344,7 @@ message IndoorUnitControls {
344
344
  google.protobuf.Timestamp updated_ts = 7;
345
345
  // f8, f9: uuid-string field confirmed at f9 in captures; purpose TBD
346
346
  IndoorUnitLouverMode louver_mode = 10;
347
- float louver_fixed_position = 11; // degrees, used when louver_mode=FIXED
347
+ float louver_fixed_position = 11; // position fraction 0.20–1.00 (see LouverAngle.to_wire), used when louver_mode=FIXED; 0.0 = not applicable
348
348
  LightAnimation led_animation = 12; // LED_ANIMATION_FIELD_NUMBER=12; wire-confirmed
349
349
  LightState led_state = 13; // wire-confirmed f13: UNSPECIFIED=0,ON=1,OFF=2
350
350
  // sent when mobile_led_scheduling_enabled Statsig gate is on;
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "quilt-hp-python"
7
- version = "0.5.3"
7
+ version = "0.5.4"
8
8
  description = "Async Python client for Quilt mini-split HVAC systems"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -0,0 +1,46 @@
1
+ """quilt_hp — Async Python client for Quilt mini-split HVAC systems."""
2
+
3
+ from quilt_hp.auth import OtpCallback
4
+ from quilt_hp.client import QuiltClient
5
+ from quilt_hp.const import Environment
6
+ from quilt_hp.exceptions import (
7
+ QuiltAuthError,
8
+ QuiltConnectionError,
9
+ QuiltError,
10
+ QuiltNotFoundError,
11
+ QuiltStreamError,
12
+ )
13
+ from quilt_hp.services.streaming import NotifierStream, StreamEvent
14
+ from quilt_hp.tokens import (
15
+ CachedTokens,
16
+ LegacyTokenStore,
17
+ RefreshFailureAction,
18
+ TokenRefreshContext,
19
+ TokenRefreshHooks,
20
+ TokenRefreshPolicy,
21
+ TokenRefreshReason,
22
+ TokenStore,
23
+ )
24
+
25
+ __version__ = "0.5.4"
26
+
27
+ __all__ = [
28
+ "CachedTokens",
29
+ "Environment",
30
+ "LegacyTokenStore",
31
+ "NotifierStream",
32
+ "OtpCallback",
33
+ "QuiltAuthError",
34
+ "QuiltClient",
35
+ "QuiltConnectionError",
36
+ "QuiltError",
37
+ "QuiltNotFoundError",
38
+ "QuiltStreamError",
39
+ "RefreshFailureAction",
40
+ "StreamEvent",
41
+ "TokenRefreshContext",
42
+ "TokenRefreshHooks",
43
+ "TokenRefreshPolicy",
44
+ "TokenRefreshReason",
45
+ "TokenStore",
46
+ ]
@@ -20,7 +20,11 @@ _APP = "quilt-hp"
20
20
 
21
21
 
22
22
  def app_config_dir() -> Path:
23
- """Return the platform-appropriate config directory, creating if needed."""
23
+ """Return the platform-appropriate config directory, creating if needed.
24
+
25
+ The directory is created user-only (0o700) because it holds cached
26
+ authentication tokens.
27
+ """
24
28
  d = Path(user_config_dir(_APP))
25
- d.mkdir(parents=True, exist_ok=True)
29
+ d.mkdir(parents=True, exist_ok=True, mode=0o700)
26
30
  return d
@@ -1039,7 +1039,7 @@ class IndoorUnitControls(_message.Message):
1039
1039
  f1,f2=absent; f3=ledColorCode(varint/uint32), f4=ledColorBrightnessPercent(float),
1040
1040
  f5=fanSpeedMode(varint), f6=fanSpeedPercent(float), f7=updatedTs(Timestamp),
1041
1041
  f8,f9=absent; f10=louverMode(varint), f11=louverFixedPosition(float),
1042
- f12=lightState(varint), f13=lightAnimation(varint).
1042
+ f12=lightAnimation(varint), f13=lightState(varint).
1043
1043
  NOTE: These are FLAT fields, NOT nested sub-messages.
1044
1044
  """
1045
1045
 
@@ -1066,7 +1066,7 @@ class IndoorUnitControls(_message.Message):
1066
1066
  louver_mode: Global___IndoorUnitLouverMode.ValueType
1067
1067
  """f8, f9: uuid-string field confirmed at f9 in captures; purpose TBD"""
1068
1068
  louver_fixed_position: _builtins.float
1069
- """degrees, used when louver_mode=FIXED"""
1069
+ """position fraction 0.20–1.00 (see LouverAngle.to_wire), used when louver_mode=FIXED; 0.0 = not applicable"""
1070
1070
  led_animation: Global___LightAnimation.ValueType
1071
1071
  """LED_ANIMATION_FIELD_NUMBER=12; wire-confirmed"""
1072
1072
  led_state: Global___LightState.ValueType
@@ -207,12 +207,20 @@ async def authenticate(
207
207
 
208
208
  Token persistence is delegated to *token_store*. Pass ``None`` for
209
209
  purely in-memory/stateless operation (caller handles caching).
210
+
211
+ When *refresh_context* indicates the server rejected the current token
212
+ (transport/stream ``UNAUTHENTICATED``), the locally cached IdToken is
213
+ not trusted even if unexpired — a refresh is forced. Otherwise a
214
+ revoked-but-unexpired token would be returned right back to the caller.
210
215
  """
211
- now = time.time()
212
216
  cached = await _load_tokens(token_store, email) if token_store else None
217
+ server_rejected_token = refresh_context is not None and refresh_context.reason in (
218
+ TokenRefreshReason.TRANSPORT_UNAUTHENTICATED,
219
+ TokenRefreshReason.STREAM_UNAUTHENTICATED,
220
+ )
213
221
 
214
222
  # 1. Valid cached IdToken
215
- if cached is not None and not cached.is_expired:
223
+ if cached is not None and not cached.is_expired and not server_rejected_token:
216
224
  logger.debug("Using cached token")
217
225
  return cached.id_token
218
226
 
@@ -239,10 +247,15 @@ async def authenticate(
239
247
  raise
240
248
  logger.warning("Refresh failed; falling back to OTP")
241
249
  else:
250
+ # Cognito user pools may rotate the refresh token; persist the
251
+ # new one or the stored token dies after first use.
252
+ rotated = result.get("RefreshToken")
242
253
  tokens = CachedTokens(
243
254
  id_token=_require_str(result, "IdToken"),
244
- refresh_token=cached.refresh_token,
245
- expires_at=now + _expires_in_s(result),
255
+ refresh_token=(
256
+ rotated if isinstance(rotated, str) and rotated else cached.refresh_token
257
+ ),
258
+ expires_at=time.time() + _expires_in_s(result),
246
259
  )
247
260
  if token_store:
248
261
  await _save_tokens(token_store, email, tokens)
@@ -260,10 +273,12 @@ async def authenticate(
260
273
  )
261
274
 
262
275
  result = await _do_otp_login(email, otp_callback)
276
+ # Expiry is measured from token receipt — the user may take minutes to
277
+ # enter the OTP code.
263
278
  tokens = CachedTokens(
264
279
  id_token=_require_str(result, "IdToken"),
265
280
  refresh_token=_require_str(result, "RefreshToken") if "RefreshToken" in result else "",
266
- expires_at=now + _expires_in_s(result),
281
+ expires_at=time.time() + _expires_in_s(result),
267
282
  )
268
283
  if token_store:
269
284
  await _save_tokens(token_store, email, tokens)
@@ -0,0 +1,25 @@
1
+ """Shared CLI/TUI constants.
2
+
3
+ Setpoint bounds
4
+ ---------------
5
+ Quilt uses 8.0 °C as the STANDBY heat sentinel (``STANDBY_HEAT_SENTINEL_C``
6
+ in ``quilt_hp.models.space``), which is the lowest temperature the system
7
+ will ever hold. 32 °C is a sensible upper bound for a user-facing comfort
8
+ setpoint (the 40 °C STANDBY cool sentinel is a placeholder, not a real
9
+ setpoint). User-supplied heating/cooling setpoints are validated/clamped to
10
+ this range.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ SETPOINT_MIN_C = 8.0
16
+ SETPOINT_MAX_C = 32.0
17
+
18
+ # Fallbacks used when a space has no current setpoint to step from.
19
+ DEFAULT_HEAT_SETPOINT_C = 20.0
20
+ DEFAULT_COOL_SETPOINT_C = 26.0
21
+
22
+
23
+ def clamp_setpoint_c(value_c: float) -> float:
24
+ """Clamp a setpoint to the supported [SETPOINT_MIN_C, SETPOINT_MAX_C] range."""
25
+ return max(SETPOINT_MIN_C, min(SETPOINT_MAX_C, value_c))
@@ -19,6 +19,7 @@ except ImportError:
19
19
  sys.exit(1)
20
20
 
21
21
  from quilt_hp import __version__
22
+ from quilt_hp.cli.constants import SETPOINT_MAX_C, SETPOINT_MIN_C
22
23
  from quilt_hp.cli.settings import SettingsStore
23
24
  from quilt_hp.cli.store import FileStore
24
25
  from quilt_hp.client import QuiltClient
@@ -39,6 +40,25 @@ class OutputMode(StrEnum):
39
40
  JSON = "json"
40
41
 
41
42
 
43
+ class EnergyPeriod(StrEnum):
44
+ """Reporting period for the energy command."""
45
+
46
+ DAY = "day"
47
+ WEEK = "week"
48
+ MONTH = "month"
49
+
50
+
51
+ # User-settable HVAC modes (FALLBACK_* are device-side fallback states).
52
+ _SETTABLE_MODES = (
53
+ HVACMode.COOL,
54
+ HVACMode.HEAT,
55
+ HVACMode.AUTO,
56
+ HVACMode.FAN,
57
+ HVACMode.DRY,
58
+ HVACMode.STANDBY,
59
+ )
60
+
61
+
42
62
  class _EntityWithId(Protocol):
43
63
  id: str
44
64
 
@@ -415,7 +435,9 @@ def login(
415
435
  console.print(
416
436
  f"[yellow]✉ OTP sent to {challenge_email} — check your email.[/yellow]"
417
437
  )
418
- return cast("str", typer.prompt("Enter OTP code")).strip()
438
+ # typer.prompt blocks on stdin — run it off the event loop.
439
+ code = await asyncio.to_thread(typer.prompt, "Enter OTP code")
440
+ return cast("str", code).strip()
419
441
 
420
442
  await client.login(otp_callback=_prompt_for_otp)
421
443
  console.print("[green]✓ Successfully logged in![/green]")
@@ -647,8 +669,12 @@ def presets(
647
669
  console.print("\n[bold]═══ Comfort Settings ═══[/bold]")
648
670
  for cs in settings:
649
671
  mode = cs.hvac_mode.name
650
- heat = f"{cs.heating_setpoint_c:.1f}°C" if cs.heating_setpoint_c else "--"
651
- cool = f"{cs.cooling_setpoint_c:.1f}°C" if cs.cooling_setpoint_c else "--"
672
+ heat = (
673
+ f"{cs.heating_setpoint_c:.1f}°C" if cs.heating_setpoint_c is not None else "--"
674
+ )
675
+ cool = (
676
+ f"{cs.cooling_setpoint_c:.1f}°C" if cs.cooling_setpoint_c is not None else "--"
677
+ )
652
678
  fan = cs.fan_speed.name
653
679
  console.print(f"\n [cyan]{cs.name}[/cyan] ({cs.type.name})")
654
680
  console.print(f" Mode: {mode} Heat: {heat} Cool: {cool} Fan: {fan}")
@@ -701,7 +727,10 @@ def schedules(
701
727
  def energy(
702
728
  email: str | None = typer.Option(None, envvar="QUILT_EMAIL", help="Quilt account email"),
703
729
  home: str | None = typer.Option(None, help="Specific home name to connect to"),
704
- period: str = typer.Option("day", help="Time period: day, week, month"),
730
+ period: EnergyPeriod = typer.Option( # noqa: B008
731
+ EnergyPeriod.DAY,
732
+ help="Time period: day, week, month",
733
+ ),
705
734
  ) -> None:
706
735
  """Show energy consumption metrics."""
707
736
  email, home = _resolve(email, home)
@@ -716,12 +745,12 @@ def energy(
716
745
  now = datetime.now(tz=zoneinfo.ZoneInfo(snapshot.timezone or "UTC"))
717
746
  start = now.replace(hour=0, minute=0, second=0, microsecond=0)
718
747
 
719
- if period == "day":
748
+ if period == EnergyPeriod.DAY:
720
749
  end = start + timedelta(days=1) - timedelta(seconds=1)
721
- elif period == "week":
750
+ elif period == EnergyPeriod.WEEK:
722
751
  start = start - timedelta(days=start.weekday())
723
752
  end = start + timedelta(weeks=1) - timedelta(seconds=1)
724
- else: # month
753
+ else: # EnergyPeriod.MONTH
725
754
  start = start.replace(day=1)
726
755
  if start.month == 12:
727
756
  end = start.replace(year=start.year + 1, month=1) - timedelta(seconds=1)
@@ -733,7 +762,7 @@ def energy(
733
762
  console.print(f"\n [bold][{period.upper()}][/bold] {header}\n")
734
763
  for sm in metrics:
735
764
  name = name_by_id.get(sm.space_id, sm.space_id[:8])
736
- total = getattr(sm, "total_kwh", 0)
765
+ total = sm.total_kwh
737
766
  if total == 0:
738
767
  continue
739
768
  console.print(f" {name:<22} total={total:.3f} kWh")
@@ -745,14 +774,42 @@ def energy(
745
774
  def set_space(
746
775
  space_name: str = typer.Argument(..., help="Exact name of the room to update"),
747
776
  mode: str | None = typer.Option(None, help="HVAC mode: COOL, HEAT, AUTO, FAN, DRY, STANDBY"),
748
- heat: float | None = typer.Option(None, help="Heating setpoint in °C"),
749
- cool: float | None = typer.Option(None, help="Cooling setpoint in °C"),
777
+ heat: float | None = typer.Option(
778
+ None,
779
+ min=SETPOINT_MIN_C,
780
+ max=SETPOINT_MAX_C,
781
+ help=f"Heating setpoint in °C ({SETPOINT_MIN_C:.0f}–{SETPOINT_MAX_C:.0f})",
782
+ ),
783
+ cool: float | None = typer.Option(
784
+ None,
785
+ min=SETPOINT_MIN_C,
786
+ max=SETPOINT_MAX_C,
787
+ help=f"Cooling setpoint in °C ({SETPOINT_MIN_C:.0f}–{SETPOINT_MAX_C:.0f})",
788
+ ),
750
789
  email: str | None = typer.Option(None, envvar="QUILT_EMAIL", help="Quilt account email"),
751
790
  home: str | None = typer.Option(None, help="Specific home name to connect to"),
752
791
  ) -> None:
753
792
  """Update HVAC mode and setpoints for a room."""
793
+ if mode is None and heat is None and cool is None:
794
+ console.print(
795
+ "[red]Nothing to update:[/red] provide at least one of --mode, --heat, --cool."
796
+ )
797
+ raise typer.Exit(1)
798
+
754
799
  email, home = _resolve(email, home)
755
800
 
801
+ if mode:
802
+ try:
803
+ hvac_mode: HVACMode | None = HVACMode[mode.upper()]
804
+ if hvac_mode not in _SETTABLE_MODES:
805
+ raise KeyError(mode)
806
+ except KeyError:
807
+ valid = ", ".join(m.name.lower() for m in _SETTABLE_MODES)
808
+ console.print(f"[red]Invalid mode {mode!r}. Valid: {valid}[/red]")
809
+ raise typer.Exit(1) from None
810
+ else:
811
+ hvac_mode = None
812
+
756
813
  async def _set() -> None:
757
814
  async with _client_snapshot(email, home) as (client, snap):
758
815
  space = next(
@@ -763,16 +820,6 @@ def set_space(
763
820
  console.print(f"[red]Room {space_name!r} not found.[/red]")
764
821
  raise typer.Exit(1)
765
822
 
766
- if mode:
767
- try:
768
- hvac_mode: HVACMode | None = HVACMode[mode.upper()]
769
- except KeyError:
770
- valid = ", ".join(m.name.lower() for m in HVACMode if m.value)
771
- console.print(f"[red]Invalid mode {mode!r}. Valid: {valid}[/red]")
772
- raise typer.Exit(1) from None
773
- else:
774
- hvac_mode = None
775
-
776
823
  await client.set_space(
777
824
  space.id,
778
825
  mode=hvac_mode,