Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions docs/how-to/automation-daemon.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,28 +56,33 @@ _snapshot: SystemSnapshot | None = None
async def on_space_update(space: Space) -> None:
global _snapshot
if _snapshot is not None:
_snapshot.spaces[space.id] = space
space = _snapshot.apply_space(space)

temp = (
f"{space.state.current_temp_c:.1f}°C"
if space.state.current_temp_c is not None
f"{space.state.ambient_temperature_c:.1f}°C"
if space.state.ambient_temperature_c is not None
else "unknown"
)
LOG.info("[space] %s — mode=%s temp=%s", space.name, space.controls.mode.value, temp)
LOG.info(
"[space] %s — mode=%s temp=%s",
space.name,
space.controls.hvac_mode.value,
temp,
)

if (
space.state.current_temp_c is not None
and space.state.current_temp_c > 27.0
and space.controls.mode.value in ("auto", "cool")
space.state.ambient_temperature_c is not None
and space.state.ambient_temperature_c > 27.0
and space.controls.hvac_mode.value in ("auto", "cool")
):
LOG.warning("[space] %s is above 27°C — check cooling", space.name)


async def on_idu_update(idu: IndoorUnit) -> None:
global _snapshot
if _snapshot is not None:
_snapshot.indoor_units[idu.id] = idu
LOG.debug("[idu] %s — fan=%s online=%s", idu.id, idu.controls.fan_speed.value, idu.state.is_online)
idu = _snapshot.apply_indoor_unit(idu)
LOG.debug("[idu] %s — fan=%s online=%s", idu.id, idu.controls.fan_speed.value, idu.is_online)


async def run() -> None:
Expand All @@ -91,7 +96,7 @@ async def run() -> None:
_snapshot = await client.get_snapshot()
LOG.info(
"Snapshot loaded: system=%s rooms=%d idus=%d",
_snapshot.system_id,
client.system_name,
len(_snapshot.rooms),
len(_snapshot.indoor_units),
)
Expand All @@ -100,8 +105,7 @@ async def run() -> None:
stream = client.stream(topics, max_reconnects=-1, reconnect_delay_s=2.0)
stream.on_space_update(on_space_update)
stream.on_indoor_unit_update(on_idu_update)
stream.on_connected(lambda: LOG.info("Stream connected"))
stream.on_disconnected(lambda: LOG.warning("Stream disconnected; will reconnect automatically"))
stream.on_error(lambda exc: LOG.error("Stream stopped: %s", exc))

async with stream:
LOG.info("Daemon running. Send SIGINT or SIGTERM to stop.")
Expand Down
14 changes: 7 additions & 7 deletions docs/how-to/configure-comfort-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ To retrieve all comfort presets:
```python
settings = await client.list_comfort_settings()
for s in settings:
print(f"{s.name}: mode={s.hvac_mode}, heat={s.heat_setpoint_c}°C, cool={s.cool_setpoint_c}°C, fan={s.fan_speed}")
print(f"{s.name}: mode={s.hvac_mode}, heat={s.heating_setpoint_c}°C, cool={s.cooling_setpoint_c}°C, fan={s.fan_speed}")
```

Alternatively, comfort settings are embedded in `SystemSnapshot`:

```python
snapshot = await client.get_snapshot()
for cs in snapshot.comfort_settings.values():
for cs in snapshot.comfort_settings:
print(f"{cs.name}: {cs.hvac_mode}")
```

Expand All @@ -40,7 +40,7 @@ updated = await client.update_comfort_setting(
cool_setpoint_c=25.0,
fan_speed=FanSpeed.AUTO,
)
print(f"Updated '{updated.name}': heat={updated.heat_setpoint_c}°C cool={updated.cool_setpoint_c}°C")
print(f"Updated '{updated.name}': heat={updated.heating_setpoint_c}°C cool={updated.cooling_setpoint_c}°C")
```

Omit any parameter to keep its current value. You can also update by comfort setting ID string:
Expand Down Expand Up @@ -73,20 +73,20 @@ async def main() -> None:
snapshot = await client.get_snapshot()

preset = next(
(cs for cs in snapshot.comfort_settings.values() if cs.name == PRESET_NAME),
(cs for cs in snapshot.comfort_settings if cs.name == PRESET_NAME),
None,
)
if preset is None:
names = [cs.name for cs in snapshot.comfort_settings.values()]
names = [cs.name for cs in snapshot.comfort_settings]
print(f"Preset '{PRESET_NAME}' not found. Available: {names}")
return

for space in snapshot.rooms:
updated = await client.set_space(
space,
mode=preset.hvac_mode,
heat_setpoint_c=preset.heat_setpoint_c,
cool_setpoint_c=preset.cool_setpoint_c,
heat_setpoint_c=preset.heating_setpoint_c,
cool_setpoint_c=preset.cooling_setpoint_c,
)
print(f" {updated.name}: mode={updated.controls.hvac_mode}")

Expand Down
52 changes: 32 additions & 20 deletions docs/how-to/configure-schedules.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,31 @@ To create a day program with timed comfort-setting transitions:
```python
from quilt_hp.models.schedule import ScheduleEvent

