From 123455a873fcf9ab60a9bc3dddaca0cbd7f0f3fa Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 13:12:40 +0000 Subject: [PATCH 01/15] Add missing oven cook modes for NE63A6511SS/AA range (issue #138) The range/oven-combo device in issue #138 (NE63A6511SS/AA, no /information/vs/0) already resolves cleanly to the range registry via the issue #74 for_device_by_resources fallback, with zero unbound hrefs -- the reporter was just on an older release (0.11.1) predating that fix. However its /mode/vs/0 supportedModes advertises ConvectionRoast, KeepWarm, BreadProof, AirFryer, Dehydrate, SelfClean, and SteamClean, none of which were in oven._OVEN_MODES. Since range.py reuses oven.OVEN_MODE's SelectDesc wholesale, those modes were silently rejected by the mode select's write validation and missing from its options. Extend the confirmed mode list and lock in a scrubbed fixture, golden, and test for this dump. --- .../localthings/registry/capabilities/oven.py | 16 ++ tests/fixtures/golden/range_ne63a6511.json | 27 +++ tests/fixtures/range_ne63a6511_device.json | 200 ++++++++++++++++++ tests/test_golden_regression.py | 17 ++ tests/test_range_ne63a6511_capabilities.py | 61 ++++++ 5 files changed, 321 insertions(+) create mode 100644 tests/fixtures/golden/range_ne63a6511.json create mode 100644 tests/fixtures/range_ne63a6511_device.json create mode 100644 tests/test_range_ne63a6511_capabilities.py diff --git a/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index 4b47a38..d866927 100644 --- a/custom_components/localthings/registry/capabilities/oven.py +++ b/custom_components/localthings/registry/capabilities/oven.py @@ -51,6 +51,15 @@ SETPOINT_STEP_F = 5 # Mode options seen on NV7000BS-class. No dump exists so this list is inferred # from Samsung documentation and firmware observations. The firmware will # reject unknown modes; missing entries here are a coverage gap, not a bug. +# +# ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean +# are confirmed against issue #138's range dump (NE63A6511SS/AA) -- its +# /mode/vs/0 supportedModes lists all of them, and the range family shares +# this select via range.py reusing oven.OVEN_MODE wholesale. 'AirFryer' is +# kept alongside 'AirFry' rather than replacing it -- the NV7000BS-class +# dump this list was originally inferred from spells it without the +# trailing 'er', and neither model's actual token is independently +# confirmed, so both are treated as real device-reported spellings. _OVEN_MODES = ( 'NoOperation', 'Bake', @@ -58,10 +67,17 @@ _OVEN_MODES = ( 'Convection', 'ConvectionBake', 'ConvectionBroil', + 'ConvectionRoast', 'FrozenPizzaPlus', 'SlowCook', 'PlateWarm', 'AirFry', + 'AirFryer', + 'KeepWarm', + 'BreadProof', + 'Dehydrate', + 'SelfClean', + 'SteamClean', ) _SAMSUNG_STATE_TO_OCF = { diff --git a/tests/fixtures/golden/range_ne63a6511.json b/tests/fixtures/golden/range_ne63a6511.json new file mode 100644 index 0000000..a7ef5a5 --- /dev/null +++ b/tests/fixtures/golden/range_ne63a6511.json @@ -0,0 +1,27 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "cloud_connected", + "cook_time", + "cooktop_running_state", + "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", + "warming_center_state" + ] +} diff --git a/tests/fixtures/range_ne63a6511_device.json b/tests/fixtures/range_ne63a6511_device.json new file mode 100644 index 0000000..973891b --- /dev/null +++ b/tests/fixtures/range_ne63a6511_device.json @@ -0,0 +1,200 @@ +{ + "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": "OV_E_OFF", + "x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00" + } + ] + } + }, + { + "href": "/configuration/vs/0", + "rep": {} + }, + { + "href": "/connected/vs/0", + "rep": { + "x.com.samsung.da.connected": "On" + } + }, + { + "href": "/cooktopmonitoring/vs/0", + "rep": { + "x.com.samsung.da.warmingCenterState": "Off", + "x.com.samsung.da.cooktopMonitoring": "0", + "x.com.samsung.da.cooktopRunningState": "Ready", + "supportedHoodLampStateList": [ + "Run", + "Ready" + ] + } + }, + { + "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", + "x.com.samsung.da.lock": "Unlock" + } + ], + "rt": [ + "x.com.samsung.da.doors" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Bake", + "Broil", + "ConvectionBake", + "ConvectionRoast", + "KeepWarm", + "BreadProof", + "AirFryer", + "Dehydrate", + "SelfClean", + "SteamClean" + ], + "x.com.samsung.da.modes": [ + "NoOperation" + ], + "x.com.samsung.da.options": [ + "DeviceType_NE6516A-/AA0", + "SettingPossible_0", + "UpperLamp_Off", + "Sound_Off", + "AdjustingTemp_0", + "Sabbath_Off", + "EnergySaving_On", + "BurnerOnAlert_Off" + ], + "x.com.samsung.da.defaultMode": "ConvectionBake", + "x.com.samsung.da.modeSpec": "[{\"mode\":\"Bake\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"175\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"350\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\"},{\"mode\":\"ConvectionRoast\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"160\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"325\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\"},{\"mode\":\"AirFryer\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"175\",\"tempMaxC\":\"260\",\"tempDefaultC\":\"220\",\"tempListLengthC\":\"0\",\"tempMinF\":\"350\",\"tempMaxF\":\"500\",\"tempDefaultF\":\"425\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\"}]", + "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": "1", + "rt": [ + "x.com.samsung.da.operation" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/oven/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "rt": [ + "x.com.samsung.da.oven" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "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": "/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": "175", + "x.com.samsung.da.increment": "1", + "x.com.samsung.da.unit": "Fahrenheit" + } + ], + "rt": [ + "x.com.samsung.da.temperatures" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + } + ] +} diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 917e12c..7846277 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -544,6 +544,23 @@ def test_registry_reproduces_golden_state_keys_for_range_ne8300d(): ) +def test_registry_reproduces_golden_state_keys_for_range_ne63a6511(): + """NE63A6511SS/AA (issue #138) -- reports no /information/vs/0 at all, + same shape as issue #74's NE63B8411SS; resolved via the same + 'Bake'-in-supportedModes + /cooktopmonitoring/vs/0 signature in + for_device_by_resources. Binds cleanly against the existing range + registry with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('range_ne63a6511') + golden = json.loads((GOLDEN / 'range_ne63a6511.json').read_text()) + state_keys = _new_state_keys('range_ne63a6511', 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_airconditioner_ara_ww_tp1_22(): """ARA-WW-TP1-22-COMMON wall-mount RACs (model AR10/13/18BYEAAWKNME, issues #115/#116/#117/#120) report no oneUiVersion and no diff --git a/tests/test_range_ne63a6511_capabilities.py b/tests/test_range_ne63a6511_capabilities.py new file mode 100644 index 0000000..7b99006 --- /dev/null +++ b/tests/test_range_ne63a6511_capabilities.py @@ -0,0 +1,61 @@ +"""Tests for the NE63A6511SS/AA range (issue #138) -- same no-/information/vs/0, +no-burner-status shape as issue #74's NE63B8411SS, confirming the existing +range_no_info detection path still resolves this model with zero unbound +hrefs, plus the ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/ +SelfClean/SteamClean modes this dump's supportedModes adds to oven.OVEN_MODE.""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_resources +from custom_components.localthings.registry.capabilities import oven +from custom_components.localthings.registry.discovery import discover + +from tests.conftest import _load_device + + +def _range(): + resources = _load_device('range_ne63a6511') + reg = for_device_by_resources(resources) + return reg, resources + + +def _state(): + reg, resources = _range() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + return flatten(bound, resources) + + +def test_resolves_to_range_registry(): + reg, _ = _range() + assert reg is not None and reg.name == 'range' + + +def test_no_unbound_hrefs(): + reg, resources = _range() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_expected_entities_present(): + state = _state() + for key in ( + 'power_switch', 'oven_setpoint', 'current_temp_c', 'oven_mode', + 'machine_state', 'door_open', 'cloud_connected', 'child_lock', + 'cooktop_running_state', 'warming_center_state', + ): + assert key in state, key + + +def test_oven_mode_accepts_this_devices_supported_modes(): + """This dump's /mode/vs/0 supportedModes reports ConvectionRoast, + KeepWarm, BreadProof, AirFryer, Dehydrate, SelfClean, and SteamClean -- + none of which were in oven._OVEN_MODES before issue #138, so writes to + them were silently rejected even though the device advertises them.""" + desc = oven.OVEN_MODE.entities[0] + rep = {'x.com.samsung.da.modes': ['NoOperation']} + for mode in ( + 'ConvectionRoast', 'KeepWarm', 'BreadProof', 'AirFryer', + 'Dehydrate', 'SelfClean', 'SteamClean', + ): + path, body = desc.write_fn(mode, rep) + assert path == ['mode', 'vs', '0'] + assert body['x.com.samsung.da.modes'] == [mode] From 1fb27c30aecada55a8082a972d4b9784835f09e0 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 13:18:46 +0000 Subject: [PATCH 02/15] Skill: call out preferring dynamic select options over hardcoded lists Prompted by issue #138's fix, which extended oven._OVEN_MODES with newly confirmed modes instead of reading them from the device's own supportedModes field via options_field -- the pattern laundry.py already uses for buzzer/finish sound. Document that preference so future device-support work reaches for options_field/a callable first and treats a static tuple as a last resort, not the default. --- .claude/skills/adding-device-support/SKILL.md | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/.claude/skills/adding-device-support/SKILL.md b/.claude/skills/adding-device-support/SKILL.md index 5861bd6..d7a2477 100644 --- a/.claude/skills/adding-device-support/SKILL.md +++ b/.claude/skills/adding-device-support/SKILL.md @@ -6,6 +6,7 @@ description: >- raises the "incomplete capability coverage" repair, a diagnostics JSON needs triaging, or you're mapping OCF resources to HA entities. Covers reading dumps, OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy, + preferring dynamic (device-reported) select options over hardcoded lists, ensuring every href is bound or ignored, and locking it in with a fixture + golden + test. --- @@ -103,7 +104,37 @@ sub-polled between summary polls. Pick descriptor types from `entities.py` as a gap for a human, or ignore it with a documented reason — never invent an entity on a hunch (`ignored.py`'s rule). -## 5. Names and enum labels live in translations, never in Python +## 5. Select options: read them from the device, don't hardcode + +A `SelectDesc`'s `options` should come from the device's own advertised list +whenever the resource carries one, not from a Python tuple typed in from a +single dump. Two dynamic forms already exist in the repo and should be +reached for first: +- `options_field='x.com.samsung.da.supportedModes'` (or whatever the + resource's own supported-values field is called) — reads the live rep on + the capability's own href. See `laundry.py`'s `buzzer_sound`/ + `finish_sound` (`options_field='supportedBuzzerSound'`/ + `'supportedFinishSound'`). +- `options=` — for option lists that live on a **different** + resource than the select's own href (e.g. a course table keyed off a + sibling href). See `laundry.cycle_select`'s `options=cycle_options`. + +A static `options=(...)` tuple is a coverage gap waiting to happen: the next +dump from a different board generation will report modes/values the tuple +doesn't have, and both the HA options list *and* `write_fn`'s validation (if +it checks the same tuple) will silently reject values the device itself +advertises as supported. That's exactly what happened with `oven._OVEN_MODES` +in issue #138 — a hardcoded list rejected `AirFryer`/`Dehydrate`/ +`SelfClean`/etc. even though the device's own `supportedModes` field listed +them. Reach for a static tuple only when the dump genuinely has no +supported-values field to read (e.g. the NV7000BS-class oven dump +`_OVEN_MODES` was inferred before any live oven dump existed — see that +module's docstring), and treat it as an interim best-guess rather than a +permanent design choice: migrate it to `options_field`/a callable the moment +a dump with a real supported-values list surfaces, instead of just adding +the new values to the static tuple. + +## 6. Names and enum labels live in translations, never in Python Descriptors have **no `name` field**. Every entity is named from the shipped catalog, keyed by `translation_key` — which defaults to the descriptor's own @@ -142,7 +173,7 @@ no `[%key:...%]` resolution (that's Core build tooling). Every other language must mirror `en.json` key for key — also enforced by `tests/test_translations.py`. -## 6. Coverage discipline: bound or ignored +## 7. Coverage discipline: bound or ignored Every href in the dump must resolve, or the repair fires. If a resource isn't worth an entity, add it to `capabilities/ignored.py` (a no-entity `Capability`) @@ -156,7 +187,7 @@ friendlier href**. ignored because washers bind it. When only one family should ignore an href that another binds, scope the ignore to that family's registry. -## 7. Reuse before writing new code +## 8. Reuse before writing new code Check `common.py` (generic OCF: power, energy, alarms, water) and `laundry.py` (shared washer/dryer/dishwasher: buzzer, job status, `cycle_select` + course @@ -165,7 +196,7 @@ registry uses `fridge.FIRMWARE_UPDATE`; all three laundry families share `laundry.cycle_select`. If two families hand-roll the same helper, hoist it to a shared module rather than copying. -## 8. Lock it in +## 9. Lock it in 1. Add a **scrubbed** fixture `tests/fixtures/_device.json` (`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs, From 1cdad7f84c374b316671e62c4d22850617e2b9fb Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 13:26:41 +0000 Subject: [PATCH 03/15] Read oven cook modes live from the device instead of a hardcoded list Follow-up to the issue #138 fix: rather than hand-adding ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean to oven._OVEN_MODES, read them from the device's own /mode/vs/0 supportedModes when it reports one, falling back to the static NV7000BS-era guess only when it doesn't. Matches the adding-device-support skill's preference for device-reported option lists over hardcoded ones, and the SelectDesc's write validation now checks the same live list it displays instead of a separate static tuple. --- .../localthings/registry/capabilities/oven.py | 36 ++++++++++--------- tests/test_oven_capabilities.py | 34 ++++++++++++++++-- tests/test_range_ne63a6511_capabilities.py | 19 ++++++---- 3 files changed, 65 insertions(+), 24 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index d866927..0a28cc9 100644 --- a/custom_components/localthings/registry/capabilities/oven.py +++ b/custom_components/localthings/registry/capabilities/oven.py @@ -52,14 +52,13 @@ SETPOINT_STEP_F = 5 # from Samsung documentation and firmware observations. The firmware will # reject unknown modes; missing entries here are a coverage gap, not a bug. # +# This is a fallback only, used when a device's own /mode/vs/0 doesn't report +# x.com.samsung.da.supportedModes at all -- see _oven_mode_options/ +# _oven_mode_write below. issue #138's range dump (NE63A6511SS/AA) reports # ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean -# are confirmed against issue #138's range dump (NE63A6511SS/AA) -- its -# /mode/vs/0 supportedModes lists all of them, and the range family shares -# this select via range.py reusing oven.OVEN_MODE wholesale. 'AirFryer' is -# kept alongside 'AirFry' rather than replacing it -- the NV7000BS-class -# dump this list was originally inferred from spells it without the -# trailing 'er', and neither model's actual token is independently -# confirmed, so both are treated as real device-reported spellings. +# in its own supportedModes; those are read live rather than added here, per +# the adding-device-support skill's preference for device-reported option +# lists over hardcoded ones. _OVEN_MODES = ( 'NoOperation', 'Bake', @@ -67,17 +66,10 @@ _OVEN_MODES = ( 'Convection', 'ConvectionBake', 'ConvectionBroil', - 'ConvectionRoast', 'FrozenPizzaPlus', 'SlowCook', 'PlateWarm', 'AirFry', - 'AirFryer', - 'KeepWarm', - 'BreadProof', - 'Dehydrate', - 'SelfClean', - 'SteamClean', ) _SAMSUNG_STATE_TO_OCF = { @@ -189,8 +181,20 @@ def _cook_time_write(p, rep, href=None): } +def _oven_mode_options(resources): + """Live mode list from the device's own /mode/vs/0 supportedModes when + it reports one; the NV7000BS-era _OVEN_MODES guess otherwise. Mirrors + laundry.py's options_field pattern (buzzer_sound/finish_sound), but + needs the callable form rather than options_field because a static + fallback has to kick in when the device's own field is absent.""" + rep = resources.get('/mode/vs/0') or {} + live = rep.get('x.com.samsung.da.supportedModes') + return list(live) if live else list(_OVEN_MODES) + + def _oven_mode_write(p, rep, href=None): - if p not in _OVEN_MODES: + valid = rep.get('x.com.samsung.da.supportedModes') or _OVEN_MODES + if p not in valid: return None return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]} @@ -367,7 +371,7 @@ OVEN_MODE = Capability( # SelectDesc first — test_oven_mode_options_nonempty uses entities[0] SelectDesc(key='oven_mode', field='x.com.samsung.da.modes', icon='mdi:tune', - options=_OVEN_MODES, + options=_oven_mode_options, value_fn=lambda v: v[0] if v else None, write_fn=_oven_mode_write), SwitchDesc(key='lamp', field='x.com.samsung.da.options', diff --git a/tests/test_oven_capabilities.py b/tests/test_oven_capabilities.py index 22074df..926fd76 100644 --- a/tests/test_oven_capabilities.py +++ b/tests/test_oven_capabilities.py @@ -120,12 +120,29 @@ def test_oven_setpoint_native_bounds_track_live_unit(): # --------------------------------------------------------------------------- def test_oven_mode_options_nonempty(): - assert len(oven.OVEN_MODE.entities[0].options) > 0 + desc = oven.OVEN_MODE.entities[0] + assert callable(desc.options) + assert len(desc.options({})) > 0 + + +def test_oven_mode_options_falls_back_when_no_live_supported_modes(): + desc = oven.OVEN_MODE.entities[0] + assert desc.options({}) == list(oven._OVEN_MODES) + + +def test_oven_mode_options_reads_live_supported_modes(): + """issue #138: the device's own supportedModes list is used verbatim + when present, instead of the static _OVEN_MODES guess.""" + desc = oven.OVEN_MODE.entities[0] + resources = {'/mode/vs/0': { + 'x.com.samsung.da.supportedModes': ['Bake', 'AirFryer', 'SelfClean'], + }} + assert desc.options(resources) == ['Bake', 'AirFryer', 'SelfClean'] def test_oven_mode_write_round_trips(): desc = oven.OVEN_MODE.entities[0] - valid_mode = desc.options[1] # e.g. 'Bake' + valid_mode = desc.options({})[1] # e.g. 'Bake' path, body = desc.write_fn(valid_mode, {}) assert path == ['mode', 'vs', '0'] assert body['x.com.samsung.da.modes'] == [valid_mode] @@ -136,6 +153,19 @@ def test_oven_mode_rejects_unknown(): assert desc.write_fn('SpaghettiMode', {}) is None +def test_oven_mode_write_validates_against_live_supported_modes(): + """A device reporting its own supportedModes is validated against that + list, not the static fallback -- issue #138's AirFryer/SelfClean/etc. + are accepted, and a mode outside the device's own list is rejected even + if some other model's static guess would have allowed it.""" + desc = oven.OVEN_MODE.entities[0] + rep = {'x.com.samsung.da.supportedModes': ['Bake', 'AirFryer', 'SelfClean']} + path, body = desc.write_fn('AirFryer', rep) + assert path == ['mode', 'vs', '0'] + assert body['x.com.samsung.da.modes'] == ['AirFryer'] + assert desc.write_fn('FrozenPizzaPlus', rep) is None + + # --------------------------------------------------------------------------- # OVEN_MODE options-array writes (lamp, sound, fast_preheat, natural_steam). # Confirmed on real hardware (issue #54): a write only needs to carry the diff --git a/tests/test_range_ne63a6511_capabilities.py b/tests/test_range_ne63a6511_capabilities.py index 7b99006..c52da95 100644 --- a/tests/test_range_ne63a6511_capabilities.py +++ b/tests/test_range_ne63a6511_capabilities.py @@ -1,8 +1,9 @@ """Tests for the NE63A6511SS/AA range (issue #138) -- same no-/information/vs/0, no-burner-status shape as issue #74's NE63B8411SS, confirming the existing range_no_info detection path still resolves this model with zero unbound -hrefs, plus the ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/ -SelfClean/SteamClean modes this dump's supportedModes adds to oven.OVEN_MODE.""" +hrefs, plus oven.OVEN_MODE reading this dump's ConvectionRoast/KeepWarm/ +BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean modes live from its own +/mode/vs/0 supportedModes rather than needing them hardcoded.""" from custom_components.localthings.registry.adapter import flatten from custom_components.localthings.registry.by_type import for_device_by_resources from custom_components.localthings.registry.capabilities import oven @@ -48,14 +49,20 @@ def test_expected_entities_present(): def test_oven_mode_accepts_this_devices_supported_modes(): """This dump's /mode/vs/0 supportedModes reports ConvectionRoast, KeepWarm, BreadProof, AirFryer, Dehydrate, SelfClean, and SteamClean -- - none of which were in oven._OVEN_MODES before issue #138, so writes to - them were silently rejected even though the device advertises them.""" + none of which were in the static oven._OVEN_MODES fallback, so a + hardcoded write-validation list would've silently rejected them even + though the device itself advertises them. write_fn reads supportedModes + off the live rep (the same one it's called with in production -- see + coordinator.py's async_send_command), so this passes without needing + those modes added to any Python list.""" + _, resources = _range() + live_rep = resources['/mode/vs/0'] desc = oven.OVEN_MODE.entities[0] - rep = {'x.com.samsung.da.modes': ['NoOperation']} + assert desc.options(resources) == live_rep['x.com.samsung.da.supportedModes'] for mode in ( 'ConvectionRoast', 'KeepWarm', 'BreadProof', 'AirFryer', 'Dehydrate', 'SelfClean', 'SteamClean', ): - path, body = desc.write_fn(mode, rep) + path, body = desc.write_fn(mode, live_rep) assert path == ['mode', 'vs', '0'] assert body['x.com.samsung.da.modes'] == [mode] From c8597532b089f822c0270d62a94a00084eecbbdf Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 13:28:46 +0000 Subject: [PATCH 04/15] Stop collapsing a genuine fivepercentHumidity=0 reading to unknown (#160) #146's zero-as-"not measuring" carve-out was meant for ARTIK051 boards' plain x.com.samsung.da.humidity field, which only populates while Air monitoring is briefly on and zeroes out afterward. It was accidentally applied to fivepercentHumidity too, which every other AC board relies on and which has never been documented getting stuck at zero -- so a real 0% reading on those boards silently became "unknown". Only the humidity fallback field now collapses 0; fivepercentHumidity passes 0 through as a real reading. --- .../registry/capabilities/airconditioner.py | 20 ++++++++++++------- tests/test_airconditioner_capabilities.py | 11 ++++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 5958018..47c402c 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -210,14 +210,20 @@ def _humidity(rep): (51% observed, matching what the same unit's cloud integration reported at that moment), then zeroes the field and switches Air monitoring back off by itself. So 0 reads as "not measuring" and is reported as unknown rather than - as 0% humidity, which would poison long-term history -- which is also why - the boards that do have fivepercentHumidity read a permanent 0 here. + as 0% humidity, which would poison long-term history. + + That zero-as-"not measuring" carve-out is specific to the ARTIK051 + fallback field's hardware quirk -- every other board's fivepercentHumidity + has never been documented getting stuck at zero, and collapsing a + genuine 0% reading there to unknown is a regression, not a safeguard + (issue #160). So fivepercentHumidity passes 0 through unchanged; only the + humidity fallback applies the zero-collapse. """ - for field in ('x.com.samsung.da.fivepercentHumidity', - 'x.com.samsung.da.humidity'): - if field in rep: - value = _num(rep[field]) - return value if value else None + if 'x.com.samsung.da.fivepercentHumidity' in rep: + return _num(rep['x.com.samsung.da.fivepercentHumidity']) + if 'x.com.samsung.da.humidity' in rep: + value = _num(rep['x.com.samsung.da.humidity']) + return value if value else None return None diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index f513be7..8bbb1d1 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -415,3 +415,14 @@ def test_humidity_falls_back_to_the_plain_field_where_five_percent_is_absent(): assert desc.rep_fn({'x.com.samsung.da.humidity': '51'}) == 51.0 assert desc.rep_fn({'x.com.samsung.da.humidity': '0'}) is None assert desc.rep_fn({}) is None + + +def test_humidity_five_percent_field_passes_a_genuine_zero_through(): + """issue #160: fivepercentHumidity's zero-as-"not measuring" carve-out + (added in #146 to cover ARTIK051's plain humidity field) was + over-applied to fivepercentHumidity too, silently turning a real 0% + reading on every other AC board into unknown. Only the humidity + fallback field collapses 0 -- fivepercentHumidity's 0 is a real + reading.""" + desc = airconditioner.HUMIDITY.entities[0] + assert desc.rep_fn({'x.com.samsung.da.fivepercentHumidity': '0'}) == 0.0 From 57e06c5ccbd1fea67bfc0d649a0e1b8fac3e65bb Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 13:31:37 +0000 Subject: [PATCH 05/15] De-duplicate the legacy ARTIK051 AC board-generation test (#161) capabilities/airconditioner.py's is_legacy_board() (renamed from the private _is_legacy_board -- it's now a cross-module helper) and climate.py's _legacy_airflow() implemented the same "does this board have /airflow/vs/0 but no /wind/strength/vs/0" test independently, one via literal href strings and the other via coordinator.resource() truthiness. is_legacy_board() now uses the module's own HREF_AIRFLOW/HREF_WIND_STRENGTH constants, and _legacy_airflow() delegates to it via a minimal two-key presence dict (cheaper than a full last_resources snapshot copy) instead of re-implementing the check, so the token entities and the climate card's legacy read/write paths can't drift apart on which board generation is in play. --- custom_components/localthings/climate.py | 22 +++++++++++++++---- .../registry/capabilities/airconditioner.py | 7 +++--- tests/test_airconditioner_artik051_krac.py | 19 +++++++++++++++- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 5ba2137..3de683b 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -51,6 +51,7 @@ from .registry.capabilities.airconditioner import ( HREF_WIND_OSCILLATION as WIND_OSCILLATION_HREF, HREF_CONVENIENT as CONVENIENT_HREF, HREF_AIRFLOW as AIRFLOW_HREF, + is_legacy_board, ) from .registry.capabilities.common import normalize_temp_unit @@ -247,10 +248,23 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): def _legacy_airflow(self) -> dict: """The /airflow/vs/0 rep, but only when it is the fan/swing channel to - use -- i.e. this board has no /wind/strength/vs/0.""" - if self.coordinator.resource(WIND_STRENGTH_HREF): - return {} - return self.coordinator.resource(AIRFLOW_HREF) or {} + use -- i.e. this board has no /wind/strength/vs/0. + + Delegates the board-generation test to is_legacy_board (the same + test capabilities/airconditioner.py's token entities are gated on) + instead of re-implementing it, via a minimal presence dict built + from the two hrefs it actually inspects -- cheaper than + last_resources' full snapshot copy, since is_legacy_board only + checks key membership. + """ + airflow = self.coordinator.resource(AIRFLOW_HREF) + wind_strength = self.coordinator.resource(WIND_STRENGTH_HREF) + presence = {} + if airflow: + presence[AIRFLOW_HREF] = airflow + if wind_strength: + presence[WIND_STRENGTH_HREF] = wind_strength + return airflow if is_legacy_board(presence) else {} def _legacy_preset(self) -> bool: """Whether presets come from the Comode_* token rather than a resource. diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 47c402c..f060d75 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -152,7 +152,7 @@ def _option_token(rep, prefix): return None -def _is_legacy_board(resources): +def is_legacy_board(resources): """True for the board generation whose airflow lives in /airflow/vs/0. Newer families carry several of the same option tokens (Volume, Sleep, @@ -163,13 +163,12 @@ def _is_legacy_board(resources): /airflow/vs/0. Same test as climate.py's _legacy_airflow(), so the entities below and the climate entity can never disagree about the generation. """ - return ('/airflow/vs/0' in resources - and '/wind/strength/vs/0' not in resources) + return HREF_AIRFLOW in resources and HREF_WIND_STRENGTH not in resources def _has_option_token(prefix): return lambda rep, resources: ( - _is_legacy_board(resources) and _option_token(rep, prefix) is not None) + is_legacy_board(resources) and _option_token(rep, prefix) is not None) def _option_token_on(prefix): diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py index 2e71e89..d6b4b98 100644 --- a/tests/test_airconditioner_artik051_krac.py +++ b/tests/test_airconditioner_artik051_krac.py @@ -24,7 +24,8 @@ from custom_components.localthings.registry.by_type import ( airconditioner, for_device_by_model, ) from custom_components.localthings.registry.capabilities.airconditioner import ( - HREF_AIRFLOW, _option_number_write, _option_switch_write, + HREF_AIRFLOW, HREF_WIND_STRENGTH, _option_number_write, + _option_switch_write, is_legacy_board, ) from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.entities import ClimateDesc @@ -119,6 +120,22 @@ def test_token_entities_stay_off_newer_boards(): assert key not in state, key +def test_climate_legacy_airflow_gate_agrees_with_is_legacy_board(): + """issue #161: climate.py's _legacy_airflow() delegates to + capabilities/airconditioner.py's is_legacy_board() instead of + re-implementing the same presence/absence check, so the token entities + and the climate card's legacy read/write paths can't drift apart on + which board generation is in play.""" + legacy_resources = _load_device(FIXTURE) + assert is_legacy_board(legacy_resources) is True + assert _climate(legacy_resources)._legacy_airflow() == legacy_resources[HREF_AIRFLOW] + + newer_resources = _load_device('airconditioner_tp1x_rac') + assert is_legacy_board(newer_resources) is False + assert _climate(newer_resources)._legacy_airflow() == {} + assert HREF_WIND_STRENGTH in newer_resources + + def test_absent_token_yields_no_entity(): """The issue #136 unit of this same model reports no Spi token.""" resources = _load_device(FIXTURE) From aa9cdd83b7ca494a84c6de4aeabc3414e9b478a9 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 13:38:42 +0000 Subject: [PATCH 06/15] Read AC fan speeds from the device's own modesName, not just 0-4 (#155) TP1X_DA-AC-RAC-01001_0000 (model AR07C9150HZN) reports /wind/strength/vs/0 supportedModes as "0"/"31"-"35" instead of the "0"-"4" scale climate.py's _DEVICE_TO_FAN was built from. Only "0" matched, so fan_mode/fan_modes silently dropped every speed but Auto -- exactly the reported symptom. Rather than hardcoding a second numeric scale, codes _DEVICE_TO_FAN doesn't cover now fall back to the device's own modesName label (parallel-indexed with supportedModes), mirroring how preset_mode already resolves dynamically off a device's own supportedModes instead of a per-model table. Boards using the standard 0-4 scale are unaffected -- _DEVICE_TO_FAN is still tried first, so existing auto/low/medium/high/turbo labels don't change. --- custom_components/localthings/climate.py | 43 +- .../localthings/translations/en.json | 3 +- .../localthings/translations/nl.json | 3 +- .../airconditioner_tp1x_rac_01001_device.json | 525 ++++++++++++++++++ .../golden/airconditioner_tp1x_rac_01001.json | 17 + .../test_airconditioner_tp1x_rac_01001_fan.py | 105 ++++ tests/test_golden_regression.py | 21 + 7 files changed, 712 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/airconditioner_tp1x_rac_01001_device.json create mode 100644 tests/fixtures/golden/airconditioner_tp1x_rac_01001.json create mode 100644 tests/test_airconditioner_tp1x_rac_01001_fan.py diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 3de683b..8ab93ac 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -143,6 +143,22 @@ def _oscillation_swing(rep: dict) -> str | None: return 'horizontal' return 'off' +def _wind_strength_label(code, rep: dict) -> str: + """Human label for a /wind/strength/vs/0 code from the device's own + modesName array (parallel-indexed with supportedModes), lowercased for + HA -- used only for codes _DEVICE_TO_FAN doesn't already cover (issue + #155, TP1X_DA-AC-RAC-01001_0000: codes "0"/"31"-"35" instead of the + "0"-"4" scale _DEVICE_TO_FAN was built from, with modesName giving + "Auto"/"1"/"2"/"3"/"4"/"MAX"). No per-model numeric map -- mirrors + preset_mode's dynamic code->str resolution. Falls back to the raw code + lowercased when modesName is absent or misaligned.""" + supported = rep.get('x.com.samsung.da.supportedModes') or [] + names = rep.get('x.com.samsung.da.modesName') or [] + if code in supported and len(names) == len(supported): + return str(names[supported.index(code)]).lower() + return str(code).lower() + + # Preset (convenient mode): resolved dynamically from the device's own # /mode/convenient/vs/0 supportedModes -- no per-model table. The device 'Off' # code maps to HA's PRESET_NONE ("no preset active"); every other code is @@ -428,14 +444,24 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): airflow = self._legacy_airflow() if airflow: return _DEVICE_TO_FAN.get(str(airflow.get('x.com.samsung.da.speedLevel'))) - return self._read_mode(WIND_STRENGTH_HREF, _DEVICE_TO_FAN) + rep = self._rep(WIND_STRENGTH_HREF) + code = _first(rep.get(_MODES_FIELD)) + if code is None: + return None + return _DEVICE_TO_FAN.get(code) or _wind_strength_label(code, rep) @property def fan_modes(self) -> list[str]: if self._legacy_airflow(): # This resource carries no supportedModes, so the full scale is offered. return list(_DEVICE_TO_FAN.values()) - return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN) + rep = self._rep(WIND_STRENGTH_HREF) + modes = [] + for code in self._supported(WIND_STRENGTH_HREF): + mode = _DEVICE_TO_FAN.get(code) or _wind_strength_label(code, rep) + if mode not in modes: + modes.append(mode) + return modes def _swing_via_direction(self) -> bool: """True when WIND_DIRECTION_HREF is the swing channel to use -- @@ -537,7 +563,18 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): await self.coordinator.async_send_command( self._bound, ('fan_legacy', level)) return - await self._set_mapped('fan', _FAN_TO_DEVICE, fan_mode) + device = _FAN_TO_DEVICE.get(fan_mode) + if device is None: + # fan_mode came from _wind_strength_label's dynamic path (issue + # #155) -- resolve back to the device's own code the same way + # async_set_preset_mode does for its dynamic codes. + rep = self._rep(WIND_STRENGTH_HREF) + for code in self._supported(WIND_STRENGTH_HREF): + if code not in _DEVICE_TO_FAN and _wind_strength_label(code, rep) == fan_mode: + device = code + break + if device is not None: + await self.coordinator.async_send_command(self._bound, ('fan', device)) async def async_set_swing_mode(self, swing_mode: str) -> None: if self._legacy_airflow(): diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index c875314..6b9fe90 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -120,7 +120,8 @@ "state_attributes": { "fan_mode": { "state": { - "turbo": "Turbo" + "turbo": "Turbo", + "max": "Max" } }, "preset_mode": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 693e806..87e3e18 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -120,7 +120,8 @@ "state_attributes": { "fan_mode": { "state": { - "turbo": "Turbo" + "turbo": "Turbo", + "max": "Max" } }, "preset_mode": { diff --git a/tests/fixtures/airconditioner_tp1x_rac_01001_device.json b/tests/fixtures/airconditioner_tp1x_rac_01001_device.json new file mode 100644 index 0000000..a1c2001 --- /dev/null +++ b/tests/fixtures/airconditioner_tp1x_rac_01001_device.json @@ -0,0 +1,525 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/aisleep/vs/0", + "rep": { + "x.com.samsung.da.displayNightMode": "Off", + "x.com.samsung.da.elapsedTime": "0", + "x.com.samsung.da.requestFeedback": "Off", + "x.com.samsung.da.resultFeedback": "0", + "x.com.samsung.da.statusFeedback": "Idle", + "x.com.samsung.da.sleepTime": "14002200" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "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": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm_OFF", + "x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "1DBF00A0012C3F61024804000000", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "1150000000", + "x.com.samsung.da.airconOptionList": [ + "SingleCommand_1", + "DR", + "HOMECARE_WIZARD_V2", + "AI_RAC_KOREA_COOLONLY_3.0", + "AI_3.0", + "Auto_To_AI" + ] + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 0, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.duration": "00:00:00", + "x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "power": 338.0 + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "338.000000", + "x.com.samsung.da.cumulativePower": "1362848", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "2", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterDesiredUsage": "500", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterCapacity": "500", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable", + "washable" + ] + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "42" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10250041|6001051C001711014E00482200912000", + "x.com.samsung.da.description": "TP1X_DA-AC-RAC-01001_0000", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "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": "AR1", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01", + "x.com.samsung.da.serialNumOption": "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": "02646A260327", + "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": "102500A23012700", + "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": "102472A22120800,102528A10000100", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 0 + } + }, + { + "href": "/light/vs/0", + "rep": { + "mode": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Smart", + "Nano", + "NanoSleep" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "AIComfort", + "Cool", + "Dry", + "Wind" + ], + "x.com.samsung.da.modes": [ + "Cool" + ], + "x.com.samsung.da.options": [ + "Sleep_16", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_280", + "welcomecare_Off", + "OutdoorConnection_Connected", + "OutdoorTemp_86", + "CoolCapa_28", + "WarmCapa_0", + "Volume_100", + "StopAutoClean_Set", + "Autoclean_On", + "DiagnosisAI_Off", + "ProgressDiagnosisAI_0", + "ResultDiagnosisAI_Normal", + "SmartCoolClean_Off", + "ProgressSmartClean_0", + "FreezeAlarmSetting_Off", + "DesiredFreezeAlarm_240", + "WashAlarm_Off", + "OptionCode_56440", + "ExtendOptionCode_246479", + "RacInfo_None", + "UpdateAllow_NotAllowed", + "DurationOn_115", + "welcomecareElapsedTime_0", + "welcomecareThresholdTemp_0", + "welcomecareStartDate_0000", + "welcomecareEndDate_0000", + "welcomecareSeason_None" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.settingStatus": "On", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.supportedStatus": [ + "Start", + "Stop" + ], + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "Off" + ] + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "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", + "otnStatus": "None", + "flashingProgress": "", + "otnTarget": "main", + "otnCompleteDate": "noHistory", + "otnList": [ + { + "type": "WIFI", + "modelId": "ARA-WW-TP1-24-ARXX00", + "versions": [ + "11260327" + ], + "visVersion": "260327" + }, + { + "type": "Micom", + "modelId": "045210250041FFFFFFFF", + "versions": [ + "23012700", + "FFFFFFFF" + ], + "visVersion": "230127" + }, + { + "type": "Micom", + "modelId": "04521024724110252841", + "versions": [ + "22120800", + "10000100" + ], + "visVersion": "221208" + } + ] + } + }, + { + "href": "/personality/presence/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "", + "x.com.samsung.da.deviceId": "REDACTED", + "x.com.samsung.da.value": "" + } + ] + } + }, + { + "href": "/power/0", + "rep": { + "value": true + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On", + "causeSource": "SMTS" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotedatacontrol/vs/0", + "rep": { + "x.com.samsung.da.status": "Off", + "x.com.samsung.da.connectionStatus": "Disconnected" + } + }, + { + "href": "/remotedeviceinfo/vs/0", + "rep": { + "x.com.samsung.da.didList": "" + } + }, + { + "href": "/remotetemperature/vs/0", + "rep": { + "x.com.samsung.da.temperature": "", + "x.com.samsung.da.unit": "", + "x.com.samsung.da.error": "" + } + }, + { + "href": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "1DFFFFFFFF101E121EFFFFFFFFFFFF01000000012F0100000F000000001C00", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/sensors/vs/0", + "rep": {} + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 16, + 30 + ], + "units": "C", + "temperature": 28.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 16, + 30 + ], + "units": "C", + "temperature": 25.0 + } + }, + { + "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": "25.0", + "x.com.samsung.da.current": "28.0", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.increment": "1.0", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Fix", + "x.com.samsung.da.supportedModes": [ + "Fix", + "Up_And_Low", + "Left_And_Right", + "All" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "32", + "x.com.samsung.da.supportedModes": [ + "0", + "31", + "32", + "33", + "34", + "35" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "1", + "2", + "3", + "4", + "MAX" + ] + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "REDACTED", + "macaddressBLE": "REDACTED", + "connectedApSsid": "REDACTED" + } + } + ] +} diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac_01001.json b/tests/fixtures/golden/airconditioner_tp1x_rac_01001.json new file mode 100644 index 0000000..7b498eb --- /dev/null +++ b/tests/fixtures/golden/airconditioner_tp1x_rac_01001.json @@ -0,0 +1,17 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "alarm_code", + "auto_clean", + "climate", + "current_temperature_c", + "display_light", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "humidity", + "mute_once", + "power_watts" + ] +} diff --git a/tests/test_airconditioner_tp1x_rac_01001_fan.py b/tests/test_airconditioner_tp1x_rac_01001_fan.py new file mode 100644 index 0000000..5a5dea6 --- /dev/null +++ b/tests/test_airconditioner_tp1x_rac_01001_fan.py @@ -0,0 +1,105 @@ +"""TP1X_DA-AC-RAC-01001_0000 fan-strength codes (model AR07C9150HZN, issue +#155). + +Its /wind/strength/vs/0 reports supportedModes "0"/"31"/"32"/"33"/"34"/"35" +instead of the "0"-"4" scale climate.py's _DEVICE_TO_FAN was built from +(every other AC fixture in this repo uses "0"-"4", some with a 6th "5" -- +see airconditioner_window_ac_device.json). Only "0" matched _DEVICE_TO_FAN, +so fan_modes silently dropped every speed but Auto. The fix reads the +device's own modesName labels (parallel-indexed with supportedModes) for +any code _DEVICE_TO_FAN doesn't already cover, instead of hardcoding a +second numeric scale. +""" +from custom_components.localthings.climate import ( + LocalThingsClimate, _DEVICE_TO_FAN, _wind_strength_label, +) +from custom_components.localthings.registry import by_type +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import ClimateDesc + +from tests.conftest import _load_device + +FIXTURE = 'airconditioner_tp1x_rac_01001' + + +class _FakeCoordinator: + device_serial = 'TEST-RAC-01001-SERIAL' + device_info = {} + data = {} + + def __init__(self, resources): + self.last_resources = resources + self.commands = [] + + def resource(self, href): + return self.last_resources.get(href, {}) + + async def async_send_command(self, bound, payload): + self.commands.append((bound, payload)) + + +def _climate(resources, coordinator=None): + info = resources['/information/vs/0'] + reg = by_type.for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc)) + return LocalThingsClimate(coordinator or _FakeCoordinator(resources), climate_bound) + + +def test_wind_strength_label_reads_the_devices_own_modes_name(): + rep = { + 'x.com.samsung.da.supportedModes': ['0', '31', '32', '33', '34', '35'], + 'x.com.samsung.da.modesName': ['Auto', '1', '2', '3', '4', 'MAX'], + } + assert _wind_strength_label('32', rep) == '2' + assert _wind_strength_label('35', rep) == 'max' + + +def test_wind_strength_label_falls_back_to_raw_code_when_names_absent(): + assert _wind_strength_label('32', {}) == '32' + + +def test_fan_modes_include_every_supported_speed_not_just_auto(): + """Before the fix, only '0' matched _DEVICE_TO_FAN and fan_modes was + ['auto'] -- exactly the reported symptom.""" + resources = _load_device(FIXTURE) + entity = _climate(resources) + assert entity.fan_modes == ['auto', '1', '2', '3', '4', 'max'] + + +def test_fan_mode_reads_the_current_dynamic_code(): + """Fixture's /wind/strength/vs/0 modes is '32' -> modesName '2'.""" + resources = _load_device(FIXTURE) + entity = _climate(resources) + assert entity.fan_mode == '2' + + +def test_standard_scale_codes_still_use_device_to_fan(): + """A code _DEVICE_TO_FAN already covers keeps its existing friendly + label rather than falling through to the device's own (blunter) one -- + no regression for boards using the standard "0"-"4" scale.""" + resources = _load_device(FIXTURE) + resources['/wind/strength/vs/0']['x.com.samsung.da.modes'] = '0' + entity = _climate(resources) + assert entity.fan_mode == _DEVICE_TO_FAN['0'] == 'auto' + + +async def test_set_fan_mode_resolves_a_dynamic_label_back_to_its_code(): + resources = _load_device(FIXTURE) + coordinator = _FakeCoordinator(resources) + entity = _climate(resources, coordinator) + + await entity.async_set_fan_mode('max') + + assert coordinator.commands[-1][1] == ('fan', '35') + + +async def test_set_fan_mode_still_resolves_standard_scale_labels(): + resources = _load_device(FIXTURE) + coordinator = _FakeCoordinator(resources) + entity = _climate(resources, coordinator) + + await entity.async_set_fan_mode('auto') + + assert coordinator.commands[-1][1] == ('fan', '0') diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 7846277..089e036 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -709,6 +709,27 @@ def test_registry_reproduces_golden_state_keys_for_artik051_krac_18k(): ) +def test_registry_reproduces_golden_state_keys_for_airconditioner_tp1x_rac_01001(): + """TP1X_DA-AC-RAC-01001_0000 (model AR07C9150HZN, issue #155) -- binds + cleanly against the existing airconditioner registry with zero unbound + hrefs (the registry/discovery side was never the gap here). Its + /wind/strength/vs/0 reports supportedModes "0"/"31"-"35" instead of the + "0"-"4" scale climate.py's _DEVICE_TO_FAN was built from, which silently + dropped every fan speed but Auto -- see + test_airconditioner_tp1x_rac_01001_fan.py for the climate-level fix.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_tp1x_rac_01001') + golden = json.loads( + (GOLDEN / 'airconditioner_tp1x_rac_01001.json').read_text() + ) + state_keys = _new_state_keys('airconditioner_tp1x_rac_01001', 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 = { From 053e15bba688be97ab81a98b4d9254be5d88fd64 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 13:45:49 +0000 Subject: [PATCH 07/15] Add device support for Samsung AirDresser DA_DF_A51_20_COMMON (#162) This board reports no oneUiVersion and no modelNum token any existing family routed on, so it fell back to the global unknown-device CAPABILITIES set -- exposing only power/child-lock/start-stop-pause/delay/energy/machine state, with /course/vs/0, /diagnosis/vs/0, and /washer/vs/0 all unbound and no course/mode select at all (the actual reported gap). Every one of those resources turns out to already be handled by the shared laundry machinery: /diagnosis/vs/0 reuses dishwasher.DIAGNOSIS, and /course/vs/0's cycle select works unmodified through laundry.cycle_options' existing supportedOptions fallback (this board has no /wm/editcourse/vs/0 at all, so editCourseList never populates). /washer/vs/0 gets a new minimal AIR_DRESSER_SETTINGS capability (wrinkle_prevent only) rather than reusing dryer.DRYER_SETTINGS wholesale, since this device never reports dryLevel/dryTime/dryerType at all and binding them would ship three permanently-unavailable sensors. Course codes aren't identified yet (no code->name mapping was reported), so they render as their raw codes until named in translations, same as dryer.py's precedent for unidentified codes. --- README.md | 1 + .../localthings/registry/by_type/__init__.py | 15 +- .../registry/by_type/air_dresser.py | 23 ++ .../registry/capabilities/air_dresser.py | 56 +++++ .../localthings/translations/en.json | 3 + .../localthings/translations/nl.json | 3 + tests/fixtures/air_dresser_device.json | 209 ++++++++++++++++++ tests/fixtures/golden/air_dresser.json | 20 ++ tests/test_air_dresser_capabilities.py | 70 ++++++ tests/test_golden_regression.py | 17 ++ 10 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 custom_components/localthings/registry/by_type/air_dresser.py create mode 100644 custom_components/localthings/registry/capabilities/air_dresser.py create mode 100644 tests/fixtures/air_dresser_device.json create mode 100644 tests/fixtures/golden/air_dresser.json create mode 100644 tests/test_air_dresser_capabilities.py diff --git a/README.md b/README.md index 3ac35d0..f98a9e3 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi | Washer | `by_type/washer.py` | | Water purifier | `by_type/water_purifier.py` | | Vacuum clean/auto-empty station | `by_type/vacuum_station.py` | +| Air dresser | `by_type/air_dresser.py` | Each registry composes shared and family-specific `Capability` objects from `registry/capabilities/`; those modules document the individual resources/entities in more depth than a README table can stay current with. diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 1a9b9aa..0bed782 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -3,9 +3,9 @@ from typing import Optional from ._base import DeviceRegistry from . import ( - air_purifier, airconditioner, cooktop, dehumidifier, dishwasher, dryer, - induction_cooktop, microwave, oven, range as _range, range_hood, - refrigerator, vacuum_station, washer, water_purifier, + air_dresser, air_purifier, airconditioner, cooktop, dehumidifier, + dishwasher, dryer, induction_cooktop, microwave, oven, range as _range, + range_hood, refrigerator, vacuum_station, washer, water_purifier, ) __all__ = [ @@ -15,6 +15,7 @@ __all__ = [ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { + 'air_dresser': air_dresser.REGISTRY, 'air_purifier': air_purifier.REGISTRY, 'airpurifier': air_purifier.REGISTRY, 'airconditioner': airconditioner.REGISTRY, @@ -270,6 +271,14 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # station state) shares no hrefs with anything else already modeled. if key is None and '-VSKR-' in (model_num or '').upper(): key = 'vacuum_station' + # AirDresser (e.g. DA_DF_A51_20_COMMON, issue #162) -- reports no + # oneUiVersion and carries the '_DF_' (Dresser Function) board-family + # token. Every resource it exposes (course select, wrinkle-prevent + # setting, diagnosis) is already handled by the shared laundry + # machinery; it needs its own device type only because none of the + # washer/dryer/dishwasher consumer-model prefixes below match it. + if key is None and '_DF_' in (model_num or ''): + key = 'air_dresser' # Consumer-model prefix from `description` (washer/dryer/dishwasher) -- # last, since it's the fuzziest match (a bare 2-letter prefix) and would # otherwise shadow the more specific board-family tokens above. diff --git a/custom_components/localthings/registry/by_type/air_dresser.py b/custom_components/localthings/registry/by_type/air_dresser.py new file mode 100644 index 0000000..7c54bb0 --- /dev/null +++ b/custom_components/localthings/registry/by_type/air_dresser.py @@ -0,0 +1,23 @@ +"""AirDresser device registry (issue #162). + +Reuses washer/dryer/dishwasher's shared laundry surface -- job-beginning +status, operational state, and diagnosis. air_dresser.py holds the two +pieces specific to this device type: a minimal wrinkle-prevent-only +/washer/vs/0 capability, and the course select's own translation key. +""" +from ..capabilities import air_dresser, common, dishwasher, ignored, laundry, operational +from ._base import DeviceRegistry, _build + +REGISTRY = DeviceRegistry( + name='air_dresser', + capabilities=_build([ + *ignored.IGNORED, + *common.UNIVERSAL, + *common.POWER, + air_dresser.AIR_DRESSER_SETTINGS, + air_dresser.AIR_DRESSER_COURSE, + laundry.JOB_BEGINNING_STATUS, + operational.OPERATIONAL_STATE, + dishwasher.DIAGNOSIS, + ]), +) diff --git a/custom_components/localthings/registry/capabilities/air_dresser.py b/custom_components/localthings/registry/capabilities/air_dresser.py new file mode 100644 index 0000000..4b4a376 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/air_dresser.py @@ -0,0 +1,56 @@ +"""Capabilities specific to the AirDresser family (Samsung DA_DF_A51-class, +issue #162). + +This board reports no oneUiVersion and no /information/vs/0 token any +existing family routes on, so it gets its own device type -- but most of +the resources it exposes are already handled by the shared laundry +machinery: + + /washer/vs/0 -> AIR_DRESSER_SETTINGS (wrinkle_prevent only -- a + dedicated capability rather than reusing + dryer.DRYER_SETTINGS wholesale: this board's dump + never populates dryLevel/dryTime/dryerType at all, + and those are permanently-empty-not-just-absent + dryer-only fields on an AirDresser, not merely unset + ones, so binding them here would ship three sensors + that can never read anything on this device type) + /course/vs/0 -> AIR_DRESSER_COURSE (cycle select). This board has no + /wm/editcourse/vs/0 at all, so cycle_options() falls + through entirely to its supportedOptions fallback -- + confirmed against the issue #162 dump: header nibble + '0' + 10 self-indexed one-byte records + (01/02/04/03/05/1A/1B/1C/07/08), all distinct, current + selection 'Course_01' among them. Course names aren't + identified yet (no code->name mapping was reported), + so they render as their raw codes until named in + translations, same as dryer.py's unidentified codes. + /diagnosis/vs/0 -> reuses dishwasher.DIAGNOSIS +""" +from ..capability import Capability +from ..entities import SwitchDesc +from .laundry import cycle_select + + +def _wrinkle_write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p} + + +AIR_DRESSER_SETTINGS = Capability( + href='/washer/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent', + icon='mdi:iron', + value_fn=lambda v: v == 'On', + write_fn=_wrinkle_write), + ), +) + +AIR_DRESSER_COURSE = Capability( + href='/course/vs/0', + entities=( + cycle_select(translation_key='air_dresser_cycle', icon='mdi:tshirt-crew'), + ), +) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 6b9fe90..a9bc478 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -193,6 +193,9 @@ "ai_energy_level": { "name": "AI Energy Mode level" }, + "air_dresser_cycle": { + "name": "Cycle" + }, "beverage_zone_mode": { "name": "Beverage zone mode", "state": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 87e3e18..2fb2366 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -193,6 +193,9 @@ "ai_energy_level": { "name": "Niveau AI Energy Mode" }, + "air_dresser_cycle": { + "name": "Cyclus" + }, "beverage_zone_mode": { "name": "Modus drankenzone", "state": { diff --git a/tests/fixtures/air_dresser_device.json b/tests/fixtures/air_dresser_device.json new file mode 100644 index 0000000..23847c2 --- /dev/null +++ b/tests/fixtures/air_dresser_device.json @@ -0,0 +1,209 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.countryCode": "VN" + } + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_015E", + "UpdateAllow_NotAllowed", + "Course_01", + "SteamPush_None", + "SeamlessControl_Disable", + "KidsLockBypass_On", + "WashingTimes_58", + "DrumCleanProposal_30", + "SpecialFunction_4", + "AvailableDelayTime_0", + "ProgressTimeSet_AA01E0AC00B4B20690", + "WrinklePreventRunning_Off", + "SendToDevice_Off", + "GMT_12", + "Alarm_Off", + "SilentSet_0F0FF0F0F0F0F0F0F0F00F0F0F0FF0F0F00FF0", + "Silent_Off", + "WrinklePreventSet_0F0F0F0F0F0F0F0FF0F00F0F0F0F0F0F0F0FF0", + "DelayEndSet_0F0F0F0FF00F0F0F0F0F0F0F0F0F0F0F0F0F0F", + "OneTimeCourse_761F00200000000000000000000000", + "DownloadCourseList_090A0B12100C140D0F", + "EnergyLevelSet_0502020104030204050405020303010302010305", + "MostUsed_01", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_2024-01-01T00:00:00", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "001020403051A1B1C0708" + ] + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": {} + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.cumulativePower": "78200", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1785272400", + "x.com.samsung.da.cumulativeDateUTC": "1785240000" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_DF_A51_20_COMMON|20261441|3801010200131100020100FF00000000", + "x.com.samsung.da.description": "DA_DF_A51_20_COMMON_DF8600T/DC92-02656A_0018", + "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": "DA_DF_A51_20_COMMON|20261441|3801010200131100020100FF00000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02672A230710(E431)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_DF_A51_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "20060507,20072702", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "REDACTED", + "machineStates": "REDACTED", + "jobStates": [ + "None", + "Steaming", + "Airwashing", + "Drying", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "00:39:00", + "progressPercentage": "1" + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "00:39:00", + "x.com.samsung.da.progressPercentage": "1", + "x.com.samsung.da.progress": "None", + "x.com.samsung.da.delayEndTime": "00:00:00", + "x.com.samsung.da.supportedProgress": [ + "None", + "Steaming", + "Airwashing", + "Drying", + "Finish" + ] + } + }, + { + "href": "/power/0", + "rep": { + "value": true + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "10", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": false + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.wrinklePrevent": "Off" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": {} + } + ] +} diff --git a/tests/fixtures/golden/air_dresser.json b/tests/fixtures/golden/air_dresser.json new file mode 100644 index 0000000..fdc4b43 --- /dev/null +++ b/tests/fixtures/golden/air_dresser.json @@ -0,0 +1,20 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "completion_minutes", + "cycle", + "cycle_active", + "delay_start_hours", + "diagnosis_status", + "energy_kwh", + "finish_time", + "job_beginning_status", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "wrinkle_prevent" + ] +} diff --git a/tests/test_air_dresser_capabilities.py b/tests/test_air_dresser_capabilities.py new file mode 100644 index 0000000..3756a4e --- /dev/null +++ b/tests/test_air_dresser_capabilities.py @@ -0,0 +1,70 @@ +"""Tests for the AirDresser device type (DA_DF_A51_20_COMMON, issue #162).""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import air_dresser, for_device_by_model +from custom_components.localthings.registry.discovery import discover + +from tests.conftest import _load_device + + +def _air_dresser(): + resources = _load_device('air_dresser') + 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 = _air_dresser() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + return flatten(bound, resources) + + +def test_resolves_to_air_dresser_registry(): + reg, _ = _air_dresser() + assert reg is not None and reg.name == 'air_dresser' + + +def test_no_unbound_hrefs(): + reg, resources = _air_dresser() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_course_select_present_and_reads_current_selection(): + """This was the actual reported gap: /course/vs/0 was previously + entirely unbound, so no cycle/mode select existed at all.""" + state = _state() + assert state['cycle'] == '01' + + +def test_course_options_derived_from_supported_options_fallback(): + """No /wm/editcourse/vs/0 on this board at all, so the option list must + come from laundry.cycle_options' supportedOptions decode rather than + editCourseList -- confirmed distinct codes, current selection included.""" + from custom_components.localthings.registry.capabilities.laundry import cycle_options + _, resources = _air_dresser() + codes = cycle_options(resources) + assert codes == ['01', '02', '04', '03', '05', '1A', '1B', '1C', '07', '08'] + + +def test_wrinkle_prevent_present_dry_level_fields_absent(): + """AIR_DRESSER_SETTINGS only binds wrinkle_prevent -- dryLevel/dryTime/ + dryerType are dryer-only fields this device never reports, so they + should not appear as always-empty entities.""" + state = _state() + assert state['wrinkle_prevent'] is False + for key in ('dry_level', 'dry_time', 'dryer_type'): + assert key not in state, key + + +def test_expected_entities_present(): + state = _state() + for key in ( + 'power_switch', 'child_lock', 'remote_control', 'cycle', + 'machine_state', 'progress', 'progress_percentage', 'finish_time', + 'completion_minutes', 'delay_start_hours', 'diagnosis_status', + 'job_beginning_status', 'wrinkle_prevent', 'energy_kwh', + ): + assert key in state, key diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 089e036..f96f7f7 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -730,6 +730,23 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_tp1x_rac_01001 ) +def test_registry_reproduces_golden_state_keys_for_air_dresser(): + """DA_DF_A51_20_COMMON AirDresser (model DF8600T, issue #162) -- reports + no oneUiVersion; resolved via the '_DF_' modelNum token fallback. Has no + /wm/editcourse/vs/0 at all, so the course select's option list comes + entirely from laundry.cycle_options' supportedOptions fallback. Binds + cleanly against the new air_dresser registry with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('air_dresser') + golden = json.loads((GOLDEN / 'air_dresser.json').read_text()) + state_keys = _new_state_keys('air_dresser', 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 = { From a3d9cce16442cfffcc9ee26331f95e650a446a3d Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 13:50:59 +0000 Subject: [PATCH 08/15] Extend AirDresser support to DA_DF_TP2_20_COMMON (#157) A different board generation from issue #162's DA_DF_A51_20_COMMON, also carrying the '_DF_' modelNum token and so already routed into the air_dresser registry -- but reporting two resources #162's board doesn't: /st/airdressercourse/vs/0 -- the course table id (Table_00), read the same way washer/dryer read /st/washercourse|dryercourse/vs/0. Wired up as AIR_DRESSER_COURSE's table_href and added to the global ignore list, mirroring that existing pair exactly. /airdresseroption/sanitize/vs/0 -- a genuine on/off setting (not covered by any existing capability), added as AIR_DRESSER_SANITIZE. Introducing table_href means the course select's translation_key is now always the table-lookup callable, so the bare 'air_dresser_cycle' catalog entry added for #162 (only ever used when no table_href was passed) is unreachable in every case and is removed -- both boards fall back to the shared 'cycle' entry until their course tables get named, same as washer/dryer's own precedent for an unrecognized table. --- .../registry/by_type/air_dresser.py | 1 + .../registry/capabilities/air_dresser.py | 62 ++-- .../registry/capabilities/ignored.py | 5 + .../localthings/translations/en.json | 3 - .../localthings/translations/nl.json | 3 - tests/fixtures/air_dresser_tp2_20_device.json | 282 ++++++++++++++++++ tests/fixtures/golden/air_dresser_tp2_20.json | 22 ++ tests/test_air_dresser_tp2_20_capabilities.py | 82 +++++ tests/test_golden_regression.py | 21 ++ 9 files changed, 456 insertions(+), 25 deletions(-) create mode 100644 tests/fixtures/air_dresser_tp2_20_device.json create mode 100644 tests/fixtures/golden/air_dresser_tp2_20.json create mode 100644 tests/test_air_dresser_tp2_20_capabilities.py diff --git a/custom_components/localthings/registry/by_type/air_dresser.py b/custom_components/localthings/registry/by_type/air_dresser.py index 7c54bb0..db3d526 100644 --- a/custom_components/localthings/registry/by_type/air_dresser.py +++ b/custom_components/localthings/registry/by_type/air_dresser.py @@ -16,6 +16,7 @@ REGISTRY = DeviceRegistry( *common.POWER, air_dresser.AIR_DRESSER_SETTINGS, air_dresser.AIR_DRESSER_COURSE, + air_dresser.AIR_DRESSER_SANITIZE, laundry.JOB_BEGINNING_STATUS, operational.OPERATIONAL_STATE, dishwasher.DIAGNOSIS, diff --git a/custom_components/localthings/registry/capabilities/air_dresser.py b/custom_components/localthings/registry/capabilities/air_dresser.py index 4b4a376..3fcfb11 100644 --- a/custom_components/localthings/registry/capabilities/air_dresser.py +++ b/custom_components/localthings/registry/capabilities/air_dresser.py @@ -1,30 +1,36 @@ -"""Capabilities specific to the AirDresser family (Samsung DA_DF_A51-class, -issue #162). +"""Capabilities specific to the AirDresser family (Samsung DA_DF-class, +issues #162/#157). -This board reports no oneUiVersion and no /information/vs/0 token any -existing family routes on, so it gets its own device type -- but most of -the resources it exposes are already handled by the shared laundry +This board family reports no oneUiVersion and no /information/vs/0 token +any existing family routes on, so it gets its own device type -- but most +of the resources it exposes are already handled by the shared laundry machinery: /washer/vs/0 -> AIR_DRESSER_SETTINGS (wrinkle_prevent only -- a dedicated capability rather than reusing - dryer.DRYER_SETTINGS wholesale: this board's dump - never populates dryLevel/dryTime/dryerType at all, - and those are permanently-empty-not-just-absent + dryer.DRYER_SETTINGS wholesale: this board never + populates dryLevel/dryTime/dryerType at all, and + those are permanently-empty-not-just-absent dryer-only fields on an AirDresser, not merely unset ones, so binding them here would ship three sensors that can never read anything on this device type) - /course/vs/0 -> AIR_DRESSER_COURSE (cycle select). This board has no - /wm/editcourse/vs/0 at all, so cycle_options() falls - through entirely to its supportedOptions fallback -- - confirmed against the issue #162 dump: header nibble - '0' + 10 self-indexed one-byte records - (01/02/04/03/05/1A/1B/1C/07/08), all distinct, current - selection 'Course_01' among them. Course names aren't - identified yet (no code->name mapping was reported), - so they render as their raw codes until named in - translations, same as dryer.py's unidentified codes. + /course/vs/0 -> AIR_DRESSER_COURSE (cycle select), table id from + /st/airdressercourse/vs/0 (issue #157's + DA_DF_TP2_20_COMMON reports "Table_00"; issue #162's + DA_DF_A51_20_COMMON has no table resource at all and + falls back to the name-only 'cycle' key, same as an + unrecognized table on washer/dryer). Options come + from laundry.cycle_options -- #157's board populates + /wm/editcourse/vs/0's editCourseList directly; #162's + has no editcourse resource at all and falls through + to cycle_options' supportedOptions decode instead. + Course names aren't identified yet for either table + (no code->name mapping was reported), so they render + as their raw codes until named in translations, same + as dryer.py's unidentified codes. /diagnosis/vs/0 -> reuses dishwasher.DIAGNOSIS + /airdresseroption/sanitize/vs/0 -> AIR_DRESSER_SANITIZE (issue #157 only; + #162's board doesn't report this resource at all) """ from ..capability import Capability from ..entities import SwitchDesc @@ -37,6 +43,12 @@ def _wrinkle_write(p, rep, href=None): return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p} +def _sanitize_write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + return ['airdresseroption', 'sanitize', 'vs', '0'], {'x.com.samsung.da.sanitize': p} + + AIR_DRESSER_SETTINGS = Capability( href='/washer/vs/0', poll_tier='warm', @@ -51,6 +63,18 @@ AIR_DRESSER_SETTINGS = Capability( AIR_DRESSER_COURSE = Capability( href='/course/vs/0', entities=( - cycle_select(translation_key='air_dresser_cycle', icon='mdi:tshirt-crew'), + cycle_select(translation_key='air_dresser_cycle', icon='mdi:tshirt-crew', + table_href='/st/airdressercourse/vs/0'), + ), +) + +AIR_DRESSER_SANITIZE = Capability( + href='/airdresseroption/sanitize/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize', + icon='mdi:weather-sunny', + value_fn=lambda v: v == 'On', + write_fn=_sanitize_write), ), ) diff --git a/custom_components/localthings/registry/capabilities/ignored.py b/custom_components/localthings/registry/capabilities/ignored.py index 3171475..ff064de 100644 --- a/custom_components/localthings/registry/capabilities/ignored.py +++ b/custom_components/localthings/registry/capabilities/ignored.py @@ -107,6 +107,11 @@ IGNORED: list[Capability] = [ # exposed by dryer.DRYER_COURSE at /course/vs/0 # (x.com.samsung.da.st.dryerMode is "Table_03_Course_"). Capability(href='/st/dryercourse/vs/0'), + # AirDresser counterpart of the above (issue #157): read only for its + # courseTable id (air_dresser.AIR_DRESSER_COURSE's table_href), no + # entity of its own -- same "no entity, just the table id" role as + # /st/washercourse/vs/0 and /st/dryercourse/vs/0. + Capability(href='/st/airdressercourse/vs/0'), # Empty on every washer dump seen so far. Capability(href='/wm/welcomemsg/vs/0'), # User-saved custom course slots (F1-FA). No controllable/observable diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index a9bc478..6b9fe90 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -193,9 +193,6 @@ "ai_energy_level": { "name": "AI Energy Mode level" }, - "air_dresser_cycle": { - "name": "Cycle" - }, "beverage_zone_mode": { "name": "Beverage zone mode", "state": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 2fb2366..87e3e18 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -193,9 +193,6 @@ "ai_energy_level": { "name": "Niveau AI Energy Mode" }, - "air_dresser_cycle": { - "name": "Cyclus" - }, "beverage_zone_mode": { "name": "Modus drankenzone", "state": { diff --git a/tests/fixtures/air_dresser_tp2_20_device.json b/tests/fixtures/air_dresser_tp2_20_device.json new file mode 100644 index 0000000..8e96d3c --- /dev/null +++ b/tests/fixtures/air_dresser_tp2_20_device.json @@ -0,0 +1,282 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/airdresseroption/sanitize/vs/0", + "rep": { + "x.com.samsung.da.sanitize": "Off", + "x.com.samsung.da.supportedSanitize": [ + "On", + "Off" + ] + } + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "3171000000", + "x.com.samsung.da.countryCode": "KR" + } + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_015E", + "UpdateAllow_NotAllowed", + "Course_22", + "AiOption_On", + "SteamPush_None", + "SeamlessControl_Disable", + "KidsLockBypass_On", + "FilterUsage_384019D5", + "WashingTimes_6", + "DrumCleanProposal_30", + "ProgressTimeSet_AA01A4AC00F0B20690", + "WrinklePreventRunning_Off", + "SendToDevice_Off", + "WeatherPush_Off", + "GMT_12", + "Alarm_Off", + "WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF00F0F0F0F0F0F0F0F0F0F0FF0F0", + "DelayEndSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF0F0F00F0F0FF0F0F0F0F00F0F", + "EnergyLevelSet_0502010102010202030303010401040505050503030502040503030303030405", + "UsagesDB_ok", + "DrumCleanLog_2023-04-02T08:08:55|2023-10-02T03:38:19|2024-05-26T03:25:41|2024-10-01T01:07:20|2026-06-16T02:23:29" + ], + "x.com.samsung.da.supportedOptions": [ + "12261062361020E61020961021261020C61021E61020B61021061020A61061461021361061661022462062562062F62062062040F62042761023061021561021A61021B61021C61022A61022B61022C61022D61022E6102076102086102" + ] + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": { + "x.com.samsung.da.cycleInterfaceEnabled": "Off" + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "63600", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1784404800", + "x.com.samsung.da.cumulativeDateUTC": "1784372400" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_DF_TP2_20_COMMON|20286141|380101010015110F0201000100010000", + "x.com.samsung.da.description": "DA_DF_TP2_20_COMMON_DF9500A/DC92-02888A_0002", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "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": "A00", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_DF_TP2_20_COMMON|20286141|380101010015110F0201000100010000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02673A250416(F822)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Firmware_1_DB", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "21020115,23030652", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Firmware_2_DB", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "19111852,FFFFFFFF" + } + ] + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "REDACTED", + "machineStates": "REDACTED", + "jobStates": [ + "None", + "Steaming", + "Airwashing", + "Drying", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "00:39:00", + "progressPercentage": "1" + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "00:39:00", + "x.com.samsung.da.progressPercentage": "1", + "x.com.samsung.da.progress": "None", + "x.com.samsung.da.delayEndTime": "00:00:00", + "x.com.samsung.da.supportedProgress": [ + "None", + "Steaming", + "Airwashing", + "Drying", + "Finish" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "Micom", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": false + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/st/airdressercourse/vs/0", + "rep": { + "x.com.samsung.da.st.airdresserMode": "Table_00_Course_22", + "x.com.samsung.da.st.courseTable": "Table_00" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.wrinklePrevent": "Off" + } + }, + { + "href": "/wm/editcourse/vs/0", + "rep": { + "x.com.samsung.da.editCourseList": "EditCourseList_22230C10252F0F0E09121E0B0A14131624202730151A1B1C2A2B2C2D2E0708", + "x.com.samsung.da.fixedCourseList": "FixedCourseList_22270F" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": {} + }, + { + "href": "/wm/personalcourse/vs/0", + "rep": { + "x.com.samsung.da.courses": [ + "F1_00", + "F2_00", + "F3_00", + "F4_00", + "F5_00", + "F6_00", + "F7_00", + "F8_00", + "F9_00", + "FA_00" + ], + "x.com.samsung.da.maxCourseNum": "10" + } + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "false", + "x.com.samsung.da.aiCourse": "false", + "x.com.samsung.da.isModelSettingPowerOnOff": "true" + } + }, + { + "href": "/wm/welcomemsg/vs/0", + "rep": {} + } + ] +} diff --git a/tests/fixtures/golden/air_dresser_tp2_20.json b/tests/fixtures/golden/air_dresser_tp2_20.json new file mode 100644 index 0000000..a935010 --- /dev/null +++ b/tests/fixtures/golden/air_dresser_tp2_20.json @@ -0,0 +1,22 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "completion_minutes", + "cycle", + "cycle_active", + "delay_start_hours", + "diagnosis_status", + "energy_kwh", + "finish_time", + "firmware_update", + "job_beginning_status", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "sanitize", + "wrinkle_prevent" + ] +} diff --git a/tests/test_air_dresser_tp2_20_capabilities.py b/tests/test_air_dresser_tp2_20_capabilities.py new file mode 100644 index 0000000..2d3ef00 --- /dev/null +++ b/tests/test_air_dresser_tp2_20_capabilities.py @@ -0,0 +1,82 @@ +"""Tests for the DA_DF_TP2_20_COMMON AirDresser (model DF9500A, issue #157). + +A different board generation than issue #162's DA_DF_A51_20_COMMON, routed +into the same air_dresser registry (both carry the '_DF_' modelNum token). +Exercises the two things this board does differently: a populated +/wm/editcourse/vs/0 (so cycle_options() never needs the supportedOptions +fallback #162 relies on) and the new /airdresseroption/sanitize/vs/0 +capability #162's dump doesn't report at all. +""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import air_dresser, for_device_by_model +from custom_components.localthings.registry.discovery import discover + +from tests.conftest import _load_device + + +def _air_dresser(): + resources = _load_device('air_dresser_tp2_20') + 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 = _air_dresser() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + return flatten(bound, resources) + + +def test_resolves_to_air_dresser_registry(): + reg, _ = _air_dresser() + assert reg is not None and reg.name == 'air_dresser' + + +def test_no_unbound_hrefs(): + """Confirms /st/airdressercourse/vs/0 and /airdresseroption/sanitize/vs/0 + -- the two hrefs this board reports that #162's dump doesn't -- are + covered (ignored.py and AIR_DRESSER_SANITIZE respectively).""" + reg, resources = _air_dresser() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_course_options_come_from_edit_course_list_not_the_fallback(): + """Unlike issue #162's board, this one populates /wm/editcourse/vs/0 + directly, so cycle_options() should never reach the + supportedOptions-decode fallback.""" + from custom_components.localthings.registry.capabilities.laundry import cycle_options + _, resources = _air_dresser() + codes = cycle_options(resources) + assert codes[:3] == ['22', '23', '0C'] + assert codes == cycle_options({'/wm/editcourse/vs/0': resources['/wm/editcourse/vs/0']}) + + +def test_course_select_reads_current_selection(): + state = _state() + assert state['cycle'] == '22' + + +def test_course_translation_key_falls_back_to_cycle_for_unidentified_table(): + """Table_00's course codes aren't identified yet, so the select's + translation_key resolves to the generic 'cycle' catalog entry rather + than a table-specific one that doesn't exist.""" + _, resources = _air_dresser() + desc = air_dresser.REGISTRY.capabilities['/course/vs/0'][0].entities[0] + assert desc.translation_key(resources) == 'cycle' + + +def test_sanitize_present_and_toggles(): + state = _state() + assert state['sanitize'] is False + + desc = next( + e for e in air_dresser.REGISTRY.capabilities['/airdresseroption/sanitize/vs/0'][0].entities + if e.key == 'sanitize' + ) + path, body = desc.write_fn('On', {}) + assert path == ['airdresseroption', 'sanitize', 'vs', '0'] + assert body == {'x.com.samsung.da.sanitize': 'On'} + assert desc.write_fn('Sparkle', {}) is None diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index f96f7f7..4b584b4 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -747,6 +747,27 @@ def test_registry_reproduces_golden_state_keys_for_air_dresser(): ) +def test_registry_reproduces_golden_state_keys_for_air_dresser_tp2_20(): + """DA_DF_TP2_20_COMMON AirDresser (model DF9500A, issue #157) -- a + different board generation than issue #162's DA_DF_A51_20_COMMON, also + routed via the '_DF_' modelNum fallback into the same air_dresser + registry. Unlike #162's board, this one populates /wm/editcourse/vs/0's + editCourseList directly (no supportedOptions fallback needed) and + reports two AirDresser-specific resources #162 doesn't have: + /st/airdressercourse/vs/0 (course table id, ignored.py) and + /airdresseroption/sanitize/vs/0 (air_dresser.AIR_DRESSER_SANITIZE). + Binds cleanly with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('air_dresser_tp2_20') + golden = json.loads((GOLDEN / 'air_dresser_tp2_20.json').read_text()) + state_keys = _new_state_keys('air_dresser_tp2_20', 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 = { From fcd34f73e563a3c941e5bfbca8170c5bddcacd96 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 13:58:08 +0000 Subject: [PATCH 09/15] Add device support for BESPOKE Cube Air A-VTWW-TP2-21-COMMON (#151) This board reports no oneUiVersion and no modelNum token any existing family routed on, so it fell back to unknown -- exposing nothing but power even though most of its resources (air quality sensors, HEPA filter, device-active, diagnosis, plumbing hrefs) are already handled generically by the existing air_purifier registry via the '-VTWW-' modelNum fallback. The one genuinely new piece is its fan: this board reports wind strength as numeric codes ("87"/"89"/"90"/"91") on /wind/strength/vs/0 with a separate modesName array ("SMART"/"MAX"/"WINDFREE"/"Sleep") giving the actual names, unlike the existing TP1X_DA-AC-AIR family where supportedModes IS the name list already. Generalized LocalThingsAirPurifierFan to resolve a mode code through modesName when present (same live-label pattern as climate.py's AC wind-strength fix, issue #155) instead of adding a second hardcoded fan class, and added WIND_STRENGTH_FAN reusing the same 'air_purifier_fan' translation catalog -- both board generations land on the identical smart/max/windfree/sleep vocabulary already labelled there. /mode/convenient/vs/0 is empty on this dump and added to COVERAGE alongside the existing plumbing hrefs this board also shares with the TP1X_DA-AC-AIR family. --- custom_components/localthings/fan.py | 46 ++- .../localthings/registry/by_type/__init__.py | 7 + .../registry/by_type/air_purifier.py | 7 +- .../registry/capabilities/air_purifier.py | 35 ++ tests/fixtures/air_purifier_vtww_device.json | 310 ++++++++++++++++++ tests/fixtures/golden/air_purifier_vtww.json | 17 + tests/test_air_purifier_vtww_fan.py | 91 +++++ tests/test_golden_regression.py | 18 + 8 files changed, 519 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/air_purifier_vtww_device.json create mode 100644 tests/fixtures/golden/air_purifier_vtww.json create mode 100644 tests/test_air_purifier_vtww_fan.py diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index 9c9d623..68f18fa 100644 --- a/custom_components/localthings/fan.py +++ b/custom_components/localthings/fan.py @@ -1,15 +1,18 @@ """Fan platform for Samsung range hoods and air purifiers. -Three FanDesc-bound hrefs exist, dispatched by href in async_setup_entry +Four FanDesc-bound hrefs exist, dispatched by href in async_setup_entry below since each needs different HA fan semantics: the range hood's fan speed and the older ARTIK051_TVTL air-purifier family's Auto/Sleep/Low/ Medium/High (issue #56) are both an ordered set of numeric levels (SET_SPEED) -- the latter confirmed monotonic in capabilities/ air_purifier.py's module docstring, with no named-mode list to preserve -since this board never self-reports one. The newer TP1X air-purifier -family's modes (Smart/Max/Mid/WindFree/Sleep, issue #130) are named -behaviors with no linear order (PRESET_MODE), reported directly by that -board's own supportedModes.""" +since this board never self-reports one. The TP1X air-purifier family's +modes (Smart/Max/Mid/WindFree/Sleep, issue #130) and the A-VTWW-TP2-21 +family's /wind/strength/vs/0 modes (issue #151) are both named behaviors +with no linear order (PRESET_MODE) -- LocalThingsAirPurifierFan handles +both hrefs, the only difference being whether the label comes straight +from supportedModes or from a parallel modesName array (see +_label_for_code).""" from __future__ import annotations @@ -29,6 +32,7 @@ from .coordinator import LocalThingsCoordinator from .entity import LocalThingsEntity, _is_included from .registry.capabilities.air_purifier import HREF_AIRFLOW from .registry.capabilities.air_purifier import HREF_MODE as AIR_PURIFIER_FAN_HREF +from .registry.capabilities.air_purifier import HREF_WIND_STRENGTH as AIR_PURIFIER_WIND_STRENGTH_HREF from .registry.entities import FanDesc _LOGGER = logging.getLogger(__name__) @@ -42,6 +46,7 @@ _OFF_SPEED_CODE = '0' _MODES_FIELD = 'x.com.samsung.da.modes' _SUPPORTED_MODES_FIELD = 'x.com.samsung.da.supportedModes' +_MODES_NAME_FIELD = 'x.com.samsung.da.modesName' async def async_setup_entry( @@ -54,7 +59,7 @@ async def async_setup_entry( for bound in coordinator.bound: if not (isinstance(bound.desc, FanDesc) and _is_included(bound, coordinator)): continue - if bound.href == AIR_PURIFIER_FAN_HREF: + if bound.href in (AIR_PURIFIER_FAN_HREF, AIR_PURIFIER_WIND_STRENGTH_HREF): entities.append(LocalThingsAirPurifierFan(coordinator, bound)) elif bound.href == HREF_AIRFLOW: entities.append(LocalThingsAirflowFan(coordinator, bound)) @@ -243,10 +248,29 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity): return str(power).lower() == 'on' return bool(self._rep(POWER_HREF).get('value')) + def _label_for_code(self, code) -> str: + """Lowercased HA preset label for a device mode code. + + The TP1X_DA-AC-AIR board (issue #130) reports its named modes + directly as supportedModes ('Smart'/'Max'/...), so the code IS the + label. The A-VTWW-TP2-21 board (issue #151) instead reports numeric + wind-strength codes ('87'/'89'/...) with a separate modesName array + (parallel-indexed with supportedModes) giving the actual names -- + same shape as climate.py's _wind_strength_label, and coincidentally + the same word set (Smart/Max/WindFree/Sleep), so both board + generations land on identical HA preset values without needing + their own translation catalog entry.""" + rep = self._mode_rep() + supported = list(rep.get(_SUPPORTED_MODES_FIELD, ())) + names = rep.get(_MODES_NAME_FIELD) + if names and code in supported and len(names) == len(supported): + return str(names[supported.index(code)]).lower() + return str(code).lower() + @property def preset_modes(self) -> list[str]: return [ - str(code).lower() + self._label_for_code(code) for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ()) ] @@ -254,7 +278,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity): def preset_mode(self) -> str | None: modes = self._mode_rep().get(_MODES_FIELD) code = modes[0] if isinstance(modes, (list, tuple)) and modes else modes - return str(code).lower() if code is not None else None + return self._label_for_code(code) if code is not None else None async def async_turn_on( self, percentage: int | None = None, preset_mode: str | None = None, @@ -269,10 +293,10 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: # Reverse-resolve against the unit's own supportedModes -- the - # write needs the raw device code (e.g. 'WindFree'), not the - # lowercased HA value. + # write needs the raw device code (e.g. 'WindFree', or '90' on the + # modesName-labelled board), not the lowercased HA value. for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ()): - if str(code).lower() == preset_mode: + if self._label_for_code(code) == preset_mode: await self.coordinator.async_send_command(self._bound, ('mode', code)) return _LOGGER.warning( diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 0bed782..3860fb1 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -223,6 +223,13 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi key = 'airconditioner' if key is None and '_TVTL_' in (model_num or ''): key = 'air_purifier' + # BESPOKE Cube Air (e.g. A-VTWW-TP2-21-COMMON, issue #151) reports no + # oneUiVersion and carries the hyphenated '-VTWW-' board-family token + # (distinct from the underscore-delimited '_TVTL_' ARTIK051 family + # above). Its fan lives on /wind/strength/vs/0 rather than /mode/vs/0 -- + # see capabilities/air_purifier.py's WIND_STRENGTH_FAN. + if key is None and '-VTWW-' in (model_num or '').upper(): + key = 'air_purifier' model_identity = f'{model_num} {description}'.upper() if key is None and ('_COOKTOP' in model_identity or '_GB_CT_' in model_identity): key = 'cooktop' diff --git a/custom_components/localthings/registry/by_type/air_purifier.py b/custom_components/localthings/registry/by_type/air_purifier.py index 2648aec..cf87fba 100644 --- a/custom_components/localthings/registry/by_type/air_purifier.py +++ b/custom_components/localthings/registry/by_type/air_purifier.py @@ -1,6 +1,6 @@ """Air-purifier device registry. -Spans two board generations sharing this one registry (see +Spans three board generations sharing this one registry (see capabilities/air_purifier.py's module docstring for the per-href match_fn discriminators that keep them from colliding): @@ -12,6 +12,10 @@ match_fn discriminators that keep them from colliding): reported; reuses airconditioner.DISPLAY_LIGHT and airconditioner.MUTE_ONCE for /light/vs/0 and /option/muteonce/vs/0, which are identical shapes on the shared DA-AC- board family. +- A-VTWW-TP2-21-COMMON-class (issue #151). Reports no oneUiVersion and no + existing modelNum token; falls back to unknown until routed here. Its fan + is WIND_STRENGTH_FAN on /wind/strength/vs/0 rather than FAN on + /mode/vs/0 -- see that capability's comment. Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write contract). @@ -33,6 +37,7 @@ REGISTRY = DeviceRegistry( air_purifier.AIRFLOW_VS_FALLBACK, air_purifier.MODE, air_purifier.FAN, + air_purifier.WIND_STRENGTH_FAN, air_purifier.DISPLAY, air_purifier.HEPA_FILTER, air_purifier.PANEL_STATUS, diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index f9ba661..c860084 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -74,6 +74,7 @@ from .laundry import bool_option_exists, bool_option_value, option_value, option # are mutually exclusive via this presence check rather than colliding. HREF_MODE = '/mode/vs/0' HREF_AIRFLOW = '/airflow/0' +HREF_WIND_STRENGTH = '/wind/strength/vs/0' def _has_top_level_modes(rep, resources): @@ -272,6 +273,36 @@ FAN = Capability( ), ) + +def _wind_strength_fan_write(payload, rep, href=None): + kind, value, *args = payload + if kind == 'power': + power_href = args[0] if args else '/power/vs/0' + if power_href == '/power/0': + return ['power', '0'], {'value': bool(value)} + return (['power', 'vs', '0'], + {'x.com.samsung.da.power': 'On' if value else 'Off'}) + if kind == 'mode': + return ['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value} + return None + + +# A-VTWW-TP2-21-COMMON (issue #151): named preset modes like FAN above, but +# on a distinct href with numeric codes ("87"/"89"/"90"/"91") instead of +# self-describing supportedModes -- x.com.samsung.da.modesName gives the +# actual names (SMART/MAX/WINDFREE/Sleep), read live by fan.py's +# LocalThingsAirPurifierFan._label_for_code rather than a hardcoded +# per-model map. modes here is a bare string ('87'), not a single-element +# list like HREF_MODE's -- _wind_strength_fan_write writes it back as-is. +WIND_STRENGTH_FAN = Capability( + href=HREF_WIND_STRENGTH, + poll_tier='warm', + entities=( + FanDesc(key='fan', translation_key='air_purifier_fan', + field='x.com.samsung.da.modes', write_fn=_wind_strength_fan_write), + ), +) + # --------------------------------------------------------------------------- # TP1X_DA-AC-AIR-class additions (issue #130). This board reports several # resources the older ARTIK051_TVTL family never did. @@ -425,4 +456,8 @@ COVERAGE = [ # functionState both 'false'). Same "needs a multi-field schedule # editor" treatment as fridge.py's /defrost/reservation/vs/0. Capability(href='/dnd/autosleep/vs/0'), + # Empty ({}) on the A-VTWW-TP2-21 dump (issue #151) -- this board's + # convenient-mode-equivalent behavior lives entirely in WIND_STRENGTH_FAN + # above instead. + Capability(href='/mode/convenient/vs/0'), ] diff --git a/tests/fixtures/air_purifier_vtww_device.json b/tests/fixtures/air_purifier_vtww_device.json new file mode 100644 index 0000000..e3c7df1 --- /dev/null +++ b/tests/fixtures/air_purifier_vtww_device.json @@ -0,0 +1,310 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/airlevelcheck/vs/0", + "rep": { + "x.com.samsung.da.periodicSensingActivationState": "Off", + "x.com.samsung.da.periodicSensingInterval": "600", + "x.com.samsung.da.startSensingOnce": "On", + "x.com.samsung.da.sensingState": "NonProcessing", + "x.com.samsung.da.lastSensingTime": "1785195000", + "x.com.samsung.da.lastSensingLevel": "Kr1", + "x.com.samsung.da.autoExeState": "Off", + "x.com.samsung.da.supportedAutoExeState": [ + "Off", + "Airpurify", + "Alarm" + ], + "x.com.samsung.da.periodicSensingSkipStatus": "Off", + "x.com.samsung.da.periodicSensingSkipTime": "15002100" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "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": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "0000000000000000000000000000", + "x.com.samsung.da.id": "VTL", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "3017000000", + "x.com.samsung.da.airconOptionList": [ + "WELCOMECARE", + "AI_PURIFY" + ], + "x.com.samsung.da.countryCode": "" + } + }, + { + "href": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "href": "/devicespecificinfo/vs/0", + "rep": { + "x.com.samsung.da.deviceActive": true + } + }, + { + "href": "/dnd/autosleep/vs/0", + "rep": { + "x.com.samsung.da.startTime": "14:00:00", + "x.com.samsung.da.endTime": "22:00:00", + "x.com.samsung.da.visible": "true", + "x.com.samsung.da.useTimeSetting": "true", + "x.com.samsung.da.functionState": "true" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativePower": "196696", + "x.com.samsung.da.cumulativeDate": "1785245199", + "x.com.samsung.da.cumulativeDateUTC": "1785212799", + "x.com.samsung.da.cumulativeUnit": "Wh" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00" + } + }, + { + "href": "/filter/hepafilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "0", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterCapacity": "8760", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable" + ] + } + }, + { + "href": "/humidity/0", + "rep": {} + }, + { + "href": "/humidity/vs/0", + "rep": {} + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "A-VTWW-TP2-21-COMMON|10231241|70000535001511EC0505090201000000", + "x.com.samsung.da.description": "A-VTWW-TP2-21-COMMON", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "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": "101", + "x.com.samsung.da.diagMinVersion": "1.0", + "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": "02308A260310", + "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": "21063003,21063002", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": {} + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.options": [ + "CountOfSlave_0", + "SlavePower01_NotSupported", + "Pollution_Off", + "OptionCode_23418", + "SmartSleep_Off" + ] + } + }, + { + "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", + "otnStatus": "None", + "flashingProgress": "", + "otnTarget": "main", + "otnCompleteDate": "noHistory", + "otnList": [ + { + "type": "WIFI", + "modelId": "A-VTWW-TP2-21-COMMON", + "versions": [ + "12260310" + ], + "visVersion": "260310" + }, + { + "type": "Micom", + "modelId": "09501023124110231341", + "versions": [ + "21063003", + "21063002" + ], + "visVersion": "210630" + } + ] + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "7", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/sensors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Sensor for Dust", + "x.com.samsung.da.type": "Dust", + "x.com.samsung.da.value": [ + "5", + "1" + ] + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Sensor for FineDust", + "x.com.samsung.da.type": "FineDust", + "x.com.samsung.da.value": [ + "5", + "1" + ] + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Sensor for Odor", + "x.com.samsung.da.type": "Odor", + "x.com.samsung.da.value": [ + "1" + ] + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Sensor for CleanLevel", + "x.com.samsung.da.type": "CleanLevel", + "x.com.samsung.da.value": [ + "1" + ] + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Sensor for SuperFineDust", + "x.com.samsung.da.type": "SuperFineDust", + "x.com.samsung.da.value": [ + "5", + "1" + ] + } + ], + "x.com.samsung.da.total": "5" + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "87", + "x.com.samsung.da.supportedModes": [ + "87", + "89", + "90", + "91" + ], + "x.com.samsung.da.modesName": [ + "SMART", + "MAX", + "WINDFREE", + "Sleep" + ] + } + } + ] +} diff --git a/tests/fixtures/golden/air_purifier_vtww.json b/tests/fixtures/golden/air_purifier_vtww.json new file mode 100644 index 0000000..892ba89 --- /dev/null +++ b/tests/fixtures/golden/air_purifier_vtww.json @@ -0,0 +1,17 @@ +{ + "state_keys": [ + "alarm_code", + "clean_level", + "device_active", + "dust", + "energy_kwh", + "fan", + "fine_dust", + "firmware_update", + "hepa_filter_status", + "hepa_filter_usage", + "odor", + "power_switch", + "super_fine_dust" + ] +} diff --git a/tests/test_air_purifier_vtww_fan.py b/tests/test_air_purifier_vtww_fan.py new file mode 100644 index 0000000..81d3351 --- /dev/null +++ b/tests/test_air_purifier_vtww_fan.py @@ -0,0 +1,91 @@ +"""HA fan-entity mapping tests for the A-VTWW-TP2-21-COMMON BESPOKE Cube Air +(issue #151). + +This board's /wind/strength/vs/0 reports numeric wind-strength codes +("87"/"89"/"90"/"91") with a separate modesName array ("SMART"/"MAX"/ +"WINDFREE"/"Sleep") giving the actual names, unlike the TP1X_DA-AC-AIR +family's /mode/vs/0 (issue #130) where supportedModes IS the name list +already. LocalThingsAirPurifierFan._label_for_code resolves both shapes +without a per-model map. +""" +from custom_components.localthings.fan import LocalThingsAirPurifierFan +from custom_components.localthings.registry.by_type import air_purifier, for_device_by_model +from custom_components.localthings.registry.capabilities.air_purifier import HREF_WIND_STRENGTH +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import FanDesc + +from tests.conftest import _load_device + + +class _FakeCoordinator: + device_serial = 'TEST-VTWW-SERIAL' + device_info = {} + data = {} + + def __init__(self, resources): + self.last_resources = resources + self.commands = [] + + def resource(self, href): + return self.last_resources.get(href, {}) + + async def async_send_command(self, bound, payload): + self.commands.append((bound, payload)) + + +def _resources(): + return _load_device('air_purifier_vtww') + + +def _reg(resources): + info = resources['/information/vs/0'] + return for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + + +def _entity(resources, coordinator=None): + reg = _reg(resources) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + fan_bound = next( + item for item in bound + if isinstance(item.desc, FanDesc) and item.href == HREF_WIND_STRENGTH + ) + return LocalThingsAirPurifierFan(coordinator or _FakeCoordinator(resources), fan_bound) + + +def test_resolves_to_air_purifier_registry(): + assert _reg(_resources()).name == 'air_purifier' + + +def test_no_unbound_hrefs(): + resources = _resources() + reg = _reg(resources) + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_preset_modes_come_from_modes_name_not_the_raw_codes(): + entity = _entity(_resources()) + assert entity.preset_modes == ['smart', 'max', 'windfree', 'sleep'] + + +def test_preset_mode_reads_the_current_code_via_modes_name(): + """Fixture's current mode is '87' -> modesName[0] 'SMART'.""" + entity = _entity(_resources()) + assert entity.preset_mode == 'smart' + + +async def test_set_preset_mode_writes_back_the_raw_code(): + resources = _resources() + coordinator = _FakeCoordinator(resources) + entity = _entity(resources, coordinator) + + await entity.async_set_preset_mode('windfree') + + assert coordinator.commands[-1][1] == ('mode', '90') + + +def test_is_on_reads_vendor_power(): + entity = _entity(_resources()) + assert entity.is_on is False diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 4b584b4..5385931 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -768,6 +768,24 @@ def test_registry_reproduces_golden_state_keys_for_air_dresser_tp2_20(): ) +def test_registry_reproduces_golden_state_keys_for_air_purifier_vtww(): + """A-VTWW-TP2-21-COMMON BESPOKE Cube Air (issue #151) -- reports no + oneUiVersion; resolved via the '-VTWW-' modelNum token fallback into + the existing air_purifier registry. Its fan lives on + /wind/strength/vs/0 (air_purifier.WIND_STRENGTH_FAN) rather than the + /mode/vs/0 FAN the other two board generations in this registry use. + Binds cleanly with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('air_purifier_vtww') + golden = json.loads((GOLDEN / 'air_purifier_vtww.json').read_text()) + state_keys = _new_state_keys('air_purifier_vtww', 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 = { From e568145deb6ced2a73f401057a955689483ac913 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 14:03:56 +0000 Subject: [PATCH 10/15] Fix microwave lamp switch reading/writing the wrong tokens (#152) The lamp SwitchDesc was modeled on issue #137's dump, which only ever showed 'Lamp_Off' -- 'On' was never actually confirmed as the paired value. Issue #152's ME7500D dump (same TP1X_DA-KS-MICROWAVE-01051 family) is the first to report a real non-Off value, and it's 'Lamp_High' (a brightness level), not 'Lamp_On'. So the switch always read as off regardless of the device's real state, and toggling it on wrote a token ('On') the device has never been observed to accept -- matching the reported "light control does not have any effect." value_fn now treats any non-Off/non-absent value as on; write_fn now writes back 'High'/'Off', the two tokens actually confirmed live, instead of the never-confirmed 'On'. The reporter's suspicion about the fan is unconfirmed and this dump's own /hood/fanspeed/vs/0 shape already matches the no-separate-power case fan.py's LocalThingsRangeHoodFan handles correctly (issues #137/#142), so no fan change was needed here. A second dump attached in a comment on this issue (model ME8000T, a large combi wall-oven with a very different mode vocabulary) reports its own distinct gap and doesn't resolve to any known device type at all -- that's a separate, substantial device-support task left for its own follow-up rather than folded into this fix. --- .../registry/capabilities/microwave.py | 15 ++++++++++++--- tests/test_microwave_capabilities.py | 17 ++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/microwave.py b/custom_components/localthings/registry/capabilities/microwave.py index 7dcb7f5..4e815ad 100644 --- a/custom_components/localthings/registry/capabilities/microwave.py +++ b/custom_components/localthings/registry/capabilities/microwave.py @@ -21,7 +21,12 @@ different from an oven, and defined fresh here: * Lamp: this family's option-array token is bare 'Lamp' (issue #137's 'Lamp_Off'), not oven.py's 'UpperLamp' -- and it's genuinely absent on the combi dump (issue #121), so it's gated with exists_fn rather than - assumed universal like oven.py's lamp switch. + assumed universal like oven.py's lamp switch. Issue #137's dump only + ever showed 'Off', so 'On' was a guess at the paired value; issue #152's + ME7500D dump is the first to show a real non-Off value, and it's 'High' + (a brightness level, not literally 'On') -- the switch now treats any + non-Off/non-None value as "on" for reads, and writes back 'High'/'Off' + (the two confirmed tokens) rather than the never-confirmed 'On'. Note: cooking-mode writes are unproven here, same caveat as oven.py's OVEN_MODE -- exposed as a SelectDesc for fidelity, first real-world write @@ -132,8 +137,12 @@ def _lamp_write(p, rep, href=None): return None if not rep.get('x.com.samsung.da.options'): return None + # 'High' and 'Off' are the two tokens actually confirmed on live dumps + # (issues #137/#152) -- 'On' has never been observed and the device + # likely doesn't recognize it (see module docstring). + token = 'High' if p == 'On' else 'Off' return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': option_write('Lamp', p), + 'x.com.samsung.da.options': option_write('Lamp', token), } @@ -189,7 +198,7 @@ MICROWAVE_MODE = Capability( SwitchDesc(key='lamp', field='x.com.samsung.da.options', icon='mdi:track-light', exists_fn=_lamp_exists, - value_fn=lambda opts: option_value(opts, 'Lamp') == 'On', + value_fn=lambda opts: option_value(opts, 'Lamp') not in (None, 'Off'), write_fn=_lamp_write), ), ) diff --git a/tests/test_microwave_capabilities.py b/tests/test_microwave_capabilities.py index 05f0b91..5629322 100644 --- a/tests/test_microwave_capabilities.py +++ b/tests/test_microwave_capabilities.py @@ -165,13 +165,28 @@ def test_lamp_gated_present_when_lamp_option_reported(): def test_lamp_write_is_single_token(): + """issue #152: the device has never been observed accepting 'On' -- + only 'High'/'Off' -- so the switch's "on" write uses 'High'.""" desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') rep = {'x.com.samsung.da.options': ['Lamp_Off']} path, body = desc.write_fn('On', rep) assert path == ['mode', 'vs', '0'] - assert body == {'x.com.samsung.da.options': ['Lamp_On']} + assert body == {'x.com.samsung.da.options': ['Lamp_High']} def test_lamp_write_requires_existing_options(): desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') assert desc.write_fn('On', {}) is None + + +def test_lamp_reads_off_as_false(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') + assert desc.value_fn(['Lamp_Off']) is False + + +def test_lamp_reads_any_non_off_level_as_true(): + """issue #152's ME7500D reports 'Lamp_High', not the binary 'Lamp_On' + #137's dump implied -- any non-Off/non-absent value must read as on, not + just a literal 'On'.""" + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') + assert desc.value_fn(['Lamp_High']) is True From daa7546208945cab358fee3431ac562479997be1 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 14:09:21 +0000 Subject: [PATCH 11/15] Add device support for Wind-Free 2-in-1 AC TP2X_FAC_BORA_21K (#150, #153) This board (a floor-standing + wall-mounted indoor unit pair sharing one outdoor unit and one local IP) reports no oneUiVersion and carries the '_FAC_' modelNum token, which no existing routing rule matched -- it fell back to 'unknown' and exposed nothing but a power switch, with no climate entity generated at all (both issues' reported symptom). Once routed to the existing airconditioner registry, it binds cleanly against the exact same CLIMATE composite every other room-AC family uses -- same Cool/Dry/Wind/AIComfort mode vocabulary, same wind-strength/humidity/ filter/energy resource shapes already modeled. Only two hrefs are unique to this board: /subdevices/vs/0 (an opaque paired-subdevice id list -- issue #150 asked whether the second indoor unit can be controlled separately; it can't through this or any other resource in the dump, the same "remote device ids, not locally actionable" role as the existing /remotedeviceinfo/vs/0 ignore) and /runn/vs/0 (a single undocumented int with no supported-values list to interpret). Both added to _AC_IGNORED rather than guessed at. --- .../localthings/registry/by_type/__init__.py | 7 + .../registry/capabilities/airconditioner.py | 13 + .../airconditioner_fac_bora_device.json | 446 ++++++++++++++++++ .../golden/airconditioner_fac_bora.json | 16 + tests/test_airconditioner_capabilities.py | 44 ++ tests/test_climate_ac_modes.py | 14 +- tests/test_golden_regression.py | 20 + 7 files changed, 558 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/airconditioner_fac_bora_device.json create mode 100644 tests/fixtures/golden/airconditioner_fac_bora.json diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 3860fb1..c38e89a 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -201,6 +201,13 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # here) -- no WAC-specific resources needed. if key is None and '_WAC_' in (model_num or ''): key = 'airconditioner' + # Wind-Free 2-in-1 systems (floor-standing + wall-mounted indoor units + # sharing one outdoor unit and one local IP, e.g. TP2X_FAC_BORA_21K, + # issues #150/#153) report no oneUiVersion and carry the '_FAC_' token + # instead of '_RAC_'/'_PRAC_'. Same TP1X/TP2X-class resource surface as + # the room-AC models above. + if key is None and '_FAC_' in (model_num or ''): + key = 'airconditioner' # ARA-WW-class wall-mount RACs (e.g. ARA-WW-TP1-22-COMMON, issues #115/ # #116/#117/#120) report no oneUiVersion and no '_RAC_'/'-RAC-' token at # all -- the board family is spelled 'ARA-WW-' instead. Same resource diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index f060d75..4dc5a61 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -602,6 +602,19 @@ _AC_IGNORED = [ # topology -- indoor/outdoor unit pairing, per-unit serials, MCU info. # Commissioning-time plumbing, not user-actionable appliance state. '/sac/installationinfo/vs/0', + # Wind-Free 2-in-1 systems (one outdoor unit driving a floor-standing + # *and* a wall-mounted indoor unit over one shared local IP, e.g. + # TP2X_FAC_BORA_21K, issues #150/#153): an opaque paired-subdevice id + # list, same "remote device ids, not user-actionable locally" role as + # /remotedeviceinfo/vs/0 above. This integration talks to whichever + # single local endpoint the config entry was set up against; the + # second indoor unit isn't independently reachable through this + # resource (or any other in the dump) -- it would need its own local + # DTLS session/IP, which SmartThings pairing doesn't expose here. + '/subdevices/vs/0', + # Undocumented single int (runningMode: 0 on every dump seen), no + # supported-values list to interpret it against -- 'don't guess'. + '/runn/vs/0', ] # Built as bare no-entity caps; folded into the AC registry (not global). diff --git a/tests/fixtures/airconditioner_fac_bora_device.json b/tests/fixtures/airconditioner_fac_bora_device.json new file mode 100644 index 0000000..f1deb08 --- /dev/null +++ b/tests/fixtures/airconditioner_fac_bora_device.json @@ -0,0 +1,446 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": { + "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": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "AC_V_0002_OFF", + "x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000000B4012C0000404B0C000000", + "x.com.samsung.da.id": "FAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "2824500000", + "x.com.samsung.da.airconOptionList": [ + "HOMECARE_WIZARD_V2", + "ENERGY_2.0", + "AI_2.0", + "DeviceTypeMaster", + "SingleCommand_1" + ] + } + }, + { + "href": "/diagnosis/vs/0", + "rep": {} + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 0, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.duration": "00:00:00", + "x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off" + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "energy": 100.0, + "power": 65278.0 + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "100.000000", + "x.com.samsung.da.instantaneousPower": "65278.000000", + "x.com.samsung.da.usageThreshold": "0.000000", + "x.com.samsung.da.cumulativePower": "1278818", + "x.com.samsung.da.cumulativeDate": "1785243600", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePowerType": "individual" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "100", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterDesiredUsage": "112", + "x.com.samsung.da.filterStatus": "wash", + "x.com.samsung.da.filterCapacity": "112", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "washable" + ], + "x.com.samsung.da.supportedFilterDesiredUsage": [ + "112", + "224", + "336", + "448" + ] + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0.000000", + "x.com.samsung.da.fivepercentHumidity": "73" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP2X_FAC_BORA_21K|10233041|600001110015110006000C1200830000", + "x.com.samsung.da.description": "TP2X_FAC_BORA_21K", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "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": "000", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.serialNumOption": "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": "02337A260424", + "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": "2102240021022200", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Outdoor", + "x.com.samsung.da.number": "2103300110000300" + } + ] + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 1 + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Speed" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "AIComfort", + "Cool", + "Dry", + "Wind" + ], + "x.com.samsung.da.modes": [ + "AIComfort" + ], + "x.com.samsung.da.options": [ + "Operation_Family", + "Blooming_0", + "OnTimer_0", + "OffTimer_0", + "Sleep_16", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_270", + "welcomecare_Off", + "Panel_Close", + "Weather_Off", + "Volume_100", + "StopAutoClean_Idle", + "DiagnosisAI_Off", + "Display_Off", + "ProgressDiagnosisAI_1", + "ResultDiagnosisAI_Normal", + "Service_Off", + "SmartCoolClean_Off", + "ProgressSmartClean_0", + "OutDoorVentil_Off", + "FreezeAlarmSetting_On", + "DesiredFreezeAlarm_112", + "OptionCode_529", + "ExtendOptionCode_16975", + "RacInfo_First", + "RacInfo_None_Second", + "ModelInfo_16K_BORA_VENT2", + "UpdateAllow_NotAllowed", + "EnergySaveIcon_Off", + "DurationOn_0", + "welcomecareElapsedTime_0", + "welcomecareThresholdTemp_0", + "welcomecareStartDate_0000", + "welcomecareEndDate_0000", + "welcomecareSeason_None" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.settingStatus": "On", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.supportedStatus": [ + "Start", + "SpeedClean", + "QuietClean", + "Stop" + ], + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "SpeedClean", + "QuietClean", + "Off" + ] + } + }, + { + "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", + "otnStatus": "None", + "flashingProgress": "", + "otnTarget": "main", + "otnCompleteDate": "noHistory", + "otnList": [ + { + "type": "WIFI", + "modelId": "AFA-KR-TP2-21-AF9X00", + "versions": [ + "10260424" + ], + "visVersion": "260424" + }, + { + "type": "Micom", + "modelId": "04511023304110232941", + "versions": [ + "21022400", + "21022200" + ], + "visVersion": "210224" + }, + { + "type": "Micom", + "modelId": "04511022974110229941", + "versions": [ + "21033001", + "10000300" + ], + "visVersion": "210330" + }, + { + "type": "Micom", + "modelId": "045110230741FFFFFFFF", + "versions": [ + "22050300", + "FFFFFFFF" + ], + "visVersion": "220503" + } + ] + } + }, + { + "href": "/personality/presence/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "", + "x.com.samsung.da.deviceId": "REDACTED", + "x.com.samsung.da.value": "" + } + ] + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/runn/vs/0", + "rep": { + "x.com.samsung.da.runningMode": 0 + } + }, + { + "href": "/subdevices/vs/0", + "rep": { + "x.com.samsung.da.subdeviceIdList": "REDACTED" + } + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 18.0, + 30.0 + ], + "units": "C", + "temperature": 31.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 18.0, + 30.0 + ], + "units": "C", + "temperature": 27.0 + } + }, + { + "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": "27.0", + "x.com.samsung.da.current": "31.0", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "18", + "x.com.samsung.da.increment": "1.0", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "NotSupported", + "x.com.samsung.da.supportedModes": [ + "NotSupported" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "0", + "x.com.samsung.da.supportedModes": [ + "0", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Mid", + "High", + "Turbo" + ] + } + } + ] +} diff --git a/tests/fixtures/golden/airconditioner_fac_bora.json b/tests/fixtures/golden/airconditioner_fac_bora.json new file mode 100644 index 0000000..5bedb6d --- /dev/null +++ b/tests/fixtures/golden/airconditioner_fac_bora.json @@ -0,0 +1,16 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "alarm_code", + "auto_clean", + "climate", + "current_temperature_c", + "diagnosis_status", + "energy_kwh", + "firmware_update", + "humidity", + "power_energy_kwh", + "power_watts" + ] +} diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index 8bbb1d1..1749412 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -426,3 +426,47 @@ def test_humidity_five_percent_field_passes_a_genuine_zero_through(): reading.""" desc = airconditioner.HUMIDITY.entities[0] assert desc.rep_fn({'x.com.samsung.da.fivepercentHumidity': '0'}) == 0.0 + + +# --------------------------------------------------------------------------- +# Wind-Free 2-in-1 (TP2X_FAC_BORA_21K, issues #150/#153): a floor-standing + +# wall-mounted indoor unit pair sharing one outdoor unit and one local IP. +# Reported "no climate entity is generated, only power" -- the device simply +# fell back to 'unknown' for lack of a '_FAC_' modelNum routing token; once +# routed, it binds against the exact same CLIMATE composite every other RAC +# family uses, zero unbound hrefs, no new capabilities needed beyond ignoring +# the two hrefs (/subdevices/vs/0, /runn/vs/0) unique to this board. +# --------------------------------------------------------------------------- + +def _ac_fac_bora(): + resources = _load_device('airconditioner_fac_bora') + 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 test_fac_bora_resolves_to_airconditioner_registry(): + reg, _ = _ac_fac_bora() + assert reg is not None and reg.name == 'airconditioner' + + +def test_fac_bora_no_unbound_hrefs(): + reg, resources = _ac_fac_bora() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_fac_bora_climate_entity_present(): + """The actual reported gap: only a power switch existed before, no + climate entity at all.""" + reg, resources = _ac_fac_bora() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + assert any(isinstance(item.desc, ClimateDesc) for item in bound) + + +def test_fac_bora_subdevices_and_runningmode_are_ignored_not_guessed(): + assert '/subdevices/vs/0' in airconditioner._AC_IGNORED + assert '/runn/vs/0' in airconditioner._AC_IGNORED diff --git a/tests/test_climate_ac_modes.py b/tests/test_climate_ac_modes.py index e9905b9..df28521 100644 --- a/tests/test_climate_ac_modes.py +++ b/tests/test_climate_ac_modes.py @@ -7,8 +7,8 @@ testable directly. from homeassistant.components.climate import HVACMode from custom_components.localthings.climate import ( - _AI_COMFORT_MODE, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE, PRESET_AI_COMFORT, - _preset_to_ha, + _AI_COMFORT_MODE, _DEVICE_TO_FAN, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE, + PRESET_AI_COMFORT, _preset_to_ha, ) @@ -74,3 +74,13 @@ def test_preset_to_ha_lowercases_other_codes(): assert _preset_to_ha('Sleep') == 'sleep' assert _preset_to_ha('NanoSleep') == 'nanosleep' assert _preset_to_ha('MotionIndirect') == 'motionindirect' + + +def test_fac_bora_wind_strength_codes_fit_the_standard_scale(): + """TP2X_FAC_BORA_21K's (issues #150/#153) /wind/strength/vs/0 codes + (0/2/3/4, skipping 1/'low') and modesName (Auto/Mid/High/Turbo) already + match _DEVICE_TO_FAN's own mapping exactly -- no dynamic modesName + fallback needed for this particular board, unlike issue #155's + TP1X_DA-AC-RAC-01001_0000.""" + for code in ('0', '2', '3', '4'): + assert code in _DEVICE_TO_FAN diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 5385931..7c206d1 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -786,6 +786,26 @@ def test_registry_reproduces_golden_state_keys_for_air_purifier_vtww(): ) +def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora(): + """TP2X_FAC_BORA_21K Wind-Free 2-in-1 (floor-standing + wall-mounted + indoor units sharing one outdoor unit and one local IP, issues + #150/#153) -- reports no oneUiVersion; resolved via the '_FAC_' + modelNum fallback. Binds cleanly against the existing airconditioner + registry (including a real climate entity, the actual reported gap) + with zero unbound hrefs -- /subdevices/vs/0 and /runn/vs/0 are the only + hrefs this board reports that no other AC family does, both added to + _AC_IGNORED as undocumented/not-locally-actionable.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_fac_bora') + golden = json.loads((GOLDEN / 'airconditioner_fac_bora.json').read_text()) + state_keys = _new_state_keys('airconditioner_fac_bora', 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 = { From dc3e1255d127cf4865fd7d934f38d8a89b3f9e42 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 14:27:15 +0000 Subject: [PATCH 12/15] Ignore fridge /rm/control/vs/0 plumbing href (#165) TP1X_REF_21K's EU region variant reports a bare resource-monitoring poll-interval config (minPeriod in ms) the US variant doesn't -- the only unbound href keeping the coverage-gap repair open. Door sensors, the reporter's actual ask, were already covered generically by fridge.DOOR_GENERIC/DOORS_FALLACK. --- .../registry/capabilities/ignored.py | 4 + .../golden/refrigerator_tp1x_ref_21k_eu.json | 31 ++ .../refrigerator_tp1x_ref_21k_eu_device.json | 470 ++++++++++++++++++ tests/test_golden_regression.py | 21 + 4 files changed, 526 insertions(+) create mode 100644 tests/fixtures/golden/refrigerator_tp1x_ref_21k_eu.json create mode 100644 tests/fixtures/refrigerator_tp1x_ref_21k_eu_device.json diff --git a/custom_components/localthings/registry/capabilities/ignored.py b/custom_components/localthings/registry/capabilities/ignored.py index ff064de..f931798 100644 --- a/custom_components/localthings/registry/capabilities/ignored.py +++ b/custom_components/localthings/registry/capabilities/ignored.py @@ -53,6 +53,10 @@ IGNORED: list[Capability] = [ Capability(href='/setting/vs/0'), # supported/selected UI language Capability(href='/timezone/vs/0'), # redundant with HA's own timezone Capability(href='/wm/setinfo/vs/0'), # model/manufacturing metadata + # Resource-monitoring poll-interval config (a bare minPeriod in + # milliseconds, issue #165's TP1X_REF_21K fridge) -- internal transport + # plumbing, not appliance state. + Capability(href='/rm/control/vs/0'), # Demand Response Load Control — utility-company grid signals; requires # cloud registration with a utility program we don't support locally. diff --git a/tests/fixtures/golden/refrigerator_tp1x_ref_21k_eu.json b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_eu.json new file mode 100644 index 0000000..dbd457a --- /dev/null +++ b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_eu.json @@ -0,0 +1,31 @@ +{ + "state_keys": [ + "ai_energy_level", + "alarm_code", + "auto_door_opener", + "brightness_level", + "cabinet_light_dim", + "cabinet_light_switch", + "cooler_setpoint", + "cooler_temperature", + "day_brightness", + "door_alert", + "door_cooler_open", + "door_freezer_open", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "freezer_setpoint", + "freezer_temperature", + "fridge_sound", + "night_end", + "night_start", + "power_energy_kwh", + "power_watts", + "rapid_freezing", + "rapid_fridge", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status" + ] +} diff --git a/tests/fixtures/refrigerator_tp1x_ref_21k_eu_device.json b/tests/fixtures/refrigerator_tp1x_ref_21k_eu_device.json new file mode 100644 index 0000000..a6f3b48 --- /dev/null +++ b/tests/fixtures/refrigerator_tp1x_ref_21k_eu_device.json @@ -0,0 +1,470 @@ +{ + "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.a" + ] + } + }, + { + "href": "/cabinet/light/enhanced/vs/0", + "rep": { + "light.control.status": "Off", + "level.brightness.daytime": "100", + "level.brightness.nighttime": "100", + "night.starttime": "2025-08-08T19:00:00", + "night.duration.minute": "540", + "timezone.offset": "+02:00", + "rt": [ + "x.com.samsung.da.light.enhanced" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/cabinet/light/total/vs/0", + "rep": { + "x.com.samsung.da.lightLevel": "100", + "x.com.samsung.da.lightResolution": "3", + "x.com.samsung.da.lightControl.off.include": "Off", + "x.com.samsung.da.lightControl": "Off", + "x.com.samsung.da.lightControl.hide": "true", + "light.dimming.status": "On", + "rt": [ + "x.com.samsung.da.cabinetlight" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "", + "x.com.samsung.da.countryCode": "" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/dginformation/vs/0", + "rep": { + "enrolmentstatus": "Unknown", + "devicestate": "Unknown", + "lockstatus": "Unknown", + "nextduedate": "", + "workingminutes": 0, + "paymentinfo": { + "emiplan": "Unknown", + "currency": "Unknown", + "totalemi": 0, + "totalemipaid": 0 + } + } + }, + { + "href": "/door/cooler/0", + "rep": { + "openState": "Close", + "rt": [ + "oic.r.door" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/door/freezer/0", + "rep": { + "openState": "Close", + "rt": [ + "oic.r.door" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.openState": "Close", + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Door" + }, + { + "x.com.samsung.da.openState": "Close", + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Door" + } + ] + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.override": "Not_Supported", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/energy/ailevel/vs/0", + "rep": { + "aiLevel": "1", + "supportedAiLevel": [ + "1", + "2" + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "281", + "x.com.samsung.da.instantaneousPower": "48", + "x.com.samsung.da.cumulativePower": "216620", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+01:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_REF_21K|00175941|00050126001811344100000020090000", + "x.com.samsung.da.description": "TP1X_REF_21K", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "RB2", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "WiFi Module", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "260617", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Micom", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "24070109, FFFFFFFF, 24062000, FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.modes": [ + "RVACATION_OFF", + "FCONVERT_FREEZER" + ], + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2" + ], + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "0", + "otnCompleteDate": "noHistory", + "scheduledTime": "None", + "swVersionInfo": { + "platform": "Tizen Lite", + "oneUiVersion": "7.0 Refrigerator", + "osVersion": "4.0" + }, + "otnList": [] + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/refrigeration/vs/0", + "rep": { + "x.com.samsung.da.rapidFridge": "Off", + "x.com.samsung.da.rapidFreezing": "Off", + "rt": [ + "x.com.samsung.da.fridge" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/rm/control/vs/0", + "rep": { + "minPeriod": "9000" + } + }, + { + "href": "/runningmode/vs/0", + "rep": { + "x.com.samsung.da.runningMode": 0 + } + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.supportedActions": [ + "Start" + ], + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.result": "Success", + "x.com.samsung.da.error": [ + "ErrorCode_None" + ] + } + }, + { + "href": "/settings/sound/alert/door/vs/0", + "rep": { + "alert.door": "1", + "supportedAlert.door": [ + "1", + "2", + "3", + "4" + ], + "rt": [ + "x.com.samsung.alert.door" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/status/lock/vs/0", + "rep": { + "x.com.samsung.da.device.sound": "Off", + "rt": [ + "x.com.samsung.da.lockstatus" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperature/current/cooler/0", + "rep": { + "temperature": 6.0, + "range": [ + 1.0, + 7.0 + ], + "units": "C", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperature/current/freezer/0", + "rep": { + "temperature": -17.0, + "range": [ + -23.0, + -15.0 + ], + "units": "C", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperature/desired/cooler/0", + "rep": { + "temperature": 6.0, + "range": [ + 1.0, + 7.0 + ], + "units": "C", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperature/desired/freezer/0", + "rep": { + "temperature": -17.0, + "range": [ + -23.0, + -15.0 + ], + "units": "C", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Freezer", + "x.com.samsung.da.desired": "-17", + "x.com.samsung.da.current": "-17", + "x.com.samsung.da.maximum": "-15", + "x.com.samsung.da.minimum": "-23", + "x.com.samsung.da.unit": "Celsius" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Fridge", + "x.com.samsung.da.desired": "6", + "x.com.samsung.da.current": "6", + "x.com.samsung.da.maximum": "7", + "x.com.samsung.da.minimum": "1", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Europe/Rome", + "offset": "+02:00", + "DST": "ON" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "REDACTED", + "macaddressBLE": "REDACTED", + "connectedApSsid": "SPRDFL24" + } + } + ] +} diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 7c206d1..f9db9e5 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -191,6 +191,27 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_us(): ) +def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_eu(): + """TP1X_REF_21K, EU region variant (issue #165) -- self-reports + oneUiVersion "7.0 Refrigerator" like the US variant, but additionally + carries /rm/control/vs/0 (a bare resource-monitoring poll-interval + config, added to the global ignore list) that the US dump doesn't + report at all. Door sensors (the reporter's actual ask) were already + covered by fridge.DOOR_GENERIC/DOORS_FALLBACK -- this href was the only + gap keeping the coverage repair open.""" + from tests.conftest import _load_device + resources = _load_device('refrigerator_tp1x_ref_21k_eu') + golden = json.loads( + (GOLDEN / 'refrigerator_tp1x_ref_21k_eu.json').read_text() + ) + state_keys = _new_state_keys('refrigerator_tp1x_ref_21k_eu', 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_range_hood(): from tests.conftest import _load_device resources = _load_device('range_hood') From 5a73a25005c15376c4b486e08852f38e9063898e Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 14:37:32 +0000 Subject: [PATCH 13/15] Address independent code review findings on PR #167 Two real bugs, both latent (no shipped fixture exercised them), plus a consistency gap and a couple of correctness/DRY nits flagged by review: - async_set_fan_mode resolved a fan_mode label against the static _FAN_TO_DEVICE reverse map before checking whether the resulting code is actually one of the unit's own supportedModes. A board using non-standard wind-strength codes while still spelling a standard-looking label in modesName (e.g. codes "31"-"33" named "Low"/"High"/"Turbo") would silently write a code ("1"/"3"/"4") the device never advertised. Now validates the static hit against the unit's own supported codes before trusting it, falling through to the live modesName scan otherwise. - air_purifier.WIND_STRENGTH_FAN reused key='fan', the same key as FAN in the same registry -- BoundEntity's unique_id is built from key alone, not href, so a board reporting both hrefs would have one fan entity silently shadow the other. Renamed to 'wind_strength_fan' (translation_key unchanged). No shipped fixture reports both hrefs today, but the two caps living in the same registry made this a real latent hazard, the exact one AIRFLOW_GENERIC's own comment already documents and deliberately avoids. - microwave.py's cooking_mode select still used a static, union-of-all- dumps mode list, even though both shipped microwave fixtures already report x.com.samsung.da.supportedModes on /mode/vs/0 -- the same shape oven._oven_mode_options was just built to prefer over exactly this kind of static list (issue #138's follow-up, this same PR's skill update). ME7500D advertises 4 modes; the select was offering 11. Applied the same live-first, static-fallback pattern. - Added the issue #152 fixture the microwave lamp fix was missing (the SKILL.md step this PR itself added asks for one). - climate.py's _legacy_airflow rebuilt a 2-key presence dict from coordinator.resource()'s truthiness, which collapses "href absent" and "href present with an empty {} rep" to the same falsy value -- while is_legacy_board (and discover()'s own binding) test key membership, not truthiness. Simplified to pass last_resources through directly, matching is_legacy_board's actual contract instead of a cheaper approximation of it, so the "can never disagree" claim in both docstrings is actually true. - Hoisted the 'power' payload branch duplicated verbatim across _airflow_fan_write/_fan_write/_wind_strength_fan_write into one _power_write helper (registry/capabilities/air_purifier.py). - Removed two now-unused imports (test_air_dresser_capabilities.py, test_air_purifier_vtww_fan.py) and replaced a tautological code-in-_DEVICE_TO_FAN check with one that actually exercises the live climate entity's fan_modes/fan_mode (test_climate_ac_modes.py). - Fixed a pre-existing (not from this PR) no-op test on main -- test_registry_reproduces_golden_state_keys_for_induction_cooktop computed golden/state_keys and never asserted on them. 756 tests pass. --- custom_components/localthings/climate.py | 40 +-- .../registry/capabilities/air_purifier.py | 42 +-- .../registry/capabilities/microwave.py | 17 +- tests/fixtures/golden/air_purifier_vtww.json | 4 +- .../golden/microwave_me7500d_lamp_high.json | 22 ++ .../microwave_me7500d_lamp_high_device.json | 269 ++++++++++++++++++ tests/test_air_dresser_capabilities.py | 2 +- tests/test_air_purifier_vtww_fan.py | 39 ++- .../test_airconditioner_tp1x_rac_01001_fan.py | 29 ++ tests/test_climate_ac_modes.py | 40 ++- tests/test_golden_regression.py | 22 ++ tests/test_microwave_capabilities.py | 33 ++- 12 files changed, 508 insertions(+), 51 deletions(-) create mode 100644 tests/fixtures/golden/microwave_me7500d_lamp_high.json create mode 100644 tests/fixtures/microwave_me7500d_lamp_high_device.json diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 8ab93ac..c283cce 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -268,19 +268,18 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): Delegates the board-generation test to is_legacy_board (the same test capabilities/airconditioner.py's token entities are gated on) - instead of re-implementing it, via a minimal presence dict built - from the two hrefs it actually inspects -- cheaper than - last_resources' full snapshot copy, since is_legacy_board only - checks key membership. + instead of re-implementing it. Uses last_resources rather than a + two-key presence dict built from coordinator.resource()'s truthiness + -- resource() collapses "href absent" and "href present with an + empty {} rep" to the same falsy value, while is_legacy_board (and + discover()'s own binding) test key membership, not truthiness. A + presence dict built from truthiness alone would disagree with the + token entities on a board reporting a genuinely empty /airflow/vs/0, + silently reintroducing the drift this delegation exists to prevent. """ - airflow = self.coordinator.resource(AIRFLOW_HREF) - wind_strength = self.coordinator.resource(WIND_STRENGTH_HREF) - presence = {} - if airflow: - presence[AIRFLOW_HREF] = airflow - if wind_strength: - presence[WIND_STRENGTH_HREF] = wind_strength - return airflow if is_legacy_board(presence) else {} + if not is_legacy_board(self.coordinator.last_resources): + return {} + return self.coordinator.resource(AIRFLOW_HREF) or {} def _legacy_preset(self) -> bool: """Whether presets come from the Comode_* token rather than a resource. @@ -563,14 +562,19 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): await self.coordinator.async_send_command( self._bound, ('fan_legacy', level)) return + supported = self._supported(WIND_STRENGTH_HREF) device = _FAN_TO_DEVICE.get(fan_mode) - if device is None: - # fan_mode came from _wind_strength_label's dynamic path (issue - # #155) -- resolve back to the device's own code the same way - # async_set_preset_mode does for its dynamic codes. + # A static hit is only trustworthy if this unit's own supportedModes + # actually includes that code -- a board can use non-standard codes + # (issue #155's "31"-"35") while still spelling a standard label + # ("Low"/"High") in modesName, in which case _FAN_TO_DEVICE.get would + # return a plausible-looking code ('1'/'3') the device never + # advertised at all. Fall through to the live scan whenever the + # static guess isn't actually one of this unit's own codes. + if device is None or (supported and device not in supported): rep = self._rep(WIND_STRENGTH_HREF) - for code in self._supported(WIND_STRENGTH_HREF): - if code not in _DEVICE_TO_FAN and _wind_strength_label(code, rep) == fan_mode: + for code in supported: + if _wind_strength_label(code, rep) == fan_mode: device = code break if device is not None: diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index c860084..aa80c94 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -137,14 +137,22 @@ DEVICE_ACTIVE = Capability( ), ) +def _power_write(power_href, value): + """Shared 'power' payload handling for this family's three FanDesc write + functions -- targets whichever power href fan.py's _power_payload picked + (the board may only report /power/0); a hardcoded vendor href here would + silently no-op on such a board even though the entity's own is_on + already falls back to reading it correctly.""" + if power_href == '/power/0': + return ['power', '0'], {'value': bool(value)} + return (['power', 'vs', '0'], + {'x.com.samsung.da.power': 'On' if value else 'Off'}) + + def _airflow_fan_write(payload, rep, href=None): kind, value, *args = payload if kind == 'power': - power_href = args[0] if args else '/power/vs/0' - if power_href == '/power/0': - return ['power', '0'], {'value': bool(value)} - return (['power', 'vs', '0'], - {'x.com.samsung.da.power': 'On' if value else 'Off'}) + return _power_write(args[0] if args else '/power/vs/0', value) if kind == 'speed': return ['airflow', '0'], {'speed': int(value)} return None @@ -234,15 +242,7 @@ MODE = Capability( def _fan_write(payload, rep, href=None): kind, value, *args = payload if kind == 'power': - # Targets whichever power href fan.py's _power_payload picked (the - # board may only report /power/0) -- a hardcoded vendor href here - # would silently no-op on such a board even though the entity's - # own is_on already falls back to reading it correctly. - power_href = args[0] if args else '/power/vs/0' - if power_href == '/power/0': - return ['power', '0'], {'value': bool(value)} - return (['power', 'vs', '0'], - {'x.com.samsung.da.power': 'On' if value else 'Off'}) + return _power_write(args[0] if args else '/power/vs/0', value) if kind == 'mode': return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]} return None @@ -277,11 +277,7 @@ FAN = Capability( def _wind_strength_fan_write(payload, rep, href=None): kind, value, *args = payload if kind == 'power': - power_href = args[0] if args else '/power/vs/0' - if power_href == '/power/0': - return ['power', '0'], {'value': bool(value)} - return (['power', 'vs', '0'], - {'x.com.samsung.da.power': 'On' if value else 'Off'}) + return _power_write(args[0] if args else '/power/vs/0', value) if kind == 'mode': return ['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value} return None @@ -294,11 +290,17 @@ def _wind_strength_fan_write(payload, rep, href=None): # LocalThingsAirPurifierFan._label_for_code rather than a hardcoded # per-model map. modes here is a bare string ('87'), not a single-element # list like HREF_MODE's -- _wind_strength_fan_write writes it back as-is. +# +# key is 'wind_strength_fan', NOT 'fan' -- FAN above shares this registry +# and also uses a FanDesc; BoundEntity's unique_id is built from key alone +# (entity.py's _key), not href, so two same-key FanDescs in one registry +# would collide if a board ever bound both (see AIRFLOW_GENERIC's own +# comment on this exact hazard -- missed here in the initial cut). WIND_STRENGTH_FAN = Capability( href=HREF_WIND_STRENGTH, poll_tier='warm', entities=( - FanDesc(key='fan', translation_key='air_purifier_fan', + FanDesc(key='wind_strength_fan', translation_key='air_purifier_fan', field='x.com.samsung.da.modes', write_fn=_wind_strength_fan_write), ), ) diff --git a/custom_components/localthings/registry/capabilities/microwave.py b/custom_components/localthings/registry/capabilities/microwave.py index 4e815ad..7323c60 100644 --- a/custom_components/localthings/registry/capabilities/microwave.py +++ b/custom_components/localthings/registry/capabilities/microwave.py @@ -112,8 +112,21 @@ def _power_level_watts(v): return int_or_none(s) +def _cooking_mode_options(resources): + """Live mode list from the device's own /mode/vs/0 supportedModes when + it reports one (both known dumps do); the union-of-all-dumps + _MICROWAVE_MODES guess otherwise. Same live-first, static-fallback + pattern as oven._oven_mode_options -- a fixed list here would offer + users modes their own unit doesn't have (issue #152's ME7500D reports + only 4 of _MICROWAVE_MODES' 11).""" + rep = resources.get('/mode/vs/0') or {} + live = rep.get('x.com.samsung.da.supportedModes') + return list(live) if live else list(_MICROWAVE_MODES) + + def _mode_write(p, rep, href=None): - if p not in _MICROWAVE_MODES: + valid = rep.get('x.com.samsung.da.supportedModes') or _MICROWAVE_MODES + if p not in valid: return None return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]} @@ -187,7 +200,7 @@ MICROWAVE_MODE = Capability( # SelectDesc first — test_microwave_mode_options_nonempty uses entities[0] SelectDesc(key='cooking_mode', field='x.com.samsung.da.modes', icon='mdi:tune', - options=_MICROWAVE_MODES, + options=_cooking_mode_options, value_fn=lambda v: v[0] if v else None, write_fn=_mode_write), SwitchDesc(key='sound', field='x.com.samsung.da.options', diff --git a/tests/fixtures/golden/air_purifier_vtww.json b/tests/fixtures/golden/air_purifier_vtww.json index 892ba89..9970ed3 100644 --- a/tests/fixtures/golden/air_purifier_vtww.json +++ b/tests/fixtures/golden/air_purifier_vtww.json @@ -5,13 +5,13 @@ "device_active", "dust", "energy_kwh", - "fan", "fine_dust", "firmware_update", "hepa_filter_status", "hepa_filter_usage", "odor", "power_switch", - "super_fine_dust" + "super_fine_dust", + "wind_strength_fan" ] } diff --git a/tests/fixtures/golden/microwave_me7500d_lamp_high.json b/tests/fixtures/golden/microwave_me7500d_lamp_high.json new file mode 100644 index 0000000..40df21d --- /dev/null +++ b/tests/fixtures/golden/microwave_me7500d_lamp_high.json @@ -0,0 +1,22 @@ +{ + "state_keys": [ + "alarm_code", + "cavity_state", + "child_lock", + "cloud_connected", + "cook_time", + "cooking_mode", + "cycle_active", + "door_open", + "energy_kwh", + "fan", + "finish_time", + "firmware_update", + "lamp", + "machine_state", + "operation_time_minutes", + "power_level", + "progress_percentage", + "sound" + ] +} diff --git a/tests/fixtures/microwave_me7500d_lamp_high_device.json b/tests/fixtures/microwave_me7500d_lamp_high_device.json new file mode 100644 index 0000000..3ac621a --- /dev/null +++ b/tests/fixtures/microwave_me7500d_lamp_high_device.json @@ -0,0 +1,269 @@ +{ + "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": "OV_E_OFF", + "x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00" + } + ] + } + }, + { + "href": "/connected/vs/0", + "rep": { + "x.com.samsung.da.connected": "On", + "rt": [ + "x.com.samsung.da.connected" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "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": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "103700", + "x.com.samsung.da.cumulativeUnit": "Wh" + } + }, + { + "href": "/hood/fanspeed/vs/0", + "rep": { + "x.com.samsung.da.hood.fanSpeed": "0", + "x.com.samsung.da.hood.supportedFanSpeed": [ + "0", + "1", + "2", + "3", + "4" + ], + "x.com.samsung.da.hood.settableMaxFanSpeed": "4", + "x.com.samsung.da.hood.settableMinFanSpeed": "0", + "rt": [ + "x.com.samsung.da.hood.fanSpeed" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA-KS-MICROWAVE-01051|40475141|50040000011811000A00000000000000", + "x.com.samsung.da.description": "ME7500D-/AA2", + "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": "24111400", + "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": "04751A23121900", + "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": "04754B23120800", + "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": "KM5", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01" + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "NoOperation", + "MicroWave", + "Autocook", + "KeepWarm" + ], + "x.com.samsung.da.modes": [ + "NoOperation" + ], + "x.com.samsung.da.options": [ + "DeviceType_ME7500D-/AA2", + "TimeAutoSync_On", + "weight_LBS", + "TimeSystem_12", + "Sound_Off", + "RemindBeep_Off", + "FilterRemind_Off", + "Lamp_High" + ], + "x.com.samsung.da.defaultMode": "MicroWave", + "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": "/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-23-MICROWAVE-OTR", + "versions": [ + "40241114" + ], + "visVersion": "241114" + }, + { + "type": "Micom", + "modelId": "074240475141FFFFFFFF", + "versions": [ + "23121900", + "FFFFFFFF" + ], + "visVersion": "231219" + }, + { + "type": "Micom", + "modelId": "074240475442FFFFFFFF", + "versions": [ + "23120800", + "FFFFFFFF" + ], + "visVersion": "231208" + } + ] + } + }, + { + "href": "/oven/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.recipe": "00000000000000", + "x.com.samsung.da.powerLevel": "0", + "rt": [ + "x.com.samsung.da.oven" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "America/New_York", + "offset": "-04:00", + "DST": "ON" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "REDACTED", + "macaddressBLE": "REDACTED" + } + } + ] +} diff --git a/tests/test_air_dresser_capabilities.py b/tests/test_air_dresser_capabilities.py index 3756a4e..5a4e838 100644 --- a/tests/test_air_dresser_capabilities.py +++ b/tests/test_air_dresser_capabilities.py @@ -1,6 +1,6 @@ """Tests for the AirDresser device type (DA_DF_A51_20_COMMON, issue #162).""" from custom_components.localthings.registry.adapter import flatten -from custom_components.localthings.registry.by_type import air_dresser, for_device_by_model +from custom_components.localthings.registry.by_type import for_device_by_model from custom_components.localthings.registry.discovery import discover from tests.conftest import _load_device diff --git a/tests/test_air_purifier_vtww_fan.py b/tests/test_air_purifier_vtww_fan.py index 81d3351..e03ad61 100644 --- a/tests/test_air_purifier_vtww_fan.py +++ b/tests/test_air_purifier_vtww_fan.py @@ -9,7 +9,7 @@ already. LocalThingsAirPurifierFan._label_for_code resolves both shapes without a per-model map. """ from custom_components.localthings.fan import LocalThingsAirPurifierFan -from custom_components.localthings.registry.by_type import air_purifier, for_device_by_model +from custom_components.localthings.registry.by_type import for_device_by_model from custom_components.localthings.registry.capabilities.air_purifier import HREF_WIND_STRENGTH from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.entities import FanDesc @@ -89,3 +89,40 @@ async def test_set_preset_mode_writes_back_the_raw_code(): def test_is_on_reads_vendor_power(): entity = _entity(_resources()) assert entity.is_on is False + + +def test_wind_strength_fan_key_does_not_collide_with_mode_fan(): + """WIND_STRENGTH_FAN and FAN both live in this registry and both are + FanDesc-typed; BoundEntity's unique_id is built from key alone (entity.py's + _key), not href, so a shared key would silently shadow one entity if a + board ever bound both (see AIRFLOW_GENERIC's own comment on this exact + hazard). No real dump reports both hrefs today, but the keys must stay + distinct regardless.""" + from custom_components.localthings.registry.capabilities import air_purifier + + fan_keys = { + entity.key + for cap in (air_purifier.FAN, air_purifier.WIND_STRENGTH_FAN) + for entity in cap.entities + } + assert fan_keys == {'fan', 'wind_strength_fan'} + + +def test_both_fan_hrefs_bound_simultaneously_produce_distinct_entities(): + """Synthetic combination (no real dump reports both hrefs) proving the + two FanDescs don't shadow each other in flatten()'s key-based state dict + even if a future board did report both.""" + from custom_components.localthings.registry.adapter import flatten + + resources = _resources() + resources['/mode/vs/0'] = { + 'x.com.samsung.da.modes': ['Smart'], + 'x.com.samsung.da.supportedModes': ['Smart', 'Max', 'Mid', 'WindFree', 'Sleep'], + } + reg = _reg(resources) + unbound = [] + bound = discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + state = flatten(bound, resources) + assert state['fan'] == 'Smart' + assert state['wind_strength_fan'] == '87' diff --git a/tests/test_airconditioner_tp1x_rac_01001_fan.py b/tests/test_airconditioner_tp1x_rac_01001_fan.py index 5a5dea6..0b948b8 100644 --- a/tests/test_airconditioner_tp1x_rac_01001_fan.py +++ b/tests/test_airconditioner_tp1x_rac_01001_fan.py @@ -103,3 +103,32 @@ async def test_set_fan_mode_still_resolves_standard_scale_labels(): await entity.async_set_fan_mode('auto') assert coordinator.commands[-1][1] == ('fan', '0') + + +async def test_set_fan_mode_does_not_misroute_when_static_map_and_live_codes_collide(): + """A board can use non-standard codes ('31'-'33') while modesName still + spells a standard-looking label ('Low'/'High') that _FAN_TO_DEVICE's + static reverse map also happens to have an entry for ('1'/'3') -- but + that entry is for a *different* code this unit never advertises at all. + Resolving the static hit without checking it against this unit's own + supportedModes would silently write a code the device doesn't have. + """ + resources = _load_device(FIXTURE) + resources['/wind/strength/vs/0'] = { + 'x.com.samsung.da.modes': '0', + 'x.com.samsung.da.supportedModes': ['0', '31', '32', '33'], + 'x.com.samsung.da.modesName': ['Auto', 'Low', 'High', 'Turbo'], + } + coordinator = _FakeCoordinator(resources) + entity = _climate(resources, coordinator) + + assert entity.fan_modes == ['auto', 'low', 'high', 'turbo'] + + await entity.async_set_fan_mode('high') + assert coordinator.commands[-1][1] == ('fan', '32') + + await entity.async_set_fan_mode('low') + assert coordinator.commands[-1][1] == ('fan', '31') + + await entity.async_set_fan_mode('turbo') + assert coordinator.commands[-1][1] == ('fan', '33') diff --git a/tests/test_climate_ac_modes.py b/tests/test_climate_ac_modes.py index df28521..c5076ae 100644 --- a/tests/test_climate_ac_modes.py +++ b/tests/test_climate_ac_modes.py @@ -7,7 +7,7 @@ testable directly. from homeassistant.components.climate import HVACMode from custom_components.localthings.climate import ( - _AI_COMFORT_MODE, _DEVICE_TO_FAN, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE, + _AI_COMFORT_MODE, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE, PRESET_AI_COMFORT, _preset_to_ha, ) @@ -81,6 +81,38 @@ def test_fac_bora_wind_strength_codes_fit_the_standard_scale(): (0/2/3/4, skipping 1/'low') and modesName (Auto/Mid/High/Turbo) already match _DEVICE_TO_FAN's own mapping exactly -- no dynamic modesName fallback needed for this particular board, unlike issue #155's - TP1X_DA-AC-RAC-01001_0000.""" - for code in ('0', '2', '3', '4'): - assert code in _DEVICE_TO_FAN + TP1X_DA-AC-RAC-01001_0000. + + Asserts the live climate entity's actual fan_modes/fan_mode output + (not just that the module constant _DEVICE_TO_FAN happens to have + these keys) -- a bare `code in _DEVICE_TO_FAN` check would still pass + even if fan_modes/fan_mode were completely broken, since it never + touches the entity at all. + """ + from custom_components.localthings.climate import LocalThingsClimate + from custom_components.localthings.registry import by_type + from custom_components.localthings.registry.discovery import discover + from custom_components.localthings.registry.entities import ClimateDesc + from tests.conftest import _load_device + + class _FakeCoordinator: + device_serial = 'TEST-FAC-BORA-SERIAL' + device_info = {} + data = {} + + def __init__(self, resources): + self.last_resources = resources + + def resource(self, href): + return self.last_resources.get(href, {}) + + resources = _load_device('airconditioner_fac_bora') + info = resources['/information/vs/0'] + reg = by_type.for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc)) + entity = LocalThingsClimate(_FakeCoordinator(resources), climate_bound) + + assert entity.fan_modes == ['auto', 'medium', 'high', 'turbo'] + assert entity.fan_mode == 'auto' diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index f9db9e5..b7ed571 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -478,6 +478,12 @@ def test_registry_reproduces_golden_state_keys_for_induction_cooktop(): 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_range_no_info(): """NE63B8411SS (issue #74) -- reports no oneUiVersion *and* no @@ -668,6 +674,22 @@ def test_registry_reproduces_golden_state_keys_for_microwave_me7500d(): ) +def test_registry_reproduces_golden_state_keys_for_microwave_me7500d_lamp_high(): + """Same TP1X_DA-KS-MICROWAVE-01051/ME7500D board as microwave_me7500d + above, but this live capture (issue #152) is the first to report a + non-Off Lamp token ('Lamp_High'). Locks in that the lamp switch reads + it as on rather than the previously-hardcoded 'On'-only comparison.""" + from tests.conftest import _load_device + resources = _load_device('microwave_me7500d_lamp_high') + golden = json.loads((GOLDEN / 'microwave_me7500d_lamp_high.json').read_text()) + state_keys = _new_state_keys('microwave_me7500d_lamp_high', 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_tp1x_da_ac_air(): """TP1X_DA-AC-AIR-01031_0000 (issue #130) self-reports oneUiVersion '7.0 Air purifier' and resolves via for_device() onto the existing diff --git a/tests/test_microwave_capabilities.py b/tests/test_microwave_capabilities.py index 5629322..f01d096 100644 --- a/tests/test_microwave_capabilities.py +++ b/tests/test_microwave_capabilities.py @@ -120,9 +120,23 @@ def test_power_level_handles_missing_value(): def test_microwave_mode_options_nonempty(): desc = microwave.MICROWAVE_MODE.entities[0] - assert len(desc.options) > 0 - assert 'MicroWave' in desc.options - assert 'AirFryer' in desc.options # distinct spelling from oven.py's 'AirFry' + assert callable(desc.options) + options = desc.options({}) + assert len(options) > 0 + assert 'MicroWave' in options + assert 'AirFryer' in options # distinct spelling from oven.py's 'AirFry' + + +def test_microwave_mode_options_reads_live_supported_modes(): + """issue #152's ME7500D reports only 4 of the 11 union-of-all-dumps + _MICROWAVE_MODES -- the live supportedModes list is used verbatim when + present, same live-first pattern as oven._oven_mode_options, instead of + offering users modes their own unit doesn't have.""" + desc = microwave.MICROWAVE_MODE.entities[0] + resources = {'/mode/vs/0': { + 'x.com.samsung.da.supportedModes': ['NoOperation', 'MicroWave', 'Autocook', 'KeepWarm'], + }} + assert desc.options(resources) == ['NoOperation', 'MicroWave', 'Autocook', 'KeepWarm'] def test_microwave_mode_write_round_trips(): @@ -137,6 +151,19 @@ def test_microwave_mode_rejects_unknown(): assert desc.write_fn('SpaghettiMode', {}) is None +def test_microwave_mode_write_validates_against_live_supported_modes(): + """A device reporting its own supportedModes is validated against that + list, not the static union-of-all-dumps fallback -- 'AirFryer' is a + valid _MICROWAVE_MODES entry but must still be rejected for a unit + whose own supportedModes doesn't include it.""" + desc = microwave.MICROWAVE_MODE.entities[0] + rep = {'x.com.samsung.da.supportedModes': ['NoOperation', 'MicroWave', 'Autocook', 'KeepWarm']} + path, body = desc.write_fn('MicroWave', rep) + assert path == ['mode', 'vs', '0'] + assert body['x.com.samsung.da.modes'] == ['MicroWave'] + assert desc.write_fn('AirFryer', rep) is None + + # --------------------------------------------------------------------------- # MICROWAVE_MODE — lamp/sound options-array writes # --------------------------------------------------------------------------- From 6007505e6bf5460bc4d54fb8ce33d700e25cd45f Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 14:43:21 +0000 Subject: [PATCH 14/15] Stop surfacing '_OFF'-suffixed placeholder alarm codes (#166) Samsung pre-populates /alarms/vs/0 with one row per supported alarm type, each carrying a '_OFF' placeholder code (no 'Deleted' state at all) when that alarm isn't firing. common._active_alarm_codes only ever filtered on 'Deleted' state, so every device using this shared capability (including the AC family) showed these inert placeholders -- 'ErrorCode_OFF', 'FilterAlarm_OFF' -- as if they were live alarms. Confirmed the '_OFF' suffix convention holds across every alarm code seen in this repo's fixtures so far (ErrorCode_OFF, FilterAlarm_OFF, OV_E_OFF, CT_E_OFF, WaterTankFull_OFF, AC_V_0002_OFF, all placeholders; DoorA_Opened, FilterAlarm, SNSF_Reached, all genuinely active with no suffix) -- issue #166's own dump has both a FilterAlarm_OFF placeholder and, on a second unit, a live FilterAlarm/state=Created alert, which is what motivated generalizing range_hood.py's existing (but narrower, ErrorCode_OFF-only) special case into the shared helper instead of duplicating it further. The other three points in #166 (filter-usage percentage vs. filterStatus disagreement, an "air purification" config toggle the reporter says has no physical effect, a "beep on/off" control) don't have a confirmed code fix: the percentage math already matches the device's own filterUsage/ filterCapacity fields (filterStatus is a separate device-computed field we already relay verbatim, not something we derive), the air-purify resource is correctly wired to what the board reports and its absence from the official app's own options list suggests an inert shared-board-profile stub rather than an integration bug, and a "Beep volume" NumberDesc keyed off the same Volume_100 option both dumps report already exists (0 mutes it). --- .../registry/capabilities/common.py | 20 +++++++++-- tests/test_common_capabilities.py | 34 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index ac60523..4ed9bab 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -91,12 +91,25 @@ def _ml_to_l(v): def _active_alarm_codes(items): - """Join active alarm codes; skip retained rows Samsung leaves as Deleted. + """Join active alarm codes; skip retained rows Samsung leaves as Deleted, + and any code ending in '_OFF'. Laundry boards keep a Deleted ErrorCode row in /alarms/vs/0 after the condition clears (see WD7000B diagnostics). Surface only live alarms so - HA doesn't stick on a stale ErrorCode. Range-hood has its own stricter - helper that also drops ErrorCode_OFF. + HA doesn't stick on a stale ErrorCode. + + Samsung pre-populates this array with one row per alarm *type* the board + supports, each carrying its own '_OFF' placeholder code when that + alarm isn't firing -- confirmed across independent device families + (ErrorCode_OFF, FilterAlarm_OFF, OV_E_OFF, CT_E_OFF, WaterTankFull_OFF, + AC_V_0002_OFF all appear in fixtures with no corresponding active + condition). An alarm that's actually firing instead reports a plain, + unsuffixed code (FilterAlarm, DoorA_Opened, SNSF_Reached) -- issue #166's + AC dump has both a FilterAlarm_OFF placeholder and shows what a live + filter alert looks like: code 'FilterAlarm' (no suffix), state + 'Created'. Range-hood previously special-cased only the literal + 'ErrorCode_OFF' string in its own stricter helper; this generalizes + the same rule to the whole '_OFF' suffix convention. """ if not items or not isinstance(items, list): return 'none' @@ -105,6 +118,7 @@ def _active_alarm_codes(items): for i in items if i.get('x.com.samsung.da.code') and str(i.get('x.com.samsung.da.state', '')).lower() != 'deleted' + and not str(i.get('x.com.samsung.da.code', '')).lower().endswith('_off') ] return ', '.join(codes) if codes else 'none' diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index 2050a70..d536165 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -184,6 +184,40 @@ class TestWmSetinfoFlags: }, ]) == 'LE' + def test_off_suffixed_placeholder_codes_filtered(self): + """issue #166: Samsung pre-populates /alarms/vs/0 with one row per + supported alarm *type*, each carrying a '_OFF' placeholder + code (no 'Deleted' state at all) when that alarm isn't firing -- + confirmed not just for 'ErrorCode_OFF' but also 'FilterAlarm_OFF' + on the same dump. These were previously shown as if they were + active alarms.""" + assert common._active_alarm_codes([ + { + 'x.com.samsung.da.code': 'ErrorCode_OFF', + 'x.com.samsung.da.triggeredTime': '2026-07-28T12:59:22', + }, + { + 'x.com.samsung.da.code': 'FilterAlarm_OFF', + 'x.com.samsung.da.triggeredTime': '2026-07-28T12:59:22', + }, + ]) == 'none' + + def test_real_filter_alarm_not_off_suffixed_still_shown(self): + """issue #166's actual live filter alert: code 'FilterAlarm' (no + '_OFF' suffix), state 'Created' -- distinct from the 'FilterAlarm_OFF' + placeholder and must still surface.""" + assert common._active_alarm_codes([ + { + 'x.com.samsung.da.code': 'ErrorCode_OFF', + 'x.com.samsung.da.triggeredTime': '2026-07-28T12:59:22', + }, + { + 'x.com.samsung.da.code': 'FilterAlarm', + 'x.com.samsung.da.state': 'Created', + 'x.com.samsung.da.triggeredTime': '2026-07-28T12:59:22', + }, + ]) == 'FilterAlarm' + class TestKidsLockFallback: def test_generic_read_write(self): From 71e026b804e00146f8dbc34dba8f2d8dbafc0641 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 15:08:12 +0000 Subject: [PATCH 15/15] Fix hot/warm href tiers dropped for no-entity coverage capabilities discover() only emitted a BoundEntity per capability *entity*, so a coverage-only Capability (entities=(), used to mark a href as handled elsewhere -- e.g. the AC climate card's wind/strength, wind/direction, temperature/control hrefs) produced zero rows. The coordinator computed its hot/warm href lists by walking `bound`, so every such href's poll_tier was silently discarded and it fell back to the ~30s summary poll only -- no sub-poll cadence and never attempted for OCF OBSERVE. This is the root cause of issue #166's "up to a minute" lag for remote-driven fan-speed changes: /wind/strength/vs/0 carries poll_tier 'warm' via airconditioner.COVERAGE but never reached _hot_hrefs/_warm_hrefs, so it wasn't in the OBSERVE-attempt href list and only refreshed on the summary poll. discover() now takes an optional tier_log(href, poll_tier) callback fired for every href a capability matches, entities or not. The coordinator uses it directly instead of deriving tiers from `bound`. --- custom_components/localthings/coordinator.py | 19 ++++++++++--------- .../localthings/registry/discovery.py | 12 ++++++++++++ tests/test_discovery.py | 16 ++++++++++++++++ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index d7591f1..65c728a 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -366,14 +366,22 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): if reg is None: reg = for_device_by_resources(resources) unbound: list[str] = [] + hot, warm = set(), set() + + def _tier_log(href: str, tier: str) -> None: + if tier == 'hot': + hot.add(href) + elif tier == 'warm': + warm.add(href) + if reg is not None: self._log.debug("device type: %s (oneUiVersion=%r)", reg.name, one_ui) bound = discover(resources, reg.capabilities, reg.pattern_capabilities, - log=unbound.append) + log=unbound.append, tier_log=_tier_log) self.device_type_name = reg.name else: self._log.warning("unknown device type oneUiVersion=%r; using common caps", one_ui) - bound = discover(resources, CAPABILITIES, log=unbound.append) + bound = discover(resources, CAPABILITIES, log=unbound.append, tier_log=_tier_log) self.device_type_name = None self.bound = bound self._unbound_hrefs = unbound @@ -398,13 +406,6 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) self._update_coverage_gap_issue(reg is None, unbound, name) - hot, warm = set(), set() - for be in bound: - tier = be.capability.poll_tier - if tier == 'hot': - hot.add(be.href) - elif tier == 'warm': - warm.add(be.href) self._hot_hrefs = sorted(hot) self._warm_hrefs = sorted(warm) diff --git a/custom_components/localthings/registry/discovery.py b/custom_components/localthings/registry/discovery.py index 18cbaf9..2e2cbd1 100644 --- a/custom_components/localthings/registry/discovery.py +++ b/custom_components/localthings/registry/discovery.py @@ -73,7 +73,15 @@ def discover( registry: dict[str, list[Capability]], pattern_caps: Iterable[Capability] = (), log: Optional[Callable[[str], None]] = None, + tier_log: Optional[Callable[[str, str], None]] = None, ) -> list[BoundEntity]: + """`tier_log(href, poll_tier)` fires for every href a capability actually + matches, even a no-entity "coverage-only" capability (see COVERAGE lists + in capabilities/*.py) that `_bind()` turns into zero `BoundEntity` rows. + Callers that need a href's poll cadence (the coordinator's hot/warm + sub-poll and OBSERVE-attempt lists) must use this, not `bound` -- a + coverage-only capability's `poll_tier` would otherwise be silently + dropped since it never appears in `bound`.""" out: list[BoundEntity] = [] for href, rep in resources.items(): @@ -91,6 +99,8 @@ def discover( inst = instance_suffix(href) out.extend(_bind(cap, href, inst, _instance_name(cap, rep))) matched = True + if tier_log is not None: + tier_log(href, cap.poll_tier) if matched: continue @@ -109,6 +119,8 @@ def discover( segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs'] out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs))) matched = True + if tier_log is not None: + tier_log(href, cap.poll_tier) break if not matched and not caps and log is not None: diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 59702e2..89591e8 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -209,3 +209,19 @@ def test_discover_rt_filter_gates_binding(): resources = {'/mode/vs/0': {'rt': ['x.com.samsung.da.mode'], 'x.com.samsung.da.mode': 'Bake'}} bound = discover(resources, {'/mode/vs/0': [oven_mode_cap]}) assert bound == [] + + +def test_discover_tier_log_fires_for_no_entity_coverage_cap(): + """A coverage-only Capability (entities=(), used to mark a href as + handled by e.g. a composite climate entity rather than its own HA + entity) produces zero BoundEntity rows -- `bound` alone can't tell a + caller its poll_tier. tier_log must still report it, or a consumer + (the coordinator's hot/warm href lists) silently treats it as 'cold' + regardless of what poll_tier the capability actually declares.""" + coverage_cap = Capability(href='/wind/strength/vs/0', poll_tier='warm') + resources = {'/wind/strength/vs/0': {'x.com.samsung.da.modes': '0'}} + seen = [] + bound = discover(resources, {coverage_cap.href: [coverage_cap]}, + tier_log=lambda href, tier: seen.append((href, tier))) + assert bound == [] + assert seen == [('/wind/strength/vs/0', 'warm')]