quilt-hp-python 0.2.1__tar.gz → 0.3.0__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 (124) hide show
  1. quilt_hp_python-0.3.0/CHANGELOG.md +78 -0
  2. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/PKG-INFO +1 -1
  3. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/how-to/automation-daemon.md +16 -12
  4. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/how-to/configure-comfort-settings.md +7 -7
  5. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/how-to/configure-schedules.md +32 -20
  6. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/how-to/control-spaces.md +1 -1
  7. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/how-to/stream-updates.md +55 -12
  8. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/how-to/tui-app.md +18 -21
  9. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/reference/client.md +21 -3
  10. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/reference/models.md +186 -58
  11. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/pyproject.toml +1 -1
  12. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/__init__.py +3 -1
  13. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/auth.py +14 -3
  14. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/cli/main.py +61 -27
  15. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/cli/settings.py +2 -1
  16. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/cli/store.py +45 -6
  17. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/cli/tui.py +67 -9
  18. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/client.py +130 -95
  19. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/models/__init__.py +2 -0
  20. quilt_hp_python-0.3.0/src/quilt_hp/models/_helpers.py +31 -0
  21. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/models/controller.py +7 -5
  22. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/models/energy.py +11 -4
  23. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/models/enums.py +11 -0
  24. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/models/indoor_unit.py +3 -1
  25. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/models/outdoor_unit.py +30 -19
  26. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/models/qsm.py +4 -4
  27. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/models/schedule.py +3 -2
  28. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/models/sensor.py +8 -4
  29. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/models/space.py +9 -5
  30. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/models/system.py +39 -2
  31. quilt_hp_python-0.3.0/src/quilt_hp/services/__init__.py +113 -0
  32. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/services/hds.py +41 -3
  33. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/services/streaming.py +284 -84
  34. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/services/system.py +20 -9
  35. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/services/user.py +7 -0
  36. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/transport.py +21 -4
  37. quilt_hp_python-0.3.0/tests/conftest.py +87 -0
  38. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/tests/test_auth_store_settings_edges.py +1 -1
  39. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/tests/test_cli_login.py +2 -1
  40. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/tests/test_cli_surfaces_extra.py +32 -1
  41. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/tests/test_client_service_error_paths.py +35 -0
  42. quilt_hp_python-0.3.0/tests/test_grpc_retry.py +113 -0
  43. quilt_hp_python-0.3.0/tests/test_hds_payloads.py +124 -0
  44. quilt_hp_python-0.3.0/tests/test_models_extra.py +285 -0
  45. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/tests/test_models_from_proto.py +400 -15
  46. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/tests/test_streaming.py +17 -0
  47. quilt_hp_python-0.3.0/tests/test_streaming_concurrency.py +140 -0
  48. quilt_hp_python-0.3.0/tests/test_streaming_debounce.py +99 -0
  49. quilt_hp_python-0.3.0/tests/test_streaming_health.py +127 -0
  50. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/tests/test_streaming_reconnect_dispatch_extra.py +7 -2
  51. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/tests/test_transport_interceptor_extra.py +33 -0
  52. quilt_hp_python-0.3.0/tests/test_tui_bindings.py +70 -0
  53. quilt_hp_python-0.2.1/CHANGELOG.md +0 -48
  54. quilt_hp_python-0.2.1/src/quilt_hp/services/__init__.py +0 -1
  55. quilt_hp_python-0.2.1/tests/conftest.py +0 -3
  56. quilt_hp_python-0.2.1/tests/test_tui_bindings.py +0 -23
  57. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/.github/copilot-instructions.md +0 -0
  58. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/.github/workflows/ci.yml +0 -0
  59. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/.github/workflows/docs-deploy.yml +0 -0
  60. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/.github/workflows/release.yml +0 -0
  61. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/.gitignore +0 -0
  62. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/LICENSE +0 -0
  63. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/README.md +0 -0
  64. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/explanation/architecture.md +0 -0
  65. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/explanation/authentication.md +0 -0
  66. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/explanation/grpc-and-protobuf.md +0 -0
  67. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/explanation/snapshot-and-stream.md +0 -0
  68. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/explanation/streaming-protocol.md +0 -0
  69. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/how-to/authenticate.md +0 -0
  70. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/how-to/cli-scripting.md +0 -0
  71. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/how-to/contribute.md +0 -0
  72. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/how-to/home-assistant.md +0 -0
  73. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/how-to/regenerate-protos.md +0 -0
  74. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/index.md +0 -0
  75. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/reference/api-reference.md +0 -0
  76. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/reference/documentation-standards.md +0 -0
  77. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/reference/grpc-services-matrix.md +0 -0
  78. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/reference/hds-entities.md +0 -0
  79. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/reference/token-management.md +0 -0
  80. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/docs/tutorial/get-started.md +0 -0
  81. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/mkdocs.yml +0 -0
  82. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/proto/cleaned/quilt_device_pairing.proto +0 -0
  83. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/proto/cleaned/quilt_hds.proto +0 -0
  84. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/proto/cleaned/quilt_notifier.proto +0 -0
  85. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/proto/cleaned/quilt_services.proto +0 -0
  86. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/proto/cleaned/quilt_system.proto +0 -0
  87. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/scripts/bump_version.py +0 -0
  88. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/scripts/check_docs_nav.py +0 -0
  89. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/scripts/generate_public_api_reference.py +0 -0
  90. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/scripts/regen_protos.sh +0 -0
  91. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/_paths.py +0 -0
  92. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/_proto/__init__.py +0 -0
  93. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/_proto/quilt_device_pairing_pb2.py +0 -0
  94. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/_proto/quilt_device_pairing_pb2.pyi +0 -0
  95. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/_proto/quilt_device_pairing_pb2_grpc.py +0 -0
  96. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/_proto/quilt_hds_pb2.py +0 -0
  97. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/_proto/quilt_hds_pb2.pyi +0 -0
  98. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/_proto/quilt_hds_pb2_grpc.py +0 -0
  99. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/_proto/quilt_notifier_pb2.py +0 -0
  100. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/_proto/quilt_notifier_pb2.pyi +0 -0
  101. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/_proto/quilt_notifier_pb2_grpc.py +0 -0
  102. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/_proto/quilt_services_pb2.py +0 -0
  103. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/_proto/quilt_services_pb2.pyi +0 -0
  104. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/_proto/quilt_services_pb2_grpc.py +0 -0
  105. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/_proto/quilt_system_pb2.py +0 -0
  106. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/_proto/quilt_system_pb2.pyi +0 -0
  107. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/_proto/quilt_system_pb2_grpc.py +0 -0
  108. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/cli/__init__.py +0 -0
  109. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/const.py +0 -0
  110. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/exceptions.py +0 -0
  111. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/models/comfort.py +0 -0
  112. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/models/software_update.py +0 -0
  113. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/py.typed +0 -0
  114. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/src/quilt_hp/tokens.py +0 -0
  115. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/tests/__init__.py +0 -0
  116. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/tests/test_auth.py +0 -0
  117. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/tests/test_cli_feature_completion.py +0 -0
  118. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/tests/test_client_cache.py +0 -0
  119. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/tests/test_hds_schedule_mapping.py +0 -0
  120. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/tests/test_hds_service_branches.py +0 -0
  121. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/tests/test_models.py +0 -0
  122. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/tests/test_settings_store.py +0 -0
  123. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/tests/test_tokens.py +0 -0
  124. {quilt_hp_python-0.2.1 → quilt_hp_python-0.3.0}/tests/test_transport.py +0 -0
