Skip to content
Merged
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,64 @@

## [Unreleased]

Fixes all findings from a full architecture/code/performance/bug-hunt evaluation.

### Critical

- **Proto3 absence detection never fired on real protos.** Truthiness and
getattr-with-default checks always pass on generated messages (unset
sub-messages return truthy default instances), so sparse stream diffs zeroed
room state, IDU controls, sensor readings, and controller temperatures on
merge. All model parsing now uses `HasField`-based helpers, and
`SystemSnapshot.apply_*` preserves identity/relationship fields, controller
temperatures, ODU telemetry, and QSM LED state. New test suite
(`tests/test_models_real_proto_merge.py`) reproduces the notifier stream's
serialize/parse path with real generated protos.
- **Transport UNAUTHENTICATED retry was dead code.** In `grpc.aio`, awaiting the
interceptor continuation returns the call object; `AioRpcError` only surfaces
when the call is awaited — so token refresh/retry never executed and clients
broke permanently ~1h after token expiry. The interceptor now awaits the call
and retries once. Tests updated to model real `grpc.aio` semantics.

### High

- Stream reconnect backoff/budget reset after a healthy connection (routine LB
stream recycling no longer escalates to permanent 60s delays or kills the
stream after N lifetime disconnects); non-gRPC failures reconnect and surface
via `on_error` instead of dying silently; malformed events are skipped;
jitter added; prompt reconnect after successful token refresh.
- `authenticate()` no longer returns a locally-unexpired token the server just
rejected; rotated Cognito refresh tokens are persisted; expiry measured from
token receipt.
- `grpc_call` passes `CancelledError` through untranslated (asyncio.timeout/
cancellation semantics restored).
- TUI: fatal stream death now shows a disconnect indicator and auto-recovers;
first-time users get an OTP modal; boot failures show an error screen
instead of a stuck spinner.

### Medium / Low

- Unified error translation: all hds/user RPCs route through `grpc_call`;
`UNAVAILABLE`/`DEADLINE_EXCEEDED` → `QuiltConnectionError` and `NOT_FOUND` →
`QuiltNotFoundError` everywhere.
- Single-flight guards on token refresh and cold snapshot fetch;
`get_system_id(home=...)` no longer poisons the default-system cache;
`close()` stops tracked streams; duplicate authorization metadata
eliminated.
- Stream API: `on_connected` callback, unsubscribe handles from all `on_*`
registrations, `apply_software_update_info`, descriptor-derived wire-scan
field numbers.
- CLI/TUI: app-owned snapshot (stacked screens no longer go stale), cursor
preservation, setpoint bounds, energy period validation, app-level °C/°F,
LED brightness restoration, 0.0 readings rendered correctly, token store
corruption recovery + fsync + flock, config dir 0700, dead code removal.
- Docs: README no longer demonstrates raw-stream usage; stream/token types
re-exported at package top level; stale OutdoorUnit/Controller reference
listings corrected; contradictory proto comments fixed.

Intentionally unchanged: Python >= 3.14 floor and boto3 dependency (HA 2026.3+
runs Python 3.14).

## [0.5.3] - 2026-06-30

## [0.5.2] - 2026-06-30
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,15 @@ async def main():
spaces = await client.list_spaces()
await client.set_space(spaces[0].id, mode=HVACMode.COOL, cool_setpoint_c=22.0)

# Stream real-time updates
spaces = await client.list_spaces()
topics = [f"hds/space/{s.id}" for s in spaces]
async with client.stream(topics) as stream:
stream.on_space_update(lambda s: print(f"{s.name}: {s.state.ambient_temperature_c}°C"))
# Stream real-time updates — stream events are sparse diffs, so
# always merge them into a snapshot before use
snapshot = await client.get_snapshot()
async with client.stream(snapshot.stream_topics()) as stream:
def on_space(space):
merged = snapshot.apply_space(space)
print(f"{merged.name}: {merged.state.ambient_temperature_c}°C")

stream.on_space_update(on_space)
await asyncio.sleep(60)

asyncio.run(main())
Expand Down
2 changes: 1 addition & 1 deletion docs/explanation/streaming-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ stateDiagram-v2
Fatal --> [*]
```

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.
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.

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.

Expand Down
4 changes: 2 additions & 2 deletions docs/reference/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ async def __aenter__(self) -> QuiltClient: ...
async def __aexit__(self, *_: object) -> None: ...
```

`__aexit__` calls `close()` to close the gRPC channel. Prefer the async context manager, or call `await close()` yourself when managing lifecycle manually.
`__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.

---

Expand Down Expand Up @@ -528,7 +528,7 @@ Creates a `NotifierStream`. It does not start the stream; call `start()`, `run_f

