diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 0939dc3..4fa49d9 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -127,6 +127,11 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # and don't match the washer/dryer/dishwasher consumer-prefix map either. if key is None and '-RANGE-' in (model_num or '').upper(): key = 'range' + # Wall ovens (e.g. TP1X_DA-KS-OVEN-0107X, issue #55) -- same board-family + # naming as the range combo above, minus the burners; also reports no + # oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map. + if key is None and '-OVEN-' in (model_num or '').upper(): + key = 'oven' return _REGISTRY_BY_KEY.get(key) if key else None diff --git a/custom_components/localthings/registry/capabilities/operational.py b/custom_components/localthings/registry/capabilities/operational.py index bc9efb2..99d853d 100644 --- a/custom_components/localthings/registry/capabilities/operational.py +++ b/custom_components/localthings/registry/capabilities/operational.py @@ -70,6 +70,15 @@ def _finish_time(remaining_str): return None +# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly +# rather than keeping its own copy, since both wrote the identical +# state='Ready' RMW. +STOP_BUTTON = ButtonDesc(key='stop', field='', name='Stop', payload='Ready', + icon='mdi:stop', + write_fn=lambda p, rep, href=None: ( + ['operational', 'state', 'vs', '0'], + {'x.com.samsung.da.state': p})) + OPERATIONAL_STATE = Capability( href='/operational/state/vs/0', poll_tier='hot', @@ -83,8 +92,12 @@ OPERATIONAL_STATE = Capability( # Harmless for non-oven appliances — just an extra bool in state. # Samsung firmware keeps state='Run' after progress reaches 'Finish', # so we also gate on progress to avoid a stuck 'Running' indication. + # Named 'Running' rather than 'Cycle active' -- this href (and the + # start/pause/stop buttons below) is shared across the dryer/ + # dishwasher/oven/washer families, and 'cycle' is laundry-specific + # vocabulary that doesn't fit an oven's bake/roast/etc. BinarySensorDesc(key='cycle_active', device_class='running', - name='Cycle active', + name='Running', rep_fn=lambda rep: ( _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active' and rep.get('x.com.samsung.da.progress') != 'Finish' @@ -119,20 +132,16 @@ OPERATIONAL_STATE = Capability( write_fn=lambda p, rep, href=None: ( ['operational', 'state', 'vs', '0'], {_delay_field(rep): _format_delay(p)})), - ButtonDesc(key='start', field='', name='Start cycle', payload='Run', + ButtonDesc(key='start', field='', name='Start', payload='Run', icon='mdi:play', write_fn=lambda p, rep, href=None: ( ['operational', 'state', 'vs', '0'], {'x.com.samsung.da.state': p})), - ButtonDesc(key='pause', field='', name='Pause cycle', payload='Pause', + ButtonDesc(key='pause', field='', name='Pause', payload='Pause', icon='mdi:pause', write_fn=lambda p, rep, href=None: ( ['operational', 'state', 'vs', '0'], {'x.com.samsung.da.state': p})), - ButtonDesc(key='stop', field='', name='Stop cycle', payload='Ready', - icon='mdi:stop', - write_fn=lambda p, rep, href=None: ( - ['operational', 'state', 'vs', '0'], - {'x.com.samsung.da.state': p})), + STOP_BUTTON, ), ) diff --git a/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index 2eadfc4..b8d8ea8 100644 --- a/custom_components/localthings/registry/capabilities/oven.py +++ b/custom_components/localthings/registry/capabilities/oven.py @@ -26,10 +26,10 @@ from datetime import datetime, timezone, timedelta from ..capability import Capability from ..entities import ( - BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc, - SwitchDesc, + BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc, ) from .common import normalize_temp_unit +from .operational import STOP_BUTTON # --------------------------------------------------------------------------- # Constants @@ -166,12 +166,6 @@ def _cook_time_write(p, rep, href=None): } -def _stop_write(p, rep, href=None): - return ['operational', 'state', 'vs', '0'], { - 'x.com.samsung.da.state': 'Ready', - } - - def _oven_mode_write(p, rep, href=None): if p not in _OVEN_MODES: return None @@ -237,7 +231,7 @@ OVEN_OPERATIONAL_STATE = Capability( device_class='enum', options=('idle', 'active', 'pause'), translation_key='machine_state', value_fn=_to_ocf), BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state', - name='Cycle active', device_class='running', + name='Running', device_class='running', value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'), SensorDesc(key='progress_percentage', field='x.com.samsung.da.progressPercentage', @@ -254,8 +248,7 @@ OVEN_OPERATIONAL_STATE = Capability( name='Cook time', unit='min', native_min=0, native_max=1439, step=1.0, icon='mdi:timer', value_fn=_op_minutes, write_fn=_cook_time_write), - ButtonDesc(key='stop', field='', name='Stop cycle', icon='mdi:stop', - payload='Stop', write_fn=_stop_write), + STOP_BUTTON, ), ) diff --git a/tests/fixtures/golden/oven.json b/tests/fixtures/golden/oven.json new file mode 100644 index 0000000..e2f168d --- /dev/null +++ b/tests/fixtures/golden/oven.json @@ -0,0 +1,25 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "cloud_connected", + "cook_time", + "current_temp_c", + "cycle_active", + "door_open", + "fast_preheat", + "finish_time", + "firmware_update", + "lamp", + "machine_state", + "natural_steam", + "operation_time_minutes", + "oven_mode", + "oven_setpoint", + "oven_state", + "power_switch", + "progress_percentage", + "remote_control", + "sound" + ] +} diff --git a/tests/fixtures/oven_device.json b/tests/fixtures/oven_device.json new file mode 100644 index 0000000..a6fdb2b --- /dev/null +++ b/tests/fixtures/oven_device.json @@ -0,0 +1,289 @@ +{ + "meta": { + "model": "TP1X_DA-KS-OVEN-0107X (NV7000BS/ET5)", + "device_type": "oven", + "source": "issue #55 diagnostics (scrubbed)", + "note": "Wall oven reports no oneUiVersion; resolved via the '-OVEN-' modelNum token fallback in for_device_by_model, mirroring the '-RANGE-' fallback added for issue #44." + }, + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "flashingProgress": "", + "otnStatus": "None", + "otnList": [ + { + "type": "WIFI", + "modelId": "AKS-WW-TP1-22-OVEN-1", + "versions": [ + "40250221" + ], + "visVersion": "250221" + }, + { + "type": "Micom", + "modelId": "07134046004140472641", + "versions": [ + "23011700", + "23122000" + ], + "visVersion": "231220" + }, + { + "type": "Micom", + "modelId": "071360127441FFFFFFFF", + "versions": [ + "21123000", + "FFFFFFFF" + ], + "visVersion": "211230" + } + ] + } + }, + { + "href": "/connected/vs/0", + "rep": { + "x.com.samsung.da.connected": "On" + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close" + } + ], + "rt": [ + "x.com.samsung.da.doors" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA-KS-OVEN-0107X|40460041|50030018001611020A00000000000000", + "x.com.samsung.da.description": "NV7000BS/ET5", + "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": "25022100", + "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": "04600A23011700, 04726A23122000", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "01274A21123000", + "x.com.samsung.da.newVersionAvailable": "0" + } + ], + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "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": "KV1", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Autocook", + "Convection", + "TopHeatPluseConvection", + "Conventional", + "LargeGrill", + "SmallGrill", + "BottomHeatPluseConvection", + "PlateWarm", + "KeepWarm", + "Bottom", + "EcoConvection", + "FanGrill", + "Defrost", + "SteamClean" + ], + "x.com.samsung.da.modes": [ + "NoOperation" + ], + "x.com.samsung.da.options": [ + "DeviceType_NV7000BS/ET5", + "UpperTimerCurrent_0", + "UpperTimerSet_0", + "UpperTimerState_Ready", + "UpperLamp_Off", + "Sound_On" + ], + "x.com.samsung.da.defaultMode": "Convection", + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.operationTime": "00:00:00", + "x.com.samsung.da.remainingTime": "00:00:00", + "x.com.samsung.da.progressPercentage": "0", + "rt": [ + "x.com.samsung.da.operation" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "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": "OV_E_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-23T15:26:07" + } + ] + } + }, + { + "href": "/oven/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.recipe": "00000000000000", + "rt": [ + "x.com.samsung.da.oven" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "0", + "x.com.samsung.da.current": "0", + "x.com.samsung.da.increment": "5", + "x.com.samsung.da.unit": "Celsius" + } + ], + "rt": [ + "x.com.samsung.da.temperatures" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Europe/Rome", + "offset": "+02:00", + "DST": "ON" + } + }, + { + "href": "/configuration/vs/0", + "rep": {} + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "true", + "rt": [ + "x.com.samsung.da.configuration" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} diff --git a/tests/test_by_type.py b/tests/test_by_type.py index e709c93..656f937 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -229,6 +229,18 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'washer' + def test_oven_via_oven_token(self): + """Issue #55: a wall oven (NV7000BS/ET5) reports no oneUiVersion and + an unrecognized consumer token ('NV'); it falls back to the '-OVEN-' + token in modelNum, mirroring the '-RANGE-' fallback for issue #44.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'TP1X_DA-KS-OVEN-0107X|40460041|50030018001611020A00000000000000', + 'NV7000BS/ET5', + ) + assert reg is not None + assert reg.name == 'oven' + 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 13ad978..e3159cb 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -258,6 +258,24 @@ def test_registry_reproduces_golden_state_keys_for_air_purifier(): ) +def test_registry_reproduces_golden_state_keys_for_oven(): + """Wall oven (model TP1X_DA-KS-OVEN-0107X, issue #55) -- reports no + oneUiVersion; resolved via the '-OVEN-' modelNum token fallback in + for_device_by_model, mirroring the '-RANGE-' fallback added for + issue #44. Before that fallback existed the device type came back + 'unknown' and every href fell through to the global CAPABILITIES + registry instead of the oven family's own.""" + from tests.conftest import _load_device + resources = _load_device('oven') + golden = json.loads((GOLDEN / 'oven.json').read_text()) + state_keys = _new_state_keys('oven', 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_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { diff --git a/tests/test_oven_capabilities.py b/tests/test_oven_capabilities.py index aafad2b..1c91da7 100644 --- a/tests/test_oven_capabilities.py +++ b/tests/test_oven_capabilities.py @@ -1,8 +1,30 @@ """Unit tests for oven-family capabilities.""" +from custom_components.localthings.registry.by_type import for_device_by_model from custom_components.localthings.registry.capabilities import oven from custom_components.localthings.registry.discovery import discover +# --------------------------------------------------------------------------- +# Device-type detection + full-dump coverage (issue #55) +# --------------------------------------------------------------------------- + +def test_oven_fixture_resolves_and_has_no_unbound_hrefs(): + """The issue #55 dump previously came back device_type='unknown' with + /connected/vs/0 unbound -- resolving via the '-OVEN-' modelNum token + fallback must leave every href in the oven registry bound or ignored.""" + from tests.conftest import _load_device + resources = _load_device('oven') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + assert reg is not None + assert reg.name == 'oven' + + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + # --------------------------------------------------------------------------- # OVEN_SETPOINT — NumberDesc with RMW write semantics # ---------------------------------------------------------------------------