@@ -0,0 +1,78 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.3.0] - 2026-05-12
6
+
7
+ ### Added
8
+ - `NotifierStream` health properties: `is_connected`, `last_event_at`, `stream_state`
9
+ - `NotifierStream` `debounce_s` parameter to coalesce rapid update bursts
10
+ - `MetricBucketStatus` enum exposed on `EnergyBucket.status` (was untyped `int`)
11
+ - `grpc_call()` context manager in `services` for consistent gRPC error translation and optional retry
12
+ - Structured `logging.getLogger(__name__)` across all modules (auth, client, transport, services, CLI)
13
+ - `QuiltStreamError` re-exported from the top-level `quilt_hp` package
14
+ - Shared model helpers (`_helpers.py`) for WiFi signal parsing and hardware lookup
15
+
16
+ ### Changed
17
+ - `ScheduleEvent.hvac_mode` is now typed as `HVACMode` (was `int`)
18
+ - Token temp file created with `os.open(..., 0o600)` so permissions are secure from creation (no transient world-readable window)
19
+ - Signature cache in transport layer uses `weakref.WeakKeyDictionary` instead of `dict[int, bool]` — prevents unbounded growth and id-reuse bugs after GC
20
+ - `login()` clears the token cache so a re-login always fetches fresh credentials
21
+ - `_GrpcCallContext` avoids self-chaining when re-raising a `QuiltError` unchanged
22
+
23
+ ### Fixed
24
+ - `EnergyBucket.has_missing_energy_value` now treats `None` (absent proto field) as missing, not just `NaN`; prevents `TypeError` in `SpaceEnergyMetrics.total_kwh`
25
+ - `MetricBucketStatus()` conversion in `get_energy_metrics` catches `ValueError` for unknown server values and falls back to `UNSPECIFIED` instead of raising
26
+ - `WeakKeyDictionary.get()` for the refresh-callback signature cache is now guarded against `TypeError` for non-weakrefable callables
27
+ - AUTO mode setpoint deadband clamp now runs before setpoint selection, ensuring the correct (clamped) value is sent to the device
28
+ - CLI settings `bool` coercion uses `isinstance(v, bool)` to avoid `bool("false") == True`
29
+ - Zero-value proto3 fields (0 °C temperature, 0 dBm WiFi signal, 0% humidity) are now preserved instead of being dropped as falsy
30
+ - `NotifierStream` reconnect subscription is now protected by an `asyncio.Lock` to prevent concurrent subscribe/reconnect races
31
+ - CLI enum lookups raise a clear error showing valid options on invalid input
32
+ - `auth.py` narrows broad `except Exception` to `except (QuiltAuthError, ClientError)` to avoid swallowing unexpected errors
33
+
34
+ ## [0.2.2] - 2026-05-11
35
+
36
+ ### Fixed
37
+ - Corrected mapping of outdoor units to indoor units in SystemSnapshot
38
+ - Fixed TUI interaction issues with button handling and bindings
39
+
40
+ ## [0.2.1] - 2026-05-10
41
+
42
+ ### Fixed
43
+ - Restored CI/release quality-gate stability by applying required `ruff format`
44
+ updates in model files.
45
+
46
+ ## [0.2.0] - 2026-05-10
47
+
48
+ ## [0.1.4] - 2026-05-08
49
+
50
+ ### Fixed
51
+ - `boto3.client()` was called synchronously inside async functions, causing a
52
+ blocking HTTP request to the EC2 instance metadata service (IMDS) at
53
+ `169.254.169.254` during credential resolution. This manifested as an
54
+ `HTTPClientError` in Home Assistant's async event loop. The client is now
55
+ created via `loop.run_in_executor()` like the subsequent API calls.
56
+
57
+ ## [0.1.3] - 2026-05-08
58
+
59
+ ### Fixed
60
+ - Regenerated gRPC stubs with `grpcio-tools==1.78.0` so the library works
61
+ inside Home Assistant, which hard-pins `grpcio==1.78.0` in its package
62
+ constraints. Previously the stubs were generated with 1.80.0 and raised
63
+ `RuntimeError` at import time on older grpcio versions.
64
+
65
+ ## [0.1.2] - 2026-05-08
66
+
67
+ ## [0.1.1] - 2026-05-08
68
+
69
+ ## [0.1.0]
70
+
71
+ ### Added
72
+ - GitHub Actions release automation for SemVer tags (`vX.Y.Z`) that enforces quality gates, creates a GitHub Release, and publishes distribution artifacts to PyPI via trusted publishing
73
+ - Initial async client for Quilt cloud gRPC API
74
+ - Cognito OTP authentication with token caching
75
+ - HomeDatastoreService: spaces, indoor units, comfort settings, schedules
76
+ - SystemInformationService: system listing, energy metrics
77
+ - NotifierService: real-time streaming subscriptions
78
+ - CLI for interactive use (`quilt` command)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quilt-hp-python
3
- Version: 0.2.1
3
+ Version: 0.3.0
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
@@ -56,19 +56,24 @@ _snapshot: SystemSnapshot | None = None
56
56
  async def on_space_update(space: Space) -> None:
