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 authored and GitHub committed 2026-08-04 21:40:03 -04:00
commit ccdfe7088e
9 files changed
+353 -8

No files matched your search

+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)