From c8b98f0f39a9c58961f01646bace093b4f3ee9fc Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Mon, 20 Jul 2026 15:15:12 -0700 Subject: [PATCH 1/3] Clarify presence/occupancy naming; add IndoorUnit.presence_detected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library exposed three occupancy-ish signals with no indication of which are realtime and which are derived, and the TUI labeled the two radar channels "Radar L"/"Radar R" — implying two physical sensors. Evidence (single radar_sensor_distance_from_floor_m, one detection fence, two raw radar channels on QuiltSmartModuleState, and the vendor app's combinedSensorPresence ORing the pair) says: one radar, two detection channels, combined by the app into a single realtime presence value; IndoorUnitOccupancy is the debounced auto-away decision (~3 min set / ~20 min clear). - Add IndoorUnit.presence_detected: realtime OR of both channels, None when offline/unreported (gated like effective_occupancy_state) - Rewrite IndoorUnitPresence/IndoorUnitOccupancy docstrings around the realtime-vs-derived distinction - TUI: "Radar L"/"Radar R" → "Radar ch 0"/"Radar ch 1"; "Occupancy" → "Occupancy (auto-away)" - Docs: drop phantom IndoorUnitState.presence_detected from the model reference, document the three-tier presence model, fix the HA guide mapping the derived state to an entity named "Presence" - Tests for the new property Closes #21 --- CHANGELOG.md | 22 +++++++++++ docs/how-to/home-assistant.md | 5 ++- docs/reference/models.md | 24 +++++++++++- src/quilt_hp/cli/tui.py | 18 +++++---- src/quilt_hp/models/indoor_unit.py | 40 +++++++++++++++++++- tests/test_models_from_proto.py | 59 +++++++++++++++++++++++++++++- 6 files changed, 154 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed8f33f..b81e188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ ## [Unreleased] +### Added +- `IndoorUnit.presence_detected` — realtime room presence as a single bool: True + if either radar channel reports DETECTED (mirrors the vendor app's + `combinedSensorPresence`), False if a channel reports UNDETECTED, None when the + IDU is offline or the radar hasn't reported. This is the fast (seconds-latency) + counterpart to the debounced `effective_occupancy_state` (auto-away decision, + ~3 min to set / ~20 min to clear by default). ([#21]) + +### Changed +- Clarified presence/occupancy semantics throughout ([#21]): + `IndoorUnitPresence.sensor0_presence`/`sensor1_presence` are the two detection + channels of the IDU's single mm-wave radar (they move in lockstep in practice; + channel semantics unconfirmed) — not two physical sensors. TUI labels renamed + accordingly: "Radar L"/"Radar R" → "Radar ch 0"/"Radar ch 1", and "Occupancy" → + "Occupancy (auto-away)". Docs: removed the phantom + `IndoorUnitState.presence_detected` field from the model reference, documented + the three-tier presence model (raw channels / realtime presence / derived + occupancy), and fixed the Home Assistant guide, which previously mapped the + *derived* occupancy state to an entity named "Presence". + +[#21]: https://github.com/eman/quilt-hp-python/issues/21 + ## [0.5.6] - 2026-07-04 ### Fixed diff --git a/docs/how-to/home-assistant.md b/docs/how-to/home-assistant.md index 0cc1905..0ec3351 100644 --- a/docs/how-to/home-assistant.md +++ b/docs/how-to/home-assistant.md @@ -145,13 +145,14 @@ def controller_device_info(ctrl, snapshot) -> DeviceInfo: | `climate` | Living Room | `Space.controls` + `IndoorUnit.controls` | `hvac_mode`, `heating_setpoint_c`, `cooling_setpoint_c`, `fan_speed` | Yes — `client.set_space()` / `client.set_indoor_unit()` | | `sensor` (temperature) | Living Room Temperature | `IndoorUnit.state` | `ambient_temperature_c` | No | | `sensor` (humidity) | Living Room Humidity | `IndoorUnit.state` | `ambient_humidity_percent` | No | -| `binary_sensor` (presence) | Living Room Presence | `IndoorUnit.effective_occupancy_state` | `occupancy_state != 0` | No | +| `binary_sensor` (presence) | Living Room Presence | `IndoorUnit.presence_detected` | OR of both radar channels | No | +| `binary_sensor` (occupancy) | Living Room Occupancy (auto-away) | `IndoorUnit.effective_occupancy_state` | `occupancy_state == OccupancyState.DETECTED` | No | | `light` | Living Room Light | `IndoorUnit.controls` | `led_state`, `led_brightness`, `led_color_code` | Yes — `client.set_indoor_unit()` | | `select` (fan speed) | Living Room Fan Speed | `IndoorUnit.controls` | `fan_speed` | Yes — `client.set_indoor_unit()` | | `select` (louver) | Living Room Louver | `IndoorUnit.controls` | `louver_mode` | Yes — `client.set_indoor_unit()` | | `sensor` (Dial temperature) | Living Room Dial Temperature | `Controller` | `ambient_temperature_c` | No | -> **Presence note**: Use `idu.effective_occupancy_state` rather than reading `idu.occupancy` directly. The property returns `None` when the IDU is offline, avoiding stale occupancy data being presented as current. +> **Presence vs occupancy**: these are different signals — don't expose just one under an ambiguous name. `idu.presence_detected` is the **realtime** radar presence (flips within seconds; the vendor app's combined value). `idu.effective_occupancy_state` is the **derived** auto-away decision, debounced by ~3 min to set and ~20 min to clear (server defaults). Use the properties rather than reading `idu.presence` / `idu.occupancy` directly — both return `None` when the IDU is offline, avoiding stale data being presented as current. ### Resolving IDUs and Controllers for a space diff --git a/docs/reference/models.md b/docs/reference/models.md index efbb007..92d11bc 100644 --- a/docs/reference/models.md +++ b/docs/reference/models.md @@ -286,12 +286,34 @@ class IndoorUnitState: target_temp_c: float | None actual_temp_c: float | None is_online: bool # updated_at within last 5 minutes - presence_detected: bool led_on: bool # True only when is_online ``` `is_online` is computed locally from `updated_at`: `datetime.now(UTC) - updated_at < timedelta(minutes=5)`. `led_on` returns `False` whenever `is_online` is `False`, even if `led_color_code` is non-zero. +#### `IndoorUnitPresence` and `IndoorUnitOccupancy` — realtime vs derived + +```python +@dataclass +class IndoorUnitPresence: + sensor0_presence: Presence # radar detection channel 0 + sensor1_presence: Presence # radar detection channel 1 + +@dataclass +class IndoorUnitOccupancy: + occupancy_state: int # OccupancyState proto value +``` + +Occupancy data comes in three tiers — don't treat them as interchangeable: + +| Tier | Accessor | Latency | Meaning | +|---|---|---|---| +| Raw radar channels | `idu.presence.sensor0_presence` / `sensor1_presence` | seconds | The two detection channels of the IDU's single mm-wave radar. Channel semantics are unconfirmed and they move in lockstep in practice — avoid labeling them "motion" vs "presence". | +| Realtime presence | `idu.presence_detected` | seconds | OR of both channels — the value the vendor app uses. `True`/`False`, or `None` when offline or unreported. | +| Derived occupancy | `idu.effective_occupancy_state` | minutes | The server's auto-away decision: ~3 min of sustained presence to set, ~20 min of absence to clear (configurable via `SpaceSettings.occupied_timeout_s` / `unoccupied_timeout_s`). | + +Use `presence_detected` for "is someone in the room right now" and `effective_occupancy_state` for "does Quilt consider the room occupied for away/return setback". Both return `None` for offline IDUs so stale data is never presented as current. + --- ### `OutdoorUnit` diff --git a/src/quilt_hp/cli/tui.py b/src/quilt_hp/cli/tui.py index 7492181..8e95516 100644 --- a/src/quilt_hp/cli/tui.py +++ b/src/quilt_hp/cli/tui.py @@ -1447,9 +1447,11 @@ def _populate_status(self) -> None: occ_str, occ_style = "--", "dim italic" # occupancy_state is the auto-away engine decision (lags real presence # unoccupied_timeout_s). It controls HVAC setback, not live radar. - self._kv("sen-occ-state", "Occupancy", occ_str, occ_style) + self._kv("sen-occ-state", "Occupancy (auto-away)", occ_str, occ_style) - # Presence sensors — binary DETECTED / UNDETECTED per radar sensor. + # Presence channels — binary DETECTED / UNDETECTED per radar detection + # channel. One physical radar, two channels; they move in lockstep in + # practice and the vendor app ORs them (never "left"/"right" sensors). if idu and idu.presence: from quilt_hp.models.enums import Presence @@ -1460,13 +1462,13 @@ def _presence_str(p: Presence) -> tuple[str, str]: return "Not Detected", "dim" return "--", "dim italic" - l_str, l_style = _presence_str(idu.presence.sensor0_presence) - r_str, r_style = _presence_str(idu.presence.sensor1_presence) - self._kv("sen-presence-l", "Radar L", l_str, l_style) - self._kv("sen-presence-r", "Radar R", r_str, r_style) + ch0_str, ch0_style = _presence_str(idu.presence.sensor0_presence) + ch1_str, ch1_style = _presence_str(idu.presence.sensor1_presence) + self._kv("sen-presence-l", "Radar ch 0", ch0_str, ch0_style) + self._kv("sen-presence-r", "Radar ch 1", ch1_str, ch1_style) else: - self._kv("sen-presence-l", "Radar L", "--") - self._kv("sen-presence-r", "Radar R", "--") + self._kv("sen-presence-l", "Radar ch 0", "--") + self._kv("sen-presence-r", "Radar ch 1", "--") # Presence detection level and fence geometry (from IDU settings) if idu: diff --git a/src/quilt_hp/models/indoor_unit.py b/src/quilt_hp/models/indoor_unit.py index 398924c..0609673 100644 --- a/src/quilt_hp/models/indoor_unit.py +++ b/src/quilt_hp/models/indoor_unit.py @@ -208,7 +208,17 @@ def any_active(self) -> bool: @dataclass(slots=True) class IndoorUnitPresence: - """Radar presence sensor data — binary DETECTED / UNDETECTED per sensor.""" + """Realtime radar presence — binary DETECTED / UNDETECTED per channel. + + The IDU has a single mm-wave radar with two detection channels; the two + fields are those channels, not two physical sensors. Which channel maps + to which radar measurement is unconfirmed, and in practice they move in + lockstep. The vendor app never distinguishes them — it ORs both into one + room-presence value (see :attr:`IndoorUnit.presence_detected`). + + These flip within seconds of someone entering or leaving the detection + fence. For the slow auto-away decision, see :class:`IndoorUnitOccupancy`. + """ sensor0_presence: Presence sensor1_presence: Presence @@ -216,7 +226,14 @@ class IndoorUnitPresence: @dataclass(slots=True) class IndoorUnitOccupancy: - """Computed room occupancy state.""" + """Derived occupancy — the server's auto-away engine decision. + + Unlike the realtime :class:`IndoorUnitPresence` channels, this is a + debounced state: it needs roughly ``occupied_timeout_s`` (default 3 min) + of sustained presence to become DETECTED and ``unoccupied_timeout_s`` + (default 20 min) of absence to clear. It drives HVAC away/return setback, + so short walk-throughs do not move it. + """ occupancy_state: int @@ -288,6 +305,25 @@ def effective_occupancy_state(self) -> int | None: return None return self.occupancy.occupancy_state + @property + def presence_detected(self) -> bool | None: + """Realtime room presence — True if either radar channel detects someone. + + Mirrors the vendor app's ``combinedSensorPresence`` (OR of the two + channels). Flips within seconds; use :attr:`effective_occupancy_state` + for the debounced auto-away decision instead. Returns None when the + IDU is offline, has no presence data, or neither channel has reported + (both UNSPECIFIED). + """ + if not self.is_online or self.presence is None: + return None + channels = (self.presence.sensor0_presence, self.presence.sensor1_presence) + if Presence.DETECTED in channels: + return True + if Presence.UNDETECTED in channels: + return False + return None + def _idu_from_proto(proto: object, hw_map: dict[str, object] | None = None) -> IndoorUnit: """Internal: convert a proto IndoorUnit to our model. diff --git a/tests/test_models_from_proto.py b/tests/test_models_from_proto.py index c79f3c2..b8f5dec 100644 --- a/tests/test_models_from_proto.py +++ b/tests/test_models_from_proto.py @@ -25,10 +25,11 @@ LocalCommsHealthStatus, LouverMode, OccupancyMode, + Presence, RemoteSensorControlMode, SafetyHeatingMode, ) -from quilt_hp.models.indoor_unit import IndoorUnit +from quilt_hp.models.indoor_unit import IndoorUnit, IndoorUnitPresence from quilt_hp.models.outdoor_unit import OutdoorUnit from quilt_hp.models.qsm import QuiltSmartModule, WifiInfo from quilt_hp.models.schedule import ScheduleDay, ScheduleEvent, ScheduleWeek @@ -1952,3 +1953,59 @@ def test_apply_controller_preserves_local_comms_health_on_sparse_diff() -> None: result = snap.apply_controller(diff) assert result.local_comms_health == LocalCommsHealthStatus.DEGRADED + + +# ---------------------------------------------------------------- presence_detected + + +def _online_idu_with_presence(s0: Presence, s1: Presence) -> IndoorUnit: + idu = IndoorUnit.from_proto(_make_idu_proto()) + idu.presence = IndoorUnitPresence(sensor0_presence=s0, sensor1_presence=s1) + return idu + + +def test_presence_detected_true_when_either_channel_detects() -> None: + assert ( + _online_idu_with_presence(Presence.DETECTED, Presence.UNDETECTED).presence_detected + is True + ) + assert ( + _online_idu_with_presence(Presence.UNDETECTED, Presence.DETECTED).presence_detected + is True + ) + assert ( + _online_idu_with_presence(Presence.DETECTED, Presence.DETECTED).presence_detected + is True + ) + + +def test_presence_detected_false_when_both_channels_clear() -> None: + assert ( + _online_idu_with_presence(Presence.UNDETECTED, Presence.UNDETECTED).presence_detected + is False + ) + # One channel silent, the other reporting absence → still False + assert ( + _online_idu_with_presence(Presence.UNSPECIFIED, Presence.UNDETECTED).presence_detected + is False + ) + + +def test_presence_detected_none_when_unreported_offline_or_absent() -> None: + # Both channels UNSPECIFIED = sensor has not reported + assert ( + _online_idu_with_presence(Presence.UNSPECIFIED, Presence.UNSPECIFIED).presence_detected + is None + ) + # No presence sub-message at all + idu = IndoorUnit.from_proto(_make_idu_proto()) + assert idu.presence is None + assert idu.presence_detected is None + # Offline IDU gates out stale presence data + proto = _make_idu_proto() + proto.state.updated_ts = _ns(seconds=0) + offline = IndoorUnit.from_proto(proto) + offline.presence = IndoorUnitPresence( + sensor0_presence=Presence.DETECTED, sensor1_presence=Presence.DETECTED + ) + assert offline.presence_detected is None From f54f74c8951e88a2276fdd3115a9e7d2342daffb Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Mon, 20 Jul 2026 15:48:33 -0700 Subject: [PATCH 2/3] Fix lint errors and address PR review comments --- CHANGELOG.md | 9 +++++---- docs/how-to/home-assistant.md | 2 +- scripts/check_docs_nav.py | 1 - scripts/generate_public_api_reference.py | 1 - 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b81e188..5095fbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,11 @@ ### Added - `IndoorUnit.presence_detected` — realtime room presence as a single bool: True if either radar channel reports DETECTED (mirrors the vendor app's - `combinedSensorPresence`), False if a channel reports UNDETECTED, None when the - IDU is offline or the radar hasn't reported. This is the fast (seconds-latency) - counterpart to the debounced `effective_occupancy_state` (auto-away decision, - ~3 min to set / ~20 min to clear by default). ([#21]) + `combinedSensorPresence`), False if neither channel is DETECTED and at least one + is UNDETECTED, None when the IDU is offline or the radar hasn't reported. This + is the fast (seconds-latency) counterpart to the debounced + `effective_occupancy_state` (auto-away decision, ~3 min to set / ~20 min to + clear by default). ([#21]) ### Changed - Clarified presence/occupancy semantics throughout ([#21]): diff --git a/docs/how-to/home-assistant.md b/docs/how-to/home-assistant.md index 0ec3351..96b5fcb 100644 --- a/docs/how-to/home-assistant.md +++ b/docs/how-to/home-assistant.md @@ -146,7 +146,7 @@ def controller_device_info(ctrl, snapshot) -> DeviceInfo: | `sensor` (temperature) | Living Room Temperature | `IndoorUnit.state` | `ambient_temperature_c` | No | | `sensor` (humidity) | Living Room Humidity | `IndoorUnit.state` | `ambient_humidity_percent` | No | | `binary_sensor` (presence) | Living Room Presence | `IndoorUnit.presence_detected` | OR of both radar channels | No | -| `binary_sensor` (occupancy) | Living Room Occupancy (auto-away) | `IndoorUnit.effective_occupancy_state` | `occupancy_state == OccupancyState.DETECTED` | No | +| `binary_sensor` (occupancy) | Living Room Occupancy (auto-away) | `IndoorUnit.effective_occupancy_state` | `effective_occupancy_state == OccupancyState.DETECTED` | No | | `light` | Living Room Light | `IndoorUnit.controls` | `led_state`, `led_brightness`, `led_color_code` | Yes — `client.set_indoor_unit()` | | `select` (fan speed) | Living Room Fan Speed | `IndoorUnit.controls` | `fan_speed` | Yes — `client.set_indoor_unit()` | | `select` (louver) | Living Room Louver | `IndoorUnit.controls` | `louver_mode` | Yes — `client.set_indoor_unit()` | diff --git a/scripts/check_docs_nav.py b/scripts/check_docs_nav.py index 34ca68e..661ef89 100644 --- a/scripts/check_docs_nav.py +++ b/scripts/check_docs_nav.py @@ -8,7 +8,6 @@ import yaml - ROOT = Path(__file__).resolve().parent.parent diff --git a/scripts/generate_public_api_reference.py b/scripts/generate_public_api_reference.py index 9a1d330..eac7b60 100644 --- a/scripts/generate_public_api_reference.py +++ b/scripts/generate_public_api_reference.py @@ -9,7 +9,6 @@ from pathlib import Path from typing import Any - ROOT = Path(__file__).resolve().parents[1] OUT = ROOT / "docs" / "python-api" / "public-api-reference.md" From 0b8b8551ef67505f94e5d1e2720497bbbc6f8dd4 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Mon, 20 Jul 2026 15:53:03 -0700 Subject: [PATCH 3/3] Apply ruff formatting to tests/test_models_from_proto.py --- tests/test_models_from_proto.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/test_models_from_proto.py b/tests/test_models_from_proto.py index b8f5dec..6478c0e 100644 --- a/tests/test_models_from_proto.py +++ b/tests/test_models_from_proto.py @@ -1966,16 +1966,13 @@ def _online_idu_with_presence(s0: Presence, s1: Presence) -> IndoorUnit: def test_presence_detected_true_when_either_channel_detects() -> None: assert ( - _online_idu_with_presence(Presence.DETECTED, Presence.UNDETECTED).presence_detected - is True + _online_idu_with_presence(Presence.DETECTED, Presence.UNDETECTED).presence_detected is True ) assert ( - _online_idu_with_presence(Presence.UNDETECTED, Presence.DETECTED).presence_detected - is True + _online_idu_with_presence(Presence.UNDETECTED, Presence.DETECTED).presence_detected is True ) assert ( - _online_idu_with_presence(Presence.DETECTED, Presence.DETECTED).presence_detected - is True + _online_idu_with_presence(Presence.DETECTED, Presence.DETECTED).presence_detected is True )