57
57
  global _snapshot
58
58
  if _snapshot is not None:
59
- _snapshot.spaces[space.id] = space
59
+ space = _snapshot.apply_space(space)
60
60
 
61
61
  temp = (
62
- f"{space.state.current_temp_c:.1f}°C"
63
- if space.state.current_temp_c is not None
62
+ f"{space.state.ambient_temperature_c:.1f}°C"
63
+ if space.state.ambient_temperature_c is not None
64
64
  else "unknown"
65
65
  )
66
- LOG.info("[space] %s — mode=%s temp=%s", space.name, space.controls.mode.value, temp)
66
+ LOG.info(
67
+ "[space] %s — mode=%s temp=%s",
68
+ space.name,
69
+ space.controls.hvac_mode.value,
70
+ temp,
71
+ )
67
72
 
68
73
  if (
69
- space.state.current_temp_c is not None
70
- and space.state.current_temp_c > 27.0
71
- and space.controls.mode.value in ("auto", "cool")
74
+ space.state.ambient_temperature_c is not None
75
+ and space.state.ambient_temperature_c > 27.0
76
+ and space.controls.hvac_mode.value in ("auto", "cool")
72
77
  ):
73
78
  LOG.warning("[space] %s is above 27°C — check cooling", space.name)
74
79
 
@@ -76,8 +81,8 @@ async def on_space_update(space: Space) -> None:
76
81
  async def on_idu_update(idu: IndoorUnit) -> None:
