diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index b5ff66e..cef9377 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -4,7 +4,8 @@ from typing import Optional from ._base import DeviceRegistry from . import ( air_purifier, airconditioner, cooktop, dehumidifier, dishwasher, dryer, - oven, range as _range, range_hood, refrigerator, washer, water_purifier, + induction_cooktop, oven, range as _range, range_hood, refrigerator, + washer, water_purifier, ) __all__ = [ @@ -22,6 +23,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { 'dehumidifier': dehumidifier.REGISTRY, 'dishwasher': dishwasher.REGISTRY, 'dryer': dryer.REGISTRY, + 'induction_cooktop': induction_cooktop.REGISTRY, 'oven': oven.REGISTRY, 'hood': range_hood.REGISTRY, 'range': _range.REGISTRY, @@ -198,6 +200,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 88c88f9..ea77f06 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)], ), ) @@ -134,6 +168,30 @@ COOKTOP_SAFETY = Capability( ), ) +# 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')), + ), +) + # Some range boards (issue #74's NE63B8411SS) report no /cooktop/status/vs/0 # burner array at all -- their local API only exposes this coarse # monitoring resource for the cooktop half, with no per-burner detail. diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index ada50d7..7ce43c0 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" }, @@ -61,6 +67,9 @@ "power_state": { "name": "Power state" }, + "probe_connected": { + "name": "Probe connected" + }, "power_switch": { "name": "Power" }, @@ -626,6 +635,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" }, @@ -726,6 +744,9 @@ "coldwater_lock": { "name": "Cold water 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 e6e3813..f47b701 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" }, @@ -61,6 +67,9 @@ "power_state": { "name": "Voedingsstatus" }, + "probe_connected": { + "name": "Sonde verbonden" + }, "power_switch": { "name": "Voeding" }, @@ -626,6 +635,15 @@ "power_watts": { "name": "Vermogen" }, + "probe_battery": { + "name": "Sonde batterij" + }, + "probe_target_temperature": { + "name": "Doeltemperatuur sonde" + }, + "probe_temperature": { + "name": "Temperatuur sonde" + }, "progress": { "name": "Voortgang" }, @@ -726,6 +744,9 @@ "coldwater_lock": { "name": "Koudwaterslot" }, + "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 240636f..6ecb818 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -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') diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 26c45f2..1764c17 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -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 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