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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
`local_comms_connection_state`, and `local_comms_version`).

### Added
- `IndoorUnit` now exposes `model_sku`, `serial_number`, and `firmware_version`,
resolved from `HomeDatastoreSystem.indoor_unit_hardware` (previously discarded).
This mirrors `OutdoorUnit` and `Controller` and lets downstream consumers (e.g.
the Home Assistant integration) populate IDU device serial/firmware.
- 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.
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/hds-entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ An IndoorUnit is a wall-mounted mini-split head unit (7⅞ in tall × 38¼ in wi
| `space_id` | `str` | Space this IDU serves |
| `outdoor_unit_id` | `str \| None` | Connected outdoor unit |
| `qsm_id` | `str \| None` | Embedded QuiltSmartModule ID |
| `model_sku` | `str \| None` | Hardware model identifier (from `indoor_unit_hardware`) |
| `serial_number` | `str \| None` | Unit serial number (from `indoor_unit_hardware`) |
| `firmware_version` | `str \| None` | Current firmware version string (from `indoor_unit_hardware`) |
| `controls.fan_speed` | `FanSpeed` | `AUTO`, `QUIET`, `LOW`, `MEDIUM`, `HIGH`, `BLAST` |
| `controls.louver_mode` | `LouverMode` | `CLOSED`, `SWEEP`, `FIXED`, `AUTO` |
| `controls.louver_fixed_position` | `float` | Position 0.0–1.0 when `louver_mode=FIXED` |
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ class IndoorUnit:
space_id: str
system_id: str
serial_number: str | None
model_sku: str | None
firmware_version: str | None
model_name: str | None
controls: IndoorUnitControls
settings: IndoorUnitSettings
Expand Down
33 changes: 28 additions & 5 deletions src/quilt_hp/models/indoor_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
ABSENT_FAN_SPEED_MODE_SENTINEL,
LOUVER_FIXED_POSITION_SENTINEL,
)
from quilt_hp.models._helpers import present_submsg, timestamp_or_none
from quilt_hp.models._helpers import lookup_hardware, present_submsg, timestamp_or_none
from quilt_hp.models.enums import (
FallbackControlCommand,
FanSpeed,
Expand Down Expand Up @@ -242,11 +242,19 @@ class IndoorUnit:
occupancy: IndoorUnitOccupancy | None
firmware_update_info_id: str | None = None
commands: IndoorUnitCommands | None = None
model_sku: str | None = None # IndoorUnitHardware.attributes.model_sku
serial_number: str | None = None # IndoorUnitHardware.attributes.serial_number
firmware_version: str | None = None # IndoorUnitHardware.attributes.firmware_version

@classmethod
def from_proto(cls, proto: object) -> IndoorUnit:
"""Construct from a protobuf IndoorUnit message."""
return _idu_from_proto(proto)
def from_proto(cls, proto: object, hw_map: dict[str, object] | None = None) -> IndoorUnit:
"""Construct from a protobuf IndoorUnit message.

``hw_map`` maps hardware_id → IndoorUnitHardware proto, built once from
``HomeDatastoreSystem.indoor_unit_hardware`` and passed in at snapshot
load time. Stream diffs won't have it; hardware fields default to None.
"""
return _idu_from_proto(proto, hw_map)

@property
def is_online(self) -> bool:
Expand Down Expand Up @@ -281,7 +289,7 @@ def effective_occupancy_state(self) -> int | None:
return self.occupancy.occupancy_state


def _idu_from_proto(proto: object) -> IndoorUnit:
def _idu_from_proto(proto: object, hw_map: dict[str, object] | None = None) -> IndoorUnit:
"""Internal: convert a proto IndoorUnit to our model.

Sub-messages absent from a sparse stream diff parse to ``None`` (for
Expand Down Expand Up @@ -466,6 +474,18 @@ def _idu_from_proto(proto: object) -> IndoorUnit:

rel = cast("Any", present_submsg(proto, "relationships"))
p = cast("Any", proto)

model_sku: str | None = None
serial_number: str | None = None
firmware_version: str | None = None
if hw_map and rel is not None:
hw = lookup_hardware(hw_map, rel.hardware_id)
if hw is not None:
a = cast("Any", hw).attributes
model_sku = a.model_sku or None
serial_number = a.serial_number or None
firmware_version = a.firmware_version or None

return IndoorUnit(
id=p.header.object_id,
system_id=p.header.system_id,
Expand All @@ -486,4 +506,7 @@ def _idu_from_proto(proto: object) -> IndoorUnit:
(rel.firmware_update_info_id or None) if rel is not None else None
),
commands=commands,
model_sku=model_sku,
serial_number=serial_number,
firmware_version=firmware_version,
)
29 changes: 26 additions & 3 deletions src/quilt_hp/models/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,10 @@ def apply_indoor_unit(self, idu: IndoorUnit) -> IndoorUnit:
which would make ``state.updated_at=None`` and therefore
``is_online=False``, causing ``led_on`` and
``effective_occupancy_state`` to report stale data.

Hardware info (``model_sku``, ``serial_number``, ``firmware_version``)
is only populated at initial snapshot load from ``indoor_unit_hardware``
and is never present in stream diffs, so preserve it.
"""
for i, u in enumerate(self.indoor_units):
if u.id == idu.id:
Expand Down Expand Up @@ -278,6 +282,16 @@ def apply_indoor_unit(self, idu: IndoorUnit) -> IndoorUnit:
updates["presence"] = u.presence
if idu.occupancy is None and u.occupancy is not None:
updates["occupancy"] = u.occupancy
# Preserve hardware info — stream diffs are parsed without a
# hw_map, so each field is absent (None) in a diff. Preserve
# them independently: model_sku can be absent while serial or
# firmware are present (real IDUs report model_sku="N/A").
if idu.model_sku is None and u.model_sku is not None:
updates["model_sku"] = u.model_sku
if idu.serial_number is None and u.serial_number is not None:
updates["serial_number"] = u.serial_number
if idu.firmware_version is None and u.firmware_version is not None:
updates["firmware_version"] = u.firmware_version
if updates:
idu = replace(idu, **updates)
self.indoor_units[i] = idu
Expand Down Expand Up @@ -308,10 +322,15 @@ def apply_outdoor_unit(self, odu: OutdoorUnit) -> OutdoorUnit:
# Preserve compressor telemetry when the diff omits it
if odu.performance_data is None and u.performance_data is not None:
updates["performance_data"] = u.performance_data
# Preserve hardware info — stream diffs are parsed without hw_map
# Preserve hardware info — stream diffs are parsed without a
# hw_map, so each field is absent (None) in a diff. Preserve
# them independently: model_sku may be absent while serial or
# firmware are present.
if odu.model_sku is None and u.model_sku is not None:
updates["model_sku"] = u.model_sku
if odu.serial_number is None and u.serial_number is not None:
updates["serial_number"] = u.serial_number
if odu.firmware_version is None and u.firmware_version is not None:
updates["firmware_version"] = u.firmware_version
if updates:
odu = replace(odu, **updates)
Expand Down Expand Up @@ -375,10 +394,13 @@ def apply_controller(self, ctrl: Controller) -> Controller:
):
updates["local_comms_health"] = c.local_comms_health
# Hardware fields are never in stream diffs — always preserve
# from snapshot
# from snapshot. Preserve each independently: model_sku may be
# absent while serial or firmware are present.
if ctrl.serial_number is None and c.serial_number is not None:
updates["serial_number"] = c.serial_number
if ctrl.model_sku is None and c.model_sku is not None:
updates["model_sku"] = c.model_sku
if ctrl.firmware_version is None and c.firmware_version is not None:
updates["firmware_version"] = c.firmware_version
if updates:
ctrl = replace(ctrl, **updates)
Expand Down Expand Up @@ -571,6 +593,7 @@ def _build_hw_map(items: object) -> dict[str, object]:
return hw_map

