diff --git a/CHANGELOG.md b/CHANGELOG.md index 18162d0..3b5bcdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ ## [Unreleased] +### Fixed +- **`LocalCommsStatus` fields were mislabeled** (previously guessed from limited + captures). Corrected to the verified field semantics: field 2 `health` → + `status`, field 3 `link_state` → `visible_devices_count`, field 4 `version` → + `expected_devices_count`, field 5 `health_changed_ts` → + `last_session_change_ts`, field 6 `connection_state` (int32) → `reason` + (`LocalCommsHealthReason` enum). Decoding still worked by field number, but the + model exposed wrong names/semantics and discarded the reason diagnostics. + `QuiltSmartModule`/`Controller` now expose `local_comms_visible_devices`, + `local_comms_expected_devices`, `local_comms_reason`, and + `local_comms_last_session_change` (replacing `local_comms_link_state`, + `local_comms_connection_state`, and `local_comms_version`). + +### Added +- CLI `set` command gained a `--fan` option (`AUTO`, `QUIET`, `LOW`, `MEDIUM`, + `HIGH`, `BLAST`) that applies the chosen fan speed to every indoor unit in the + target space. +- `SystemSnapshot.indoor_units_for_space()` returns all indoor units linked to a + space (accepts a `Space` or space-ID string). +- `LocalCommsHealthReason` enum (12 values). `ZENOH_SESSION_DOWN` + identifies the local mesh transport as **Eclipse Zenoh**, not NATS (port 7447 + is Zenoh's default). +- `IndoorUnitConditions.compressor_minimum_run_time` (proto field 13). +- Marked `SpaceSettings.safety_heating=9` confirmed (was "unconfirmed"). + +### CI +- Docs deploy workflow upgraded to the current Node 24 GitHub Pages actions + (`configure-pages@v6`, `upload-pages-artifact@v4`, `deploy-pages@v5`), + removing the deprecated Node 20 runtime path. +- Docs deploy now retries `deploy-pages` once on transient GitHub Pages backend + failures, failing the job only if both attempts fail. + ## [0.5.5] - 2026-07-03 ## [0.5.4] - 2026-07-03 diff --git a/docs/how-to/control-spaces.md b/docs/how-to/control-spaces.md index 703f2b6..2a7d04d 100644 --- a/docs/how-to/control-spaces.md +++ b/docs/how-to/control-spaces.md @@ -96,6 +96,16 @@ print(f"Fan speed: {updated.controls.fan_speed}") Available fan speeds: `FanSpeed.AUTO`, `QUIET`, `LOW`, `MEDIUM`, `HIGH`, `BLAST`. +From the CLI, set the fan speed for a room's indoor unit(s) with the `--fan` +flag on `quilt set` (it resolves the space's indoor units for you): + +```bash +quilt set "Living Room" --fan medium +quilt set "Living Room" --mode cool --cool 22 --fan auto +``` + +In the TUI, press `f` on a room screen to cycle through the fan speeds. + --- ## Set louver mode and position diff --git a/src/quilt_hp/cli/main.py b/src/quilt_hp/cli/main.py index c4b30d2..2417408 100644 --- a/src/quilt_hp/cli/main.py +++ b/src/quilt_hp/cli/main.py @@ -24,7 +24,7 @@ from quilt_hp.cli.store import FileStore from quilt_hp.client import QuiltClient from quilt_hp.exceptions import QuiltAuthError, QuiltError -from quilt_hp.models.enums import HVACMode +from quilt_hp.models.enums import FanSpeed, HVACMode from quilt_hp.models.system import SystemSnapshot app = typer.Typer(help="Quilt HVAC command-line interface.") @@ -786,13 +786,14 @@ def set_space( max=SETPOINT_MAX_C, help=f"Cooling setpoint in °C ({SETPOINT_MIN_C:.0f}–{SETPOINT_MAX_C:.0f})", ), + fan: str | None = typer.Option(None, help="Fan speed: AUTO, QUIET, LOW, MEDIUM, HIGH, BLAST"), email: str | None = typer.Option(None, envvar="QUILT_EMAIL", help="Quilt account email"), home: str | None = typer.Option(None, help="Specific home name to connect to"), ) -> None: """Update HVAC mode and setpoints for a room.""" - if mode is None and heat is None and cool is None: + if mode is None and heat is None and cool is None and fan is None: console.print( - "[red]Nothing to update:[/red] provide at least one of --mode, --heat, --cool." + "[red]Nothing to update:[/red] provide at least one of --mode, --heat, --cool, --fan." ) raise typer.Exit(1) @@ -820,12 +821,34 @@ async def _set() -> None: console.print(f"[red]Room {space_name!r} not found.[/red]") raise typer.Exit(1) - await client.set_space( - space.id, - mode=hvac_mode, - heat_setpoint_c=heat, - cool_setpoint_c=cool, - ) + if fan: + try: + fan_speed: FanSpeed | None = FanSpeed[fan.upper()] + except KeyError: + valid = ", ".join(f.name.lower() for f in FanSpeed) + console.print(f"[red]Invalid fan speed {fan!r}. Valid: {valid}[/red]") + raise typer.Exit(1) from None + else: + fan_speed = None + + if hvac_mode is not None or heat is not None or cool is not None: + await client.set_space( + space.id, + mode=hvac_mode, + heat_setpoint_c=heat, + cool_setpoint_c=cool, + ) + + if fan_speed is not None: + idus = snap.indoor_units_for_space(space) + if not idus: + console.print( + f"[red]No indoor unit found for {space.name}; cannot set fan speed.[/red]" + ) + raise typer.Exit(1) + for idu in idus: + await client.set_indoor_unit(idu.id, fan_speed=fan_speed) + console.print(f"[green]✓ Updated {space.name}[/green]") _run(_set()) diff --git a/src/quilt_hp/models/system.py b/src/quilt_hp/models/system.py index 48801a0..4759ef9 100644 --- a/src/quilt_hp/models/system.py +++ b/src/quilt_hp/models/system.py @@ -136,6 +136,15 @@ def away_comfort_setting(self, space: Space | str) -> ComfortSetting | None: return cs return None + def indoor_units_for_space(self, space: Space | str) -> list[IndoorUnit]: + """Return all indoor units linked to a space. + + Args: + space: A ``Space`` object or space ID string. + """ + space_id = space if isinstance(space, str) else space.id + return [u for u in self.indoor_units if u.space_id == space_id] + def enrich_space(self, space: Space) -> Space: """Resolve active_comfort_setting_type on a stream-updated Space. diff --git a/tests/test_models_from_proto.py b/tests/test_models_from_proto.py index f4da10a..560cc44 100644 --- a/tests/test_models_from_proto.py +++ b/tests/test_models_from_proto.py @@ -1181,6 +1181,35 @@ def test_system_snapshot_rooms() -> None: assert rooms[0].name == "Living Room" +def test_system_snapshot_indoor_units_for_space() -> None: + """indoor_units_for_space filters IDUs by space id.""" + from quilt_hp.models.system import SystemSnapshot + + snap = SystemSnapshot( + spaces=[], + indoor_units=[ + _ns(space_id="room-1"), + _ns(space_id="room-2"), + _ns(space_id="room-1"), + ], + outdoor_units=[], + controllers=[], + quilt_smart_modules=[], + comfort_settings=[], + schedule_weeks=[], + schedule_days=[], + remote_sensors=[], + controller_remote_sensors=[], + software_update_infos=[], + locations=[], + timezone="UTC", + ) + result = snap.indoor_units_for_space("room-1") + assert len(result) == 2 + assert all(u.space_id == "room-1" for u in result) + assert snap.indoor_units_for_space("nope") == [] + + def test_system_snapshot_primary_location() -> None: from quilt_hp._proto import quilt_hds_pb2 as hds