77
82
  global _snapshot
78
83
  if _snapshot is not None:
79
- _snapshot.indoor_units[idu.id] = idu
80
- LOG.debug("[idu] %s — fan=%s online=%s", idu.id, idu.controls.fan_speed.value, idu.state.is_online)
84
+ idu = _snapshot.apply_indoor_unit(idu)
85
+ LOG.debug("[idu] %s — fan=%s online=%s", idu.id, idu.controls.fan_speed.value, idu.is_online)
81
86
 
82
87
 
83
88
  async def run() -> None:
@@ -91,7 +96,7 @@ async def run() -> None:
91
96
  _snapshot = await client.get_snapshot()
92
97
  LOG.info(
93
98
  "Snapshot loaded: system=%s rooms=%d idus=%d",
94
- _snapshot.system_id,
99
+ client.system_name,
95
100
  len(_snapshot.rooms),
96
101
  len(_snapshot.indoor_units),
97
102
  )
@@ -100,8 +105,7 @@ async def run() -> None:
100
105
  stream = client.stream(topics, max_reconnects=-1, reconnect_delay_s=2.0)
101
106
  stream.on_space_update(on_space_update)
102
107
  stream.on_indoor_unit_update(on_idu_update)
103
- stream.on_connected(lambda: LOG.info("Stream connected"))
104
- stream.on_disconnected(lambda: LOG.warning("Stream disconnected; will reconnect automatically"))
108
+ stream.on_error(lambda exc: LOG.error("Stream stopped: %s", exc))
105
109
 
106
110
  async with stream:
107
111
  LOG.info("Daemon running. Send SIGINT or SIGTERM to stop.")
@@ -11,14 +11,14 @@ To retrieve all comfort presets:
11
11
  ```python
12
12
  settings = await client.list_comfort_settings()
13
13
  for s in settings:
14
- print(f"{s.name}: mode={s.hvac_mode}, heat={s.heat_setpoint_c}°C, cool={s.cool_setpoint_c}°C, fan={s.fan_speed}")
14
+ print(f"{s.name}: mode={s.hvac_mode}, heat={s.heating_setpoint_c}°C, cool={s.cooling_setpoint_c}°C, fan={s.fan_speed}")
15
15
  ```
16
16
 
17
17
  Alternatively, comfort settings are embedded in `SystemSnapshot`:
18
18
 
19
19
  ```python
20
20
  snapshot = await client.get_snapshot()
21
- for cs in snapshot.comfort_settings.values():
21
+ for cs in snapshot.comfort_settings:
22
22
  print(f"{cs.name}: {cs.hvac_mode}")
23
23
  ```
24
24
 
@@ -40,7 +40,7 @@ updated = await client.update_comfort_setting(
40
40
  cool_setpoint_c=25.0,
41
41
  fan_speed=FanSpeed.AUTO,
42
42
  )
43
- print(f"Updated '{updated.name}': heat={updated.heat_setpoint_c}°C cool={updated.cool_setpoint_c}°C")
43
+ print(f"Updated '{updated.name}': heat={updated.heating_setpoint_c}°C cool={updated.cooling_setpoint_c}°C")
44
44
  ```
45
45
 
46
46
  Omit any parameter to keep its current value. You can also update by comfort setting ID string:
@@ -73,11 +73,11 @@ async def main() -> None:
73
73
  snapshot = await client.get_snapshot()
74
74
 
75
75
  preset = next(
76
- (cs for cs in snapshot.comfort_settings.values() if cs.name == PRESET_NAME),
76
+ (cs for cs in snapshot.comfort_settings if cs.name == PRESET_NAME),
77
77
  None,
78
78
  )
79
79
  if preset is None:
80
- names = [cs.name for cs in snapshot.comfort_settings.values()]
80
+ names = [cs.name for cs in snapshot.comfort_settings]
81
81
  print(f"Preset '{PRESET_NAME}' not found. Available: {names}")
82
82
  return
83
83
 
@@ -85,8 +85,8 @@ async def main() -> None:
85
85
  updated = await client.set_space(
86
86
  space,
87
87
  mode=preset.hvac_mode,
88
- heat_setpoint_c=preset.heat_setpoint_c,
89
- cool_setpoint_c=preset.cool_setpoint_c,
88
+ heat_setpoint_c=preset.heating_setpoint_c,
89
+ cool_setpoint_c=preset.cooling_setpoint_c,
90
90
  )
91
91
  print(f" {updated.name}: mode={updated.controls.hvac_mode}")
92
92
 
@@ -13,19 +13,31 @@ To create a day program with timed comfort-setting transitions:
13
13
  ```python
