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.
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/CHANGELOG.md +72 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/PKG-INFO +10 -6
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/README.md +9 -5
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/explanation/streaming-protocol.md +1 -1
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/reference/client.md +3 -3
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/reference/hds-entities.md +2 -2
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/reference/models.md +49 -20
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/reference/token-management.md +5 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/proto/cleaned/quilt_hds.proto +2 -2
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/pyproject.toml +1 -1
- quilt_hp_python-0.5.4/src/quilt_hp/__init__.py +46 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_paths.py +6 -2
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_hds_pb2.pyi +2 -2
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/auth.py +38 -7
- quilt_hp_python-0.5.4/src/quilt_hp/cli/constants.py +25 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/cli/main.py +67 -20
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/cli/store.py +101 -58
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/cli/tui.py +470 -188
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/client.py +91 -40
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/_helpers.py +40 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/comfort.py +11 -1
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/controller.py +56 -32
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/indoor_unit.py +96 -40
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/outdoor_unit.py +23 -26
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/qsm.py +24 -14
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/sensor.py +41 -20
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/space.py +72 -25
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/system.py +102 -26
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/services/__init__.py +8 -1
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/services/hds.py +31 -49
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/services/streaming.py +176 -47
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/services/user.py +17 -17
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/tokens.py +9 -4
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/transport.py +52 -18
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_auth.py +58 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_auth_store_settings_edges.py +47 -4
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_cli_surfaces_extra.py +35 -2
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_client_cache.py +88 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_client_service_error_paths.py +1 -1
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_hds_service_branches.py +2 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_models_from_proto.py +6 -14
- quilt_hp_python-0.5.4/tests/test_models_real_proto_merge.py +352 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_streaming.py +13 -1
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_streaming_reconnect_dispatch_extra.py +156 -1
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_tokens.py +13 -7
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_transport_interceptor_extra.py +84 -8
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_tui_bindings.py +52 -0
- quilt_hp_python-0.5.1/src/quilt_hp/__init__.py +0 -24
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/.github/copilot-instructions.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/.github/workflows/ci.yml +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/.github/workflows/docs-deploy.yml +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/.github/workflows/release.yml +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/.gitignore +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/LICENSE +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/explanation/architecture.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/explanation/authentication.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/explanation/grpc-and-protobuf.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/explanation/snapshot-and-stream.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/authenticate.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/automation-daemon.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/cli-scripting.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/configure-comfort-settings.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/configure-schedules.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/contribute.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/control-spaces.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/home-assistant.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/regenerate-protos.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/stream-updates.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/how-to/tui-app.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/index.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/reference/api-reference.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/reference/documentation-standards.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/reference/grpc-services-matrix.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/docs/tutorial/get-started.md +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/mkdocs.yml +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/proto/cleaned/quilt_device_pairing.proto +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/proto/cleaned/quilt_notifier.proto +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/proto/cleaned/quilt_services.proto +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/proto/cleaned/quilt_system.proto +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/scripts/bump_version.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/scripts/check_docs_nav.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/scripts/generate_public_api_reference.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/scripts/regen_protos.sh +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/__init__.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_device_pairing_pb2.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_device_pairing_pb2.pyi +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_device_pairing_pb2_grpc.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_hds_pb2.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_hds_pb2_grpc.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_notifier_pb2.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_notifier_pb2.pyi +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_notifier_pb2_grpc.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_services_pb2.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_services_pb2.pyi +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_services_pb2_grpc.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_system_pb2.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_system_pb2.pyi +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/_proto/quilt_system_pb2_grpc.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/cli/__init__.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/cli/settings.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/const.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/exceptions.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/__init__.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/energy.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/enums.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/schedule.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/models/software_update.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/py.typed +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/src/quilt_hp/services/system.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/__init__.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/conftest.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_cli_feature_completion.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_cli_login.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_grpc_retry.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_hds_payloads.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_hds_schedule_mapping.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_models.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_models_extra.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_settings_store.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_streaming_concurrency.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_streaming_debounce.py +0 -0
- {quilt_hp_python-0.5.1 → quilt_hp_python-0.5.4}/tests/test_streaming_health.py +0 -0
- {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.
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
async with client.stream(
|
|
108
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
async with client.stream(
|
|
43
|
-
|
|
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.
|
|
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()`
|
|
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
|
|
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
|
-
|
|
296
|
-
|
|
306
|
+
firmware_version: str | None
|
|
307
|
+
firmware_update_info_id: str | None
|
|
308
|
+
performance_data: OutdoorUnitPerformanceData | None
|
|
297
309
|
```
|
|
298
310
|
|
|
299
|
-
#### `
|
|
311
|
+
#### `OutdoorUnitPerformanceData`
|
|
300
312
|
|
|
301
313
|
```python
|
|
302
314
|
@dataclass
|
|
303
|
-
class
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
354
|
+
state_updated_at: datetime | None
|
|
355
|
+
local_comms_health: LocalCommsHealthStatus
|
|
321
356
|
```
|
|
322
357
|
|
|
323
|
-
|
|
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=
|
|
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; //
|
|
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;
|
|
@@ -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=
|
|
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
|
-
"""
|
|
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.
|
|
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=
|
|
229
|
-
|
|
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=
|
|
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))
|