Add standalone induction-cooktop support (issue #86)

TP1X_DA-KS-COOKTOP-01011 (NV8500T-/KO4) is the same board family and
/cooktop/status/vs/0 resource shape as issue #44's range combo, minus
the oven -- but its modelNum uses the hyphenated '-COOKTOP-' token,
which for_device_by_model's existing '_COOKTOP' check (underscore-
delimited, matching the unrelated NA9300K gas-cooktop family in
cooktop.py) doesn't match. The device fell back to 'unknown' with only
energy/alarms from the global fallback.

Add the hyphenated token check, routing to a new 'induction_cooktop'
registry (by_type/induction_cooktop.py) that reuses range.py's
COOKTOP_STATUS/COOKTOP_SPEC/COOKTOP_SAFETY/PROBE_STATUS and
cooktop.PAIRED_HOOD_STATUS wholesale rather than pulling in range.py's
oven capabilities, which this device has no hrefs for at all.

Along the way, three fields the reporter asked for turned out to be
gaps in the shared range.py capability itself, not just missing
routing -- also present (and previously unmodeled) on issue #44's
original combo-range dump:
- /cooktop/status/vs/0's own `power` and `childLock` fields (distinct
  from common.POWER's /power/0 or /power/vs/0, which a combo range
  additionally carries for the whole appliance) -- childLock gets a
  write_fn (a safe lock toggle, direct single-field PUT), power stays
  read-only (no live device to confirm a remote write wouldn't leave a
  burner active unattended).
- Each burner's `panDetection` field.

