Merge pull request #281 from atc722/agent/nv9000d-regression-fix

Fix read-only sensor categories and add NV9000D coverage
This commit is contained in:
Marc Billow
2026-08-04 21:40:03 -04:00
committed by GitHub
9 changed files with 353 additions and 8 deletions
@@ -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")
@@ -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,
),
@@ -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",
),
),
+23
View File
@@ -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"
]
}
+240
View File
@@ -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"
}
}
]
}
+13
View File
@@ -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 = [
{},
+28
View File
@@ -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 "<pattern>"
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()
+15
View File
@@ -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
+25 -4
View File
@@ -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)