# Get a comfort setting ID from the snapshot
# Get comfort settings for one room from the snapshot
snapshot = await client.get_snapshot()
space = snapshot.space_by_name("Bedroom")
active_cs = next(
cs for cs in snapshot.comfort_settings.values() if cs.name == "Active"
)
sleep_cs = next(
cs for cs in snapshot.comfort_settings.values() if cs.name == "Sleep"
)
assert space is not None
space_settings = snapshot.comfort_settings_for_space(space)
active_cs = next(cs for cs in space_settings if cs.name == "Active")
sleep_cs = next(cs for cs in space_settings if cs.name == "Sleep")

events = [
ScheduleEvent(time_of_day_s=7 * 3600, comfort_setting_id=active_cs.id), # 07:00 → Active
ScheduleEvent(time_of_day_s=22 * 3600, comfort_setting_id=sleep_cs.id), # 22:00 → Sleep
ScheduleEvent(
start_s=7 * 3600,
comfort_setting_id=active_cs.id,
hvac_mode=active_cs.hvac_mode,
heating_setpoint_c=active_cs.heating_setpoint_c,
Comment thread
eman marked this conversation as resolved.
cooling_setpoint_c=active_cs.cooling_setpoint_c,
precondition=False,
),
ScheduleEvent(
start_s=22 * 3600,
comfort_setting_id=sleep_cs.id,
hvac_mode=sleep_cs.hvac_mode,
heating_setpoint_c=sleep_cs.heating_setpoint_c,
cooling_setpoint_c=sleep_cs.cooling_setpoint_c,
precondition=False,
),
]