**Parameters:**
- `topics`: topic strings. Use `snapshot.stream_topics()` to get all topics for a system.
- `max_reconnects`: maximum reconnect attempts per disconnect. `-1` = unlimited (default). `0` = no retries.
- `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.
- `reconnect_delay_s`: initial back-off in seconds. Doubles on each attempt, capped at 60 s.

**Returns:** `NotifierStream` instance.
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/hds-entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,8 @@ The Quilt Dial is a compact circular thermostat (58 mm diameter) that can be wal
| `id` | `str` | Unique object ID |
| `space_id` | `str` | Space this Dial controls |
| `name` | `str` | Display name |
| `ambient_temperature_c` | `float` | Temperature measured by the Dial's built-in sensor |
| `raw_thermistor_c` | `float` | Raw uncalibrated thermistor reading |
| `ambient_temperature_c` | `float \| None` | Temperature measured by the Dial's built-in sensor; `None` when no state reading is available |
| `raw_thermistor_c` | `float \| None` | Raw uncalibrated thermistor reading; `None` when no state reading is available |
| `remote_sensor_mode` | `HvacControllerType` | How the Dial's temperature reading influences the space setpoint |
| `model_sku` | `str \| None` | Hardware model identifier |
| `serial_number` | `str \| None` | Unit serial number |
Expand Down
69 changes: 49 additions & 20 deletions docs/reference/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ service = UserService(channel)
### `NotifierStream`

```python
from quilt_hp.services.streaming import NotifierStream
from quilt_hp import NotifierStream # also importable from quilt_hp.services.streaming