odu_hw_map = _build_hw_map(p.outdoor_unit_hardware)
idu_hw_map = _build_hw_map(p.indoor_unit_hardware)
ctrl_hw_map = _build_hw_map(p.controller_hardware)

locations = [Location.from_proto(loc) for loc in p.locations]
Expand All @@ -589,7 +612,7 @@ def _build_hw_map(items: object) -> dict[str, object]:

return cls(
spaces=spaces,
indoor_units=[IndoorUnit.from_proto(u) for u in p.indoor_units],
indoor_units=[IndoorUnit.from_proto(u, idu_hw_map) for u in p.indoor_units],
outdoor_units=[OutdoorUnit.from_proto(u, odu_hw_map) for u in p.outdoor_units],
controllers=[Controller.from_proto(c, ctrl_hw_map) for c in p.controllers],
quilt_smart_modules=[QuiltSmartModule.from_proto(q) for q in p.quilt_smart_modules],
Expand Down
93 changes: 93 additions & 0 deletions tests/test_models_from_proto.py
Original file line number Diff line number Diff line change
Expand Up @@ -1157,6 +1157,7 @@ def test_system_snapshot_rooms() -> None:
indoor_units=[],
outdoor_units=[],
outdoor_unit_hardware=[],
indoor_unit_hardware=[],
controller_hardware=[],
controllers=[],
quilt_smart_modules=[],
Expand Down Expand Up @@ -1218,6 +1219,7 @@ def test_system_snapshot_primary_location() -> None:
indoor_units=[],
outdoor_units=[],
outdoor_unit_hardware=[],
indoor_unit_hardware=[],
controller_hardware=[],
controllers=[],
quilt_smart_modules=[],
Expand Down Expand Up @@ -1247,6 +1249,7 @@ def test_system_snapshot_no_locations() -> None:
indoor_units=[],
outdoor_units=[],
outdoor_unit_hardware=[],
indoor_unit_hardware=[],
controller_hardware=[],
controllers=[],
quilt_smart_modules=[],
Expand Down Expand Up @@ -1274,6 +1277,7 @@ def test_system_snapshot_space_by_name() -> None:
indoor_units=[],
outdoor_units=[],
outdoor_unit_hardware=[],
indoor_unit_hardware=[],
controller_hardware=[],
controllers=[],
quilt_smart_modules=[],
Expand Down Expand Up @@ -1324,6 +1328,7 @@ def test_system_snapshot_hardware_map_deserializes_model_sku_with_prefixed_ids()
),
)
],
indoor_unit_hardware=[],
controller_hardware=[
_ns(
header=_make_header("controller_hardware/ctrl-hw-1"),
Expand Down Expand Up @@ -1394,6 +1399,91 @@ def test_system_snapshot_hardware_map_deserializes_model_sku_with_prefixed_ids()
assert snap.controllers[0].firmware_version == "9.8.7"


def test_system_snapshot_resolves_indoor_unit_hardware() -> None:
"""IDU serial/firmware/model_sku are resolved from indoor_unit_hardware."""
proto = _ns(
spaces=[],
indoor_units=[_make_idu_proto("idu-1")], # relationships.hardware_id == "hw-1"
outdoor_units=[],
outdoor_unit_hardware=[],
indoor_unit_hardware=[
_ns(
header=_make_header("hw-1"),
attributes=_ns(
model_sku="IDU-SKU-1",
serial_number="QS1-1B0RG633B",
firmware_version="43",
),
)
],
controller_hardware=[],
controllers=[],
quilt_smart_modules=[],
comfort_settings=[],
schedule_weeks=[],
schedule_days=[],
remote_sensors=[],
controller_remote_sensors=[],
software_update_infos=[],
locations=[],
)

snap = SystemSnapshot.from_proto(proto)
assert snap.indoor_units[0].model_sku == "IDU-SKU-1"
assert snap.indoor_units[0].serial_number == "QS1-1B0RG633B"
assert snap.indoor_units[0].firmware_version == "43"

# A sparse stream diff (no hw_map) must not erase the resolved hardware info.
diff = IndoorUnit.from_proto(_make_idu_proto("idu-1"))
assert diff.serial_number is None
merged = snap.apply_indoor_unit(diff)
assert merged.serial_number == "QS1-1B0RG633B"
assert merged.firmware_version == "43"
assert merged.model_sku == "IDU-SKU-1"


def test_apply_indoor_unit_preserves_hardware_fields_independently() -> None:
"""serial/firmware are preserved even when model_sku is absent (model_sku='N/A')."""
proto = _ns(
spaces=[],
indoor_units=[_make_idu_proto("idu-1")], # relationships.hardware_id == "hw-1"
outdoor_units=[],
outdoor_unit_hardware=[],
indoor_unit_hardware=[
_ns(
header=_make_header("hw-1"),
attributes=_ns(
model_sku="", # real IDUs report "N/A"/empty → resolves to None
serial_number="QS1-1B0RG4P3V",
firmware_version="43",
),
)
],
controller_hardware=[],
controllers=[],
quilt_smart_modules=[],
comfort_settings=[],
schedule_weeks=[],
schedule_days=[],
remote_sensors=[],
controller_remote_sensors=[],
software_update_infos=[],
locations=[],
)

snap = SystemSnapshot.from_proto(proto)
assert snap.indoor_units[0].model_sku is None
assert snap.indoor_units[0].serial_number == "QS1-1B0RG4P3V"

# A sparse stream diff must not erase serial/firmware just because the
# snapshot's model_sku was absent.
diff = IndoorUnit.from_proto(_make_idu_proto("idu-1"))
merged = snap.apply_indoor_unit(diff)
assert merged.serial_number == "QS1-1B0RG4P3V"
assert merged.firmware_version == "43"
assert merged.model_sku is None


def test_system_snapshot_hardware_map_deserializes_model_sku_with_colon_and_case_variants() -> (
None
):
Expand Down Expand Up @@ -1421,6 +1511,7 @@ def test_system_snapshot_hardware_map_deserializes_model_sku_with_colon_and_case
),
)
],
indoor_unit_hardware=[],
controller_hardware=[
_ns(
header=_make_header("controller_hardware:ctrl-hw-1"),
Expand Down Expand Up @@ -1525,6 +1616,7 @@ def _cs_proto(
indoor_units=[],
outdoor_units=[],
outdoor_unit_hardware=[],
indoor_unit_hardware=[],
controller_hardware=[],
controllers=[],
quilt_smart_modules=[],
Expand Down Expand Up @@ -1701,6 +1793,7 @@ def _idu(idu_id: str, space_id: str, odu_id: str) -> SimpleNamespace:
_make_odu_proto("odu-3"), # present but not linked to any IDU
],
outdoor_unit_hardware=[],
indoor_unit_hardware=[],
controller_hardware=[],
controllers=[],
quilt_smart_modules=[],
Expand Down