14
14
  from quilt_hp.models.schedule import ScheduleEvent
15
15
 
16
- # Get a comfort setting ID from the snapshot
16
+ # Get comfort settings for one room from the snapshot
17
17
  snapshot = await client.get_snapshot()
18
18
  space = snapshot.space_by_name("Bedroom")
19
- active_cs = next(
20
- cs for cs in snapshot.comfort_settings.values() if cs.name == "Active"
21
- )
22
- sleep_cs = next(
23
- cs for cs in snapshot.comfort_settings.values() if cs.name == "Sleep"
24
- )
19
+ assert space is not None
20
+ space_settings = snapshot.comfort_settings_for_space(space)
21
+ active_cs = next(cs for cs in space_settings if cs.name == "Active")
22
+ sleep_cs = next(cs for cs in space_settings if cs.name == "Sleep")
25
23
 
26
24
  events = [
27
- ScheduleEvent(time_of_day_s=7 * 3600, comfort_setting_id=active_cs.id), # 07:00 → Active
28
- ScheduleEvent(time_of_day_s=22 * 3600, comfort_setting_id=sleep_cs.id), # 22:00 → Sleep
25
+ ScheduleEvent(
26
+ start_s=7 * 3600,
27
+ comfort_setting_id=active_cs.id,
28
+ hvac_mode=active_cs.hvac_mode,
29
+ heating_setpoint_c=active_cs.heating_setpoint_c,
30
+ cooling_setpoint_c=active_cs.cooling_setpoint_c,
31
+ precondition=False,
32
+ ),
33
+ ScheduleEvent(
34
+ start_s=22 * 3600,
35
+ comfort_setting_id=sleep_cs.id,
36
+ hvac_mode=sleep_cs.hvac_mode,
37
+ heating_setpoint_c=sleep_cs.heating_setpoint_c,
38
+ cooling_setpoint_c=sleep_cs.cooling_setpoint_c,
39
+ precondition=False,
40
+ ),
29
41
  ]
30
42
 