day = await client.create_schedule_day(
Expand All @@ -36,7 +48,7 @@ day = await client.create_schedule_day(
print(f"Created schedule day: {day.id} ({len(day.events)} events)")
```

`time_of_day_s` is the number of seconds from midnight (e.g., `7 * 3600` = 07:00).
`start_s` is the number of seconds from midnight (e.g., `7 * 3600` = 07:00).

---

Expand All @@ -47,17 +59,17 @@ To create a schedule week and assign day programs to each weekday:
```python
from quilt_hp.models.schedule import ScheduleWeekDay

# day_of_week: 0 = Monday, 6 = Sunday
# weekday: 1 = Monday, 7 = Sunday
week = await client.create_schedule_week(
space_id=space.id,
days=[
ScheduleWeekDay(day_of_week=0, schedule_day_id=weekday_program.id), # Mon
ScheduleWeekDay(day_of_week=1, schedule_day_id=weekday_program.id), # Tue
ScheduleWeekDay(day_of_week=2, schedule_day_id=weekday_program.id), # Wed
ScheduleWeekDay(day_of_week=3, schedule_day_id=weekday_program.id), # Thu
ScheduleWeekDay(day_of_week=4, schedule_day_id=weekday_program.id), # Fri
ScheduleWeekDay(day_of_week=5, schedule_day_id=weekend_program.id), # Sat
ScheduleWeekDay(day_of_week=6, schedule_day_id=weekend_program.id), # Sun
ScheduleWeekDay(weekday=1, day_id=weekday_program.id), # Mon
ScheduleWeekDay(weekday=2, day_id=weekday_program.id), # Tue
ScheduleWeekDay(weekday=3, day_id=weekday_program.id), # Wed
ScheduleWeekDay(weekday=4, day_id=weekday_program.id), # Thu
ScheduleWeekDay(weekday=5, day_id=weekday_program.id), # Fri
ScheduleWeekDay(weekday=6, day_id=weekend_program.id), # Sat
ScheduleWeekDay(weekday=7, day_id=weekend_program.id), # Sun
],
)
print(f"Created schedule week: {week.id}")
Expand All @@ -74,7 +86,7 @@ updated_week = await client.update_schedule_week(
schedule_week_id=week.id,
space_id=space.id,
days=[
ScheduleWeekDay(day_of_week=0, schedule_day_id=new_monday_program.id),
ScheduleWeekDay(weekday=1, day_id=new_monday_program.id),
# ... include all 7 days; omitted days are cleared
],
)
Expand Down Expand Up @@ -114,4 +126,4 @@ To resume:
await client.set_schedule_execution(paused=False)
```

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`.
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.
2 changes: 1 addition & 1 deletion docs/how-to/control-spaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ To set the fan speed on an indoor unit:
from quilt_hp.models.enums import FanSpeed

snapshot = await client.get_snapshot()
idu = snapshot.indoor_units[next(iter(snapshot.indoor_units))] # first IDU
idu = snapshot.indoor_units[0] # first IDU

updated = await client.set_indoor_unit(idu, fan_speed=FanSpeed.MEDIUM)
print(f"Fan speed: {updated.controls.fan_speed}")
Expand Down
67 changes: 55 additions & 12 deletions docs/how-to/stream-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ def on_idu(idu: IndoorUnit) -> None:

async with client.stream(snapshot.stream_topics()) as stream:
stream.on_space_update(on_space)
stream.on_indoor_unit_update(on_idu)
stream.on_indoor_unit_update(lambda idu: print(snapshot.apply_indoor_unit(idu).id))
stream.on_outdoor_unit_update(snapshot.apply_outdoor_unit)
stream.on_controller_update(snapshot.apply_controller)
stream.on_qsm_update(snapshot.apply_qsm)
stream.on_remote_sensor_update(snapshot.apply_remote_sensor)
stream.on_controller_remote_sensor_update(snapshot.apply_controller_remote_sensor)
stream.on_software_update_info(lambda info: print(f"Update info: {info.id}"))
stream.on_error(lambda e: print(f"Fatal error: {e}"))
await asyncio.sleep(3600) # run for 1 hour
```
Expand Down Expand Up @@ -54,24 +60,59 @@ For indoor units:
```python
def on_idu(idu: IndoorUnit) -> None:
merged = snapshot.apply_indoor_unit(idu)
print(f"{merged.id}: online={merged.state.is_online}")
print(f"{merged.id}: online={merged.is_online}")
```

For background on why sparse diffs require merging, see [Snapshot and stream data model](../explanation/snapshot-and-stream.md).

---

## Run the stream as a background task
## Callback registration methods

`NotifierStream` accepts both synchronous and async callbacks. Register whichever entity types you care about:

| Method | Callback argument | Typical use |
| --- | --- | --- |
| `on_space_update()` | `Space` | Merge room diffs with `snapshot.apply_space()` |
| `on_indoor_unit_update()` | `IndoorUnit` | Merge IDU diffs with `snapshot.apply_indoor_unit()` |
| `on_outdoor_unit_update()` | `OutdoorUnit` | Merge ODU diffs with `snapshot.apply_outdoor_unit()` |
| `on_controller_update()` | `Controller` | Merge Dial diffs with `snapshot.apply_controller()` |
| `on_qsm_update()` | `QuiltSmartModule` | Merge QSM diffs with `snapshot.apply_qsm()` |
| `on_remote_sensor_update()` | `RemoteSensor` | Merge standalone sensor diffs with `snapshot.apply_remote_sensor()` |
| `on_controller_remote_sensor_update()` | `ControllerRemoteSensor` | Merge Dial sensor diffs with `snapshot.apply_controller_remote_sensor()` |
| `on_software_update_info()` | `SoftwareUpdateInfo` | Observe firmware/software update records |
| `on_error()` | `Exception` | Handle fatal stream failure after reconnects are exhausted |

---

## Lifecycle methods

Use these methods to control the stream explicitly:

| Method / property | What it does |
| --- | --- |
| `await stream.start()` | Starts the listener in the background |
| `await stream.run_forever()` | Runs inline until cancelled or a fatal error stops it |
| `await stream.stop()` | Cancels the background task and closes the stream |
| `await stream.subscribe(topics)` | Adds topic subscriptions after startup |
| `await stream.unsubscribe(topics)` | Removes topic subscriptions |
| `stream.error` | Last fatal exception, or `None` while healthy |

### Run the stream as a background task

To run the stream while doing other work concurrently:

```python
async with client.stream(snapshot.stream_topics()) as stream:
stream.on_space_update(on_space)
# Stream runs in the background — do other work here
stream = client.stream(snapshot.stream_topics())
stream.on_space_update(on_space)
await stream.start()
try:
result = await do_something_else()
await asyncio.sleep(3600)
# Stream is stopped when the async with block exits
finally:
await stream.stop()
if stream.error is not None:
print(f"Stream stopped with error: {stream.error}")
```

Use this pattern in integrations (Home Assistant, automation daemons) where the stream is just one part of a larger async application.
Expand Down Expand Up @@ -115,7 +156,7 @@ async with client.stream(snapshot.stream_topics()) as stream:

## Handle stream errors and reconnect

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

```python
# Unlimited reconnects (default: -1)
Expand All @@ -132,14 +173,16 @@ stream = client.stream(
)
```

To observe connection lifecycle events:
To observe fatal stream failures:

```python
stream.on_connected(lambda: print("Stream connected"))
stream.on_disconnected(lambda: print("Stream disconnected; will reconnect"))
stream.on_error(lambda e: print(f"Fatal error (budget exhausted): {e}"))

await stream.run_forever()
if stream.error is not None:
print(f"Last fatal error: {stream.error}")
```

`on_error` is called only when the reconnect budget is exhausted. Until then, disconnects and errors trigger automatic reconnection without invoking `on_error`.
`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.

For the full reconnect state machine, see [The streaming protocol](../explanation/streaming-protocol.md).
Loading