# metadata_provider returns gRPC call metadata (e.g. auth headers).
# Obtain a token from your QuiltClient or token store.
Expand All @@ -84,20 +84,27 @@ stream = NotifierStream.create(

See [Streaming protocol behavior](../explanation/streaming-protocol.md) for the full state machine, event types, and reconnect behavior.

Callback registration methods:
Callback registration methods (each returns an *unsubscribe* callable —
call it to detach the callback without stopping the stream):

```python
stream.on_space_update(callback)
unsub = stream.on_space_update(callback)
stream.on_indoor_unit_update(callback)
stream.on_outdoor_unit_update(callback)
stream.on_controller_update(callback)
stream.on_qsm_update(callback)
stream.on_remote_sensor_update(callback)
stream.on_controller_remote_sensor_update(callback)
stream.on_software_update_info(callback)
stream.on_error(callback)
stream.on_error(callback) # fatal stream errors
stream.on_connected(callback) # fired on every successful (re)connect
unsub() # detach the space callback
```

`on_connected` fires on every successful connect and reconnect. Events
published while disconnected are lost — use it to re-fetch a snapshot and
close the gap.

Lifecycle methods:

```python
Expand Down Expand Up @@ -158,6 +165,7 @@ snapshot.apply_controller(controller)
snapshot.apply_qsm(qsm)
snapshot.apply_remote_sensor(sensor)
snapshot.apply_controller_remote_sensor(sensor)
snapshot.apply_software_update_info(info)
```

---
Expand Down Expand Up @@ -291,19 +299,28 @@ class IndoorUnitState:
class OutdoorUnit:
id: str
system_id: str
space_id: str
hvac_state: HVACState
model_sku: str | None
serial_number: str | None
model_name: str | None
state: OutdoorUnitState
firmware_version: str | None
firmware_update_info_id: str | None
performance_data: OutdoorUnitPerformanceData | None
```

#### `OutdoorUnitState`
#### `OutdoorUnitPerformanceData`

```python
@dataclass
class OutdoorUnitState:
updated_at: datetime | None
outdoor_temp_c: float | None
is_online: bool
class OutdoorUnitPerformanceData:
measurement_interval_s: float
energy_measurement_j: float
compressor_frequency_hz: float
ambient_temperature_c: float
coil_temperature_c: float
exhaust_temperature_c: float
high_pressure_kpa: float
low_pressure_kpa: float
```

---
Expand All @@ -315,19 +332,31 @@ class OutdoorUnitState:
class Controller:
id: str
system_id: str
space_id: str
name: str
raw_thermistor_c: float | None # None when no state reading available
pcb_temperature_a_c: float | None
pcb_temperature_b_c: float | None
calibrated_ambient_c: float | None # exposed as ambient_temperature_c
wifi_ssid: str | None
wifi_ip: str | None
wifi_signal_dbm: int | None
wifi_freq_mhz: int | None
wifi_last_seen: datetime | None
ap_wifi: WifiInfo | None
p2p_wifi: WifiInfo | None
remote_sensor_mode: RemoteSensorControlMode
software_update_info_id: str | None
firmware_update_info_id: str | None
serial_number: str | None
model_sku: str | None
firmware_version: str | None
state: ControllerState
state_updated_at: datetime | None
local_comms_health: LocalCommsHealthStatus
```

#### `ControllerState`

```python
@dataclass
class ControllerState:
updated_at: datetime | None
is_online: bool
```
Useful properties: `ambient_temperature_c` (→ `calibrated_ambient_c`, `None`
when no state reading is available), `wifi_band`, `is_online`.

---

Expand Down
5 changes: 5 additions & 0 deletions docs/reference/token-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,11 @@ class TokenRefreshReason(StrEnum):
- `TRANSPORT_UNAUTHENTICATED`: The gRPC transport interceptor received `UNAUTHENTICATED` on a unary RPC.
- `STREAM_UNAUTHENTICATED`: The `NotifierStream` received `UNAUTHENTICATED` and is refreshing before reconnecting.

For the two `*_UNAUTHENTICATED` reasons, `authenticate()` does **not** trust a
locally-unexpired cached IdToken — the server just rejected it, so a Cognito
refresh is forced. If the Cognito response contains a rotated `RefreshToken`,
the new value is persisted to the token store.

---

## `RefreshFailureAction`
Expand Down
4 changes: 2 additions & 2 deletions proto/cleaned/quilt_hds.proto
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ message IndoorUnitSettings {
// f1,f2=absent; f3=ledColorCode(varint/uint32), f4=ledColorBrightnessPercent(float),
// f5=fanSpeedMode(varint), f6=fanSpeedPercent(float), f7=updatedTs(Timestamp),
// f8,f9=absent; f10=louverMode(varint), f11=louverFixedPosition(float),
// f12=lightState(varint), f13=lightAnimation(varint).
// f12=lightAnimation(varint), f13=lightState(varint).
// NOTE: These are FLAT fields, NOT nested sub-messages.
message IndoorUnitControls {
// f1, f2 absent from wire captures
Expand All @@ -344,7 +344,7 @@ message IndoorUnitControls {
google.protobuf.Timestamp updated_ts = 7;
// f8, f9: uuid-string field confirmed at f9 in captures; purpose TBD
IndoorUnitLouverMode louver_mode = 10;
float louver_fixed_position = 11; // degrees, used when louver_mode=FIXED
float louver_fixed_position = 11; // position fraction 0.20–1.00 (see LouverAngle.to_wire), used when louver_mode=FIXED; 0.0 = not applicable
LightAnimation led_animation = 12; // LED_ANIMATION_FIELD_NUMBER=12; wire-confirmed
LightState led_state = 13; // wire-confirmed f13: UNSPECIFIED=0,ON=1,OFF=2
// sent when mobile_led_scheduling_enabled Statsig gate is on;
Expand Down
24 changes: 23 additions & 1 deletion src/quilt_hp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""quilt_hp — Async Python client for Quilt mini-split HVAC systems."""

from quilt_hp.auth import OtpCallback
from quilt_hp.client import QuiltClient
from quilt_hp.const import Environment
from quilt_hp.exceptions import (
Expand All @@ -9,16 +10,37 @@
QuiltNotFoundError,
QuiltStreamError,
)
from quilt_hp.services.streaming import NotifierStream, StreamEvent
from quilt_hp.tokens import (
CachedTokens,
LegacyTokenStore,
RefreshFailureAction,
TokenRefreshContext,
TokenRefreshHooks,
TokenRefreshPolicy,
TokenRefreshReason,
TokenStore,
)

__version__ = "0.5.3"

__all__ = [
"CachedTokens",
"Environment",
"LegacyTokenStore",
"NotifierStream",
"OtpCallback",
"QuiltAuthError",
"QuiltClient",
"QuiltConnectionError",
"QuiltError",
"QuiltNotFoundError",
"QuiltStreamError",
"__version__",
"RefreshFailureAction",
"StreamEvent",
"TokenRefreshContext",
"TokenRefreshHooks",
"TokenRefreshPolicy",
"TokenRefreshReason",
"TokenStore",
]
8 changes: 6 additions & 2 deletions src/quilt_hp/_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@


def app_config_dir() -> Path:
"""Return the platform-appropriate config directory, creating if needed."""
"""Return the platform-appropriate config directory, creating if needed.

The directory is created user-only (0o700) because it holds cached
authentication tokens.
"""
d = Path(user_config_dir(_APP))
d.mkdir(parents=True, exist_ok=True)
d.mkdir(parents=True, exist_ok=True, mode=0o700)
return d
4 changes: 2 additions & 2 deletions src/quilt_hp/_proto/quilt_hds_pb2.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -1039,7 +1039,7 @@ class IndoorUnitControls(_message.Message):
f1,f2=absent; f3=ledColorCode(varint/uint32), f4=ledColorBrightnessPercent(float),
f5=fanSpeedMode(varint), f6=fanSpeedPercent(float), f7=updatedTs(Timestamp),
f8,f9=absent; f10=louverMode(varint), f11=louverFixedPosition(float),
f12=lightState(varint), f13=lightAnimation(varint).
f12=lightAnimation(varint), f13=lightState(varint).
NOTE: These are FLAT fields, NOT nested sub-messages.
"""

Expand All @@ -1066,7 +1066,7 @@ class IndoorUnitControls(_message.Message):
louver_mode: Global___IndoorUnitLouverMode.ValueType
"""f8, f9: uuid-string field confirmed at f9 in captures; purpose TBD"""
louver_fixed_position: _builtins.float
"""degrees, used when louver_mode=FIXED"""
"""position fraction 0.20–1.00 (see LouverAngle.to_wire), used when louver_mode=FIXED; 0.0 = not applicable"""
led_animation: Global___LightAnimation.ValueType
"""LED_ANIMATION_FIELD_NUMBER=12; wire-confirmed"""
led_state: Global___LightState.ValueType
Expand Down
Loading