From a453dc9ece955c4b44ee9434cffb8296f94ba511 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 01:12:16 +0000 Subject: [PATCH 1/2] 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. --- .../localthings/registry/by_type/__init__.py | 13 +- .../registry/by_type/induction_cooktop.py | 30 +++ .../registry/capabilities/ignored.py | 8 + .../registry/capabilities/range.py | 60 ++++- .../localthings/translations/en.json | 21 ++ .../localthings/translations/nl.json | 21 ++ tests/fixtures/golden/induction_cooktop.json | 32 +++ tests/fixtures/golden/range.json | 6 + tests/fixtures/induction_cooktop_device.json | 243 ++++++++++++++++++ tests/test_by_type.py | 22 ++ tests/test_golden_regression.py | 17 ++ tests/test_induction_cooktop_capabilities.py | 65 +++++ tests/test_range_capabilities.py | 38 +++ 13 files changed, 574 insertions(+), 2 deletions(-) create mode 100644 custom_components/localthings/registry/by_type/induction_cooktop.py create mode 100644 tests/fixtures/golden/induction_cooktop.json create mode 100644 tests/fixtures/induction_cooktop_device.json create mode 100644 tests/test_induction_cooktop_capabilities.py diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 7f6b864..6debc69 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -3,7 +3,7 @@ from typing import Optional from ._base import DeviceRegistry from . import ( - air_purifier, airconditioner, cooktop, dishwasher, dryer, oven, + air_purifier, airconditioner, cooktop, dishwasher, dryer, induction_cooktop, oven, range as _range, range_hood, refrigerator, washer, ) @@ -21,6 +21,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { 'cooktop': cooktop.REGISTRY, 'dishwasher': dishwasher.REGISTRY, 'dryer': dryer.REGISTRY, + 'induction_cooktop': induction_cooktop.REGISTRY, 'oven': oven.REGISTRY, 'hood': range_hood.REGISTRY, 'range': _range.REGISTRY, @@ -141,6 +142,16 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map. if key is None and '-OVEN-' in (model_num or '').upper(): key = 'oven' + # Standalone induction cooktops (e.g. TP1X_DA-KS-COOKTOP-01011, issue + # #86) -- same board family and '/cooktop/status/vs/0' resource shape + # as the range combo above, but no oven attached at all. Distinct from + # the '_COOKTOP'/'_GB_CT_' underscore-delimited check above: that one + # matches a different, older gas-cooktop family (cooktop.py's NA9300K + # class, burner state embedded in /mode/vs/0's options array) whose + # modelNum token is underscore-delimited, not hyphenated like this + # board family's. + if key is None and '-COOKTOP-' in (model_num or '').upper(): + key = 'induction_cooktop' return _REGISTRY_BY_KEY.get(key) if key else None diff --git a/custom_components/localthings/registry/by_type/induction_cooktop.py b/custom_components/localthings/registry/by_type/induction_cooktop.py new file mode 100644 index 0000000..6c153db --- /dev/null +++ b/custom_components/localthings/registry/by_type/induction_cooktop.py @@ -0,0 +1,30 @@ +"""Standalone induction-cooktop device registry (Samsung +TP1X_DA-KS-COOKTOP-class, issue #86) -- the cooktop half of the +range/oven board family (see capabilities/range.py), but with no oven +attached at all. + +Reuses range.py's COOKTOP_STATUS/COOKTOP_SPEC/COOKTOP_SAFETY/PROBE_STATUS +wholesale (identical resource shapes to the range combo's cooktop half) +and cooktop.PAIRED_HOOD_STATUS for the Bluetooth-paired range hood some +units pair with. Distinct registry key from cooktop.REGISTRY ('cooktop') +-- that family is the unrelated NA9300K-class gas cooktop (burner state +embedded in /mode/vs/0's options array, a completely different OCF +surface that happens to share the English word "cooktop"). +""" +from ..capabilities import common, cooktop as cooktop_caps, ignored +from ..capabilities import range as range_caps +from ._base import DeviceRegistry, _build + +REGISTRY = DeviceRegistry( + name='induction_cooktop', + capabilities=_build([ + *ignored.IGNORED, + *common.UNIVERSAL, + *common.POWER, + range_caps.COOKTOP_STATUS, + range_caps.COOKTOP_SPEC, + range_caps.COOKTOP_SAFETY, + range_caps.PROBE_STATUS, + cooktop_caps.PAIRED_HOOD_STATUS, + ]), +) diff --git a/custom_components/localthings/registry/capabilities/ignored.py b/custom_components/localthings/registry/capabilities/ignored.py index b7e3cc1..3171475 100644 --- a/custom_components/localthings/registry/capabilities/ignored.py +++ b/custom_components/localthings/registry/capabilities/ignored.py @@ -131,4 +131,12 @@ IGNORED: list[Capability] = [ # parallel write-capable capability around an unverified generic OCF # write contract. Capability(href='/operational/state/0'), + + # Cooktop guided-cooking/recipe status (issue #86, TP1X_DA-KS-COOKTOP + # family): sequenceNumber, operationBurnerNumber, a stageInfo block, and + # a textData.menu string -- every field empty/zero on the only dump + # seen so far (device idle, no guided-cooking program active). Same + # "don't guess" treatment as the microwave family's /recipe/cook/vs/0. + # Revisit if a dump with an active recipe surfaces. + Capability(href='/cooktop/recipe/status/vs/0'), ] diff --git a/custom_components/localthings/registry/capabilities/range.py b/custom_components/localthings/registry/capabilities/range.py index 2ebb8af..9794b3b 100644 --- a/custom_components/localthings/registry/capabilities/range.py +++ b/custom_components/localthings/registry/capabilities/range.py @@ -24,7 +24,8 @@ write pattern already proven safe elsewhere in this codebase (oven setpoint, icemaker toggles). """ from ..capability import Capability -from ..entities import BinarySensorDesc, SelectDesc, SensorDesc +from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc +from .common import normalize_temp_unit # Observed as high as 4 (this issue's dump); user-reported hardware with 5 # burners exists. Kept a little above both since exists_fn gates unused @@ -52,6 +53,10 @@ def _burner_hot_surface_fn(i): return lambda burner_list: get_state(burner_list) not in (None, 'normal') +def _burner_pan_detection_fn(i): + return lambda burner_list: bool(_burner_field_fn(i, 'panDetection')(burner_list)) + + def _power_level_options(resources): spec = resources.get('/cooktop/spec/vs/0') or {} return list(spec.get('supportedPowerLevelList') or []) @@ -100,15 +105,44 @@ def _burner_entities(i): translation_placeholders={'number': str(n)}, exists_fn=exists, value_fn=_burner_hot_surface_fn(i)), + BinarySensorDesc(key=f'burner_{i}_pan_detected', field='burnerList', + icon='mdi:pot', + translation_key='burner_pan_detected', + translation_placeholders={'number': str(n)}, + entity_category='diagnostic', + exists_fn=exists, + value_fn=_burner_pan_detection_fn(i)), ) +def _child_lock_write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + return ['cooktop', 'status', 'vs', '0'], {'childLock': p.lower()} + + COOKTOP_STATUS = Capability( href='/cooktop/status/vs/0', poll_tier='hot', entities=( SensorDesc(key='cooktop_state', field='operationState', icon='mdi:pot-steam'), + # The cooktop section's own on/off (issue #86) -- distinct from + # common.POWER's /power/0 or /power/vs/0, which some boards in this + # family (the range combo) additionally carry for the whole + # appliance. Read-only: no live device to confirm remotely turning + # a cooktop on wouldn't leave a burner active unattended. + BinarySensorDesc(key='cooktop_power', field='power', + device_class='power', icon='mdi:pot-steam', + value_fn=lambda v: str(v).lower() == 'on'), + # Safe to write -- a lock toggle, not a heat control -- via a + # direct single-field PUT (no RMW needed; unlike burnerList this + # is a lone scalar, not an array of siblings to preserve). + SwitchDesc(key='cooktop_child_lock', field='childLock', + device_class='lock', entity_category='config', + icon='mdi:lock', + value_fn=lambda v: str(v).lower() == 'on', + write_fn=_child_lock_write), *[e for i in range(MAX_BURNERS) for e in _burner_entities(i)], ), ) @@ -133,3 +167,27 @@ COOKTOP_SAFETY = Capability( value_fn=lambda v: (v or {}).get('state') == 'on'), ), ) + +# Bluetooth meat probe (issue #86). All-idle sentinel values when +# disconnected (operationBurnerNumber -1, temperatures 0) -- no special +# gating on those, matching cooktop.PAIRED_HOOD_STATUS's own precedent of +# showing a disconnected accessory's fields plainly rather than hiding the +# whole capability. +PROBE_STATUS = Capability( + href='/bluetooth/probe/status/vs/0', + poll_tier='warm', + entities=( + BinarySensorDesc(key='probe_connected', field='connectionState', + device_class='connectivity', + value_fn=lambda v: str(v).lower() == 'connected'), + SensorDesc(key='probe_battery', field='batteryPercentage', + device_class='battery', state_class='measurement', + unit='%', entity_category='diagnostic'), + SensorDesc(key='probe_temperature', field='currentTemperature', + device_class='temperature', state_class='measurement', + unit_fn=lambda rep: normalize_temp_unit(rep.get('temperatureUnit'), '°C')), + SensorDesc(key='probe_target_temperature', field='targetTemperature', + device_class='temperature', entity_category='diagnostic', + unit_fn=lambda rep: normalize_temp_unit(rep.get('temperatureUnit'), '°C')), + ), +) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index f990cc6..eb1302c 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -10,9 +10,15 @@ "burner_hot_surface": { "name": "Burner {number} hot surface" }, + "burner_pan_detected": { + "name": "Burner {number} pan detected" + }, "cloud_connected": { "name": "Cloud connected" }, + "cooktop_power": { + "name": "Cooktop power" + }, "cooktop_safety_shutoff_enabled": { "name": "Hot surface auto-shutoff enabled" }, @@ -55,6 +61,9 @@ "power_state": { "name": "Power state" }, + "probe_connected": { + "name": "Probe connected" + }, "rapid_freezing": { "name": "Rapid freezing" }, @@ -574,6 +583,15 @@ "power_watts": { "name": "Power" }, + "probe_battery": { + "name": "Probe battery" + }, + "probe_target_temperature": { + "name": "Probe target temperature" + }, + "probe_temperature": { + "name": "Probe temperature" + }, "progress": { "name": "Progress" }, @@ -650,6 +668,9 @@ "child_lock": { "name": "Child lock" }, + "cooktop_child_lock": { + "name": "Child lock" + }, "defrost_delay": { "name": "Defrost delay" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index c922d82..9ae2155 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -10,9 +10,15 @@ "burner_hot_surface": { "name": "Brander {number} heet oppervlak" }, + "burner_pan_detected": { + "name": "Brander {number} pan gedetecteerd" + }, "cloud_connected": { "name": "Verbonden met cloud" }, + "cooktop_power": { + "name": "Kookplaat voeding" + }, "cooktop_safety_shutoff_enabled": { "name": "Automatische uitschakeling heet oppervlak ingeschakeld" }, @@ -55,6 +61,9 @@ "power_state": { "name": "Voedingsstatus" }, + "probe_connected": { + "name": "Sonde verbonden" + }, "rapid_freezing": { "name": "Snelvriezen" }, @@ -574,6 +583,15 @@ "power_watts": { "name": "Vermogen" }, + "probe_battery": { + "name": "Sonde batterij" + }, + "probe_target_temperature": { + "name": "Doeltemperatuur sonde" + }, + "probe_temperature": { + "name": "Temperatuur sonde" + }, "progress": { "name": "Voortgang" }, @@ -650,6 +668,9 @@ "child_lock": { "name": "Kinderslot" }, + "cooktop_child_lock": { + "name": "Kinderslot" + }, "defrost_delay": { "name": "Ontdooien uitstellen" }, diff --git a/tests/fixtures/golden/induction_cooktop.json b/tests/fixtures/golden/induction_cooktop.json new file mode 100644 index 0000000..df0c1c5 --- /dev/null +++ b/tests/fixtures/golden/induction_cooktop.json @@ -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" + ] +} diff --git a/tests/fixtures/golden/range.json b/tests/fixtures/golden/range.json index c4721de..f386342 100644 --- a/tests/fixtures/golden/range.json +++ b/tests/fixtures/golden/range.json @@ -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", diff --git a/tests/fixtures/induction_cooktop_device.json b/tests/fixtures/induction_cooktop_device.json new file mode 100644 index 0000000..212ef12 --- /dev/null +++ b/tests/fixtures/induction_cooktop_device.json @@ -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" + } + } + ] +} \ No newline at end of file diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 656f937..21b8f57 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -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') diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 56ba878..91b3177 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -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.""" diff --git a/tests/test_induction_cooktop_capabilities.py b/tests/test_induction_cooktop_capabilities.py new file mode 100644 index 0000000..1c3dfea --- /dev/null +++ b/tests/test_induction_cooktop_capabilities.py @@ -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 diff --git a/tests/test_range_capabilities.py b/tests/test_range_capabilities.py index cc5de15..82c141d 100644 --- a/tests/test_range_capabilities.py +++ b/tests/test_range_capabilities.py @@ -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 From 78f1bb0f92b00651e77e489f60e7818c8624b189 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 03:46:55 +0000 Subject: [PATCH 2/2] Fix unclosed PROBE_STATUS capability from a bad merge conflict resolution The merge of main into this branch dropped PROBE_STATUS's closing ),\n) and glued issue #74's COOKTOP_MONITORING addition directly onto its entities tuple, leaving an unclosed paren (SyntaxError on import, breaking Pytest and Hassfest CI). Restores the closing parens; no functional change. --- custom_components/localthings/registry/capabilities/range.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/localthings/registry/capabilities/range.py b/custom_components/localthings/registry/capabilities/range.py index e7ad0d7..ea77f06 100644 --- a/custom_components/localthings/registry/capabilities/range.py +++ b/custom_components/localthings/registry/capabilities/range.py @@ -189,6 +189,8 @@ PROBE_STATUS = Capability( SensorDesc(key='probe_target_temperature', field='targetTemperature', device_class='temperature', entity_category='diagnostic', unit_fn=lambda rep: normalize_temp_unit(rep.get('temperatureUnit'), '°C')), + ), +) # Some range boards (issue #74's NE63B8411SS) report no /cooktop/status/vs/0 # burner array at all -- their local API only exposes this coarse