From 00cf8d676d19c623994a057f65e908258d07db49 Mon Sep 17 00:00:00 2001 From: vmvarga Date: Sat, 25 Jul 2026 09:16:32 +0200 Subject: [PATCH 01/11] feat: laundry firmware-flag gating, fridge vendor temp writes, course names --- custom_components/localthings/coordinator.py | 17 ++- .../registry/by_type/refrigerator.py | 1 + .../registry/capabilities/common.py | 86 +++++++++++- .../registry/capabilities/fridge.py | 41 ++++++ .../localthings/translations/en.json | 13 +- .../localthings/translations/nl.json | 13 +- tests/localthings/test_coordinator.py | 125 ++++++++++++++++++ tests/test_common_capabilities.py | 65 ++++++++- 8 files changed, 349 insertions(+), 12 deletions(-) diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 171630f..9e65827 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -22,7 +22,11 @@ from smartthings_local.ocf.state_cache import StateCache from .registry.batch import parse_device0_batch from .registry.by_type import for_device, for_device_by_model, for_device_by_resources -from .registry.capabilities.common import merge_options_field, remote_control_enabled +from .registry.capabilities.common import ( + merge_options_field, + remote_control_enabled, + remote_control_required_for_write, +) from .registry.discovery import discover, BoundEntity from .registry import CAPABILITIES from .registry.adapter import flatten @@ -531,7 +535,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): opted this device out of it via CONF_BYPASS_REMOTE_CONTROL (issue #54: some devices accept certain writes, e.g. a washer's default dosing levels, even while reporting remote control off, so the - block's assumption doesn't hold for every model).""" + block's assumption doesn't hold for every model), or the laundry + firmware flag isModelSettingWithoutSC declares settings writable + without Smart Control (cycle start/pause/stop on /operational/state + still require it).""" desc = bound_entity.desc write_fn = getattr(desc, 'write_fn', None) if write_fn is None: @@ -540,7 +547,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): rep = self._cache.get(href or '') or {} resources = self._cache.snapshot() bypass_remote_control = self._entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False) - if not bypass_remote_control and not remote_control_enabled(resources): + if ( + not bypass_remote_control + and remote_control_required_for_write(resources, href or '') + and not remote_control_enabled(resources) + ): raise ServiceValidationError( translation_domain=DOMAIN, translation_key="remote_control_disabled", diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index b893475..9d70ebd 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -33,6 +33,7 @@ REGISTRY = DeviceRegistry( ]), pattern_capabilities=[ fridge.TEMP_CURRENT_GENERIC, + fridge.TEMP_SETPOINT_VENDOR, fridge.TEMP_SETPOINT_GENERIC, fridge.ICEMAKER_GENERIC, fridge.DOOR_GENERIC, diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index eb14c04..eb33d37 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -59,9 +59,21 @@ def _ml_to_l(v): def _active_alarm_codes(items): + """Join active alarm codes; skip retained rows Samsung leaves as Deleted. + + 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. + """ if not items or not isinstance(items, list): return 'none' - codes = [i.get('x.com.samsung.da.code') for i in items if i.get('x.com.samsung.da.code')] + codes = [ + i.get('x.com.samsung.da.code') + for i in items + if i.get('x.com.samsung.da.code') + and str(i.get('x.com.samsung.da.state', '')).lower() != 'deleted' + ] return ', '.join(codes) if codes else 'none' @@ -92,6 +104,52 @@ def merge_options_field(cached, new_tokens): return merged +# /wm/setinfo/vs/0 -- laundry-family firmware capability flags. Present on +# washers, dryers, and dishwashers; absent on fridge/oven/AC. Static for the +# life of a given board, so reading them from the /device/0 seed (no dedicated +# poll_tier) is enough. +_SETINFO_HREF = '/wm/setinfo/vs/0' +_POWER_ON_OFF_FIELD = 'x.com.samsung.da.isModelSettingPowerOnOff' +_WITHOUT_SC_FIELD = 'x.com.samsung.da.isModelSettingWithoutSC' + + +def model_allows_power_on_off(resources: dict) -> bool: + """True unless firmware explicitly declares remote power on/off unsupported. + + `/wm/setinfo/vs/0`.`isModelSettingPowerOnOff` is `"false"` on many laundry + boards (washers/dryers): `/power/0` and `/power/vs/0` still report state, + but CoAP writes are ignored. Absent setinfo (non-laundry families) keeps + the writable switch -- current behavior. + """ + setinfo = resources.get(_SETINFO_HREF) + if setinfo is None: + return True + flag = setinfo.get(_POWER_ON_OFF_FIELD) + if flag is None: + return True + return str(flag).lower() != 'false' + + +def model_setting_without_sc(resources: dict) -> bool: + """True when firmware declares settings writable without Smart Control. + + `/wm/setinfo/vs/0`.`isModelSettingWithoutSC` is `"true"` on washers/dryers + that accept temperature/spin/cycle-option writes while remote control is + off. Cycle start/pause/stop still need Smart Control on those boards -- + the flag name is settings-specific, not a blanket remote-control bypass. + """ + setinfo = resources.get(_SETINFO_HREF) or {} + return str(setinfo.get(_WITHOUT_SC_FIELD, '')).lower() == 'true' + + +def _power_switch_exists(rep, resources): + return model_allows_power_on_off(resources) + + +def _power_sensor_exists(rep, resources): + return not model_allows_power_on_off(resources) + + def sensor_item_value(items, sensor_type, index=0): """Pull one reading out of a `/sensors/vs/0`-style items[] list -- each item is `{type, value: [...]}`; `index` picks which slot of a possibly @@ -128,10 +186,17 @@ def sensor_item_value(items, sensor_type, index=0): POWER_GENERIC = Capability( href='/power/0', entities=( + # Writable when firmware allows remote power; otherwise a read-only + # binary_sensor with the same key keeps HA state without a dead switch. SwitchDesc(key='power_switch', field='value', value_fn=lambda v: bool(v), + exists_fn=_power_switch_exists, write_fn=lambda p, rep, href=None: ( ['power', '0'], {'value': p == 'On'})), + BinarySensorDesc(key='power_switch', field='value', + device_class='power', + value_fn=lambda v: bool(v), + exists_fn=_power_sensor_exists), ), ) @@ -141,9 +206,14 @@ POWER_VS_FALLBACK = Capability( entities=( SwitchDesc(key='power_switch', field='x.com.samsung.da.power', value_fn=lambda v: v == 'On', + exists_fn=_power_switch_exists, write_fn=lambda p, rep, href=None: ( ['power', 'vs', '0'], {'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})), + BinarySensorDesc(key='power_switch', field='x.com.samsung.da.power', + device_class='power', + value_fn=lambda v: v == 'On', + exists_fn=_power_sensor_exists), ), ) @@ -192,6 +262,20 @@ def remote_control_enabled(resources: dict) -> bool: return True +def remote_control_required_for_write(resources: dict, bound_href: str) -> bool: + """Whether a write to bound_href should be gated on Smart Control. + + When isModelSettingWithoutSC is true, laundry firmware accepts settings + writes (wash temp, spin, course options, buzzer, ...) with remote + control off, but cycle start/pause/stop on /operational/state still + need Smart Control. Absent that flag, keep the historical blanket gate. + """ + if not model_setting_without_sc(resources): + return True + href = bound_href or '' + return href.startswith('/operational/state') + + REMOTE_CONTROL_GENERIC = Capability( href='/remotectrl/0', poll_tier='warm', diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 72bf573..14ff82a 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -62,6 +62,47 @@ TEMP_CURRENT_GENERIC = Capability( ), ) +def _temp_setpoint_vendor_write(p, rep, href=None): + """Write temperature via the vendor /temperatures/vs/0 resource. + + Samsung fridges expose both OCF-standard /temperature/desired/* (readable) + and vendor /temperatures/vs/0 (writable). The OCF resource ACKs POSTs but + ignores them; only the vendor path actually commits the setpoint change. + Item IDs follow the Samsung convention: "0" = Freezer, "1" = Fridge/Cooler. + """ + if not href: + return None + if '/cooler/' in href: + item_id = '1' + elif '/freezer/' in href: + item_id = '0' + else: + return None + return ( + ['temperatures', 'vs', '0'], + {'x.com.samsung.da.items': [ + {'x.com.samsung.da.id': item_id, + 'x.com.samsung.da.desired': str(int(round(float(p))))} + ]} + ) + + +TEMP_SETPOINT_VENDOR = Capability( + href=None, + href_prefix='/temperature/desired/', + strip_prefix_in_key=True, + match_fn=lambda rep, resources: '/temperatures/vs/0' in resources, + poll_tier='warm', + entities=( + NumberDesc(key='setpoint', field='temperature', + translation_key='instance_setpoint', + use_instance_name=True, device_class='temperature', unit_fn=_temp_unit, + native_min=-20.0, native_max=50.0, + range_field='range', entity_category='config', + write_fn=_temp_setpoint_vendor_write), + ), +) + TEMP_SETPOINT_GENERIC = Capability( href=None, href_prefix='/temperature/desired/', diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index f990cc6..767e6b1 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -55,6 +55,9 @@ "power_state": { "name": "Power state" }, + "power_switch": { + "name": "Power" + }, "rapid_freezing": { "name": "Rapid freezing" }, @@ -379,16 +382,20 @@ "27": "Rinse+Spin", "28": "Drain/Spin", "29": "Drum Clean+", + "2a": "Jeans", + "2b": "AI Wash", "2d": "Silent Wash", "2e": "Baby Care", "2f": "Activewear", "30": "Cloudy Day", "32": "Shirts", "33": "Bedding", + "34": "Mixed", "36": "Wash+Dry", "37": "Air Wash", "38": "Cotton Dry", "39": "Synthetics Dry", + "3a": "Drum Clean", "53": "Heavy Duty", "55": "Activewear", "57": "Delicate", @@ -398,11 +405,11 @@ "7c": "Whites", "7d": "Bedding/Waterproof", "7e": "Self-Clean", + "7f": "Wool/Delicate", "86": "Deep Wash", + "87": "Download", "8f": "Intense Cold", - "96": "Less Microfiber", - "2b": "AI Wash", - "2a": "Jeans" + "96": "Less Microfiber" } }, "washer_dry_level": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index c922d82..49a4d2b 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -55,6 +55,9 @@ "power_state": { "name": "Voedingsstatus" }, + "power_switch": { + "name": "Voeding" + }, "rapid_freezing": { "name": "Snelvriezen" }, @@ -379,16 +382,20 @@ "27": "Spoelen+centrifugeren", "28": "Afpompen/centrifugeren", "29": "Drum Clean+", + "2a": "Spijkerbroek", + "2b": "AI Wash", "2d": "Stille was", "2e": "Babyverzorging", "2f": "Sportkleding", "30": "Bewolkte dag", "32": "Overhemden", "33": "Beddengoed", + "34": "Gemengd", "36": "Wassen+drogen", "37": "Air Wash", "38": "Katoen drogen", "39": "Synthetisch drogen", + "3a": "Trommel reinigen", "53": "Intensief", "55": "Sportkleding", "57": "Fijn", @@ -398,11 +405,11 @@ "7c": "Witte was", "7d": "Beddengoed/Waterdicht", "7e": "Zelfreinigend", + "7f": "Wol/Fijn", "86": "Diep wassen", + "87": "Downloaden", "8f": "Intensief koud", - "96": "Minder microvezels", - "2b": "AI Wash", - "2a": "Spijkerbroek" + "96": "Minder microvezels" } }, "washer_dry_level": { diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 0a1b420..718baca 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -16,6 +16,7 @@ from custom_components.localthings.const import ( from custom_components.localthings.coordinator import LocalThingsCoordinator from custom_components.localthings.registry.capabilities.common import ( remote_control_enabled, + remote_control_required_for_write, ) from custom_components.localthings.observe import MODE_OBSERVE, MODE_POLL, PUSH_HEALTH_WINDOW_S @@ -880,6 +881,38 @@ class TestRemoteControlEnabled: assert remote_control_enabled({}) is True +class TestRemoteControlRequiredForWrite: + """isModelSettingWithoutSC auto-exempts settings writes, not cycle control.""" + + def test_without_flag_always_requires(self): + resources = {} + assert remote_control_required_for_write(resources, '/washer/vs/0') is True + assert remote_control_required_for_write( + resources, '/operational/state/vs/0') is True + + def test_without_sc_exempts_settings_keeps_operational(self): + resources = { + '/wm/setinfo/vs/0': { + 'x.com.samsung.da.isModelSettingWithoutSC': 'true', + }, + } + assert remote_control_required_for_write(resources, '/washer/vs/0') is False + assert remote_control_required_for_write(resources, '/course/vs/0') is False + assert remote_control_required_for_write(resources, '/buzzersound/vs/0') is False + assert remote_control_required_for_write( + resources, '/operational/state/vs/0') is True + assert remote_control_required_for_write( + resources, '/operational/state/0') is True + + def test_without_sc_false_still_requires(self): + resources = { + '/wm/setinfo/vs/0': { + 'x.com.samsung.da.isModelSettingWithoutSC': 'false', + }, + } + assert remote_control_required_for_write(resources, '/washer/vs/0') is True + + async def test_send_command_blocked_when_remote_control_disabled( hass: HomeAssistant, mock_entry, mock_coordinator_observe_session ) -> None: @@ -1024,3 +1057,95 @@ async def test_send_command_bypasses_remote_control_when_option_enabled( await coordinator.async_send_command(bound, 5) assert coordinator._cache.get('/some/path') == {'value': 5} + + +async def test_send_command_settings_allowed_when_without_sc( + hass: HomeAssistant, mock_entry, mock_coordinator_observe_session +) -> None: + """Laundry boards with isModelSettingWithoutSC=true accept settings + writes while remote control is off -- no options-flow bypass needed.""" + from custom_components.localthings.registry.discovery import BoundEntity + from custom_components.localthings.registry.entities import SelectDesc + + fake = mock_coordinator_observe_session + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] + coordinator._cache.apply_rep( + '/remotectrl/vs/0', + {'x.com.samsung.da.remoteControlEnabled': 'false'}, + source='test', + ) + coordinator._cache.apply_rep( + '/wm/setinfo/vs/0', + {'x.com.samsung.da.isModelSettingWithoutSC': 'true'}, + source='test', + ) + + def _write_fn(payload, rep, href=None): + return (['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': payload}) + + desc = SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel', + write_fn=_write_fn) + bound = BoundEntity( + href='/washer/vs/0', capability=coordinator.bound[0].capability, desc=desc, + ) + + with patch.object(fake, 'subscribe'): + fake.post = lambda *a, **k: (0x44, b'') + await coordinator.async_send_command(bound, '1000') + + assert coordinator._cache.get('/washer/vs/0') == { + 'x.com.samsung.da.spinLevel': '1000', + } + + +async def test_send_command_operational_still_blocked_when_without_sc( + hass: HomeAssistant, mock_entry, mock_coordinator_observe_session +) -> None: + """Start/Pause/Stop stay gated on Smart Control even when settings + are allowed without it -- isModelSettingWithoutSC is settings-only.""" + from custom_components.localthings.registry.discovery import BoundEntity + from custom_components.localthings.registry.entities import ButtonDesc + + fake = mock_coordinator_observe_session + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] + coordinator._cache.apply_rep( + '/remotectrl/vs/0', + {'x.com.samsung.da.remoteControlEnabled': 'false'}, + source='test', + ) + coordinator._cache.apply_rep( + '/wm/setinfo/vs/0', + {'x.com.samsung.da.isModelSettingWithoutSC': 'true'}, + source='test', + ) + + def _write_fn(payload, rep, href=None): + return (['operational', 'state', 'vs', '0'], + {'x.com.samsung.da.state': payload}) + + desc = ButtonDesc(key='start', payload='Run', write_fn=_write_fn) + bound = BoundEntity( + href='/operational/state/vs/0', + capability=coordinator.bound[0].capability, + desc=desc, + ) + + posted = False + + def _post(*a, **k): + nonlocal posted + posted = True + return (0x44, b'') + + with patch.object(fake, 'subscribe'): + fake.post = _post + with pytest.raises(ServiceValidationError) as exc_info: + await coordinator.async_send_command(bound, 'Run') + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == 'remote_control_disabled' + assert posted is False diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index ef6adda..cc0a2fe 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -1,5 +1,7 @@ from custom_components.localthings.registry.capabilities import common from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import BinarySensorDesc, SwitchDesc +from tests.conftest import _load_device def _reg(): @@ -65,7 +67,7 @@ class TestMergeOptionsField: class TestPowerFallback: def test_generic_href_read_write(self): assert common.POWER_GENERIC.href == '/power/0' - desc = common.POWER_GENERIC.entities[0] + desc = next(e for e in common.POWER_GENERIC.entities if isinstance(e, SwitchDesc)) assert desc.value_fn(True) is True assert desc.value_fn(False) is False path, body = desc.write_fn('On', {}) @@ -80,13 +82,72 @@ class TestPowerFallback: {}, {'/power/0': {}, '/power/vs/0': {}}) is False def test_vs_fallback_read_write(self): - desc = common.POWER_VS_FALLBACK.entities[0] + desc = next(e for e in common.POWER_VS_FALLBACK.entities if isinstance(e, SwitchDesc)) assert desc.value_fn('On') is True assert desc.value_fn('Off') is False path, body = desc.write_fn('On', {}) assert path == ['power', 'vs', '0'] assert body == {'x.com.samsung.da.power': 'On'} + def test_power_switch_hidden_when_firmware_disallows(self): + switch = next(e for e in common.POWER_GENERIC.entities if isinstance(e, SwitchDesc)) + sensor = next(e for e in common.POWER_GENERIC.entities if isinstance(e, BinarySensorDesc)) + resources = { + '/power/0': {'value': True}, + '/wm/setinfo/vs/0': {'x.com.samsung.da.isModelSettingPowerOnOff': 'false'}, + } + assert switch.exists_fn(resources['/power/0'], resources) is False + assert sensor.exists_fn(resources['/power/0'], resources) is True + + def test_power_switch_writable_when_firmware_allows(self): + switch = next(e for e in common.POWER_GENERIC.entities if isinstance(e, SwitchDesc)) + sensor = next(e for e in common.POWER_GENERIC.entities if isinstance(e, BinarySensorDesc)) + resources = { + '/power/0': {'value': True}, + '/wm/setinfo/vs/0': {'x.com.samsung.da.isModelSettingPowerOnOff': 'true'}, + } + assert switch.exists_fn(resources['/power/0'], resources) is True + assert sensor.exists_fn(resources['/power/0'], resources) is False + + def test_power_switch_default_writable_without_setinfo(self): + switch = next(e for e in common.POWER_GENERIC.entities if isinstance(e, SwitchDesc)) + sensor = next(e for e in common.POWER_GENERIC.entities if isinstance(e, BinarySensorDesc)) + resources = {'/power/0': {'value': True}} + assert switch.exists_fn(resources['/power/0'], resources) is True + assert sensor.exists_fn(resources['/power/0'], resources) is False + + +class TestWmSetinfoFlags: + def test_washer_fixture_carries_setinfo_from_device0(self): + """/wm/setinfo/vs/0 lands in the seed snapshot -- no dedicated capability.""" + resources = _load_device('washer') + assert '/wm/setinfo/vs/0' in resources + assert common.model_allows_power_on_off(resources) is False + assert common.model_setting_without_sc(resources) is True + + def test_dishwasher_allows_power_on_off(self): + resources = _load_device('dishwasher') + assert common.model_allows_power_on_off(resources) is True + assert common.model_setting_without_sc(resources) is False + + def test_deleted_alarms_filtered(self): + assert common._active_alarm_codes([ + { + 'x.com.samsung.da.code': 'ErrorCode', + 'x.com.samsung.da.state': 'Deleted', + }, + ]) == 'none' + assert common._active_alarm_codes([ + { + 'x.com.samsung.da.code': 'LE', + 'x.com.samsung.da.state': 'Triggered', + }, + { + 'x.com.samsung.da.code': 'ErrorCode', + 'x.com.samsung.da.state': 'Deleted', + }, + ]) == 'LE' + class TestKidsLockFallback: def test_generic_read_write(self): From 2fd8ff8260da2fd5526ccaca1e80db9a2bc085ed Mon Sep 17 00:00:00 2001 From: vmvarga Date: Sun, 26 Jul 2026 11:05:13 +0200 Subject: [PATCH 02/11] merge temp_setpoint --- custom_components/localthings/coordinator.py | 5 +- .../registry/by_type/refrigerator.py | 3 +- .../registry/capabilities/fridge.py | 61 ++++++++----------- tests/test_fridge_capabilities.py | 4 +- 4 files changed, 31 insertions(+), 42 deletions(-) diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 9e65827..58635ff 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -564,7 +564,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): translation_domain=DOMAIN, translation_key=error, ) - result = write_fn(payload, rep, href) + try: + result = write_fn(payload, rep, href, resources) + except TypeError: + result = write_fn(payload, rep, href) if result is None: self._log.warning("write_fn rejected payload %r for %s", payload, href) return diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index 9d70ebd..43e776b 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -33,8 +33,7 @@ REGISTRY = DeviceRegistry( ]), pattern_capabilities=[ fridge.TEMP_CURRENT_GENERIC, - fridge.TEMP_SETPOINT_VENDOR, - fridge.TEMP_SETPOINT_GENERIC, + fridge.TEMP_SETPOINT, fridge.ICEMAKER_GENERIC, fridge.DOOR_GENERIC, ], diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 14ff82a..855f3b5 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -62,48 +62,38 @@ TEMP_CURRENT_GENERIC = Capability( ), ) -def _temp_setpoint_vendor_write(p, rep, href=None): - """Write temperature via the vendor /temperatures/vs/0 resource. +def _temp_setpoint_write(p, rep, href=None, resources=None): + """Write temperature — prefer vendor /temperatures/vs/0 when available, + fall back to direct OCF /temperature/desired/ write otherwise. - Samsung fridges expose both OCF-standard /temperature/desired/* (readable) - and vendor /temperatures/vs/0 (writable). The OCF resource ACKs POSTs but - ignores them; only the vendor path actually commits the setpoint change. + Samsung fridges expose both OCF-standard /temperature/desired/* and vendor + /temperatures/vs/0. On some models only the vendor path commits the change; + on others both work. Using the vendor path when present is always correct. Item IDs follow the Samsung convention: "0" = Freezer, "1" = Fridge/Cooler. """ if not href: return None - if '/cooler/' in href: - item_id = '1' - elif '/freezer/' in href: - item_id = '0' - else: - return None + if resources and '/temperatures/vs/0' in resources: + if '/cooler/' in href: + item_id = '1' + elif '/freezer/' in href: + item_id = '0' + else: + return None + return ( + ['temperatures', 'vs', '0'], + {'x.com.samsung.da.items': [ + {'x.com.samsung.da.id': item_id, + 'x.com.samsung.da.desired': str(int(round(float(p))))} + ]} + ) return ( - ['temperatures', 'vs', '0'], - {'x.com.samsung.da.items': [ - {'x.com.samsung.da.id': item_id, - 'x.com.samsung.da.desired': str(int(round(float(p))))} - ]} + [s for s in href.strip('/').split('/') if s], + {'temperature': int(round(float(p)))} ) -TEMP_SETPOINT_VENDOR = Capability( - href=None, - href_prefix='/temperature/desired/', - strip_prefix_in_key=True, - match_fn=lambda rep, resources: '/temperatures/vs/0' in resources, - poll_tier='warm', - entities=( - NumberDesc(key='setpoint', field='temperature', - translation_key='instance_setpoint', - use_instance_name=True, device_class='temperature', unit_fn=_temp_unit, - native_min=-20.0, native_max=50.0, - range_field='range', entity_category='config', - write_fn=_temp_setpoint_vendor_write), - ), -) - -TEMP_SETPOINT_GENERIC = Capability( +TEMP_SETPOINT = Capability( href=None, href_prefix='/temperature/desired/', strip_prefix_in_key=True, @@ -114,10 +104,7 @@ TEMP_SETPOINT_GENERIC = Capability( use_instance_name=True, device_class='temperature', unit_fn=_temp_unit, native_min=-20.0, native_max=50.0, range_field='range', entity_category='config', - write_fn=lambda p, rep, href=None: ( - [s for s in href.strip('/').split('/') if s], - {'temperature': int(round(float(p)))} - ) if href else None), + write_fn=_temp_setpoint_write), ), ) diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index d35f5d4..d2bd4c5 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -20,9 +20,9 @@ class TestTempCurrentGeneric: assert desc.unit_fn({'temperature': 5.0}) == '°F' -class TestTempSetpointGeneric: +class TestTempSetpoint: def test_unit_reads_celsius(self): - desc = fridge.TEMP_SETPOINT_GENERIC.entities[0] + desc = fridge.TEMP_SETPOINT.entities[0] assert desc.unit_fn({'temperature': -19.0, 'units': 'C'}) == '°C' From c72b5a988eb1774d3ca40bfb05d30666716b0fbf Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 00:23:04 +0000 Subject: [PATCH 03/11] Add range/oven detection for boards missing /information/vs/0 (issue #74) NE63B8411SS reports no oneUiVersion and no /information/vs/0 resource at all, so neither for_device nor for_device_by_model's modelNum tokens have anything to key off -- it fell through to the unknown-device fallback, which also mis-binds /temperatures/vs/0 against fridge.py's setpoint capability (a collision the oven family's own capabilities are normally excluded from the global registry specifically to avoid). Add a resources-based signature to for_device_by_resources(): 'Bake' in /mode/vs/0's supportedModes is oven/range-exclusive vocabulary, and paired with the /oven/vs/0 cavity resource it reliably identifies this family even with no model info at all. Route to 'range' when a cooktop-status resource is also present, else plain 'oven'. This board's cooktop half also doesn't expose the per-burner /cooktop/status/vs/0 array range.py already models -- only the coarser /cooktopmonitoring/vs/0 summary resource. Add a read-only COOKTOP_MONITORING capability for it (cooktop running state, warming center state) rather than leaving it unbound. --- .../localthings/registry/by_type/__init__.py | 14 ++ .../localthings/registry/by_type/range.py | 1 + .../registry/capabilities/range.py | 19 ++ .../localthings/translations/en.json | 6 + .../localthings/translations/nl.json | 6 + tests/fixtures/golden/range_no_info.json | 27 +++ tests/fixtures/range_no_info_device.json | 201 ++++++++++++++++++ tests/test_by_type.py | 43 ++++ tests/test_golden_regression.py | 20 ++ tests/test_range_no_info_capabilities.py | 58 +++++ 10 files changed, 395 insertions(+) create mode 100644 tests/fixtures/golden/range_no_info.json create mode 100644 tests/fixtures/range_no_info_device.json create mode 100644 tests/test_range_no_info_capabilities.py diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 7f6b864..200c2a4 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -170,4 +170,18 @@ def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegist and '/hood/lamp/vs/0' in resources ): return _REGISTRY_BY_KEY['range_hood'] + # Oven/range-combo boards that report no /information/vs/0 at all + # (issue #74's NE63B8411SS -- the resource is simply absent from the + # dump, not just empty) can't be matched via for_device_by_model's + # modelNum tokens either. 'Bake' is oven/range cook-mode vocabulary that + # no other family's /mode/vs/0 uses (confirmed against the microwave, + # cooktop, and every laundry fixture), so its presence alongside the + # oven cavity resource is a safe signature. Distinguish range (has a + # cooktop half) from a plain wall oven by which cooktop-status resource, + # if any, is also present. + supported_modes = mode.get('x.com.samsung.da.supportedModes') or () + if '/oven/vs/0' in resources and 'Bake' in supported_modes: + if '/cooktopmonitoring/vs/0' in resources or '/cooktop/status/vs/0' in resources: + return _REGISTRY_BY_KEY['range'] + return _REGISTRY_BY_KEY['oven'] return None diff --git a/custom_components/localthings/registry/by_type/range.py b/custom_components/localthings/registry/by_type/range.py index 7cc891f..c53bdca 100644 --- a/custom_components/localthings/registry/by_type/range.py +++ b/custom_components/localthings/registry/by_type/range.py @@ -25,5 +25,6 @@ REGISTRY = DeviceRegistry( range_caps.COOKTOP_STATUS, range_caps.COOKTOP_SPEC, range_caps.COOKTOP_SAFETY, + range_caps.COOKTOP_MONITORING, ]), ) diff --git a/custom_components/localthings/registry/capabilities/range.py b/custom_components/localthings/registry/capabilities/range.py index 2ebb8af..88c88f9 100644 --- a/custom_components/localthings/registry/capabilities/range.py +++ b/custom_components/localthings/registry/capabilities/range.py @@ -133,3 +133,22 @@ COOKTOP_SAFETY = Capability( value_fn=lambda v: (v or {}).get('state') == 'on'), ), ) + +# Some range boards (issue #74's NE63B8411SS) report no /cooktop/status/vs/0 +# burner array at all -- their local API only exposes this coarse +# monitoring resource for the cooktop half, with no per-burner detail. +# Meaning of `cooktopMonitoring` (a bare "0" on the only dump seen) and +# `warmingCenterState`'s full value set aren't confirmed, so both are +# exposed as plain sensors rather than guessed at as a switch/select -- +# `supportedHoodLampStateList` has no corresponding live-state field on +# this resource, so nothing to bind it to yet. +COOKTOP_MONITORING = Capability( + href='/cooktopmonitoring/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='cooktop_running_state', field='x.com.samsung.da.cooktopRunningState', + icon='mdi:pot-steam-outline'), + SensorDesc(key='warming_center_state', field='x.com.samsung.da.warmingCenterState', + icon='mdi:heat-wave', entity_category='diagnostic'), + ), +) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index f990cc6..3f5418e 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -448,6 +448,9 @@ "poll": "Polling" } }, + "cooktop_running_state": { + "name": "Cooktop running state" + }, "cooktop_state": { "name": "Cooktop state" }, @@ -592,6 +595,9 @@ "super_fine_dust": { "name": "Super fine dust" }, + "warming_center_state": { + "name": "Warming center state" + }, "water_liters": { "name": "Water consumption" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index c922d82..c1ef633 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -448,6 +448,9 @@ "poll": "Polling" } }, + "cooktop_running_state": { + "name": "Loopstatus kookplaat" + }, "cooktop_state": { "name": "Status kookplaat" }, @@ -592,6 +595,9 @@ "super_fine_dust": { "name": "Ultrafijnstof" }, + "warming_center_state": { + "name": "Status warmhoudzone" + }, "water_liters": { "name": "Waterverbruik" }, diff --git a/tests/fixtures/golden/range_no_info.json b/tests/fixtures/golden/range_no_info.json new file mode 100644 index 0000000..a7ef5a5 --- /dev/null +++ b/tests/fixtures/golden/range_no_info.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_no_info_device.json b/tests/fixtures/range_no_info_device.json new file mode 100644 index 0000000..dcf88bf --- /dev/null +++ b/tests/fixtures/range_no_info_device.json @@ -0,0 +1,201 @@ +{ + "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-06-03T04:35:25" + } + ] + } + }, + { + "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_NE8411B-/AC0", + "SettingPossible_0", + "meatprobe_disconnected", + "UpperLamp_Off", + "Sound_On", + "AdjustingTemp_11", + "Sabbath_Off", + "EnergySaving_On", + "BurnerOnAlert_On" + ], + "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\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"Broil\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"61441\",\"tempMaxC\":\"61442\",\"tempDefaultC\":\"61441\",\"tempListLengthC\":\"2\",\"tempListDataC\":[\"61441\",\"61442\"],\"tempMinF\":\"61441\",\"tempMaxF\":\"61442\",\"tempDefaultF\":\"61441\",\"tempListLengthF\":\"2\",\"tempListDataF\":[\"61441\",\"61442\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"ConvectionBake\",\"version\":\"0100\",\"default\":\"Default\",\"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\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"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\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"KeepWarm\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"80\",\"tempDefaultC\":\"80\",\"tempListLengthC\":\"1\",\"tempListDataC\":[\"80\"],\"tempMinF\":\"175\",\"tempMaxF\":\"175\",\"tempDefaultF\":\"175\",\"tempListLengthF\":\"1\",\"tempListDataF\":[\"175\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"BreadProof\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"35\",\"tempMaxC\":\"35\",\"tempDefaultC\":\"35\",\"tempListLengthC\":\"1\",\"tempListDataC\":[\"35\"],\"tempMinF\":\"95\",\"tempMaxF\":\"95\",\"tempDefaultF\":\"95\",\"tempListLengthF\":\"1\",\"tempListDataF\":[\"95\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"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\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"Dehydrate\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"40\",\"tempMaxC\":\"105\",\"tempDefaultC\":\"65\",\"tempListLengthC\":\"0\",\"tempMinF\":\"100\",\"tempMaxF\":\"225\",\"tempDefaultF\":\"150\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"SelfClean\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"SteamClean\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"}]", + "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": "false", + "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": "80", + "x.com.samsung.da.increment": "1", + "x.com.samsung.da.unit": "Celsius" + } + ], + "rt": [ + "x.com.samsung.da.temperatures" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 656f937..02e4ed5 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -284,3 +284,46 @@ class TestForDeviceByResources: reg = for_device_by_resources(resources) assert reg is not None assert reg.name == 'range_hood' + + def test_ne63b8411ss_without_information_or_burner_status_is_range(self): + """Issue #74: no oneUiVersion, no /information/vs/0 at all, and no + /cooktop/status/vs/0 burner array -- only /cooktopmonitoring/vs/0. + 'Bake' in supportedModes plus that monitoring resource must still + route this to the range registry, not plain oven or unknown.""" + from custom_components.localthings.registry.by_type import for_device_by_resources + resources = { + '/mode/vs/0': { + 'x.com.samsung.da.supportedModes': ['Bake', 'Broil', 'SelfClean'], + 'x.com.samsung.da.options': ['DeviceType_NE8411B-/AC0'], + }, + '/oven/vs/0': {'x.com.samsung.da.state': 'Ready'}, + '/cooktopmonitoring/vs/0': {'x.com.samsung.da.cooktopRunningState': 'Ready'}, + } + reg = for_device_by_resources(resources) + assert reg is not None + assert reg.name == 'range' + + def test_bake_without_cooktop_resource_is_plain_oven(self): + from custom_components.localthings.registry.by_type import for_device_by_resources + resources = { + '/mode/vs/0': { + 'x.com.samsung.da.supportedModes': ['Bake', 'Broil'], + 'x.com.samsung.da.options': ['DeviceType_SOME_OVEN'], + }, + '/oven/vs/0': {'x.com.samsung.da.state': 'Ready'}, + } + reg = for_device_by_resources(resources) + assert reg is not None + assert reg.name == 'oven' + + def test_bake_without_oven_cavity_resource_is_not_matched(self): + """'Bake' alone isn't enough -- the oven cavity resource must also + be present, or this falls through to None like any other unknown + shape.""" + from custom_components.localthings.registry.by_type import for_device_by_resources + resources = { + '/mode/vs/0': { + 'x.com.samsung.da.supportedModes': ['Bake', 'Broil'], + }, + } + assert for_device_by_resources(resources) is None diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 56ba878..1f6daf7 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -262,6 +262,26 @@ def test_registry_reproduces_golden_state_keys_for_range(): ) +def test_registry_reproduces_golden_state_keys_for_range_no_info(): + """NE63B8411SS (issue #74) -- reports no oneUiVersion *and* no + /information/vs/0 at all, so neither for_device nor + for_device_by_model has anything to key off; resolved via the 'Bake'- + in-supportedModes + /cooktopmonitoring/vs/0 signature in + for_device_by_resources. This board's local API has no per-burner + /cooktop/status/vs/0 array either -- only the coarse + /cooktopmonitoring/vs/0 monitoring resource covered by range.py's + COOKTOP_MONITORING.""" + from tests.conftest import _load_device + resources = _load_device('range_no_info') + golden = json.loads((GOLDEN / 'range_no_info.json').read_text()) + state_keys = _new_state_keys('range_no_info', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_registry_reproduces_golden_state_keys_for_air_purifier(): """ARTIK051_TVTL_18K (issue #56) -- reports no oneUiVersion; resolved via the '_TVTL_' modelNum token fallback in for_device_by_model.""" diff --git a/tests/test_range_no_info_capabilities.py b/tests/test_range_no_info_capabilities.py new file mode 100644 index 0000000..71d11ff --- /dev/null +++ b/tests/test_range_no_info_capabilities.py @@ -0,0 +1,58 @@ +"""Tests for the no-/information/vs/0, no-burner-status range variant +(NE63B8411SS-class, issue #74).""" +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 range as range_caps +from custom_components.localthings.registry.discovery import discover + +from tests.conftest import _load_device + + +def _range(): + resources = _load_device('range_no_info') + 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(): + """Every resource in the issue #74 dump binds or is ignored -- clears + the coverage-gap repair.""" + reg, resources = _range() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_no_burner_entities(): + """This board reports no /cooktop/status/vs/0, so none of range.py's + per-burner entities should appear -- only COOKTOP_MONITORING's.""" + state = _state() + assert not any(k.startswith('burner_') for k in state) + + +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', + 'cooktop_running_state', 'warming_center_state', + ): + assert key in state, key + + +def test_cooktop_monitoring_reads_live_fields(): + desc = next(e for e in range_caps.COOKTOP_MONITORING.entities if e.key == 'cooktop_running_state') + assert desc.value_fn('Ready') == 'Ready' + desc = next(e for e in range_caps.COOKTOP_MONITORING.entities if e.key == 'warming_center_state') + assert desc.value_fn('Off') == 'Off' From 3afbe6c6c151f65b07fe99febd2b6c5466ae7dd0 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 00:33:35 +0000 Subject: [PATCH 04/11] Add humidity/temperature sensors and horizontal swing for AC (issue #75) Three gaps reported against an ARTIK051_PRAC_20K WindFree unit vs. the SmartThings integration: 1. Missing WindFree/motion convenient-mode presets -- left alone here. PR #91 replaces climate.py's static _DEVICE_TO_PRESET table with a generic resolver that reads any preset code straight off the unit's own supportedModes, which already covers this (and more generically than a per-model dict would) -- adding one here would just conflict. 2. No horizontal oscillation: /wind/direction/vs/0's supportedModes includes Left_And_Right, which _DEVICE_TO_SWING had no mapping for. Add it to HA's standard 'horizontal' swing constant. 3. No standalone humidity/current-temperature sensors: the climate card already reads both internally, but nothing exposed them as entities for history/automations. Add CURRENT_TEMPERATURE (OCF /temperature/current/0) with a CURRENT_TEMPERATURE_VS vendor fallback (same match_fn-gated pair shape as common.py's POWER_GENERIC/ POWER_VS_FALLBACK), and a HUMIDITY sensor reading /humidity/vs/0's fivepercentHumidity field -- the only one of the three humidity-shaped fields across /humidity/0 and /humidity/vs/0 that isn't permanently stuck at 0 on every dump seen. Regenerates the five existing AC goldens (all pick up current_temperature_c; most pick up humidity) and adds a dedicated fixture from the issue's WindFree dump, whose /humidity/vs/0 actually has live fivepercentHumidity data. --- custom_components/localthings/climate.py | 1 + .../registry/by_type/airconditioner.py | 3 + .../registry/capabilities/airconditioner.py | 69 +++- .../localthings/translations/en.json | 6 + .../localthings/translations/nl.json | 6 + .../airconditioner_windfree_device.json | 380 ++++++++++++++++++ tests/fixtures/golden/airconditioner.json | 2 + .../golden/airconditioner_caww_tp2.json | 2 + .../airconditioner_tp1x_da_ac_rac_01011.json | 2 + .../golden/airconditioner_tp1x_rac.json | 1 + .../golden/airconditioner_tp2x_rac_20k.json | 2 + .../golden/airconditioner_windfree.json | 15 + tests/test_airconditioner_capabilities.py | 53 ++- tests/test_climate_windfree.py | 23 ++ tests/test_golden_regression.py | 17 + 15 files changed, 579 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/airconditioner_windfree_device.json create mode 100644 tests/fixtures/golden/airconditioner_windfree.json create mode 100644 tests/test_climate_windfree.py diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 79d38af..12e3cb6 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -84,6 +84,7 @@ _DEVICE_TO_SWING: dict[str, str] = { 'Fix': 'off', 'All': 'both', 'Up_And_Low': 'vertical', + 'Left_And_Right': 'horizontal', # issue #75 } _SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()} diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index e218de5..7a7c38c 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -25,6 +25,9 @@ REGISTRY = DeviceRegistry( airconditioner.DISPLAY_LIGHT, airconditioner.MUTE_ONCE, airconditioner.CURRENT_LIMIT, + airconditioner.CURRENT_TEMPERATURE, + airconditioner.CURRENT_TEMPERATURE_VS, + airconditioner.HUMIDITY, *airconditioner.COVERAGE, ]), ) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 70f5e1b..599affd 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -16,6 +16,7 @@ by_type registry. """ from ..capability import Capability from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc +from .common import normalize_temp_unit # --------------------------------------------------------------------------- # Canonical AC resource hrefs. The climate entity (climate.py) binds the @@ -50,6 +51,25 @@ def _num(v): return None +def _temps_vs_item(rep): + """First item of the vendor `/temperatures/vs/0` items[] array -- the + Tizen Lite board's only current-temperature source (see climate.py's + identical helper; duplicated rather than imported to avoid a + capabilities<->platform import cycle).""" + items = rep.get('x.com.samsung.da.items') + if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict): + return items[0] + return {} + + +def _temps_vs_current(rep): + return _num(_temps_vs_item(rep).get('x.com.samsung.da.current')) + + +def _temps_vs_unit(rep): + return normalize_temp_unit(_temps_vs_item(rep).get('x.com.samsung.da.unit'), '°C') + + def _filter_usage_percent(rep): """Filter usage as a percentage of rated capacity. The device reports `filterUsage` as a raw count in `filterCapacityUnit` (Hours here, e.g. @@ -199,6 +219,48 @@ CURRENT_LIMIT = Capability( ), ) +# The climate entity already surfaces current_temperature as a card +# attribute, but that's not enough for history graphs/automations/ +# statistics -- issue #75 asked for a standalone sensor. Same OCF-standard- +# with-vendor-fallback shape as common.py's POWER_GENERIC/POWER_VS_FALLBACK +# pair: both share key='current_temperature_c' so only one ever binds +# (match_fn gates the vendor one off when the OCF resource is present). +CURRENT_TEMPERATURE = Capability( + href=HREF_TEMP_CURRENT, + poll_tier='warm', + entities=( + SensorDesc(key='current_temperature_c', field='temperature', + device_class='temperature', state_class='measurement', + unit_fn=lambda rep: normalize_temp_unit(rep.get('units'), '°C')), + ), +) + +CURRENT_TEMPERATURE_VS = Capability( + href=HREF_TEMPS_VS, + poll_tier='warm', + match_fn=lambda rep, resources: HREF_TEMP_CURRENT not in resources, + entities=( + SensorDesc(key='current_temperature_c', rep_fn=_temps_vs_current, + device_class='temperature', state_class='measurement', + unit_fn=_temps_vs_unit), + ), +) + +# Only the vendor resource's `fivepercentHumidity` (current reading, rounded +# to the nearest 5%) has live data on the issue #75 dump -- its `humidity` +# field, and the OCF-standard /humidity/0 resource entirely, both read a +# stuck "0" there and stay ignored per the 'don't guess' rule (see +# _AC_IGNORED below). +HUMIDITY = Capability( + href='/humidity/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='humidity', field='x.com.samsung.da.fivepercentHumidity', + device_class='humidity', state_class='measurement', unit='%', + value_fn=_num), + ), +) + # --------------------------------------------------------------------------- # AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate # entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources. @@ -221,8 +283,10 @@ _AC_IGNORED = [ # All-zero and ambiguously encoded on this model (2-value arrays); the # 'don't guess' rule -- leave unmodeled rather than invent entities. '/sensors/vs/0', + # Stuck at "0" on every dump seen -- HUMIDITY above reads the vendor + # resource's usable fivepercentHumidity field instead; this OCF-standard + # one has no corresponding live value confirmed yet. '/humidity/0', - '/humidity/vs/0', # Presence-personalization plumbing (empty item list here). '/personality/presence/vs/0', # --- TP1X/TP2X-class housekeeping / opaque blobs. These carry no @@ -251,8 +315,11 @@ _AC_IGNORED = [ ] # Built as bare no-entity caps; folded into the AC registry (not global). +# HREF_TEMP_CURRENT and HREF_TEMPS_VS are excluded here -- CURRENT_TEMPERATURE +# / CURRENT_TEMPERATURE_VS above already cover those two with real entities. COVERAGE = [ Capability(href=h, poll_tier='warm') for h in CLIMATE_CONSUMED_HREFS + if h not in (HREF_TEMP_CURRENT, HREF_TEMPS_VS) ] + [ Capability(href=h) for h in _AC_IGNORED ] diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index f990cc6..cf0e20d 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -457,6 +457,9 @@ "current_temp_c": { "name": "Temperature" }, + "current_temperature_c": { + "name": "Temperature" + }, "diagnosis": { "name": "Diagnosis" }, @@ -526,6 +529,9 @@ "hood_filter_usage": { "name": "Filter usage" }, + "humidity": { + "name": "Humidity" + }, "instance_temperature": { "name": "{instance_name} temperature" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index c922d82..73d8eb0 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -457,6 +457,9 @@ "current_temp_c": { "name": "Temperatuur" }, + "current_temperature_c": { + "name": "Temperatuur" + }, "diagnosis": { "name": "Diagnose" }, @@ -526,6 +529,9 @@ "hood_filter_usage": { "name": "Filterverbruik" }, + "humidity": { + "name": "Luchtvochtigheid" + }, "instance_temperature": { "name": "Temperatuur {instance_name}" }, diff --git a/tests/fixtures/airconditioner_windfree_device.json b/tests/fixtures/airconditioner_windfree_device.json new file mode 100644 index 0000000..9fca817 --- /dev/null +++ b/tests/fixtures/airconditioner_windfree_device.json @@ -0,0 +1,380 @@ +{ + "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": "2026-07-25T08:19:03" + }, + { + "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": "2026-07-25T08:19:03" + } + ] + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.airconOptionList": [ + "SingleCommand_1", + "HOMECARE_WIZARD_V2", + "PRODUCT_GLOBAL", + "AI_RAC_GLOBAL_2.0", + "AI_2.0", + "Auto_To_AI", + "AI_Heat" + ] + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "power": 0.0 + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "0", + "x.com.samsung.da.cumulativePower": "320466", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+00:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "41", + "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": "ARTIK051_PRAC_20K|10217841|60010519001411010200001000000000", + "x.com.samsung.da.description": "ARTIK051_PRAC_20K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "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": "02181A230313", + "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": "20082000,FFFFFFFF", + "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": "20091600,10000400" + } + ] + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Smart", + "Speed", + "MotionIndirect", + "MotionDirect", + "Nano", + "NanoSleep" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Cool", + "Dry", + "Wind", + "Auto", + "Heat" + ], + "x.com.samsung.da.modes": [ + "Dry" + ], + "x.com.samsung.da.options": [ + "Sleep_0", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "MotionState_Normal", + "AiTempChanged_Off", + "AiTemp_240", + "OutdoorTemp_87", + "CoolCapa_25", + "WarmCapa_32", + "Light_Off", + "Volume_100", + "MotionMode_Default", + "OptionCode_56376", + "ExtendOptionCode_781", + "RacInfo_None", + "UpdateAllow_NotAllowed", + "DurationOn_0", + "WelcomeCoolingState_Off" + ] + } + }, + { + "href": "/option/airpurify/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "On", + "Off" + ] + } + }, + { + "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": "/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": "false", + "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 CleanLevel", + "x.com.samsung.da.type": "CleanLevel", + "x.com.samsung.da.value": [ + "0" + ] + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Sensor for Odor", + "x.com.samsung.da.type": "Odor", + "x.com.samsung.da.value": [ + "0" + ] + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Sensor for Dust", + "x.com.samsung.da.type": "Dust", + "x.com.samsung.da.value": [ + "0", + "0" + ] + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Sensor for FineDust", + "x.com.samsung.da.type": "FineDust", + "x.com.samsung.da.value": [ + "0", + "0" + ] + }, + { + "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": [ + "0", + "0" + ] + } + ] + } + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 27.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 26.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": "26.0", + "x.com.samsung.da.current": "27.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": {} + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Fix", + "x.com.samsung.da.supportedModes": [ + "Fix", + "All", + "Up_And_Low", + "Left_And_Right" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "0", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Low", + "Mid", + "High", + "Turbo" + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner.json b/tests/fixtures/golden/airconditioner.json index 68abd0f..6a6596c 100644 --- a/tests/fixtures/golden/airconditioner.json +++ b/tests/fixtures/golden/airconditioner.json @@ -6,9 +6,11 @@ "alarm_code", "auto_clean", "climate", + "current_temperature_c", "diagnosis_status", "energy_kwh", "energy_saved_kwh", + "humidity", "power_watts" ] } diff --git a/tests/fixtures/golden/airconditioner_caww_tp2.json b/tests/fixtures/golden/airconditioner_caww_tp2.json index 290a269..c3cdd7c 100644 --- a/tests/fixtures/golden/airconditioner_caww_tp2.json +++ b/tests/fixtures/golden/airconditioner_caww_tp2.json @@ -5,10 +5,12 @@ "alarm_code", "auto_clean", "climate", + "current_temperature_c", "diagnosis_status", "energy_kwh", "energy_saved_kwh", "firmware_update", + "humidity", "mute_once", "power_watts" ] diff --git a/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json index b9643bf..af71051 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json +++ b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json @@ -6,10 +6,12 @@ "alarm_code", "auto_clean", "climate", + "current_temperature_c", "display_light", "energy_kwh", "energy_saved_kwh", "firmware_update", + "humidity", "mute_once", "selfcheck_error", "selfcheck_result", diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac.json b/tests/fixtures/golden/airconditioner_tp1x_rac.json index b7ce7c3..b192cdf 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_rac.json +++ b/tests/fixtures/golden/airconditioner_tp1x_rac.json @@ -8,6 +8,7 @@ "climate", "current_limit_enabled", "current_limit_level", + "current_temperature_c", "display_light", "energy_kwh", "energy_saved_kwh", diff --git a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json index 4b50703..a2c11e4 100644 --- a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json +++ b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json @@ -5,8 +5,10 @@ "alarm_code", "auto_clean", "climate", + "current_temperature_c", "energy_kwh", "firmware_update", + "humidity", "mute_once", "power_watts" ] diff --git a/tests/fixtures/golden/airconditioner_windfree.json b/tests/fixtures/golden/airconditioner_windfree.json new file mode 100644 index 0000000..70ba9c5 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_windfree.json @@ -0,0 +1,15 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "air_purify", + "alarm_code", + "auto_clean", + "climate", + "current_temperature_c", + "diagnosis_status", + "energy_kwh", + "humidity", + "power_watts" + ] +} diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index 981dc5d..a7ea9d6 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -114,11 +114,13 @@ def test_climate_write_targets(): def test_climate_consumed_hrefs_declared_as_coverage(): """The climate-consumed and ambiguous hrefs are declared in the AC registry (as no-entity coverage caps) so they don't leak as gaps -- but produce no - standalone entities.""" + standalone entities. /temperature/current/0 and /temperatures/vs/0 are + NOT in this list -- CURRENT_TEMPERATURE / CURRENT_TEMPERATURE_VS give + those two real sensor entities (issue #75).""" reg, _ = _ac() for href in ('/power/0', '/power/vs/0', '/temperature/desired/0', '/wind/strength/vs/0', '/mode/convenient/vs/0', - '/temperatures/vs/0', '/sensors/vs/0', '/humidity/0'): + '/sensors/vs/0', '/humidity/0'): caps = reg.capabilities.get(href) assert caps, href assert all(c.entities == () for c in caps), href @@ -257,3 +259,50 @@ def test_current_limit_is_read_only(): than a guessed writable control.""" for desc in airconditioner.CURRENT_LIMIT.entities: assert getattr(desc, 'write_fn', None) is None + + +# --------------------------------------------------------------------------- +# WindFree unit (issue #75): same ARTIK051_PRAC_20K modelNum family as the +# original issue #17 fixture, but its /mode/convenient/vs/0 additionally +# supports Nano/NanoSleep/MotionDirect/MotionIndirect, /wind/direction/vs/0 +# additionally supports Left_And_Right, and /humidity/vs/0's +# fivepercentHumidity is populated (unlike the all-zero original dump). +# --------------------------------------------------------------------------- + +def _ac_windfree(): + resources = _load_device('airconditioner_windfree') + 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_windfree_no_unbound_hrefs(): + reg, resources = _ac_windfree() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_windfree_humidity_and_temperature_sensors_present(): + reg, resources = _ac_windfree() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + assert state['humidity'] == 42.0 # fivepercentHumidity, not the stuck humidity=0 field + assert state['current_temperature_c'] == 27.0 + + +def test_current_temperature_vs_only_binds_when_ocf_href_absent(): + """CURRENT_TEMPERATURE_VS's match_fn must not double-bind alongside + CURRENT_TEMPERATURE when a device (like this one) reports both + /temperature/current/0 and /temperatures/vs/0.""" + match = airconditioner.CURRENT_TEMPERATURE_VS.match_fn + assert match({}, {'/temperature/current/0': {}}) is False + assert match({}, {}) is True + + +def test_humidity_reads_five_percent_field_not_stuck_humidity_field(): + desc = airconditioner.HUMIDITY.entities[0] + rep = {'x.com.samsung.da.humidity': '0', 'x.com.samsung.da.fivepercentHumidity': '42'} + assert desc.value_fn(rep.get(desc.field)) == 42.0 diff --git a/tests/test_climate_windfree.py b/tests/test_climate_windfree.py new file mode 100644 index 0000000..d9e74f0 --- /dev/null +++ b/tests/test_climate_windfree.py @@ -0,0 +1,23 @@ +"""Tests for the horizontal-swing mapping added to climate.py for issue #75 +-- pure dict lookups, testable without a coordinator/entity fixture (see +test_climate_temperature_fallback.py). + +The preset side of issue #75 (WindFree/motion convenient modes not +surfacing) is intentionally not addressed here: PR #91 replaces +climate.py's static _DEVICE_TO_PRESET table with a generic resolver that +reads any device preset code straight off the unit's own supportedModes, +which covers WindFree/motion generically instead of a per-model dict -- +duplicating that here would just conflict with it. +""" +from custom_components.localthings.climate import _DEVICE_TO_SWING, _SWING_TO_DEVICE + + +def test_horizontal_swing_maps_both_directions(): + assert _DEVICE_TO_SWING['Left_And_Right'] == 'horizontal' + assert _SWING_TO_DEVICE['horizontal'] == 'Left_And_Right' + + +def test_existing_swing_modes_unchanged(): + assert _DEVICE_TO_SWING['Fix'] == 'off' + assert _DEVICE_TO_SWING['All'] == 'both' + assert _DEVICE_TO_SWING['Up_And_Low'] == 'vertical' diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 56ba878..05829b0 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -245,6 +245,23 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_rac(): ) +def test_registry_reproduces_golden_state_keys_for_airconditioner_windfree(): + """ARTIK051_PRAC_20K, WindFree-capable unit (issue #75) -- same modelNum + family as the original issue #17 fixture, but its /mode/convenient/vs/0 + additionally reports Nano/NanoSleep/MotionDirect/MotionIndirect, its + /wind/direction/vs/0 reports Left_And_Right, and /humidity/vs/0's + fivepercentHumidity is actually populated.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_windfree') + golden = json.loads((GOLDEN / 'airconditioner_windfree.json').read_text()) + state_keys = _new_state_keys('airconditioner_windfree', 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(): """Range/cooktop-oven combo (model TP1X_DA-KS-RANGE-0102X, issue #44) -- reports no oneUiVersion; resolved via the '-RANGE-' modelNum token From bae1ac43370b1f0ff5573c4a69da2f54825103bf Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 00:44:23 +0000 Subject: [PATCH 05/11] Fix dryer detection when description pairs two model numbers (issue #79) DVE50A8600V/A3 reports description 'DA_WM_TP1_21_COMMON_DVE50A8800_8600/DC92-02835A_0080' -- a paired listing of two related model numbers (DVE50A8800 and DVE50A8600) joined by an underscore, rather than the usual single trailing consumer-model token. for_device_by_model only ever checked the literal last underscore segment ('8600', which has no recognizable 2-letter prefix on its own), so the real 'DV' token one segment earlier was never reached and the device fell back to 'unknown' with only common capabilities -- no dry level, cycle, or wrinkle-prevent entities. Replace the single last-segment extraction with _consumer_model_key, which scans segments from the end and returns the first one that resolves. Behavior is unchanged for every existing single-token description (the last segment still matches first); it just keeps looking when that segment doesn't. --- .../localthings/registry/by_type/__init__.py | 23 +- tests/fixtures/dryer_dve50a8600_device.json | 391 ++++++++++++++++++ tests/fixtures/golden/dryer_dve50a8600.json | 24 ++ tests/test_by_type.py | 35 ++ tests/test_golden_regression.py | 17 + 5 files changed, 488 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/dryer_dve50a8600_device.json create mode 100644 tests/fixtures/golden/dryer_dve50a8600.json diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 7f6b864..f191df4 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -87,6 +87,26 @@ _CONSUMER_PREFIX_TO_KEY: dict[str, str] = { } +def _consumer_model_key(description: str) -> Optional[str]: + """Registry key from the consumer-model token in `description`, or None. + + Usually that token is the last '_'-delimited segment before any + '/board-info' suffix (e.g. '..._WW90DG6U25LEU4' -> 'WW90DG6U25LEU4'). + But issue #79's dryer pairs two model numbers in one description -- + '..._DVE50A8800_8600/DC92-...' -- so the true consumer token + ('DVE50A8800') sits one segment *before* the actual last segment + ('8600', a bare second model number with no recognizable prefix). Scan + segments from the end and take the first one that resolves, rather + than assuming the last segment is always it. + """ + segments = (description or '').split('/', 1)[0].split('_') + for segment in reversed(segments): + key = _CONSUMER_PREFIX_TO_KEY.get(segment[:2].upper()) + if key is not None: + return key + return None + + def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]: """Fallback device-type detection for hardware that never reports oneUiVersion (confirmed for washers -- their /otninformation/vs/0 has @@ -100,8 +120,7 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi DeviceRegistry if the consumer-model code or modelNum resolves to a known type, None otherwise. """ - token = (description or '').split('/', 1)[0].rsplit('_', 1)[-1] - key = _CONSUMER_PREFIX_TO_KEY.get(token[:2].upper()) + key = _consumer_model_key(description) if key is None and '_REF_' in (model_num or ''): key = 'refrigerator' # Room air conditioners (e.g. ARTIK051_PRAC_20K) report no oneUiVersion and diff --git a/tests/fixtures/dryer_dve50a8600_device.json b/tests/fixtures/dryer_dve50a8600_device.json new file mode 100644 index 0000000..cfc135b --- /dev/null +++ b/tests/fixtures/dryer_dve50a8600_device.json @@ -0,0 +1,391 @@ +{ + "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": "US" + } + }, + { + "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": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_0152", + "UpdateAllow_NotAllowed", + "Course_01", + "AiOption_On", + "MixedLoadBell_Disable", + "MixedLoadBellNoti_Nothing", + "LaundryOutTime_0", + "SeamlessControl_Enable", + "KidsLockBypass_On", + "DetergentOnce_1", + "DetergentLeft_0", + "DetergentBase_5", + "DetergentAlarm_Off", + "DetergentType_3", + "DetergentTotal_0", + "SpecialFunction_20", + "AvailableDelayTime_39", + "LaundryPlannerUserSetTime_0", + "ProgressTimeSet_B20870B400B4", + "WrinklePreventRunning_Off", + "DryTime_0", + "SendToDevice_Off", + "GMT_F6", + "DryTimeSet_0A00000000500A005A00000000000000001E0A005A00000000000000001414005A000000000000000000000000000000000000000000000000000000000000000000000000140A005A280A005A", + "EnergyLevelSet_0504040504040205020204040305020203030103", + "MostUsed_068A3ED000", + "WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F", + "MixedLoadBellSet_02FF02FFFF02FFFFFF0202FF02FFFF02FFFFFF", + "GeoFenceAlarm", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_Empty", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "2018410D33E06833ED0002F8520D33E178520D3083E8520D000078102D33E338520D520358520D000348520D0000E8520D33E058520D33E308410D308318410D33E098410D3080F8204D308328308D33E2E8102D308368000D0000D8102D000" + ] + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": { + "x.com.samsung.da.cycleInterfaceEnabled": "On" + } + }, + { + "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": "2323800", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1784991600", + "x.com.samsung.da.cumulativeDateUTC": "1785009600" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "-05:00" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20286441|300000010015110002A3031700000000", + "x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_DVE50A8800_8600/DC92-02835A_0080", + "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": "310", + "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": "DA_WM_TP1_21_COMMON|20286441|300000010015110002A3031700000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02986A260118(A182)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Firmware_1_DB_20286441210514150FFFFF202835412105131904FFFF(01522028644120283541_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "02864A21051415,02835A21051319", + "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", + "Drying", + "Cooling", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "01:15:00", + "progressPercentage": "1" + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "01:15: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", + "Drying", + "Cooling", + "Finish" + ] + } + }, + { + "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": "2026-03-04", + "otnList": [ + { + "type": "WIFI", + "modelId": "DA_WM_TP1_21_COMMON", + "versions": [ + "30260118" + ], + "visVersion": "260118" + }, + { + "type": "Micom", + "modelId": "01522028644120283541", + "versions": [ + "21051415", + "21051319" + ], + "visVersion": "210514" + } + ] + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "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": "/setting/vs/0", + "rep": { + "x.com.samsung.da.supportedSetLanguage": [ + "en_US", + "ko_KR", + "fr_CA", + "es_MX", + "pt_BR" + ], + "x.com.samsung.da.setLanguage": "en_US" + } + }, + { + "href": "/st/dryercourse/vs/0", + "rep": { + "x.com.samsung.da.st.dryerMode": "Table_03_Course_01", + "x.com.samsung.da.st.courseTable": "Table_03" + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "America/Chicago", + "offset": "-05:00", + "DST": "ON" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.wrinklePrevent": "Off", + "x.com.samsung.da.waterTemperature": "Medium", + "x.com.samsung.da.supportedWaterTemperature": [ + "None", + "ExtraLow", + "Low", + "MediumLow", + "Medium", + "High" + ], + "x.com.samsung.da.dryLevel": "Normal", + "x.com.samsung.da.supportedDryLevel": [ + "None", + "Damp", + "Less", + "Normal", + "More", + "Very" + ], + "x.com.samsung.da.dryerType": "Electricity" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + }, + { + "href": "/wm/editcourse/vs/0", + "rep": { + "x.com.samsung.da.editCourseList": "EditCourseList_0106", + "x.com.samsung.da.fixedCourseList": "FixedCourseList_0101" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": { + "x.com.samsung.da.currentStatus": "None" + } + }, + { + "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": "true", + "x.com.samsung.da.aiCourse": "false", + "x.com.samsung.da.isModelSettingPowerOnOff": "false", + "x.com.samsung.da.modelCode": "M(None),W(DVE50A8600V/A3)" + } + }, + { + "href": "/wm/welcomemsg/vs/0", + "rep": {} + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/dryer_dve50a8600.json b/tests/fixtures/golden/dryer_dve50a8600.json new file mode 100644 index 0000000..da10e04 --- /dev/null +++ b/tests/fixtures/golden/dryer_dve50a8600.json @@ -0,0 +1,24 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "completion_minutes", + "cycle", + "cycle_active", + "delay_start_hours", + "diagnosis", + "dry_level", + "dry_time", + "dryer_type", + "energy_kwh", + "finish_time", + "firmware_update", + "job_beginning_status", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "wrinkle_prevent" + ] +} diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 656f937..05cbcf6 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -111,6 +111,27 @@ class TestWasherRegistry: assert href in registry.capabilities, f"{href} missing from washer registry" +class TestConsumerModelKey: + def test_finds_key_in_last_segment(self): + from custom_components.localthings.registry.by_type import _consumer_model_key + assert _consumer_model_key('DA_WM_TP1_21_COMMON_WW5000C') == 'washer' + + def test_finds_key_before_a_trailing_unrecognized_segment(self): + """Issue #79: 'DVE50A8800_8600' pairs two model numbers -- the real + consumer token is the second-to-last segment, not the last.""" + from custom_components.localthings.registry.by_type import _consumer_model_key + assert _consumer_model_key( + 'DA_WM_TP1_21_COMMON_DVE50A8800_8600/DC92-02835A_0080') == 'dryer' + + def test_ignores_everything_after_first_slash(self): + from custom_components.localthings.registry.by_type import _consumer_model_key + assert _consumer_model_key('DA_WM_TP1_21_COMMON_WW5000C/DW9000_board') == 'washer' + + def test_none_when_no_segment_matches(self): + from custom_components.localthings.registry.by_type import _consumer_model_key + assert _consumer_model_key('ARTIK051_DONGLE_REF') is None + + class TestForDeviceByModel: """Fallback device-type detection for hardware without oneUiVersion.""" @@ -142,6 +163,20 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'dryer' + def test_dryer_dve50a8600_paired_model_numbers_in_description(self): + """Issue #79: description pairs two model numbers + ('..._DVE50A8800_8600/DC92-...'), so the 'DV' consumer token is one + segment before the literal last segment ('8600', which has no + recognizable prefix on its own). The old last-segment-only check + fell through to 'unknown' here.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'DA_WM_TP1_21_COMMON|20286441|300000010015110002A3031700000000', + 'DA_WM_TP1_21_COMMON_DVE50A8800_8600/DC92-02835A_0080', + ) + assert reg is not None + assert reg.name == 'dryer' + def test_dishwasher_not_misdetected_as_washer(self): """Dishwasher's modelNum contains the substring 'WW' -- must not be misrouted by a naive substring match.""" diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 56ba878..b84f39b 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -73,6 +73,23 @@ def test_registry_reproduces_golden_state_keys_for_dryer(): ) +def test_registry_reproduces_golden_state_keys_for_dryer_dve50a8600(): + """DVE50A8600V/A3 (issue #79) -- description pairs two model numbers + ('..._DVE50A8800_8600/...'), so the true 'DV' consumer-model token sits + one segment before the actual last segment ('8600'). The old + last-segment-only check missed it and fell back to 'unknown'; resolved + via _consumer_model_key scanning segments from the end.""" + from tests.conftest import _load_device + resources = _load_device('dryer_dve50a8600') + golden = json.loads((GOLDEN / 'dryer_dve50a8600.json').read_text()) + state_keys = _new_state_keys('dryer_dve50a8600', 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(): from tests.conftest import _load_device resources = _load_device('airconditioner') From 5d7789eb4eaf485ff9c112e533627d35da43c705 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 00:47:16 +0000 Subject: [PATCH 06/11] Add confirmed washer/dryer cycle code labels (issue #80) Five raw course codes were rendering unlabeled because no translation entry existed for them, confirmed by the reporter selecting each cycle on the physical appliance and reading back the raw code from the entity's state: - washer_cycle_table_02: '52' Eco Cold, '54' Towels, '60' Self Clean+ (a WF50A8600AV/US). '54' shares a display name with the existing '24' Towels -- a different code on the same table legitimately landing on the same label, matching the existing '21'/'65' Colors and '27'/'5E' Rinse+Spin pairs, not a duplicate-in-error. - dryer_cycle_table_03: '01' Normal, '06' Time dry (a DVE50A8600V/A3, the same model added in the previous commit's detection fix). No code changes -- select.py already derives which raw values it normalizes from the shipped catalog, so labelling a code is purely a translations/en.json (mirrored to nl.json) addition. --- .../localthings/registry/capabilities/dryer.py | 5 ++++- .../localthings/registry/capabilities/washer.py | 8 ++++++++ .../localthings/translations/en.json | 5 +++++ .../localthings/translations/nl.json | 5 +++++ tests/test_translations.py | 15 +++++++++++++++ 5 files changed, 37 insertions(+), 1 deletion(-) diff --git a/custom_components/localthings/registry/capabilities/dryer.py b/custom_components/localthings/registry/capabilities/dryer.py index f1fcb70..8d5224c 100644 --- a/custom_components/localthings/registry/capabilities/dryer.py +++ b/custom_components/localthings/registry/capabilities/dryer.py @@ -42,7 +42,10 @@ DRYER_SETTINGS = Capability( # under entity.select.dryer_cycle (Table_03, DV5000-class, captured # 2026-05-29). Codes 0x21 and 0x4C appear in the issue #14 DV90BB5245AES1 # editCourseList but aren't identified yet -- they render as the raw code -# until named. The /st/dryercourse/vs/0 resource re-encodes the same selected +# until named. Codes '01' Normal and '06' Time dry were confirmed on a +# DVE50A8600V/A3 (also Table_03) by selecting each cycle on the physical +# appliance and reading back the raw code from the entity's state (issue +# #80). The /st/dryercourse/vs/0 resource re-encodes the same selected # course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0 # is ignored for washers. DRYER_COURSE = Capability( diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 615f058..a006c16 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -45,6 +45,14 @@ from .laundry import ( # (issue #22). Combo units carry their own course set, so these codes # don't imply anything about '1F' on a plain washer. # +# Three more -- '52' Eco Cold, '54' Towels, '60' Self Clean+ -- came from a +# WF50A8600AV/US, verified directly rather than by inference: the reporter +# selected each cycle on the physical appliance and read back the resulting +# raw code from the cycle_select entity's state (issue #80). '54' shares a +# display name with the existing '24' Towels -- a different code on a +# different course table legitimately landing on the same label, not a typo +# (same pattern as '21'/'65' Colors and '27'/'5E' Rinse+Spin above). +# # No static fallback list of those codes is kept here, deliberately: other # washer models have a different actual course set (a second dump's active # course, '65', isn't even in the list above; models with 'AI Wash'/'Mixed diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index f990cc6..ac199df 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -201,6 +201,8 @@ "dryer_cycle_table_03": { "name": "Cycle", "state": { + "01": "Normal", + "06": "Time dry", "16": "Cotton", "18": "Synthetics", "19": "Delicates", @@ -389,10 +391,13 @@ "37": "Air Wash", "38": "Cotton Dry", "39": "Synthetics Dry", + "52": "Eco Cold", "53": "Heavy Duty", + "54": "Towels", "55": "Activewear", "57": "Delicate", "5e": "Rinse+Spin", + "60": "Self Clean+", "65": "Colors", "66": "Denim", "7c": "Whites", diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index c922d82..9a72317 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -201,6 +201,8 @@ "dryer_cycle_table_03": { "name": "Programma", "state": { + "01": "Normaal", + "06": "Tijdprogramma", "16": "Katoen", "18": "Synthetisch", "19": "Fijne was", @@ -389,10 +391,13 @@ "37": "Air Wash", "38": "Katoen drogen", "39": "Synthetisch drogen", + "52": "Eco koud", "53": "Intensief", + "54": "Handdoeken", "55": "Sportkleding", "57": "Fijn", "5e": "Spoelen+centrifugeren", + "60": "Zelfreinigend+", "65": "Bonte was", "66": "Spijkergoed", "7c": "Witte was", diff --git a/tests/test_translations.py b/tests/test_translations.py index c360ffd..0fa9782 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -168,3 +168,18 @@ def test_all_entity_state_translation_keys_are_lowercase(): for translation in platform.values(): for state_key in translation.get("state", {}): assert state_key == state_key.lower() + + +def test_issue_80_cycle_codes_are_labelled(): + """Confirmed by the reporter selecting each cycle on the physical + appliance and reading back the raw code from the entity's state -- + regression-lock those five mappings against accidental removal.""" + select_strings = _load("en")["entity"]["select"] + washer_state = select_strings["washer_cycle_table_02"]["state"] + assert washer_state["52"] == "Eco Cold" + assert washer_state["54"] == "Towels" + assert washer_state["60"] == "Self Clean+" + + dryer_state = select_strings["dryer_cycle_table_03"]["state"] + assert dryer_state["01"] == "Normal" + assert dryer_state["06"] == "Time dry" From 2c9fda3db15f4923d3e25d618e30443cb9ff280a Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 01:00:05 +0000 Subject: [PATCH 07/11] Catch the 'Nothing(SVC)' placeholder serial in both fallback sites (issue #83) The ARTIK051_DONGLE_REF firmware family reports the literal string "Nothing(SVC)" as serialNum on every unit -- non-empty, so the existing `if not serial` checks in config_flow.py's _probe_and_validate and coordinator.py's _run_discovery don't catch it. Two such appliances on one install (a fridge and a freezer, each its own dongle) then collide: config_flow gives both the same unique_id and rejects the second as "already configured" (bug 2), and even once that's worked around, device_serial feeds every entity's unique_id too, so the second appliance's entities get silently dropped with "does not generate unique IDs" log lines (bug 4). Add _is_placeholder_serial (duplicated in both modules rather than imported, to avoid pulling config_flow into the runtime coordinator's import graph or vice versa for a two-line check) and treat it the same as an empty serial: fall back to host/port. Type detection (bug 1) and the door sensor's field-name gap (bug 3), also reported in this issue, are already fixed via the claude/device-support-issue-77-freezer branch, which hit the same ARTIK051_DONGLE_REF family from a different report -- not duplicated here. --- custom_components/localthings/config_flow.py | 14 +++++++++++- custom_components/localthings/coordinator.py | 18 ++++++++++++++- tests/localthings/test_config_flow.py | 16 ++++++++++++++ tests/localthings/test_coordinator.py | 23 ++++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 664f170..37a8595 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -197,6 +197,18 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]: return _order_candidates(live) +def _is_placeholder_serial(serial: str) -> bool: + """True for a non-empty serialNum that isn't actually a real identity. + + The ARTIK051_DONGLE_REF firmware family reports the literal string + 'Nothing(SVC)' for every unit -- non-empty, so the plain `if not + serial` check here (and the equivalent one in coordinator.py's + `_run_discovery`) doesn't catch it, and two such units get the same + config-entry unique_id / entity unique_ids and collide (issue #83). + """ + return serial.strip().lower().startswith('nothing') + + def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: """Fetch UUID, mint leaf cert, probe each port. Returns config entry data dict.""" import cbor2 @@ -261,7 +273,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: .get('/information/vs/0', {}) .get('x.com.samsung.da.serialNum', '') ) - if not serial: + if not serial or _is_placeholder_serial(serial): serial = f"{host}:{port}" one_ui_version = ( resources diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 171630f..643b822 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -49,6 +49,22 @@ class _NoOpDescriptor: _RECOVERY_RETRY_S = 600.0 # re-attempt observe mode this often while polling +def _is_placeholder_serial(serial: str) -> bool: + """True for a non-empty serialNum that isn't actually a real identity. + + The ARTIK051_DONGLE_REF firmware family reports the literal string + 'Nothing(SVC)' for every unit -- non-empty, so the plain `if not + serial` check below doesn't catch it, and `device_serial` feeds both + the HA device-registry identifier and every entity's unique_id + (entity.py), so two such units on the same install silently collide + and the second one's entities get dropped (issue #83). Mirrors the + identical helper in config_flow.py's `_probe_and_validate` -- kept + separate rather than imported to avoid pulling the config-flow module + into the runtime coordinator's import graph for a two-line check. + """ + return serial.strip().lower().startswith('nothing') + + class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Manages one Samsung appliance: session, discovery, polling.""" @@ -312,7 +328,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._unbound_hrefs = unbound serial = info.get('x.com.samsung.da.serialNum', '') - if not serial: + if not serial or _is_placeholder_serial(serial): serial = self._entry.data[CONF_HOST] self.device_serial = serial diff --git a/tests/localthings/test_config_flow.py b/tests/localthings/test_config_flow.py index 0f4fee5..20f4270 100644 --- a/tests/localthings/test_config_flow.py +++ b/tests/localthings/test_config_flow.py @@ -508,3 +508,19 @@ async def test_options_flow_finish_preserves_existing_options( assert result['type'] == FlowResultType.CREATE_ENTRY assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True + + +def test_is_placeholder_serial_catches_nothing_svc(): + """Issue #83: the ARTIK051_DONGLE_REF firmware family reports the + literal string 'Nothing(SVC)' as serialNum on every unit -- non-empty, + so it must be caught by name, not by the plain `if not serial` check.""" + from custom_components.localthings.config_flow import _is_placeholder_serial + assert _is_placeholder_serial('Nothing(SVC)') is True + assert _is_placeholder_serial('nothing(svc)') is True + assert _is_placeholder_serial(' Nothing(SVC) ') is True + + +def test_is_placeholder_serial_accepts_real_serials(): + from custom_components.localthings.config_flow import _is_placeholder_serial + assert _is_placeholder_serial('0A1B2C3D4E5F') is False + assert _is_placeholder_serial('') is False diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 0a1b420..bc5f343 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -136,6 +136,29 @@ def test_run_discovery_detects_washer_via_model_fallback( assert coordinator.device_type_name == 'washer' +def test_run_discovery_falls_back_to_host_for_placeholder_serial( + hass: HomeAssistant, mock_entry +) -> None: + """Issue #83: the ARTIK051_DONGLE_REF firmware family reports the + literal string 'Nothing(SVC)' as serialNum on every unit. Left as-is, + two such units get the same device_serial (which feeds both the HA + device-registry identifier and every entity's unique_id), so the + second one's entities silently collide and get dropped. It must be + treated the same as an empty serial and fall back to the host.""" + resources = { + '/information/vs/0': { + 'x.com.samsung.da.modelNum': + 'ARTIK051_DONGLE_REF|00127641|00080020001430300100000000000000', + 'x.com.samsung.da.description': 'ARTIK_REF_17K', + 'x.com.samsung.da.serialNum': 'Nothing(SVC)', + }, + '/otninformation/vs/0': {}, + } + coordinator = LocalThingsCoordinator(hass, mock_entry) + coordinator._run_discovery(resources) + assert coordinator.device_serial == mock_entry.data[CONF_HOST] + + def test_run_discovery_detects_cooktop_via_resource_signature( hass: HomeAssistant, mock_entry ) -> None: From b47eeaf7bcd9a56e66cd629613e8b9245fbe2be0 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 01:17:45 +0000 Subject: [PATCH 08/11] Add device support for Bespoke Window AC (issue #87) The AW06C7155EWAZ window air conditioner (modelNum TP1X_DA_AC_WAC_01001_0000) reports no oneUiVersion and uses the '_WAC_' (Window Air Conditioner) modelNum token instead of the '_RAC_'/'_PRAC_' tokens already handled by for_device_by_model. Add a fallback rule for that token, routing it to the existing airconditioner registry. The device's resource surface (mode/convenient/wind/temperature/power/ filter/humidity) is already fully modeled by the airconditioner capability set, so this is purely a detection-routing fix -- confirmed against the issue #87 diagnostics dump with zero unbound hrefs. --- .../localthings/registry/by_type/__init__.py | 9 + .../airconditioner_window_ac_device.json | 523 ++++++++++++++++++ .../golden/airconditioner_window_ac.json | 18 + tests/test_by_type.py | 13 + tests/test_golden_regression.py | 17 + 5 files changed, 580 insertions(+) create mode 100644 tests/fixtures/airconditioner_window_ac_device.json create mode 100644 tests/fixtures/golden/airconditioner_window_ac.json diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 7f6b864..73a6c95 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -122,6 +122,15 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # resource (see airconditioner.py's _AC_IGNORED). if key is None and '-CAWW-' in (model_num or '').upper(): key = 'airconditioner' + # Window air conditioners (e.g. TP1X_DA_AC_WAC_01001_0000, issue #87) + # report no oneUiVersion and carry the '_WAC_' (Window Air Conditioner) + # token instead of '_RAC_'/'_PRAC_'. Same TP1X-class resource surface + # as the room-AC models above (mode/convenient/wind/temperature/power/ + # filter/humidity all confirmed against the issue #87 dump binding + # cleanly against the existing airconditioner registry once routed + # here) -- no WAC-specific resources needed. + if key is None and '_WAC_' in (model_num or ''): + key = 'airconditioner' # Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no # oneUiVersion either, and carry the '_TVTL_' board-family token. if key is None and '_TVTL_' in (model_num or ''): diff --git a/tests/fixtures/airconditioner_window_ac_device.json b/tests/fixtures/airconditioner_window_ac_device.json new file mode 100644 index 0000000..c02ae37 --- /dev/null +++ b/tests/fixtures/airconditioner_window_ac_device.json @@ -0,0 +1,523 @@ +{ + "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": "2026-07-26T06:26:40", + "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": "2026-07-26T06:26:40", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "8D0D00B4012C3F21064804000000", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "2647000000", + "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": 244.0 + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "244.000000", + "x.com.samsung.da.cumulativePower": "11001", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePowerType": "total" + } + }, + { + "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": "20", + "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": "60" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA_AC_WAC_01001_0000|10254341|60050000001711014E00402000002000", + "x.com.samsung.da.description": "TP1X_DA_AC_WAC_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": "AW0", + "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": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02545A260601", + "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": "02543A24061800,FFFFFFFFFFFFFF", + "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": "02580A10000100,FFFFFFFFFFFFFF" + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Touch IC", + "x.com.samsung.da.number": "02553A23031600,FFFFFFFFFFFFFF" + } + ] + } + }, + { + "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", + "Quiet", + "Nano", + "NanoSleep" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "AIComfort", + "Cool", + "Dry", + "Fan" + ], + "x.com.samsung.da.modes": [ + "Cool" + ], + "x.com.samsung.da.options": [ + "OnTimer_0", + "OffTimer_0", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_230", + "Volume_Mute", + "OutdoorTemp_97", + "CoolCapa_0", + "StopAutoClean_Idle", + "Autoclean_Off", + "OptionCode_51320", + "ExtendOptionCode_230093", + "RacInfo_None", + "UpdateAllow_NotAllowed", + "DurationOn_93", + "WelcomeCoolingState_Off" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.settingStatus": "Off", + "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": "AWA-KR-TP1-23-AW7000", + "versions": [ + "15260601" + ], + "visVersion": "260601" + }, + { + "type": "Micom", + "modelId": "042010254341FFFFFFFF", + "versions": [ + "24061800", + "FFFFFFFF" + ], + "visVersion": "240618" + }, + { + "type": "Micom", + "modelId": "042010258041FFFFFFFF", + "versions": [ + "10000100", + "FFFFFFFF" + ], + "visVersion": "100001" + }, + { + "type": "Micom", + "modelId": "042010255341FFFFFFFF", + "versions": [ + "23031600", + "FFFFFFFF" + ], + "visVersion": "230316" + } + ] + } + }, + { + "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": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "8DFFFFFFFF121E121E0000FFFFFFFFFFFF01000000011F0000000000000000001F8C00", + "x.com.samsung.da.id": "WAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.start": "Cancel", + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.result": "NA", + "x.com.samsung.da.supportedActions": [ + "Start", + "Cancel" + ], + "x.com.samsung.da.error": [ + "DA_ERROR_NONE" + ] + } + }, + { + "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": 30.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 16, + 30 + ], + "units": "C", + "temperature": 30.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": "30.0", + "x.com.samsung.da.current": "30.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": "/welcome/temperature/vs/0", + "rep": { + "operatingStatus": "None", + "requestId": "0000" + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Fix", + "x.com.samsung.da.supportedModes": [ + "Fix", + "Left_And_Right" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "1", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3", + "4", + "5" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "1", + "2", + "3", + "4", + "5" + ] + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "**REDACTED**" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner_window_ac.json b/tests/fixtures/golden/airconditioner_window_ac.json new file mode 100644 index 0000000..ea27504 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_window_ac.json @@ -0,0 +1,18 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "alarm_code", + "auto_clean", + "climate", + "display_light", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "mute_once", + "power_watts", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status" + ] +} diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 656f937..0aed644 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -190,6 +190,19 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'airconditioner' + def test_airconditioner_via_wac_token(self): + """Issue #87: a Bespoke Window AC (AW06C7155EWAZ) reports no + oneUiVersion and a modelNum carrying the '_WAC_' (Window Air + Conditioner) token instead of '_RAC_'/'_PRAC_'; falls back to the + '_WAC_' token in modelNum.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'TP1X_DA_AC_WAC_01001_0000|40460041|50030018001611020A00000000000000', + 'AW06C7155EWAZ', + ) + assert reg is not None + assert reg.name == 'airconditioner' + def test_cooktop_via_legacy_model_description(self): """Older cooktops identify themselves as ARTIK051_GLOBAL_COOKTOP.""" from custom_components.localthings.registry.by_type import for_device_by_model diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 56ba878..3d8d413 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -231,6 +231,23 @@ def test_registry_reproduces_golden_state_keys_for_caww_tp2(): ) +def test_registry_reproduces_golden_state_keys_for_window_ac(): + """TP1X_DA_AC_WAC_01001_0000 (issue #87, Bespoke Window AC AW06C7155EWAZ) + reports no oneUiVersion and carries the '_WAC_' (Window Air Conditioner) + token instead of '_RAC_'/'_PRAC_'; resolved via the '_WAC_' modelNum + fallback in for_device_by_model. Otherwise binds cleanly against the + existing airconditioner registry with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_window_ac') + golden = json.loads((GOLDEN / 'airconditioner_window_ac.json').read_text()) + state_keys = _new_state_keys('airconditioner_window_ac', 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_tp1x_rac(): """TP1X_DA-AC-RAC-01001_0000 (issue #38) -- fuller RAC board with display light, self-check, mute-once, and a current-limit setting.""" From 8c3250b16329f8531b00bd23a76d5e64719ff6f3 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 01:43:22 +0000 Subject: [PATCH 09/11] Map AC's AIComfort mode to HVACMode.AUTO (issue #93) A-CAWW-TP2-20-COMMON (and likely other CAWW/TP2X-class boards) reports 'AIComfort' as a distinct entry in /mode/vs/0's supportedModes, alongside 'Auto' (already mapped to HEAT_COOL). _read_modes() silently drops any code missing from _DEVICE_TO_HVAC, so AIComfort was unreachable -- one of HA's five other AC hvac_modes, unused by this device family until now. The other two gaps in issue #93 are already addressed elsewhere and not duplicated here: - Fan-only via the 'Fan' device code, and the WindFree/LongWind/ NanoSleep preset codes, are covered by the still-open PR #91, which replaces the static preset table with a fully dynamic resolver over the device's own supportedModes. - The 'Left_And_Right' -> horizontal swing mapping is already on the still-open claude/device-support-issue-75-windfree-ac branch. --- custom_components/localthings/climate.py | 5 ++++ tests/test_climate_ac_modes.py | 34 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 tests/test_climate_ac_modes.py diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 79d38af..511c712 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -65,6 +65,11 @@ _DEVICE_TO_HVAC: dict[str, HVACMode] = { 'Wind': HVACMode.FAN_ONLY, 'Auto': HVACMode.HEAT_COOL, 'Heat': HVACMode.HEAT, + # AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON) -- a + # separate device code from 'Auto' above (both can appear in the same + # unit's supportedModes), so it needs its own entry rather than reusing + # 'Auto'. HVACMode.AUTO is otherwise unused by this map. + 'AIComfort': HVACMode.AUTO, } _HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()} diff --git a/tests/test_climate_ac_modes.py b/tests/test_climate_ac_modes.py new file mode 100644 index 0000000..62c3489 --- /dev/null +++ b/tests/test_climate_ac_modes.py @@ -0,0 +1,34 @@ +"""Tests for the AC HVAC-mode device<->HA map in climate.py (issue #93). + +`_DEVICE_TO_HVAC`/`_HVAC_TO_DEVICE` are plain module-level dicts with no +coordinator/entity dependency, so -- like `_temps_vs_item` in +test_climate_temperature_fallback.py -- they're testable directly. +""" +from homeassistant.components.climate import HVACMode + +from custom_components.localthings.climate import _DEVICE_TO_HVAC, _HVAC_TO_DEVICE + + +def test_aicomfort_maps_to_auto(): + """A-CAWW-TP2-20-COMMON (issue #93) reports 'AIComfort' in its + supportedModes alongside 'Auto' -- a separate AI-driven auto-comfort + mode, distinct from the existing 'Auto' -> HEAT_COOL entry.""" + assert _DEVICE_TO_HVAC['AIComfort'] == HVACMode.AUTO + + +def test_auto_still_maps_to_heat_cool(): + """'AIComfort' is additive -- the existing 'Auto' -> HEAT_COOL mapping + (a different device code) is unchanged.""" + assert _DEVICE_TO_HVAC['Auto'] == HVACMode.HEAT_COOL + + +def test_hvac_auto_writes_back_aicomfort(): + """Reverse map: selecting HA's Auto hvac_mode writes the 'AIComfort' + device code, not 'Auto' (which is reserved for HEAT_COOL).""" + assert _HVAC_TO_DEVICE[HVACMode.AUTO] == 'AIComfort' + + +def test_fan_only_still_reachable_via_wind(): + """Guard against regressing the existing 'Wind' -> FAN_ONLY entry while + editing this map.""" + assert _DEVICE_TO_HVAC['Wind'] == HVACMode.FAN_ONLY From 45931332d4b0c150c1d006d68af7b0b314c62cce Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 02:01:51 +0000 Subject: [PATCH 10/11] Rework AIComfort as an HVACMode.AUTO + preset overlay, add unmapped-mode warning (issue #93) AIComfort isn't a distinct thermodynamic operation like Cool/Dry/Heat -- it's an AI-driven overlay on top of the device's own 'Auto' behavior, confirmed by A-CAWW-TP2-20-COMMON reporting both 'Auto' and 'AIComfort' as separate, mutually-exclusive entries in /mode/vs/0's supportedModes. Modeled the idiomatic HA way instead of a flat _DEVICE_TO_HVAC entry: hvac_mode reports AUTO and a new 'ai_comfort' preset carries the distinction. Entered/left only via the preset (writes the primary mode resource, not the convenient one) -- there's no dedicated HVACMode value for it, so it's not offered in the hvac_mode dropdown directly. Also adds a once-per-(href, code) warning log when a device-reported mode has no entry in the relevant map, so a future gap like this one surfaces in the log instead of silently vanishing -- the exact failure mode issue #93 called out ("this class of gap is invisible without diffing against supportedModes"). --- custom_components/localthings/climate.py | 76 ++++++++++++++++--- .../localthings/translations/en.json | 1 + .../localthings/translations/nl.json | 1 + tests/test_climate_ac_modes.py | 42 +++++----- 4 files changed, 93 insertions(+), 27 deletions(-) diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 511c712..032909e 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -20,6 +20,8 @@ back for, power, mode, temperature and wind resources alike. """ from __future__ import annotations +import logging + from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -53,6 +55,8 @@ from .const import DOMAIN from .coordinator import LocalThingsCoordinator from .entity import LocalThingsEntity, _is_included +_LOGGER = logging.getLogger(__name__) + _MODES_FIELD = 'x.com.samsung.da.modes' _SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes' @@ -65,14 +69,22 @@ _DEVICE_TO_HVAC: dict[str, HVACMode] = { 'Wind': HVACMode.FAN_ONLY, 'Auto': HVACMode.HEAT_COOL, 'Heat': HVACMode.HEAT, - # AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON) -- a - # separate device code from 'Auto' above (both can appear in the same - # unit's supportedModes), so it needs its own entry rather than reusing - # 'Auto'. HVACMode.AUTO is otherwise unused by this map. - 'AIComfort': HVACMode.AUTO, } _HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()} +# AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON). Not a flat +# _DEVICE_TO_HVAC entry: 'AIComfort' isn't a distinct thermodynamic operation +# like Cool/Dry/Heat, it's an AI overlay on top of the device's own 'Auto' +# behavior -- confirmed by this unit reporting both 'Auto' and 'AIComfort' as +# separate, mutually-exclusive entries in /mode/vs/0's supportedModes. Modeled +# the idiomatic HA way instead: hvac_mode reports AUTO (same as plain 'Auto' +# would if it were ever mapped there) and a dedicated 'ai_comfort' preset +# carries the distinction a bare hvac_mode can't. Not reachable via +# async_set_hvac_mode -- entered/left only through the preset, since there's +# no dedicated HVACMode value for it to write back to. +_AI_COMFORT_MODE = 'AIComfort' +PRESET_AI_COMFORT = 'ai_comfort' + # Fan (wind strength): device codes "0".."4" -> HA standard fan constants where # a clean match exists so they auto-localize; "turbo" is custom (translated). _DEVICE_TO_FAN: dict[str, str] = { @@ -169,6 +181,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): | ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF ) + # (href, raw device code) pairs already logged by _warn_unmapped -- + # these properties are read on every coordinator refresh, so an + # un-deduped warning would spam the log for any device with a + # genuinely unrecognized code. + self._warned_unmapped: set[tuple[str, str]] = set() # -- resource helpers --------------------------------------------------- @@ -185,13 +202,34 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): def _supported(self, href: str) -> list[str]: return list(self._rep(href).get(_SUPPORTED_FIELD) or []) + def _warn_unmapped(self, href: str, code: str) -> None: + """Log once per (href, code) when a device-reported mode has no + entry in the relevant device<->HA map, so a real device gap surfaces + in the log instead of silently vanishing (issue #93).""" + key = (href, code) + if key in self._warned_unmapped: + return + self._warned_unmapped.add(key) + _LOGGER.warning( + "%s: device mode %r on %s has no HA mapping and was dropped; " + "please file an issue with your diagnostics dump", + self.entity_id, code, href, + ) + def _read_mode(self, href: str, mapping: dict): """Current mode of a wind/convenient resource, mapped to its HA value.""" - return mapping.get(_first(self._rep(href).get(_MODES_FIELD))) + raw = _first(self._rep(href).get(_MODES_FIELD)) + if raw is not None and raw not in mapping: + self._warn_unmapped(href, raw) + return mapping.get(raw) def _read_modes(self, href: str, mapping: dict) -> list[str]: """Supported modes of a resource, mapped to HA values (unknowns dropped).""" - return [mapping[c] for c in self._supported(href) if c in mapping] + supported = self._supported(href) + for c in supported: + if c not in mapping: + self._warn_unmapped(href, c) + return [mapping[c] for c in supported if c in mapping] # -- temperature -------------------------------------------------------- @@ -255,14 +293,23 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): if not self._is_on(): return HVACMode.OFF device = _first(self._rep(MODE_HREF).get(_MODES_FIELD)) + if device == _AI_COMFORT_MODE: + return HVACMode.AUTO + if device is not None and device not in _DEVICE_TO_HVAC: + self._warn_unmapped(MODE_HREF, device) return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO) @property def hvac_modes(self) -> list[HVACMode]: modes = [HVACMode.OFF] for m in self._supported(MODE_HREF): + if m == _AI_COMFORT_MODE: + continue mapped = _DEVICE_TO_HVAC.get(m) - if mapped is not None and mapped not in modes: + if mapped is None: + self._warn_unmapped(MODE_HREF, m) + continue + if mapped not in modes: modes.append(mapped) return modes @@ -286,11 +333,16 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): @property def preset_mode(self): + if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) == _AI_COMFORT_MODE: + return PRESET_AI_COMFORT return self._read_mode(CONVENIENT_HREF, _DEVICE_TO_PRESET) @property def preset_modes(self) -> list[str]: - return self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET) + modes = self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET) + if _AI_COMFORT_MODE in self._supported(MODE_HREF): + modes.append(PRESET_AI_COMFORT) + return modes # -- writes ------------------------------------------------------------- @@ -329,4 +381,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode) async def async_set_preset_mode(self, preset_mode: str) -> None: + if preset_mode == PRESET_AI_COMFORT: + # Writes the primary mode resource, not the convenient one -- + # 'AIComfort' lives in /mode/vs/0 alongside Cool/Dry/Auto, not in + # /mode/convenient/vs/0 with Quiet/Smart/Speed/Sleep. + await self.coordinator.async_send_command(self._bound, ('mode', _AI_COMFORT_MODE)) + return await self._set_mapped('preset', _PRESET_TO_DEVICE, preset_mode) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index f990cc6..3145d4f 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -95,6 +95,7 @@ }, "preset_mode": { "state": { + "ai_comfort": "AI Comfort", "quiet": "Quiet", "smart": "Smart", "speed": "Speed" diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index c922d82..9e0f20b 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -95,6 +95,7 @@ }, "preset_mode": { "state": { + "ai_comfort": "AI-comfort", "quiet": "Stil", "smart": "Slim", "speed": "Snel" diff --git a/tests/test_climate_ac_modes.py b/tests/test_climate_ac_modes.py index 62c3489..41c5bfe 100644 --- a/tests/test_climate_ac_modes.py +++ b/tests/test_climate_ac_modes.py @@ -1,34 +1,40 @@ -"""Tests for the AC HVAC-mode device<->HA map in climate.py (issue #93). +"""Tests for the AC HVAC-mode/preset device<->HA maps in climate.py (issue #93). -`_DEVICE_TO_HVAC`/`_HVAC_TO_DEVICE` are plain module-level dicts with no -coordinator/entity dependency, so -- like `_temps_vs_item` in -test_climate_temperature_fallback.py -- they're testable directly. +Module-level dicts/constants with no coordinator/entity dependency, so -- +like `_temps_vs_item` in test_climate_temperature_fallback.py -- they're +testable directly. """ from homeassistant.components.climate import HVACMode -from custom_components.localthings.climate import _DEVICE_TO_HVAC, _HVAC_TO_DEVICE - - -def test_aicomfort_maps_to_auto(): - """A-CAWW-TP2-20-COMMON (issue #93) reports 'AIComfort' in its - supportedModes alongside 'Auto' -- a separate AI-driven auto-comfort - mode, distinct from the existing 'Auto' -> HEAT_COOL entry.""" - assert _DEVICE_TO_HVAC['AIComfort'] == HVACMode.AUTO +from custom_components.localthings.climate import ( + _AI_COMFORT_MODE, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE, PRESET_AI_COMFORT, +) def test_auto_still_maps_to_heat_cool(): - """'AIComfort' is additive -- the existing 'Auto' -> HEAT_COOL mapping - (a different device code) is unchanged.""" + """'Auto' is unchanged -- AIComfort is handled separately, not folded + into this map.""" assert _DEVICE_TO_HVAC['Auto'] == HVACMode.HEAT_COOL -def test_hvac_auto_writes_back_aicomfort(): - """Reverse map: selecting HA's Auto hvac_mode writes the 'AIComfort' - device code, not 'Auto' (which is reserved for HEAT_COOL).""" - assert _HVAC_TO_DEVICE[HVACMode.AUTO] == 'AIComfort' +def test_aicomfort_not_in_flat_hvac_map(): + """AIComfort isn't a flat _DEVICE_TO_HVAC entry -- it's an AI overlay on + top of 'Auto', modeled as hvac_mode=AUTO + a dedicated preset instead of + a distinct HVACMode value (see the climate.py module comment).""" + assert _AI_COMFORT_MODE not in _DEVICE_TO_HVAC + + +def test_hvac_auto_not_writable_via_hvac_mode(): + """HVACMode.AUTO has no _DEVICE_TO_HVAC entry, so it's unreachable via + async_set_hvac_mode -- entered/left only through the ai_comfort preset.""" + assert HVACMode.AUTO not in _HVAC_TO_DEVICE def test_fan_only_still_reachable_via_wind(): """Guard against regressing the existing 'Wind' -> FAN_ONLY entry while editing this map.""" assert _DEVICE_TO_HVAC['Wind'] == HVACMode.FAN_ONLY + + +def test_preset_ai_comfort_constant(): + assert PRESET_AI_COMFORT == 'ai_comfort' From 48b34c9e31f3a3e009f7703ff39f8ed1f857e076 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sun, 26 Jul 2026 21:53:06 -0500 Subject: [PATCH 11/11] Delete test_issue_80_cycle_codes_are_labelled Removed test for issue 80 regarding cycle codes. --- tests/test_translations.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/tests/test_translations.py b/tests/test_translations.py index 0fa9782..0d3c7c3 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -168,18 +168,4 @@ def test_all_entity_state_translation_keys_are_lowercase(): for translation in platform.values(): for state_key in translation.get("state", {}): assert state_key == state_key.lower() - - -def test_issue_80_cycle_codes_are_labelled(): - """Confirmed by the reporter selecting each cycle on the physical - appliance and reading back the raw code from the entity's state -- - regression-lock those five mappings against accidental removal.""" - select_strings = _load("en")["entity"]["select"] - washer_state = select_strings["washer_cycle_table_02"]["state"] - assert washer_state["52"] == "Eco Cold" - assert washer_state["54"] == "Towels" - assert washer_state["60"] == "Self Clean+" - - dryer_state = select_strings["dryer_cycle_table_03"]["state"] - assert dryer_state["01"] == "Normal" - assert dryer_state["06"] == "Time dry" +