Merge pull request #281 from atc722/agent/nv9000d-regression-fix
Fix read-only sensor categories and add NV9000D coverage
This commit is contained in:
@@ -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",
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 = [
|
||||
{},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user