diff --git a/custom_components/localthings/registry/batch.py b/custom_components/localthings/registry/batch.py index e5d5079..39cd4cb 100644 --- a/custom_components/localthings/registry/batch.py +++ b/custom_components/localthings/registry/batch.py @@ -18,12 +18,17 @@ def is_stub_rep(rep: dict) -> bool: def parse_device0_batch(device0: list) -> dict[str, dict]: """Extract {href: rep} from a /device/0 CBOR list response. + Most devices put a collection representation without an ``href`` at + index 0, while some firmware starts directly with resource entries. + Iterate the whole list and let the existing href check ignore collection + metadata so the first real resource is preserved in either shape. + A stub rep is passed through unchanged rather than collapsed to {} -- downstream code (entity._is_included, capability exists_fns) uses is_stub_rep to tell "not fetched yet" apart from a confirmed-empty {}. """ out = {} - for entry in device0[1:]: # skip [0] (device-level rep) + for entry in device0: if not isinstance(entry, dict): continue href = entry.get("href") diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index a1caa0d..3182738 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -957,7 +957,7 @@ REFRIGERATION_FALLBACK = Capability( key="rapid_fridge", field="rapidCool", icon="mdi:fridge-industrial", - entity_category="config", + entity_category="diagnostic", value_fn=lambda v: bool(v), exists_fn=lambda rep, resources: "/refrigeration/vs/0" not in resources, ), @@ -965,7 +965,7 @@ REFRIGERATION_FALLBACK = Capability( key="rapid_freezing", field="rapidFreeze", icon="mdi:snowflake", - entity_category="config", + entity_category="diagnostic", value_fn=lambda v: bool(v), exists_fn=lambda rep, resources: "/refrigeration/vs/0" not in resources, ), diff --git a/custom_components/localthings/registry/capabilities/range.py b/custom_components/localthings/registry/capabilities/range.py index ab49122..9941c19 100644 --- a/custom_components/localthings/registry/capabilities/range.py +++ b/custom_components/localthings/registry/capabilities/range.py @@ -178,7 +178,7 @@ COOKTOP_SAFETY = Capability( BinarySensorDesc( key="cooktop_safety_shutoff_enabled", field="safetyAlert", - entity_category="config", + entity_category="diagnostic", value_fn=lambda v: (v or {}).get("state") == "on", ), ), diff --git a/tests/fixtures/golden/induction_cooktop_nv9000d.json b/tests/fixtures/golden/induction_cooktop_nv9000d.json new file mode 100644 index 0000000..423ed24 --- /dev/null +++ b/tests/fixtures/golden/induction_cooktop_nv9000d.json @@ -0,0 +1,23 @@ +{ + "state_keys": [ + "alarm_code", + "burner_0_hot_surface", + "burner_0_pan_detected", + "burner_0_power_level", + "burner_0_state", + "burner_1_hot_surface", + "burner_1_pan_detected", + "burner_1_power_level", + "burner_1_state", + "burner_2_hot_surface", + "burner_2_pan_detected", + "burner_2_power_level", + "burner_2_state", + "cooktop_child_lock", + "cooktop_power", + "cooktop_safety_shutoff_enabled", + "cooktop_state", + "energy_kwh", + "firmware_update" + ] +} diff --git a/tests/fixtures/induction_cooktop_nv9000d_device.json b/tests/fixtures/induction_cooktop_nv9000d_device.json new file mode 100644 index 0000000..6d200eb --- /dev/null +++ b/tests/fixtures/induction_cooktop_nv9000d_device.json @@ -0,0 +1,240 @@ +{ + "meta": { + "note": "Samsung NV9000D induction cooktop (NV9000D-/KO2). Scrubbed real /device/0 dump captured for compatibility regression coverage. This firmware starts directly with /connectionconfig/vs/0 instead of a device-level collection representation.", + "model": "TP1X_DA-KS-COOKTOP-01011", + "product": "NV9000D-/KO2" + }, + "device0": [ + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.newVersionNo": "00000000", + "x.com.samsung.da.currentVersionInfo": "00000000", + "flashingProgress": "", + "otnTarget": "main", + "otnStatus": "None", + "otnCompleteDate": "noHistory", + "otnList": [ + { + "type": "WIFI", + "modelId": "AKS-WW-TP1-21-COOKTOP-2", + "versions": [ + "40260330" + ], + "visVersion": "260330" + }, + { + "type": "Micom", + "modelId": "051140474341FFFFFFFF", + "versions": [ + "26032400", + "FFFFFFFF" + ], + "visVersion": "260324" + } + ] + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "CT_E_OFF", + "x.com.samsung.da.triggeredTime": "**REDACTED**" + } + ] + } + }, + { + "href": "/cooktop/spec/vs/0", + "rep": { + "deviceType": "cooktop", + "micomModelId": "NV9000D-/KO2", + "supportedFeatureList": [ + "" + ], + "numberOfBurners": 3, + "supportedBurnerList": [ + 0, + 1, + 2 + ], + "supportedPowerLevelList": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "boost", + "0" + ] + } + }, + { + "href": "/cooktop/status/vs/0", + "rep": { + "sequenceNumber": 485, + "power": "off", + "childLock": "off", + "operationState": "ready", + "additionalStateList": [ + "" + ], + "burnerList": [ + { + "burnerNumber": 0, + "mode": "normal", + "operationState": "ready", + "powerLevel": "0", + "hotSurfaceState": "normal", + "panDetection": true, + "timer": { + "operationState": "ready", + "cookingTime": 0, + "remainingTime": 0 + } + }, + { + "burnerNumber": 1, + "mode": "normal", + "operationState": "ready", + "powerLevel": "0", + "hotSurfaceState": "normal", + "panDetection": true, + "timer": { + "operationState": "ready", + "cookingTime": 0, + "remainingTime": 0 + } + }, + { + "burnerNumber": 2, + "mode": "normal", + "operationState": "ready", + "powerLevel": "0", + "hotSurfaceState": "normal", + "panDetection": true, + "timer": { + "operationState": "ready", + "cookingTime": 0, + "remainingTime": 0 + } + } + ] + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA-KS-COOKTOP-01011|40474341|50000203001811000A00000000000000", + "x.com.samsung.da.description": "NV9000D-/KO2", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "26033000", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "04743A26032400", + "x.com.samsung.da.newVersionAvailable": "0" + } + ], + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "KC2", + "x.com.samsung.da.diagTsId": "DA01" + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/cooktop/settings/status/vs/0", + "rep": { + "sequenceNumber": 104, + "safetyAlert": { + "state": "on", + "settingTime": 3600 + } + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "218600", + "x.com.samsung.da.cumulativeUnit": "Wh" + } + } + ] +} diff --git a/tests/test_batch.py b/tests/test_batch.py index c71ac84..5957fe9 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -29,6 +29,19 @@ class TestIsStubRep: class TestParseDevice0Batch: + def test_keeps_first_resource_when_collection_rep_is_absent(self): + """Some firmware starts directly with resource entries instead of a + device-level collection representation.""" + device0 = [ + {"href": "/connectionconfig/vs/0", "rep": {"autoReconnection": "true"}}, + {"href": "/power/vs/0", "rep": {"power": "Off"}}, + ] + + assert parse_device0_batch(device0) == { + "/connectionconfig/vs/0": {"autoReconnection": "true"}, + "/power/vs/0": {"power": "Off"}, + } + def test_stub_rep_kept_distinct_from_genuine_empty(self): device0 = [ {}, diff --git a/tests/test_entities.py b/tests/test_entities.py index b0cd81c..0bd112c 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -1,3 +1,4 @@ +from custom_components.localthings.registry.by_type import _REGISTRY_BY_KEY from custom_components.localthings.registry.entities import ( PLATFORM_OF, BinarySensorDesc, @@ -54,3 +55,30 @@ def test_select_carries_options_and_write_fn(): ) assert d.write_fn is not None assert d.write_fn("tone", {}) == (["settings", "sound", "mode", "vs", "0"], {"mode": "tone"}) + + +def test_read_only_sensor_platforms_do_not_use_config_category(): + """Home Assistant rejects config-category Sensor/BinarySensor entities. + + Traverse device registries rather than the smaller global capability set + so device-specific and pattern capabilities stay covered as the registry + grows. + """ + violations = set() + + for registry in _REGISTRY_BY_KEY.values(): + capabilities = [ + capability for group in registry.capabilities.values() for capability in group + ] + capabilities.extend(registry.pattern_capabilities) + + for capability in capabilities: + href = capability.href or capability.href_prefix or "" + for desc in capability.entities: + if ( + isinstance(desc, (SensorDesc, BinarySensorDesc)) + and desc.entity_category == "config" + ): + violations.add((registry.name, href, desc.key)) + + assert violations == set() diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index dafc1fb..a39cd94 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -573,6 +573,21 @@ def test_registry_reproduces_golden_state_keys_for_induction_cooktop(): ) +def test_registry_reproduces_golden_state_keys_for_induction_cooktop_nv9000d(): + """NV9000D-/KO2 compatibility fixture: the same three-burner registry, + with the safety-shutoff status but without optional probe/hood resources.""" + from tests.conftest import _load_device + + resources = _load_device("induction_cooktop_nv9000d") + golden = json.loads((GOLDEN / "induction_cooktop_nv9000d.json").read_text()) + state_keys = _new_state_keys("induction_cooktop_nv9000d", resources) + assert set(state_keys) == set(golden["state_keys"]), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_registry_reproduces_golden_state_keys_for_range_no_info(): """NE63B8411SS (issue #74) -- reports no oneUiVersion *and* no /information/vs/0 at all, so neither for_device nor diff --git a/tests/test_induction_cooktop_capabilities.py b/tests/test_induction_cooktop_capabilities.py index 84db7a7..f69d58d 100644 --- a/tests/test_induction_cooktop_capabilities.py +++ b/tests/test_induction_cooktop_capabilities.py @@ -6,8 +6,8 @@ from custom_components.localthings.registry.discovery import discover from tests.conftest import _load_device -def _cooktop(): - resources = _load_device("induction_cooktop") +def _cooktop(name="induction_cooktop"): + resources = _load_device(name) info = resources["/information/vs/0"] reg = for_device_by_model( info["x.com.samsung.da.modelNum"], @@ -16,8 +16,8 @@ def _cooktop(): return reg, resources -def _state(): - reg, resources = _cooktop() +def _state(name="induction_cooktop"): + reg, resources = _cooktop(name) bound = discover(resources, reg.capabilities, reg.pattern_capabilities) return flatten(bound, resources) @@ -73,3 +73,24 @@ def test_recipe_status_href_is_ignored_not_unbound(): cap.href for caps in reg.capabilities.values() for cap in caps if cap.entities == () } assert "/cooktop/recipe/status/vs/0" in ignored_hrefs + + +def test_nv9000d_resolves_with_complete_coverage(): + """NV9000D-/KO2 reuses the standalone induction-cooktop surface but + omits the optional Bluetooth probe and paired-hood resources while adding + the read-only hot-surface auto-shutoff status.""" + reg, resources = _cooktop("induction_cooktop_nv9000d") + assert reg is induction_cooktop.REGISTRY + + unbound = [] + discover( + resources, + reg.capabilities, + reg.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + + state = _state("induction_cooktop_nv9000d") + assert state["cooktop_safety_shutoff_enabled"] is True + assert not any(key.startswith(("probe_", "paired_hood_")) for key in state)