quilt-hp-python 0.5.1__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.1 → quilt_hp_python-0.5.4}/CHANGELOG.md +72 -0
  2. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/PKG-INFO +10 -6
  3. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/README.md +9 -5
  4. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/explanation/streaming-protocol.md +1 -1
  5. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/reference/client.md +3 -3
  6. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/reference/hds-entities.md +2 -2
  7. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/reference/models.md +49 -20
  8. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/reference/token-management.md +5 -0
  9. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/proto/cleaned/quilt_hds.proto +2 -2
  10. {quilt_hp_python-0.5.1 → 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.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_paths.py +6 -2
  13. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_hds_pb2.pyi +2 -2
  14. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/auth.py +38 -7
  15. quilt_hp_python-0.5.4/src/quilt_hp/cli/constants.py +25 -0
  16. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/cli/main.py +67 -20
  17. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/cli/store.py +101 -58
  18. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/cli/tui.py +470 -188
  19. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/client.py +91 -40
  20. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/_helpers.py +40 -0
  21. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/comfort.py +11 -1
  22. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/controller.py +56 -32
  23. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/indoor_unit.py +96 -40
  24. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/outdoor_unit.py +23 -26
  25. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/qsm.py +24 -14
  26. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/sensor.py +41 -20
  27. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/space.py +72 -25
  28. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/system.py +102 -26
  29. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/services/__init__.py +8 -1
  30. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/services/hds.py +31 -49
  31. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/services/streaming.py +176 -47
  32. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/services/user.py +17 -17
  33. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/tokens.py +9 -4
  34. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/transport.py +52 -18
  35. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_auth.py +58 -0
  36. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_auth_store_settings_edges.py +47 -4
  37. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_cli_surfaces_extra.py +35 -2
  38. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_client_cache.py +88 -0
  39. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_client_service_error_paths.py +1 -1
  40. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_hds_service_branches.py +2 -0
  41. {quilt_hp_python-0.5.1 → 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.1 → quilt_hp_python-0.5.4}/tests/test_streaming.py +13 -1
  44. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_streaming_reconnect_dispatch_extra.py +156 -1
  45. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_tokens.py +13 -7
  46. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_transport_interceptor_extra.py +84 -8
  47. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_tui_bindings.py +52 -0
  48. quilt_hp_python-0.5.1/src/quilt_hp/__init__.py +0 -24
  49. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/.github/copilot-instructions.md +0 -0
  50. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/.github/workflows/ci.yml +0 -0
  51. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/.github/workflows/docs-deploy.yml +0 -0
  52. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/.github/workflows/release.yml +0 -0
  53. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/.gitignore +0 -0
  54. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/LICENSE +0 -0
  55. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/explanation/architecture.md +0 -0
  56. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/explanation/authentication.md +0 -0
  57. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/explanation/grpc-and-protobuf.md +0 -0
  58. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/explanation/snapshot-and-stream.md +0 -0
  59. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/authenticate.md +0 -0
  60. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/automation-daemon.md +0 -0
  61. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/cli-scripting.md +0 -0
  62. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/configure-comfort-settings.md +0 -0
  63. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/configure-schedules.md +0 -0
  64. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/contribute.md +0 -0
  65. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/control-spaces.md +0 -0
  66. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/home-assistant.md +0 -0
  67. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/regenerate-protos.md +0 -0
  68. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/stream-updates.md +0 -0
  69. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/tui-app.md +0 -0
  70. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/index.md +0 -0
  71. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/reference/api-reference.md +0 -0
  72. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/reference/documentation-standards.md +0 -0
  73. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/reference/grpc-services-matrix.md +0 -0
  74. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/tutorial/get-started.md +0 -0
  75. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/mkdocs.yml +0 -0
  76. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/proto/cleaned/quilt_device_pairing.proto +0 -0
  77. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/proto/cleaned/quilt_notifier.proto +0 -0
  78. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/proto/cleaned/quilt_services.proto +0 -0
  79. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/proto/cleaned/quilt_system.proto +0 -0
  80. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/scripts/bump_version.py +0 -0
  81. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/scripts/check_docs_nav.py +0 -0
  82. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/scripts/generate_public_api_reference.py +0 -0
  83. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/scripts/regen_protos.sh +0 -0
  84. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/__init__.py +0 -0
  85. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_device_pairing_pb2.py +0 -0
  86. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_device_pairing_pb2.pyi +0 -0
  87. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_device_pairing_pb2_grpc.py +0 -0
  88. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_hds_pb2.py +0 -0
  89. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_hds_pb2_grpc.py +0 -0
  90. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_notifier_pb2.py +0 -0
  91. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_notifier_pb2.pyi +0 -0
  92. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_notifier_pb2_grpc.py +0 -0
  93. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_services_pb2.py +0 -0
  94. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_services_pb2.pyi +0 -0
  95. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_services_pb2_grpc.py +0 -0
  96. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_system_pb2.py +0 -0
  97. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_system_pb2.pyi +0 -0
  98. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_system_pb2_grpc.py +0 -0
  99. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/cli/__init__.py +0 -0
  100. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/cli/settings.py +0 -0
  101. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/const.py +0 -0
  102. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/exceptions.py +0 -0
  103. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/__init__.py +0 -0
  104. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/energy.py +0 -0
  105. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/enums.py +0 -0
  106. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/schedule.py +0 -0
  107. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/software_update.py +0 -0
  108. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/py.typed +0 -0
  109. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/services/system.py +0 -0
  110. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/__init__.py +0 -0
  111. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/conftest.py +0 -0
  112. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_cli_feature_completion.py +0 -0
  113. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_cli_login.py +0 -0
  114. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_grpc_retry.py +0 -0
  115. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_hds_payloads.py +0 -0
  116. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_hds_schedule_mapping.py +0 -0
  117. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_models.py +0 -0
  118. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_models_extra.py +0 -0
  119. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_settings_store.py +0 -0
  120. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_streaming_concurrency.py +0 -0
  121. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_streaming_debounce.py +0 -0
  122. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_streaming_health.py +0 -0
  123. {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_transport.py +0 -0
@@ -2,6 +2,78 @@
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
+
65
+ ## [0.5.3] - 2026-06-30
66
+
67
+ ## [0.5.2] - 2026-06-30
68
+
69
+ ### Fixed
70
+ - `_make_cognito_client()` now creates the boto3 client with EC2 instance
71
+ metadata (IMDS) credential discovery disabled and explicit connect/read
72
+ timeouts. On non-EC2 hosts (e.g., Home Assistant Yellow) the IMDS endpoint
73
+ at 169.254.169.254 may accept TCP connections but never respond, causing the
74
+ calling thread to block for the full OS-level TCP timeout — well beyond the
75
+ 20-second setup window — and triggering a spurious `ConfigEntryNotReady`.
76
+
5
77
  ## [0.5.1] - 2026-06-30
6
78
 
7
79
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quilt-hp-python
3
- Version: 0.5.1
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.1"
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.1"
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
@@ -15,6 +15,8 @@ from functools import partial
15
15
  from typing import Protocol, cast
16
16
 
17
17
  import boto3
18
+ import botocore.session
19
+ from botocore.config import Config
18
20
  from botocore.exceptions import ClientError
19
21
 
20
22
  from quilt_hp.const import COGNITO_CLIENT_ID, COGNITO_REGION
@@ -66,10 +68,24 @@ def _expires_in_s(result: CognitoAuthResult) -> int:
66
68
 
67
69
 
68
70
  def _make_cognito_client() -> _CognitoClient:
69
- """Create a boto3 Cognito Identity Provider client."""
71
+ """Create a boto3 Cognito Identity Provider client.
72
+
73
+ Creates a botocore session with IMDS credential discovery disabled.
74
+ The EC2 instance metadata service (169.254.169.254) can hang on
75
+ non-EC2 hosts that accept the TCP connection but never respond,
76
+ blocking the thread for the full OS-level TCP timeout. Cognito
77
+ user-pool auth flows (CUSTOM_AUTH, REFRESH_TOKEN_AUTH) don't
78
+ require AWS IAM credentials, so skipping IMDS is safe here.
79
+ """
80
+ session = botocore.session.get_session()
81
+ session.set_config_variable("metadata_service_num_attempts", 0)
70
82
  return cast(
71
83
  "_CognitoClient",
72
- boto3.client("cognito-idp", region_name=COGNITO_REGION),
84
+ boto3.Session(botocore_session=session).client(
85
+ "cognito-idp",
86
+ region_name=COGNITO_REGION,
87
+ config=Config(connect_timeout=5, read_timeout=15),
88
+ ),
73
89
  )
74
90
 
75
91
 
@@ -191,12 +207,20 @@ async def authenticate(
191
207
 
192
208
  Token persistence is delegated to *token_store*. Pass ``None`` for
193
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.
194
215
  """
195
- now = time.time()
196
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
+ )
197
221
 
198
222
  # 1. Valid cached IdToken
199
- 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:
200
224
  logger.debug("Using cached token")
201
225
  return cached.id_token
202
226
 
@@ -223,10 +247,15 @@ async def authenticate(
223
247
  raise
224
248
  logger.warning("Refresh failed; falling back to OTP")
225
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")
226
253
  tokens = CachedTokens(
227
254
  id_token=_require_str(result, "IdToken"),
228
- refresh_token=cached.refresh_token,
229
- 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),
230
259
  )
231
260
  if token_store:
232
261
  await _save_tokens(token_store, email, tokens)
@@ -244,10 +273,12 @@ async def authenticate(
244
273
  )
245
274
 
246
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.
247
278
  tokens = CachedTokens(
248
279
  id_token=_require_str(result, "IdToken"),
249
280
  refresh_token=_require_str(result, "RefreshToken") if "RefreshToken" in result else "",
250
- expires_at=now + _expires_in_s(result),
281
+ expires_at=time.time() + _expires_in_s(result),
251
282
  )
252
283
  if token_store:
253
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))