31
43
  day = await client.create_schedule_day(
@@ -36,7 +48,7 @@ day = await client.create_schedule_day(
36
48
  print(f"Created schedule day: {day.id} ({len(day.events)} events)")
37
49
  ```
38
50
 
39
- `time_of_day_s` is the number of seconds from midnight (e.g., `7 * 3600` = 07:00).
51
+ `start_s` is the number of seconds from midnight (e.g., `7 * 3600` = 07:00).
40
52
 
41
53
  ---
42
54
 
@@ -47,17 +59,17 @@ To create a schedule week and assign day programs to each weekday:
47
59
  ```python
48
60
  from quilt_hp.models.schedule import ScheduleWeekDay
49
61
 
50
- # day_of_week: 0 = Monday, 6 = Sunday
62
+ # weekday: 1 = Monday, 7 = Sunday
51
63
  week = await client.create_schedule_week(
52
64
  space_id=space.id,
53
65
  days=[
54
- ScheduleWeekDay(day_of_week=0, schedule_day_id=weekday_program.id), # Mon
55
- ScheduleWeekDay(day_of_week=1, schedule_day_id=weekday_program.id), # Tue
56
- ScheduleWeekDay(day_of_week=2, schedule_day_id=weekday_program.id), # Wed
57
- ScheduleWeekDay(day_of_week=3, schedule_day_id=weekday_program.id), # Thu
58
- ScheduleWeekDay(day_of_week=4, schedule_day_id=weekday_program.id), # Fri
59
- ScheduleWeekDay(day_of_week=5, schedule_day_id=weekend_program.id), # Sat
60
- ScheduleWeekDay(day_of_week=6, schedule_day_id=weekend_program.id), # Sun
66
+ ScheduleWeekDay(weekday=1, day_id=weekday_program.id), # Mon
67
+ ScheduleWeekDay(weekday=2, day_id=weekday_program.id), # Tue
68
+ ScheduleWeekDay(weekday=3, day_id=weekday_program.id), # Wed
69
+ ScheduleWeekDay(weekday=4, day_id=weekday_program.id), # Thu
70
+ ScheduleWeekDay(weekday=5, day_id=weekday_program.id), # Fri
71
+ ScheduleWeekDay(weekday=6, day_id=weekend_program.id), # Sat
72
+ ScheduleWeekDay(weekday=7, day_id=weekend_program.id), # Sun
61
73
  ],
62
74
  )
63
75
  print(f"Created schedule week: {week.id}")
@@ -74,7 +86,7 @@ updated_week = await client.update_schedule_week(
74
86
  schedule_week_id=week.id,
75
87
  space_id=space.id,
76
88
  days=[
77
- ScheduleWeekDay(day_of_week=0, schedule_day_id=new_monday_program.id),
89
+ ScheduleWeekDay(weekday=1, day_id=new_monday_program.id),
78
90
  # ... include all 7 days; omitted days are cleared
79
91
  ],
80
92
  )
@@ -114,4 +126,4 @@ To resume:
114
126
  await client.set_schedule_execution(paused=False)
115
127
  ```
116
128
 
117
- This is a global switch. It affects all schedule weeks across all spaces in the system. The current pause state is available as `snapshot.schedule_paused`.
129
+ This is a global switch. It affects all schedule weeks across all spaces in the system. The current pause state is available as `snapshot.primary_location.schedule_paused` when a location is present.
@@ -88,7 +88,7 @@ To set the fan speed on an indoor unit:
88
88
  from quilt_hp.models.enums import FanSpeed
89
89
 
90
90
  snapshot = await client.get_snapshot()
91
- idu = snapshot.indoor_units[next(iter(snapshot.indoor_units))] # first IDU
91
+ idu = snapshot.indoor_units[0] # first IDU
92
92
 
93
93
  updated = await client.set_indoor_unit(idu, fan_speed=FanSpeed.MEDIUM)
94
94
  print(f"Fan speed: {updated.controls.fan_speed}")
@@ -23,7 +23,13 @@ def on_idu(idu: IndoorUnit) -> None:
23
23
 
24
24
  async with client.stream(snapshot.stream_topics()) as stream:
25
25
  stream.on_space_update(on_space)
26
- stream.on_indoor_unit_update(on_idu)
26
+ stream.on_indoor_unit_update(lambda idu: print(snapshot.apply_indoor_unit(idu).id))
27
+ stream.on_outdoor_unit_update(snapshot.apply_outdoor_unit)
28
+ stream.on_controller_update(snapshot.apply_controller)
29
+ stream.on_qsm_update(snapshot.apply_qsm)
30
+ stream.on_remote_sensor_update(snapshot.apply_remote_sensor)
31
+ stream.on_controller_remote_sensor_update(snapshot.apply_controller_remote_sensor)
32
+ stream.on_software_update_info(lambda info: print(f"Update info: {info.id}"))
27
33
  stream.on_error(lambda e: print(f"Fatal error: {e}"))
28
34
  await asyncio.sleep(3600) # run for 1 hour
29
35
  ```
@@ -54,24 +60,59 @@ For indoor units:
54
60
  ```python
55
61
  def on_idu(idu: IndoorUnit) -> None:
56
62
  merged = snapshot.apply_indoor_unit(idu)
57
- print(f"{merged.id}: online={merged.state.is_online}")
63
+ print(f"{merged.id}: online={merged.is_online}")
58
64
  ```
59
65
 
60
66
  For background on why sparse diffs require merging, see [Snapshot and stream data model](../explanation/snapshot-and-stream.md).
61
67
 
62
68
  ---
63
69
 
64
- ## Run the stream as a background task
70
+ ## Callback registration methods
71
+
72
+ `NotifierStream` accepts both synchronous and async callbacks. Register whichever entity types you care about:
73
+
74
+ | Method | Callback argument | Typical use |
75
+ | --- | --- | --- |
76
+ | `on_space_update()` | `Space` | Merge room diffs with `snapshot.apply_space()` |
77
+ | `on_indoor_unit_update()` | `IndoorUnit` | Merge IDU diffs with `snapshot.apply_indoor_unit()` |
78
+ | `on_outdoor_unit_update()` | `OutdoorUnit` | Merge ODU diffs with `snapshot.apply_outdoor_unit()` |
79
+ | `on_controller_update()` | `Controller` | Merge Dial diffs with `snapshot.apply_controller()` |
80
+ | `on_qsm_update()` | `QuiltSmartModule` | Merge QSM diffs with `snapshot.apply_qsm()` |
81
+ | `on_remote_sensor_update()` | `RemoteSensor` | Merge standalone sensor diffs with `snapshot.apply_remote_sensor()` |
82
+ | `on_controller_remote_sensor_update()` | `ControllerRemoteSensor` | Merge Dial sensor diffs with `snapshot.apply_controller_remote_sensor()` |
83
+ | `on_software_update_info()` | `SoftwareUpdateInfo` | Observe firmware/software update records |
84
+ | `on_error()` | `Exception` | Handle fatal stream failure after reconnects are exhausted |
85
+
86
+ ---
87
+
88
+ ## Lifecycle methods
89
+
90
+ Use these methods to control the stream explicitly:
91
+
92
+ | Method / property | What it does |
93
+ | --- | --- |
94
+ | `await stream.start()` | Starts the listener in the background |
95
+ | `await stream.run_forever()` | Runs inline until cancelled or a fatal error stops it |
96
+ | `await stream.stop()` | Cancels the background task and closes the stream |
97
+ | `await stream.subscribe(topics)` | Adds topic subscriptions after startup |
98
+ | `await stream.unsubscribe(topics)` | Removes topic subscriptions |
99
+ | `stream.error` | Last fatal exception, or `None` while healthy |
100
+
101
+ ### Run the stream as a background task
65
102
 
66
103
  To run the stream while doing other work concurrently:
67
104
 
68
105
  ```python
69
- async with client.stream(snapshot.stream_topics()) as stream:
70
- stream.on_space_update(on_space)
71
- # Stream runs in the background — do other work here
106
+ stream = client.stream(snapshot.stream_topics())
107
+ stream.on_space_update(on_space)
108
+ await stream.start()
109
+ try:
72
110
  result = await do_something_else()
73
111
  await asyncio.sleep(3600)
74
- # Stream is stopped when the async with block exits
112
+ finally:
113
+ await stream.stop()
114
+ if stream.error is not None:
115
+ print(f"Stream stopped with error: {stream.error}")
75
116
  ```
76
117
 
77
118
  Use this pattern in integrations (Home Assistant, automation daemons) where the stream is just one part of a larger async application.
@@ -115,7 +156,7 @@ async with client.stream(snapshot.stream_topics()) as stream:
115
156
 
116
157
  ## Handle stream errors and reconnect
117
158
 
118
- The stream reconnects automatically with exponential back-off (1 s, 2 s, 4 s, … up to a 60 s cap). Use these options to configure the reconnect budget:
159
+ The stream reconnects automatically with exponential back-off (1 s, 2 s, 4 s, … up to a 60 s cap). Use `on_error()` or the `error` property to observe only fatal failures after the reconnect budget is exhausted. Configure the reconnect budget like this:
119
160
 
120
161
  ```python
121
162
  # Unlimited reconnects (default: -1)
@@ -132,14 +173,16 @@ stream = client.stream(
132
173
  )
133
174
  ```
134
175
 
135
- To observe connection lifecycle events:
176
+ To observe fatal stream failures:
136
177
 
137
178
  ```python
138
- stream.on_connected(lambda: print("Stream connected"))
139
- stream.on_disconnected(lambda: print("Stream disconnected; will reconnect"))
140
179
  stream.on_error(lambda e: print(f"Fatal error (budget exhausted): {e}"))
180
+
181
+ await stream.run_forever()
182
+ if stream.error is not None:
183
+ print(f"Last fatal error: {stream.error}")
141
184
  ```
142
185
 
143
- `on_error` is called only when the reconnect budget is exhausted. Until then, disconnects and errors trigger automatic reconnection without invoking `on_error`.
186
+ `on_error()` is called only when the reconnect budget is exhausted. Until then, disconnects and transient errors trigger automatic reconnection without surfacing a fatal error to your callback.
144
187
 
145
188
  For the full reconnect state machine, see [The streaming protocol](../explanation/streaming-protocol.md).
@@ -64,12 +64,10 @@ class IDUUpdate(Message):
64
64
  self.idu = idu
65
65
 
66
66
 
67
- class StreamConnected(Message):
68
- pass
69
-
70
-
71
- class StreamDisconnected(Message):
72
- pass
67
+ class StreamError(Message):
68
+ def __init__(self, error: Exception) -> None:
69
+ super().__init__()
70
+ self.error = error
73
71
 
74
72
 
75
73
  class QuiltApp(App):
@@ -99,6 +97,7 @@ class QuiltApp(App):
99
97
 
100
98
  self._snapshot = await self._client.get_snapshot()
101
99
  self._refresh_table()
100
+ self.query_one("#status-bar", Label).update("● Streaming")
102
101
 
103
102
  self.run_worker(self._run_stream(), exclusive=True)
104
103
 
@@ -110,30 +109,26 @@ class QuiltApp(App):
110
109
  stream = self._client.stream(topics, max_reconnects=-1)
111
110
  stream.on_space_update(self._on_space)
112
111
  stream.on_indoor_unit_update(self._on_idu)
113
- stream.on_connected(lambda: self.post_message(StreamConnected()))
114
- stream.on_disconnected(lambda: self.post_message(StreamDisconnected()))
112
+ stream.on_error(lambda exc: self.post_message(StreamError(exc)))
115
113
 
116
114
  async with stream:
117
115
  await asyncio.Event().wait()
118
116
 
119
117
  def _on_space(self, space: Space) -> None:
120
118
  if self._snapshot is not None:
121
- self._snapshot.spaces[space.id] = space
119
+ space = self._snapshot.apply_space(space)
122
120
  self.post_message(SpaceUpdate(space))
123
121
 
124
122
  def _on_idu(self, idu: IndoorUnit) -> None:
125
123
  if self._snapshot is not None:
126
- self._snapshot.indoor_units[idu.id] = idu
124
+ idu = self._snapshot.apply_indoor_unit(idu)
127
125
  self.post_message(IDUUpdate(idu))
128
126
 
129
127
  def on_space_update(self, msg: SpaceUpdate) -> None:
130
128
  self._update_row(msg.space)
131
129
 
132
- def on_stream_connected(self, msg: StreamConnected) -> None:
133
- self.query_one("#status-bar", Label).update(" Connected")
134
-
135
- def on_stream_disconnected(self, msg: StreamDisconnected) -> None:
136
- self.query_one("#status-bar", Label).update("○ Disconnected — reconnecting…")
130
+ def on_stream_error(self, msg: StreamError) -> None:
131
+ self.query_one("#status-bar", Label).update(f" Stream stopped: {msg.error}")
137
132
 
138
133
  def _refresh_table(self) -> None:
139
134
  if self._snapshot is None:
@@ -144,15 +139,15 @@ class QuiltApp(App):
144
139
  def _update_row(self, space: Space) -> None:
145
140
  table = self.query_one("#spaces-table", DataTable)
146
141
  temp = (
147
- f"{space.state.current_temp_c:.1f}°C"
148
- if space.state.current_temp_c is not None
142
+ f"{space.state.ambient_temperature_c:.1f}°C"
143
+ if space.state.ambient_temperature_c is not None
149
144
  else "—"
150
145
  )
151
146
  setpoints = (
152
- f"{space.controls.heat_setpoint_c:.0f} / "
153
- f"{space.controls.cool_setpoint_c:.0f}°C"
147
+ f"{space.controls.heating_setpoint_c:.0f} / "
148
+ f"{space.controls.cooling_setpoint_c:.0f}°C"
154
149
  )
155
- row = (space.name, space.controls.mode.value, temp, setpoints)
150
+ row = (space.name, space.controls.hvac_mode.value, temp, setpoints)
156
151
 
157
152
  key = f"space-{space.id}"
158
153
  if key in table.rows:
@@ -203,8 +198,10 @@ async def action_set_mode(self) -> None:
203
198
  row_key = table.cursor_row_key
204
199
  if row_key is None:
205
200
  return
201
+ if self._snapshot is None:
202
+ return
206
203
  space_id = row_key.removeprefix("space-")
207
- space = self._snapshot.spaces.get(space_id)
204
+ space = next((s for s in self._snapshot.spaces if s.id == space_id), None)
208
205
  if space is None:
209
206
  return
210
207
 
@@ -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.2.1"
72
+ __version__: str # e.g. "0.3.0"
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__` closes the gRPC channel. Always use `QuiltClient` as an async context manager.
115
+ `__aexit__` calls `close()` to close the gRPC channel. Prefer the async context manager, or call `await close()` yourself when managing lifecycle manually.
116
116
 
117
117
  ---
118
118
 
@@ -143,6 +143,16 @@ async def refresh_token(self, context: TokenRefreshContext | None = None) -> Non
143
143
 
144
144
  Silently refreshes the auth token using the refresh token. Does not attempt OTP. Called automatically by the transport interceptor on `UNAUTHENTICATED`; rarely needed directly.
145
145
 
146
+ ### `get_current_token`
147
+
148
+ ```python
149
+ def get_current_token(self) -> str
150
+ ```
151
+
152
+ Returns the current JWT access token held by the client.
153
+
154
+ **Raises:** `QuiltAuthError` if the client is not authenticated yet.
155
+
146
156
  ---
147
157
 
148
158
  ## System discovery
@@ -155,7 +165,7 @@ async def list_systems(self) -> list[SystemInfo]
155
165
 
156
166
  Lists all Quilt systems the authenticated user has access to.
157
167
 
158
- **Returns:** List of `SystemInfo` objects with `id`, `name`, `timezone`, `location_id`.
168
+ **Returns:** List of `SystemInfo` objects with `id`, `name`, and `timezone`.
159
169
 
160
170
  **Raises:** `QuiltError` if the gRPC call fails.
161
171
 
@@ -205,6 +215,14 @@ def invalidate_snapshot(self) -> None
205
215
 
206
216
  Discards the cached snapshot. The next `get_snapshot()` call fetches fresh data from the server.
207
217
 
218
+ ### `close`
219
+
220
+ ```python
221
+ async def close(self) -> None
222
+ ```
223
+
224
+ Closes the underlying gRPC channel and clears the client's open channel reference. Safe to call multiple times.
225
+
208
226
  ---
209
227
 
210
228
  ## Space control