New: a Bluetooth meat-probe capability for /bluetooth/probe/status/vs/0
(read-only -- connection, battery, current/target temperature), and
/cooktop/recipe/status/vs/0 is ignored (every field empty on this idle
dump, same treatment as the microwave family's /recipe/cook/vs/0).

Regenerates the range golden fixture (gains cooktop_power/
cooktop_child_lock/burner_N_pan_detected) and adds a dedicated fixture
for the standalone cooktop.
This commit is contained in:
Marc Billow committed 2026-07-27 01:12:16 +00:00
1 parent aff7647460
commit a453dc9ece
13 files changed
+574 -2

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
{
"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_state",
"energy_kwh",
"firmware_update",
"paired_hood_connected",
"paired_hood_fan_speed",
"paired_hood_firmware",
"paired_hood_light",
"paired_hood_model",
"paired_hood_power",
"probe_battery",
"probe_connected",
"probe_target_temperature",
"probe_temperature"
]
}
+6
View File
@@ -2,20 +2,26 @@
"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",
"burner_3_hot_surface",
"burner_3_pan_detected",
"burner_3_power_level",
"burner_3_state",
"child_lock",
"cloud_connected",
"cook_time",
"cooktop_child_lock",
"cooktop_power",
"cooktop_safety_shutoff_enabled",
"cooktop_state",
"current_temp_c",
+243
View File
@@ -0,0 +1,243 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"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": "2026-07-26T06:30:18"
}
]
}
},
{
"href": "/bluetooth/hood/status/vs/0",
"rep": {
"connectionState": "disconnected",
"micomModelId": "",
"firmwareVersion": "",
"power": "off",
"fanSpeed": 0,
"lampState": "off",
"timer": {}
}
},
{
"href": "/bluetooth/probe/status/vs/0",
"rep": {
"connectionState": "disconnected",
"batteryState": "normal",
"batteryPercentage": 0,
"operationBurnerNumber": -1,
"temperatureUnit": "C",
"targetTemperature": 0,
"currentTemperature": 0
}
},
{
"href": "/cooktop/recipe/status/vs/0",
"rep": {
"sequenceNumber": 160,
"operationBurnerNumber": 2,
"stageInfo": {
"numberOfStages": 0,
"cookingTimeList": []
},
"textData": {
"menu": ""
}
}
},
{
"href": "/cooktop/spec/vs/0",
"rep": {
"deviceType": "cooktop",
"micomModelId": "NV8500T-/KO4",
"supportedFeatureList": [
"kitchenService"
],
"numberOfBurners": 3,
"supportedBurnerList": [
0,
1,
2
],
"supportedPowerLevelList": [
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"boost",
"0",
"keepWarm"
],
"supportedRecipeProtocolVersion": 3
}
},
{
"href": "/cooktop/status/vs/0",
"rep": {
"sequenceNumber": 3214,
"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": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePower": "212700",
"x.com.samsung.da.cumulativeUnit": "Wh"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_DA-KS-COOKTOP-01011|40459741|50000203001711000A00000000000000",
"x.com.samsung.da.description": "NV8500T-/KO4",
"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": "04597A23021100, 04596A23080300",
"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": "KCA",
"x.com.samsung.da.diagTsId": "DA01"
}
},
{
"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": "05114045974140459641",
"versions": [
"23021100",
"23080300"
],
"visVersion": "230803"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
}
]
}
+22
View File
@@ -241,6 +241,28 @@ class TestForDeviceByModel:
assert reg is not None
assert reg.name == 'oven'
def test_induction_cooktop_via_hyphenated_cooktop_token(self):
"""Issue #86: a standalone induction cooktop (NV8500T-/KO4) reports
no oneUiVersion and an unrecognized consumer token ('NV'); it falls
back to the hyphenated '-COOKTOP-' token in modelNum -- distinct
from the underscore-delimited '_COOKTOP'/'_GB_CT_' check, which is
the unrelated older NA9300K gas-cooktop family."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'TP1X_DA-KS-COOKTOP-01011|40459741|50000203001711000A00000000000000',
'NV8500T-/KO4',
)
assert reg is not None
assert reg.name == 'induction_cooktop'
def test_hyphenated_cooktop_token_not_confused_with_range(self):
"""'-COOKTOP-' and '-RANGE-' must route to distinct registries even
though both are TP1X_DA-KS- board-family siblings."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model('TP1X_DA-KS-COOKTOP-01011', 'NV8500T-/KO4')
assert reg is not None
assert reg.name != 'range'
def test_unknown_model_returns_none(self):
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model('SOME-UNKNOWN-BOARD', 'SOME-UNKNOWN-BOARD')
+17
View File
@@ -262,6 +262,23 @@ def test_registry_reproduces_golden_state_keys_for_range():
)
def test_registry_reproduces_golden_state_keys_for_induction_cooktop():
"""Standalone induction cooktop, no oven attached (model
TP1X_DA-KS-COOKTOP-01011, issue #86) -- reports no oneUiVersion and a
hyphenated '-COOKTOP-' modelNum token, resolved via
for_device_by_model into its own 'induction_cooktop' registry (not
cooktop.REGISTRY, which is the unrelated NA9300K gas-cooktop family)."""
from tests.conftest import _load_device
resources = _load_device('induction_cooktop')
golden = json.loads((GOLDEN / 'induction_cooktop.json').read_text())
state_keys = _new_state_keys('induction_cooktop', 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_air_purifier():
"""ARTIK051_TVTL_18K (issue #56) -- reports no oneUiVersion; resolved via
the '_TVTL_' modelNum token fallback in for_device_by_model."""
@@ -0,0 +1,65 @@
"""Tests for the standalone induction-cooktop registry (issue #86)."""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model, induction_cooktop
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _cooktop():
resources = _load_device('induction_cooktop')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
)
return reg, resources
def _state():
reg, resources = _cooktop()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
return flatten(bound, resources)
def test_model_resolves_to_induction_cooktop_registry():
reg, _ = _cooktop()
assert reg is not None and reg.name == 'induction_cooktop'
assert reg is induction_cooktop.REGISTRY
def test_no_unbound_hrefs():
"""Every resource in the issue #86 dump binds or is ignored -- clears
the coverage-gap repair."""
reg, resources = _cooktop()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_expected_entities_present():
state = _state()
for key in (
'cooktop_state', 'cooktop_power', 'cooktop_child_lock',
'burner_0_power_level', 'burner_0_state', 'burner_0_hot_surface',
'burner_0_pan_detected', 'burner_2_pan_detected',
'paired_hood_connected', 'probe_connected', 'probe_temperature',
'energy_kwh', 'alarm_code',
):
assert key in state, key
def test_unreported_burners_gated_out():
"""Dump reports numberOfBurners=3 (indices 0-2) -- burner slot 3+ of
range.MAX_BURNERS must not appear as entities."""
state = _state()
assert 'burner_3_power_level' not in state
def test_recipe_status_href_is_ignored_not_unbound():
"""/cooktop/recipe/status/vs/0 is idle/empty on this dump -- covered by
ignored.py, not modeled as an entity (same treatment as the microwave
family's /recipe/cook/vs/0)."""
reg, _ = _cooktop()
ignored_hrefs = {cap.href for caps in reg.capabilities.values() for cap in caps
if cap.entities == ()}
assert '/cooktop/recipe/status/vs/0' in ignored_hrefs
+38
View File
@@ -82,3 +82,41 @@ def test_burner_hot_surface_true_when_not_normal():
if e.key == 'burner_0_hot_surface')
assert desc.value_fn([{'burnerNumber': 0, 'hotSurfaceState': 'hot'}]) is True
assert desc.value_fn([{'burnerNumber': 0, 'hotSurfaceState': 'normal'}]) is False
def test_burner_pan_detected(): # issue #86
desc = next(e for e in range_caps.COOKTOP_STATUS.entities
if e.key == 'burner_0_pan_detected')
assert desc.value_fn([{'burnerNumber': 0, 'panDetection': True}]) is True
assert desc.value_fn([{'burnerNumber': 0, 'panDetection': False}]) is False
assert desc.value_fn([{'burnerNumber': 1, 'panDetection': True}]) is False # different burner
def test_cooktop_power_is_read_only(): # issue #86
desc = next(e for e in range_caps.COOKTOP_STATUS.entities if e.key == 'cooktop_power')
assert desc.value_fn('On') is True
assert desc.value_fn('Off') is False
assert not hasattr(desc, 'write_fn') # BinarySensorDesc has no write path at all
def test_cooktop_child_lock_write(): # issue #86
desc = next(e for e in range_caps.COOKTOP_STATUS.entities if e.key == 'cooktop_child_lock')
assert desc.value_fn('on') is True
assert desc.value_fn('off') is False
path, body = desc.write_fn('On', {})
assert path == ['cooktop', 'status', 'vs', '0']
assert body == {'childLock': 'on'}
assert desc.write_fn('Bogus', {}) is None
def test_probe_status_temperature_unit_reads_live_field(): # issue #86
desc = next(e for e in range_caps.PROBE_STATUS.entities if e.key == 'probe_temperature')
assert desc.unit_fn({'temperatureUnit': 'C'}) == '°C'
assert desc.unit_fn({'temperatureUnit': 'F'}) == '°F'
assert desc.unit_fn({}) == '°C'
def test_probe_status_connected_value_fn(): # issue #86
desc = next(e for e in range_caps.PROBE_STATUS.entities if e.key == 'probe_connected')
assert desc.value_fn('Connected') is True
assert desc.value_fn('Disconnected') is False