Merge pull request #98 from mbillow/claude/device-support-issue-86-cooktop

Add standalone induction-cooktop support (issue #86)
This commit is contained in:
Marc Billow authored and GitHub committed 2026-07-26 23:04:51 -05:00
commit b3a9192088
13 files changed
+569 -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
@@ -351,6 +351,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')
+11
View File
@@ -385,6 +385,17 @@ 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)
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
@@ -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