diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b5bcdf..627aad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/reference/hds-entities.md b/docs/reference/hds-entities.md index 969dee1..9498e42 100644 --- a/docs/reference/hds-entities.md +++ b/docs/reference/hds-entities.md @@ -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` | diff --git a/docs/reference/models.md b/docs/reference/models.md index d65785f..efbb007 100644 --- a/docs/reference/models.md +++ b/docs/reference/models.md @@ -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 diff --git a/src/quilt_hp/models/indoor_unit.py b/src/quilt_hp/models/indoor_unit.py index b7c5c31..398924c 100644 --- a/src/quilt_hp/models/indoor_unit.py +++ b/src/quilt_hp/models/indoor_unit.py @@ -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, @@ -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: @@ -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 @@ -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, @@ -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, ) diff --git a/src/quilt_hp/models/system.py b/src/quilt_hp/models/system.py index 4759ef9..10684f5 100644 --- a/src/quilt_hp/models/system.py +++ b/src/quilt_hp/models/system.py @@ -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: @@ -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 @@ -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) @@ -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) @@ -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] @@ -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], diff --git a/tests/test_models_from_proto.py b/tests/test_models_from_proto.py index 560cc44..c79f3c2 100644 --- a/tests/test_models_from_proto.py +++ b/tests/test_models_from_proto.py @@ -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=[], @@ -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=[], @@ -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=[], @@ -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=[], @@ -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"), @@ -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 ): @@ -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"), @@ -1525,6 +1616,7 @@ def _cs_proto( indoor_units=[], outdoor_units=[], outdoor_unit_hardware=[], + indoor_unit_hardware=[], controller_hardware=[], controllers=[], quilt_smart_modules=[], @@ -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=[],