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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@

## [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 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]):
`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
Expand Down
5 changes: 3 additions & 2 deletions docs/how-to/home-assistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | `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()` |
| `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

Expand Down
24 changes: 23 additions & 1 deletion docs/reference/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
1 change: 0 additions & 1 deletion scripts/check_docs_nav.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

import yaml


ROOT = Path(__file__).resolve().parent.parent


Expand Down
1 change: 0 additions & 1 deletion scripts/generate_public_api_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
18 changes: 10 additions & 8 deletions src/quilt_hp/cli/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down
40 changes: 38 additions & 2 deletions src/quilt_hp/models/indoor_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,15 +208,32 @@ 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


@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

Expand Down Expand Up @@ -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.
Expand Down
56 changes: 55 additions & 1 deletion tests/test_models_from_proto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1952,3 +1953,56 @@ 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
Loading