From 6f53b3043bbe3d21eff5cfc5aade8c19b4d63db2 Mon Sep 17 00:00:00 2001 From: themaanda Date: Tue, 21 Jul 2026 16:58:42 -0500 Subject: [PATCH 01/29] Cover TP1X refrigerator native resource mirrors --- .../registry/by_type/refrigerator.py | 2 + .../registry/capabilities/fridge.py | 16 ++ .../golden/refrigerator_tp1x_ref_21k_us.json | 30 +++ .../refrigerator_tp1x_ref_21k_us_device.json | 253 ++++++++++++++++++ tests/test_fridge_capabilities.py | 32 +++ tests/test_golden_regression.py | 14 + 6 files changed, 347 insertions(+) create mode 100644 tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json create mode 100644 tests/fixtures/refrigerator_tp1x_ref_21k_us_device.json diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index ce2f1dc..003eb8d 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -28,11 +28,13 @@ REGISTRY = DeviceRegistry( fridge.BEVERAGE_ZONE, fridge.FIRMWARE_UPDATE, fridge.DEFROST_DELAY, + fridge.DEFROST_DELAY_NATIVE_DUPLICATE, fridge.DEFROST_BLOCK_STATUS, fridge.SELF_CHECK, fridge.DOORS_FALLBACK, fridge.TEMPERATURES_FALLBACK, fridge.ICEMAKER_STATUS_FALLBACK, + fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE, fridge.REFRIGERATION_FALLBACK, ]), pattern_capabilities=[ diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 1f056e7..c788fe1 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -209,6 +209,14 @@ DEFROST_DELAY = Capability( ), ) +# OCF-native boolean mirror of DEFROST_DELAY. The captured TP1X_REF_21K +# firmware publishes the same state on both hrefs, but only the vendor resource +# above has a confirmed write contract. Bind the native mirror without another +# entity so discovery records it as an intentional duplicate. +DEFROST_DELAY_NATIVE_DUPLICATE = Capability( + href='/defrost/delay/0', +) + DEFROST_BLOCK_STATUS = Capability( href='/defrost/block/vs/0', poll_tier='warm', @@ -655,6 +663,14 @@ ICEMAKER_STATUS_FALLBACK = Capability( ), ) +# OCF-native aggregate mirror of ICEMAKER_STATUS_FALLBACK. On the captured +# TP1X_REF_21K it duplicates both the vendor aggregate and the richer per-unit +# /icemaker/one|two/vs/0 resources. Its write contract is not advertised, so +# keep the proven per-unit/vendor controls and bind this as a duplicate only. +ICEMAKER_STATUS_NATIVE_DUPLICATE = Capability( + href='/icemaker/status/0', +) + # OCF-native /refrigeration/0 (issue #7's unbound_hrefs) -- the odd one out # in this section: its three fields duplicate two *different* richer # hrefs (REFRIGERATION's rapidFridge/rapidFreezing and diff --git a/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json new file mode 100644 index 0000000..962e01c --- /dev/null +++ b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json @@ -0,0 +1,30 @@ +{ + "state_keys": [ + "alarm_code", + "autofill", + "cooler_setpoint", + "cooler_temperature", + "defrost_active", + "defrost_delay", + "door_cooler_open", + "door_cvroom_open", + "door_freezer_open", + "energy_kwh", + "filter_status", + "filter_usage", + "firmware_update", + "flex_zone_mode", + "freezer_setpoint", + "freezer_temperature", + "icemaker_one_enabled", + "icemaker_one_making_status", + "icemaker_two_enabled", + "icemaker_two_making_status", + "power_watts", + "rapid_freezing", + "rapid_fridge", + "sabbath_mode", + "selfcheck_result", + "selfcheck_status" + ] +} diff --git a/tests/fixtures/refrigerator_tp1x_ref_21k_us_device.json b/tests/fixtures/refrigerator_tp1x_ref_21k_us_device.json new file mode 100644 index 0000000..13c824e --- /dev/null +++ b/tests/fixtures/refrigerator_tp1x_ref_21k_us_device.json @@ -0,0 +1,253 @@ +{ + "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": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Freezer", + "x.com.samsung.da.desired": "0", + "x.com.samsung.da.current": "0", + "x.com.samsung.da.maximum": "5", + "x.com.samsung.da.minimum": "-8", + "x.com.samsung.da.unit": "Fahrenheit" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Fridge", + "x.com.samsung.da.desired": "39", + "x.com.samsung.da.current": "39", + "x.com.samsung.da.maximum": "44", + "x.com.samsung.da.minimum": "34", + "x.com.samsung.da.unit": "Fahrenheit" + } + ] + } + }, + {"href": "/temperature/current/freezer/0", "rep": {"range": [-8.0, 5.0], "units": "F", "temperature": 0.0}}, + {"href": "/temperature/desired/freezer/0", "rep": {"range": [-8.0, 5.0], "units": "F", "temperature": 0.0}}, + {"href": "/temperature/current/cooler/0", "rep": {"range": [34.0, 44.0], "units": "F", "temperature": 39.0}}, + {"href": "/temperature/desired/cooler/0", "rep": {"range": [34.0, 44.0], "units": "F", "temperature": 39.0}}, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.result": "Success", + "x.com.samsung.da.error": ["DA_ERROR_NONE"], + "x.com.samsung.da.supportedActions": ["Start"] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "63", + "x.com.samsung.da.cumulativePower": "1831899", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPower": "3", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.monthlyConsumption": "52000", + "x.com.samsung.da.thismonthlyConsumption": "38900", + "x.com.samsung.da.cumulativeSavedPower": "0" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2", + "SUPPORT_SABBATH_CONTROL" + ], + "x.com.samsung.da.modes": ["CVN_CONVERTIBLE_ZONE", "CV_FDR_WINE", "WATERFILTER_DISABLE"], + "x.com.samsung.da.supportedOptions": [ + "CV_FDR_WINE", + "CV_FDR_DELI", + "CV_FDR_BEVERAGE", + "CV_FDR_MEAT", + "CV_FDR_SOFT_FREEZER" + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2", + "SUPPORT_SABBATH_CONTROL" + ], + "modes": ["CVN_CONVERTIBLE_ZONE", "CV_FDR_WINE", "WATERFILTER_DISABLE"] + } + }, + { + "href": "/defrost/block/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": ["DEFROST_BLOCK_ON", "DEFROST_BLOCK_OFF"], + "x.com.samsung.da.modes": ["DEFROST_BLOCK_OFF"] + } + }, + {"href": "/sabbath/vs/0", "rep": {"x.com.samsung.da.sabbathMode": "Off"}}, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_REF_21K|00148241|00020850031611204103000021010000", + "x.com.samsung.da.description": "TP1X_REF_21K", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": ["errCode", "dump"], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "512", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "WiFi Module", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "250420", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Micom", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "23071923, FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + {"href": "/file/information/vs/0", "rep": {"x.com.samsung.timeoffset": "-05:00", "x.com.samsung.supprtedtype": 1}}, + { + "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.id": "1", "x.com.samsung.da.description": "Door", "x.com.samsung.da.openState": "Close"}, + {"x.com.samsung.da.id": "2", "x.com.samsung.da.description": "Door", "x.com.samsung.da.openState": "Close"} + ] + } + }, + {"href": "/door/freezer/0", "rep": {"openState": "Close"}}, + {"href": "/door/cooler/0", "rep": {"openState": "Close"}}, + {"href": "/door/cvroom/0", "rep": {"openState": "Close"}}, + {"href": "/configuration/vs/0", "rep": {"x.com.samsung.da.countryCode": "", "x.com.samsung.da.region": ""}}, + {"href": "/defrost/delay/0", "rep": {"value": false}}, + {"href": "/defrost/delay/vs/0", "rep": {"x.com.samsung.da.delayDefrost": "Off"}}, + { + "href": "/defrost/reservation/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Summer Season", + "x.com.samsung.da.startTime": "0000-05-01T15:00:00", + "x.com.samsung.da.period": "04:00:00", + "x.com.samsung.da.endTime": "0000-10-31T00:00:00" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Winter Season", + "x.com.samsung.da.startTime": "0000-11-01T06:00:00", + "x.com.samsung.da.period": "04:00:00", + "x.com.samsung.da.endTime": "0000-04-30T00:00:00" + } + ] + } + }, + {"href": "/icemaker/status/0", "rep": {"status": "On"}}, + {"href": "/icemaker/status/vs/0", "rep": {"x.com.samsung.da.iceMaker": "On"}}, + {"href": "/refrigeration/0", "rep": {"defrost": false, "rapidFreeze": false, "rapidCool": false}}, + { + "href": "/refrigeration/vs/0", + "rep": {"x.com.samsung.da.rapidFridge": "Off", "x.com.samsung.da.rapidFreezing": "Off"} + }, + {"href": "/drlc/0", "rep": {"DRLevel": 0, "override": false}}, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.realSaving": "Off" + } + }, + {"href": "/bespoke/vs/0", "rep": {"x.com.samsung.da.BespokeProduct": "On"}}, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "", + "otnList": [ + {"type": "WIFI", "modelId": "A-RFWW-TP1-22-REV1", "versions": ["20250420"], "visVersion": "250420"}, + {"type": "Micom", "modelId": "024100148241FFFFFFFF", "versions": ["23071923", "FFFFFFFF"], "visVersion": "230719"} + ] + } + }, + { + "href": "/filter/waterfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "31", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterResetType": ["replaceable"] + } + }, + {"href": "/autofill/vs/0", "rep": {"x.com.samsung.da.autofill": "Off"}}, + { + "href": "/icemaker/one/vs/0", + "rep": { + "x.com.samsung.da.iceMaker.name": "CUBED_ICE", + "x.com.samsung.da.iceMaker.state": "On", + "x.com.samsung.da.iceMaker.type": "toggle", + "x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP", + "x.com.samsung.da.iceType.desired": "NORMAL" + } + }, + { + "href": "/icemaker/two/vs/0", + "rep": { + "x.com.samsung.da.iceMaker.name": "ICE_BITES", + "x.com.samsung.da.iceMaker.state": "On", + "x.com.samsung.da.iceMaker.type": "toggle", + "x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP", + "x.com.samsung.da.iceType.desired": "On" + } + }, + {"href": "/energy/ailevel/vs/0", "rep": {"aiLevel": "1", "supportedAiLevel": ["1"]}}, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": ["ble_ocf", null], + "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": "/timezone/vs/0", "rep": {"timezoneid": "America/Chicago", "offset": "-05:00", "DST": "ON"}}, + {"href": "/wirelessinfo/vs/0", "rep": {"macaddressWiFi": "REDACTED", "macaddressBLE": "REDACTED"}}, + {"href": "/quickcontrol/info/vs/0", "rep": {"supportedVersion": "1.0"}} + ] +} diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index a45e7f7..068956d 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -102,3 +102,35 @@ class TestRefrigerationFallback: for key in ('rapid_fridge', 'rapid_freezing'): desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities if e.key == key) assert desc.exists_fn({}, {'/refrigeration/0': {}}) is True + + +class TestTp1xNativeDuplicateResources: + """The US TP1X_REF_21K publishes two native mirrors in addition to the + richer vendor resources. They must count as covered without producing + duplicate entities or guessing unverified write contracts.""" + + def test_duplicate_capabilities_have_no_entities(self): + assert fridge.DEFROST_DELAY_NATIVE_DUPLICATE.entities == () + assert fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE.entities == () + + def test_us_fixture_has_complete_coverage(self): + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import refrigerator + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('refrigerator_tp1x_ref_21k_us') + unbound = [] + bound = discover( + resources, + refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + + assert unbound == [] + state = flatten(bound, resources) + assert state['defrost_delay'] is False + assert 'ice_maker_enabled' not in state + assert state['icemaker_one_enabled'] is True + assert state['icemaker_two_enabled'] is True diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index e9a6cfb..ee05af3 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -81,6 +81,20 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner(): ) +def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_us(): + from tests.conftest import _load_device + resources = _load_device('refrigerator_tp1x_ref_21k_us') + golden = json.loads( + (GOLDEN / 'refrigerator_tp1x_ref_21k_us.json').read_text() + ) + state_keys = _new_state_keys('refrigerator_tp1x_ref_21k_us', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { From 0503bda0f32644c8fa37e0ab08c7aad5fb5baf89 Mon Sep 17 00:00:00 2001 From: themaanda Date: Tue, 21 Jul 2026 16:59:16 -0500 Subject: [PATCH 02/29] Detect WF-series washer models --- .../localthings/registry/by_type/__init__.py | 1 + tests/test_by_type.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 8c9021e..d9b358a 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -68,6 +68,7 @@ def for_device(one_ui_version: str) -> Optional[DeviceRegistry]: _CONSUMER_PREFIX_TO_KEY: dict[str, str] = { 'WW': 'washer', 'WD': 'washer', + 'WF': 'washer', 'DV': 'dryer', 'DW': 'dishwasher', } diff --git a/tests/test_by_type.py b/tests/test_by_type.py index ff2caaa..511aca8 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -180,6 +180,16 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'airconditioner' + def test_washer_wf_prefix(self): + """US front-load washers use the WF consumer-model prefix.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'DA_WM_TP1_21_COMMON|20313741|20010001001611244AA3021700000000', + 'DA_WM_TP1_21_COMMON_WF8900B/DC92-03129A_A0AE', + ) + assert reg is not None + assert reg.name == 'washer' + def test_unknown_model_returns_none(self): from custom_components.localthings.registry.by_type import for_device_by_model reg = for_device_by_model('SOME-UNKNOWN-BOARD', 'SOME-UNKNOWN-BOARD') From c0298d57d9c1d51ece810958f74e66a4301a38e1 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 21 Jul 2026 23:49:30 +0000 Subject: [PATCH 03/29] fix: normalize washer dosing-select codes; stop blocking the loop in diagnostics (#9) Two fixes for issue #9 (WW90T634DHE washer): - washer dosing selects: the four detergent/softener dosing selects read their current value from `LevelCtrl_` (un-padded, e.g. "3") but their options from `SupportedLevelCtrl_` (zero-padded, e.g. "03"). HA's SelectEntity renders a select "unknown" whenever current_option is not in options, so all four sat "unknown" (idle and running) even though every other select worked -- which is why it was only those four. Normalize the current value to the supported code with the same integer value so it matches an option (and its translation); convert back to the device's native un-padded format on write. - diagnostics: pkg_version("smartthings-local") reads package metadata off disk (listdir + open + read_text), tripping HA's event-loop blocking-call detector. Offload it to the executor. Audited the rest of the package: config_flow's socket/crypto and every coordinator DTLS call are already offloaded via async_add_executor_job -- this was the only blocking call left on the loop. Also documents air-conditioner support in the README (device table, capability module list, platform list), missed when that support landed. Updates the washer dosing tests to the corrected value/write format and adds a current-option-is-a-valid-option regression; adds a diagnostics test asserting the version lookup runs off the event loop. --- README.md | 7 +-- custom_components/localthings/diagnostics.py | 7 ++- .../registry/capabilities/washer.py | 51 +++++++++++++++---- tests/localthings/test_diagnostics.py | 26 ++++++++++ tests/test_washer_capabilities.py | 37 +++++++++++--- 5 files changed, 109 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 7d76a44..d269429 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi | Type | Registry | |---|---| +| Air conditioner | `by_type/airconditioner.py` | | Dryer | `by_type/dryer.py` | | Oven | `by_type/oven.py` | | Dishwasher | `by_type/dishwasher.py` | @@ -99,7 +100,7 @@ custom_components/localthings/ diagnostics.py Redacted diagnostics download (device state + coverage metadata) const.py Domain, config keys, probe ports entity.py Base entity wiring capability registry -> HA entity - sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py + sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / climate.py One module per HA platform strings.json / translations/ Config-flow copy + entity state translations registry/ @@ -110,8 +111,8 @@ custom_components/localthings/ adapter.py Flattens bound entities into HA-ready state identity.py Reads device identity for type detection redact.py Strips account/identity data before diagnostics leave HA - capabilities/ Shared + per-family Capability definitions (common, dryer, oven, - dishwasher, fridge, washer, laundry, operational, ignored) + capabilities/ Shared + per-family Capability definitions (common, airconditioner, + dryer, oven, dishwasher, fridge, washer, laundry, operational, ignored) by_type/ One DeviceRegistry per appliance type, composed from capabilities/ tests/ Registry composition, discovery, entity descriptors, coordinator/observe behavior, and golden-file regression against captured device dumps diff --git a/custom_components/localthings/diagnostics.py b/custom_components/localthings/diagnostics.py index 8d75794..b42f821 100644 --- a/custom_components/localthings/diagnostics.py +++ b/custom_components/localthings/diagnostics.py @@ -26,13 +26,18 @@ async def async_get_config_entry_diagnostics( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id] integration = await async_get_integration(hass, DOMAIN) + # importlib.metadata.version() reads the installed package's metadata off + # disk (listdir + open + read_text), which trips HA's event-loop blocking + # detector when called inline here. Offload it to the executor. + stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local") + return { "device_type": coordinator.device_type_name or "unknown", "one_ui_version": coordinator.one_ui_version, "unbound_hrefs": sorted(coordinator._unbound_hrefs), "resources": redact_resources(coordinator.last_resources), "integration_version": integration.version, - "smartthings_local_version": pkg_version("smartthings-local"), + "smartthings_local_version": stl_version, "observe_mode": coordinator.observe_mode, "observe_subscribed_hrefs": sorted(coordinator._observe.subscribed_hrefs), "observe_fallback_hrefs": sorted(coordinator._observe.fallback_hrefs), diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 859b6ce..14d3893 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -160,13 +160,50 @@ def _level_options(prefix): return lambda resources: _supported_level_options(resources, prefix) +def _dosing_level(prefix): + """Current dose code, normalized to the `Supported` code format. + + The device reports the selected level as `_` with the code + un-padded (e.g. '3'), but the valid codes -- which are also this select's + options and its translation keys -- come from `Supported_` + as zero-padded hex pairs (e.g. '03'). Left as '3', the current value sits + outside the select's own option list, so HA renders it 'unknown' (issue #9). + Resolve it to the supported code with the same integer value so + current_option matches an option (and its translation).""" + def fn(rep): + opts = rep.get('x.com.samsung.da.options') + raw = option_value(opts, prefix) + if raw is None: + return None + supported_raw = option_value(opts, f'Supported{prefix}') + try: + target = int(raw, 16) + except (TypeError, ValueError): + return raw + for code in hex_pairs(supported_raw) if supported_raw else []: + try: + if int(code, 16) == target: + return code + except (TypeError, ValueError): + continue + return raw + return fn + + def _level_write(prefix): def write(p, rep, href=None): opts = list(rep.get('x.com.samsung.da.options') or []) if not opts: return None + # `p` is the zero-padded supported code the UI selected (e.g. '03'); + # the device stores the level un-padded (e.g. '3'), matching how it + # reports it, so write it back in that native shape. + try: + native = format(int(p, 16), 'X') + except (TypeError, ValueError): + native = p return ['course', 'vs', '0'], { - 'x.com.samsung.da.options': replace_in_options(opts, prefix, p), + 'x.com.samsung.da.options': replace_in_options(opts, prefix, native), } return write @@ -201,8 +238,7 @@ WASHER_COURSE = Capability( options=_level_options('DetergentLevelCtrl'), exists_fn=lambda rep, resources: bool( _level_options('DetergentLevelCtrl')(resources)), - rep_fn=lambda rep: option_value( - rep.get('x.com.samsung.da.options'), 'DetergentLevelCtrl'), + rep_fn=_dosing_level('DetergentLevelCtrl'), write_fn=_level_write('DetergentLevelCtrl')), SelectDesc(key='detergent_water_hardness', name='Detergent water hardness', icon='mdi:water-opacity', @@ -211,8 +247,7 @@ WASHER_COURSE = Capability( options=_level_options('DetergentLevel2Ctrl'), exists_fn=lambda rep, resources: bool( _level_options('DetergentLevel2Ctrl')(resources)), - rep_fn=lambda rep: option_value( - rep.get('x.com.samsung.da.options'), 'DetergentLevel2Ctrl'), + rep_fn=_dosing_level('DetergentLevel2Ctrl'), write_fn=_level_write('DetergentLevel2Ctrl')), SelectDesc(key='softener_quantity', name='Softener quantity', icon='mdi:flask-outline', translation_key='washer_dosing_quantity', @@ -220,8 +255,7 @@ WASHER_COURSE = Capability( options=_level_options('SoftenerLevelCtrl'), exists_fn=lambda rep, resources: bool( _level_options('SoftenerLevelCtrl')(resources)), - rep_fn=lambda rep: option_value( - rep.get('x.com.samsung.da.options'), 'SoftenerLevelCtrl'), + rep_fn=_dosing_level('SoftenerLevelCtrl'), write_fn=_level_write('SoftenerLevelCtrl')), SelectDesc(key='softener_concentration', name='Softener concentration', icon='mdi:flask-plus-outline', @@ -230,8 +264,7 @@ WASHER_COURSE = Capability( options=_level_options('SoftenerLevel2Ctrl'), exists_fn=lambda rep, resources: bool( _level_options('SoftenerLevel2Ctrl')(resources)), - rep_fn=lambda rep: option_value( - rep.get('x.com.samsung.da.options'), 'SoftenerLevel2Ctrl'), + rep_fn=_dosing_level('SoftenerLevel2Ctrl'), write_fn=_level_write('SoftenerLevel2Ctrl')), BinarySensorDesc(key='detergent_low', name='Detergent low', icon='mdi:alert-circle-outline', device_class='problem', diff --git a/tests/localthings/test_diagnostics.py b/tests/localthings/test_diagnostics.py index 214d7ff..13fde6b 100644 --- a/tests/localthings/test_diagnostics.py +++ b/tests/localthings/test_diagnostics.py @@ -2,10 +2,12 @@ from __future__ import annotations import json +import threading from pathlib import Path from homeassistant.core import HomeAssistant +from custom_components.localthings import diagnostics as diagnostics_mod from custom_components.localthings.const import DOMAIN from custom_components.localthings.diagnostics import async_get_config_entry_diagnostics from custom_components.localthings.registry.redact import REDACTED @@ -49,3 +51,27 @@ async def test_diagnostics_include_observe_mode_fields( assert diag['observe_fallback_hrefs'] == [] assert 'observe_last_mode_change' in diag assert diag['observe_href_freshness_s'] == {} + + +async def test_dependency_version_read_off_the_event_loop( + hass: HomeAssistant, mock_entry, mock_coordinator_session, monkeypatch +) -> None: + """importlib.metadata.version() does blocking disk I/O, so it must run in + the executor, not on the event loop (issue #9's logs flagged it).""" + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + loop_thread_id = threading.get_ident() # this coroutine runs on the loop + seen: dict[str, int] = {} + real_pkg_version = diagnostics_mod.pkg_version + + def _spy(name: str) -> str: + seen['thread_id'] = threading.get_ident() + return real_pkg_version(name) + + monkeypatch.setattr(diagnostics_mod, 'pkg_version', _spy) + + diag = await async_get_config_entry_diagnostics(hass, mock_entry) + + assert diag['smartthings_local_version'] + assert seen['thread_id'] != loop_thread_id diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index c13a913..1213789 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -151,11 +151,32 @@ class TestDetergentSoftenerDosing: return next(e for e in washer.WASHER_COURSE.entities if e.key == key) def test_quantity_and_hardness_read(self): + """The device reports the level un-padded ('3'), but the options and + translation keys are zero-padded supported codes ('03'); rep_fn + normalizes to the supported code so the value is a valid option + (issue #9 -- otherwise HA renders the select 'unknown').""" rep = {'x.com.samsung.da.options': _DOSING_OPTIONS} - assert self._desc('detergent_quantity').rep_fn(rep) == '3' - assert self._desc('detergent_water_hardness').rep_fn(rep) == '2' - assert self._desc('softener_quantity').rep_fn(rep) == '3' - assert self._desc('softener_concentration').rep_fn(rep) == '2' + assert self._desc('detergent_quantity').rep_fn(rep) == '03' + assert self._desc('detergent_water_hardness').rep_fn(rep) == '02' + assert self._desc('softener_quantity').rep_fn(rep) == '03' + assert self._desc('softener_concentration').rep_fn(rep) == '02' + + def test_current_value_is_a_valid_option_for_every_dosing_select(self): + """The core regression: HA shows a select 'unknown' when current_option + is not in options. Each dosing select's value must be one of its own + options.""" + rep = {'x.com.samsung.da.options': _DOSING_OPTIONS} + for key in ('detergent_quantity', 'detergent_water_hardness', + 'softener_quantity', 'softener_concentration'): + desc = self._desc(key) + assert desc.rep_fn(rep) in desc.options(_DOSING_RESOURCES), key + + def test_read_passes_through_when_no_supported_match(self): + """A value with no matching supported code is returned as-is rather than + dropped, so an unexpected device stays visible instead of blank.""" + rep = {'x.com.samsung.da.options': ['DetergentLevelCtrl_7', + 'SupportedDetergentLevelCtrl_00010203']} + assert self._desc('detergent_quantity').rep_fn(rep) == '7' def test_translation_keys(self): """detergent_quantity and softener_quantity share one translation_key @@ -181,17 +202,21 @@ class TestDetergentSoftenerDosing: assert desc.exists_fn({}, _DOSING_RESOURCES) is True def test_quantity_write(self): + """The UI selects a padded supported code ('01'); the write posts the + un-padded device code ('1'), mirroring how the device reports it.""" rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)} path, body = self._desc('detergent_quantity').write_fn('01', rep) assert path == ['course', 'vs', '0'] - assert 'DetergentLevelCtrl_01' in body['x.com.samsung.da.options'] + assert 'DetergentLevelCtrl_1' in body['x.com.samsung.da.options'] assert 'DetergentLevelCtrl_3' not in body['x.com.samsung.da.options'] + # untouched siblings survive the read-modify-write + assert 'SoftenerLevelCtrl_3' in body['x.com.samsung.da.options'] def test_hardness_write(self): rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)} path, body = self._desc('softener_concentration').write_fn('03', rep) assert path == ['course', 'vs', '0'] - assert 'SoftenerLevel2Ctrl_03' in body['x.com.samsung.da.options'] + assert 'SoftenerLevel2Ctrl_3' in body['x.com.samsung.da.options'] def test_low_reservoir_off_when_alarm_off(self): rep = {'x.com.samsung.da.options': _DOSING_OPTIONS} From f4b4a04628a5aad0d263086604fa3792909399ca Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 21 Jul 2026 23:59:03 +0000 Subject: [PATCH 04/29] refactor: tidy the AC climate entity (/simplify pass) Quality-only cleanups on the air-conditioner support, no behavior change: - climate.py: reuse common.normalize_temp_unit for the C/F unit read (also handles the "Celsius"/"Fahrenheit" long forms); collapse the three fan/swing/preset read properties into _read_mode/_read_modes and the three write setters into _set_mapped, removing the copy-paste. - Single source of truth for the climate-consumed hrefs: they lived both as constants in climate.py and as a list in airconditioner.py. Declare them once in airconditioner.py (HREF_* + CLIMATE_CONSUMED_HREFS, which also builds the COVERAGE caps) and import them into climate.py, so a new sibling read can't drift out of sync with its coverage entry. --- custom_components/localthings/climate.py | 76 +++++++++++-------- .../registry/capabilities/airconditioner.py | 50 +++++++----- 2 files changed, 74 insertions(+), 52 deletions(-) diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 6e6b3b8..08dbf3a 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -30,24 +30,27 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .registry.entities import ClimateDesc +# The AC's canonical resource hrefs live in the capability module (the single +# source of truth shared with its COVERAGE caps); power prefers the OCF-standard +# href, falling back to the vendor one, mirroring common.POWER_GENERIC / +# POWER_VS_FALLBACK. +from .registry.capabilities.airconditioner import ( + HREF_MODE as MODE_HREF, + HREF_POWER as POWER_HREF, + HREF_POWER_VS as POWER_VS_HREF, + HREF_TEMP_CURRENT as TEMP_CURRENT_HREF, + HREF_TEMP_DESIRED as TEMP_DESIRED_HREF, + HREF_TEMP_CONTROL as TEMP_CONTROL_HREF, + HREF_WIND_STRENGTH as WIND_STRENGTH_HREF, + HREF_WIND_DIRECTION as WIND_DIRECTION_HREF, + HREF_CONVENIENT as CONVENIENT_HREF, +) +from .registry.capabilities.common import normalize_temp_unit from .const import DOMAIN from .coordinator import LocalThingsCoordinator from .entity import LocalThingsEntity, _is_included -# Sibling OCF resources the climate entity reads (the primary bound href is -# /mode/vs/0). Power prefers the OCF-standard href, falling back to the vendor -# one, mirroring common.POWER_GENERIC / POWER_VS_FALLBACK. -POWER_HREF = '/power/0' -POWER_VS_HREF = '/power/vs/0' -MODE_HREF = '/mode/vs/0' -TEMP_CURRENT_HREF = '/temperature/current/0' -TEMP_DESIRED_HREF = '/temperature/desired/0' -TEMP_CONTROL_HREF = '/temperature/control/vs/0' -WIND_STRENGTH_HREF = '/wind/strength/vs/0' -WIND_DIRECTION_HREF = '/wind/direction/vs/0' -CONVENIENT_HREF = '/mode/convenient/vs/0' - _MODES_FIELD = 'x.com.samsung.da.modes' _SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes' @@ -159,12 +162,22 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): def _supported(self, href: str) -> list[str]: return list(self._rep(href).get(_SUPPORTED_FIELD) or []) + 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))) + + 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] + # -- temperature -------------------------------------------------------- @property def temperature_unit(self) -> str: - units = str(self._rep(TEMP_DESIRED_HREF).get('units', 'C')).upper() - return UnitOfTemperature.FAHRENHEIT if units.startswith('F') else UnitOfTemperature.CELSIUS + raw = self._rep(TEMP_DESIRED_HREF).get('units') + return (UnitOfTemperature.FAHRENHEIT + if normalize_temp_unit(raw, '°C') == '°F' + else UnitOfTemperature.CELSIUS) @property def current_temperature(self): @@ -214,30 +227,27 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): @property def fan_mode(self): - return _DEVICE_TO_FAN.get(_first(self._rep(WIND_STRENGTH_HREF).get(_MODES_FIELD))) + return self._read_mode(WIND_STRENGTH_HREF, _DEVICE_TO_FAN) @property def fan_modes(self) -> list[str]: - return [_DEVICE_TO_FAN[c] for c in self._supported(WIND_STRENGTH_HREF) - if c in _DEVICE_TO_FAN] + return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN) @property def swing_mode(self): - return _DEVICE_TO_SWING.get(_first(self._rep(WIND_DIRECTION_HREF).get(_MODES_FIELD))) + return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING) @property def swing_modes(self) -> list[str]: - return [_DEVICE_TO_SWING[c] for c in self._supported(WIND_DIRECTION_HREF) - if c in _DEVICE_TO_SWING] + return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING) @property def preset_mode(self): - return _DEVICE_TO_PRESET.get(_first(self._rep(CONVENIENT_HREF).get(_MODES_FIELD))) + return self._read_mode(CONVENIENT_HREF, _DEVICE_TO_PRESET) @property def preset_modes(self) -> list[str]: - return [_DEVICE_TO_PRESET[c] for c in self._supported(CONVENIENT_HREF) - if c in _DEVICE_TO_PRESET] + return self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET) # -- writes ------------------------------------------------------------- @@ -263,17 +273,17 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): async def async_turn_off(self) -> None: await self.coordinator.async_send_command(self._bound, ('power', False)) - async def async_set_fan_mode(self, fan_mode: str) -> None: - device = _FAN_TO_DEVICE.get(fan_mode) + async def _set_mapped(self, kind: str, mapping: dict, value: str) -> None: + """Map an HA fan/swing/preset value back to its device code and write it.""" + device = mapping.get(value) if device is not None: - await self.coordinator.async_send_command(self._bound, ('fan', device)) + await self.coordinator.async_send_command(self._bound, (kind, device)) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + await self._set_mapped('fan', _FAN_TO_DEVICE, fan_mode) async def async_set_swing_mode(self, swing_mode: str) -> None: - device = _SWING_TO_DEVICE.get(swing_mode) - if device is not None: - await self.coordinator.async_send_command(self._bound, ('swing', device)) + await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode) async def async_set_preset_mode(self, preset_mode: str) -> None: - device = _PRESET_TO_DEVICE.get(preset_mode) - if device is not None: - await self.coordinator.async_send_command(self._bound, ('preset', device)) + await self._set_mapped('preset', _PRESET_TO_DEVICE, preset_mode) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index b085d70..7454b09 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -17,6 +17,29 @@ by_type registry. from ..capability import Capability from ..entities import ClimateDesc, SensorDesc, SwitchDesc +# --------------------------------------------------------------------------- +# Canonical AC resource hrefs. The climate entity (climate.py) binds the +# primary HREF_MODE via CLIMATE below and reads the CLIMATE_CONSUMED_HREFS +# siblings off the coordinator snapshot; those siblings are marked covered +# (no-entity caps) so discover() reports no gap. Declared once here and +# imported by climate.py, so a new sibling read can't drift out of sync with +# its coverage entry. +# --------------------------------------------------------------------------- +HREF_MODE = '/mode/vs/0' # primary (bound by CLIMATE) +HREF_POWER = '/power/0' # on/off -> HVACMode.OFF / TURN_ON/OFF +HREF_POWER_VS = '/power/vs/0' # vendor fallback for on/off +HREF_TEMP_CURRENT = '/temperature/current/0' # current_temperature +HREF_TEMP_DESIRED = '/temperature/desired/0' # target_temperature (write target) +HREF_TEMP_CONTROL = '/temperature/control/vs/0' # target_temperature_step +HREF_WIND_STRENGTH = '/wind/strength/vs/0' # fan_mode +HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode +HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode + +CLIMATE_CONSUMED_HREFS = [ + HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED, + HREF_TEMP_CONTROL, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION, HREF_CONVENIENT, +] + def _num(v): try: @@ -71,7 +94,7 @@ def _climate_write(payload, rep, href=None): CLIMATE = Capability( - href='/mode/vs/0', + href=HREF_MODE, poll_tier='warm', entities=( ClimateDesc(key='climate', translation_key='airconditioner', @@ -124,24 +147,13 @@ AIR_FILTER = Capability( ) # --------------------------------------------------------------------------- -# AC-scoped coverage: hrefs consumed by the composite climate entity, and -# vendor duplicates / all-zero-ambiguous / plumbing resources. These are NOT in -# the global ignored.IGNORED because several of them (/mode/vs/0 handled above, -# /temperatures/vs/0, /humidity/*) collide with other families' schemas. A -# no-entity Capability still marks the href as bound so discover() reports no -# coverage gap. +# AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate +# entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources. +# These are NOT in the global ignored.IGNORED because several of them +# (/mode/vs/0 handled above, /temperatures/vs/0, /humidity/*) collide with +# other families' schemas. A no-entity Capability still marks the href as +# bound so discover() reports no coverage gap. # --------------------------------------------------------------------------- -_CONSUMED_BY_CLIMATE = [ - '/power/0', # on/off -> climate HVACMode.OFF / TURN_ON/OFF - '/power/vs/0', # vendor fallback for on/off - '/temperature/current/0', # climate current_temperature - '/temperature/desired/0', # climate target_temperature (write target) - '/temperature/control/vs/0', # climate target_temperature_step - '/wind/strength/vs/0', # climate fan_mode - '/wind/direction/vs/0', # climate swing_mode - '/mode/convenient/vs/0', # climate preset_mode -] - _AC_IGNORED = [ # Vendor superset that duplicates the OCF /temperature/current+desired pair. '/temperatures/vs/0', @@ -155,4 +167,4 @@ _AC_IGNORED = [ ] # Built as bare no-entity caps; folded into the AC registry (not global). -COVERAGE = [Capability(href=h) for h in (_CONSUMED_BY_CLIMATE + _AC_IGNORED)] +COVERAGE = [Capability(href=h) for h in (CLIMATE_CONSUMED_HREFS + _AC_IGNORED)] From 3688cf24a07e08ec8fa23e1ca776595022dbf7e5 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 22 Jul 2026 00:13:14 +0000 Subject: [PATCH 05/29] chore: bump version to 0.7.0 Covers the air-conditioner support (#17) and the washer dosing-select / diagnostics fixes (#9). main was still advertising 0.6.0 despite the AC work already merging, so this moves it for the next release. --- custom_components/localthings/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/localthings/manifest.json b/custom_components/localthings/manifest.json index 1808a4c..1c39c15 100644 --- a/custom_components/localthings/manifest.json +++ b/custom_components/localthings/manifest.json @@ -12,5 +12,5 @@ "pyOpenSSL>=23.0", "smartthings-local>=0.1.0" ], - "version": "0.6.0" + "version": "0.7.0" } From 88e630b54f95bbd6a535a0319e26a44627c41761 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 22 Jul 2026 06:11:15 +0000 Subject: [PATCH 06/29] feat: detect FlexWash (WV) washers; add combo dry-level select - WV consumer-model prefix (FlexWash twin washers, e.g. WV55M9600AW) wasn't in the by-model fallback map, so these units fell through to the unknown-device registry with zero capabilities bound (#19). - Washer/dryer combo units carry a writable dryLevel field directly on /washer/vs/0 with no separate dryer resource; add a self-gating select for it, off supportedDryLevel presence, so plain washers are unaffected (#22). --- .../localthings/registry/by_type/__init__.py | 1 + .../localthings/registry/capabilities/washer.py | 13 +++++++++++++ custom_components/localthings/strings.json | 12 ++++++++++++ custom_components/localthings/translations/en.json | 12 ++++++++++++ 4 files changed, 38 insertions(+) diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index d9b358a..a8ae11f 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -69,6 +69,7 @@ _CONSUMER_PREFIX_TO_KEY: dict[str, str] = { 'WW': 'washer', 'WD': 'washer', 'WF': 'washer', + 'WV': 'washer', # FlexWash twin units (e.g. WV55M9600AW) -- issue #19 'DV': 'dryer', 'DW': 'dishwasher', } diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 14d3893..964a26c 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -82,6 +82,19 @@ WASHER_SETTINGS = Capability( options_field='x.com.samsung.da.supportedRinseCycles', write_fn=lambda p, rep, href=None: ( ['washer', 'vs', '0'], {'x.com.samsung.da.rinseCycles': p})), + # Washer/dryer combo units carry a dryLevel field on the wash + # resource itself (no separate dryer device/course) -- see issue + # #22. Self-gates off on plain washers, which never report + # supportedDryLevel. + SelectDesc(key='dry_level', field='x.com.samsung.da.dryLevel', + name='Dry level', icon='mdi:tumble-dryer', + entity_category='config', + translation_key='washer_dry_level', + options_field='x.com.samsung.da.supportedDryLevel', + exists_fn=lambda rep, resources: bool( + rep.get('x.com.samsung.da.supportedDryLevel')), + write_fn=lambda p, rep, href=None: ( + ['washer', 'vs', '0'], {'x.com.samsung.da.dryLevel': p})), ), ) diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index 2647ab0..08c7b31 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -119,6 +119,18 @@ "02": "2x", "03": "3x" } + }, + "washer_dry_level": { + "state": { + "none": "Off", + "cupboard": "Cupboard", + "30": "30 min", + "60": "1 hr", + "90": "1 hr 30", + "120": "2 hr", + "180": "3 hr", + "240": "4 hr" + } } }, "sensor": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 2647ab0..08c7b31 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -119,6 +119,18 @@ "02": "2x", "03": "3x" } + }, + "washer_dry_level": { + "state": { + "none": "Off", + "cupboard": "Cupboard", + "30": "30 min", + "60": "1 hr", + "90": "1 hr 30", + "120": "2 hr", + "180": "3 hr", + "240": "4 hr" + } } }, "sensor": { From 9a899080faa85ae8e217ff04e15abcc897bec806 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 22 Jul 2026 06:11:15 +0000 Subject: [PATCH 07/29] fix: fridge/washer coverage gaps from the #19-#27 dump batch Six new diagnostics dumps, six gaps closed: - refrigerator: bind /diagnosis/vs/0 (reuse dishwasher.DIAGNOSIS -- same shape) into the refrigerator registry; it was never wired up there, tripping the coverage repair on any fridge that reports it (#20, #26). - refrigerator: add PANTRY_ZONE for the Cool Select Zone pantry compartment (/status/pantry/one/vs/0, x.com.samsung.da.mode/supportedOptions) -- same shape as BEVERAGE_ZONE but a distinct resource/field set (#20). - refrigerator: generalize FLEX_ZONE to identify the current mode by list membership in supportedOptions instead of a hardcoded CV_TTYPE_RF9000A_ prefix check. TP1X/Bespoke-class fridges use a CV_FDR_ prefix instead, which the old code didn't recognize -- the select existed but always read as unknown, and writing to it would have appended a duplicate CV_FDR_ flag rather than replacing the existing one (#26, #27; also closes #32). - common: add energy_saved_kwh (x.com.samsung.da.cumulativeSavedPower) and power_energy_kwh (x.com.samsung.da.cumulativeConsumption) to the shared energy meter, plus fridge-only energy_last_month_kwh/energy_this_month_kwh (monthlyConsumption/thismonthlyConsumption) -- all self-gating on field presence (#26). - by_type: add the WV consumer-model prefix (FlexWash twin washers, e.g. WV55M9600AW) to the by-model fallback map. These report no oneUiVersion and previously matched no prefix at all, so they fell all the way through to the unrecognized-device registry with zero capabilities bound (#19). - washer: add a self-gating dry_level select to WASHER_SETTINGS for washer/dryer combo units, which carry a writable dryLevel field directly on /washer/vs/0 with no separate dryer resource or course (#22). The reset-water-filter button and "ice type vs. two named icemakers" requests from #26/#27 are left alone -- no write contract or exclusivity behavior is evidenced in either dump, and both icemakers report On simultaneously on the Bespoke unit, so synthesizing a single-select would be a guess rather than a fix. Adds scrubbed fixtures + goldens for FlexWash, a washer/dryer combo, ARTIK051_REF_17K, and TP2X_REF_20K, plus unit tests for the new/changed capabilities. --- .../registry/by_type/refrigerator.py | 4 +- .../registry/capabilities/common.py | 25 ++ .../registry/capabilities/fridge.py | 52 ++- custom_components/localthings/strings.json | 14 +- .../localthings/translations/en.json | 14 +- tests/fixtures/golden/airconditioner.json | 1 + tests/fixtures/golden/dishwasher.json | 2 + tests/fixtures/golden/refrigerator.json | 8 + .../golden/refrigerator_artik051_ref_17k.json | 28 ++ .../golden/refrigerator_tp1x_ref_21k_us.json | 4 + .../golden/refrigerator_tp2x_ref_20k.json | 29 ++ tests/fixtures/golden/washer.json | 1 + tests/fixtures/golden/washer_dryer_combo.json | 26 ++ tests/fixtures/golden/washer_flexwash.json | 22 ++ .../refrigerator_artik051_ref_17k_device.json | 351 ++++++++++++++++++ .../refrigerator_tp2x_ref_20k_device.json | 351 ++++++++++++++++++ tests/fixtures/washer_dryer_combo_device.json | 325 ++++++++++++++++ tests/fixtures/washer_flexwash_device.json | 253 +++++++++++++ tests/test_by_type.py | 10 + tests/test_fridge_capabilities.py | 94 +++++ tests/test_golden_regression.py | 58 +++ tests/test_washer_capabilities.py | 58 +++ 22 files changed, 1719 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/golden/refrigerator_artik051_ref_17k.json create mode 100644 tests/fixtures/golden/refrigerator_tp2x_ref_20k.json create mode 100644 tests/fixtures/golden/washer_dryer_combo.json create mode 100644 tests/fixtures/golden/washer_flexwash.json create mode 100644 tests/fixtures/refrigerator_artik051_ref_17k_device.json create mode 100644 tests/fixtures/refrigerator_tp2x_ref_20k_device.json create mode 100644 tests/fixtures/washer_dryer_combo_device.json create mode 100644 tests/fixtures/washer_flexwash_device.json diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index 003eb8d..caddc51 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -1,5 +1,5 @@ """Refrigerator device registry.""" -from ..capabilities import common, fridge, ignored +from ..capabilities import common, dishwasher, fridge, ignored from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( @@ -17,6 +17,7 @@ REGISTRY = DeviceRegistry( common.ALARMS, common.ENERGY_METER, common.WATER_FILTER, + dishwasher.DIAGNOSIS, fridge.ICEMAKER_NIGHTTIME, fridge.FLEX_ZONE, fridge.REFRIGERATION, @@ -26,6 +27,7 @@ REGISTRY = DeviceRegistry( fridge.CABINET_LIGHT_ENHANCED, fridge.SABBATH, fridge.BEVERAGE_ZONE, + fridge.PANTRY_ZONE, fridge.FIRMWARE_UPDATE, fridge.DEFROST_DELAY, fridge.DEFROST_DELAY_NATIVE_DUPLICATE, diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index eadd646..69a9c2c 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -175,6 +175,31 @@ ENERGY_METER = Capability( state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh, exists_fn=lambda rep, resources: ( not rep or 'x.com.samsung.da.cumulativePower' in rep)), + # cumulativeConsumption is a second, independently-varying running + # total alongside cumulativePower -- some fridges (issue #26) report + # both. Self-gates off where only cumulativePower is present. + SensorDesc(key='power_energy_kwh', field='x.com.samsung.da.cumulativeConsumption', + name='Power energy', device_class='energy', + state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh, + exists_fn=lambda rep, resources: 'x.com.samsung.da.cumulativeConsumption' in rep), + # AI Energy Mode's lifetime savings estimate vs. an unoptimized + # baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/ + # #27) and absent on others (issue #20/#26), unlike cumulativePower. + SensorDesc(key='energy_saved_kwh', field='x.com.samsung.da.cumulativeSavedPower', + name='Energy saved', device_class='energy', + state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh, + exists_fn=lambda rep, resources: 'x.com.samsung.da.cumulativeSavedPower' in rep), + # Monthly billing-cycle totals -- the completed prior month and the + # in-progress current month. Not ever-increasing (each resets at + # month boundary), so no state_class. + SensorDesc(key='energy_last_month_kwh', field='x.com.samsung.da.monthlyConsumption', + name='Energy (last month)', device_class='energy', + unit='kWh', value_fn=wh_to_kwh, + exists_fn=lambda rep, resources: 'x.com.samsung.da.monthlyConsumption' in rep), + SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption', + name='Energy (this month)', device_class='energy', + unit='kWh', value_fn=wh_to_kwh, + exists_fn=lambda rep, resources: 'x.com.samsung.da.thismonthlyConsumption' in rep), ), ) diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index c788fe1..94fabd2 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -502,17 +502,56 @@ BEVERAGE_ZONE = Capability( ), ) +# --------------------------------------------------------------------------- +# Pantry / Cool Select Zone -- a convertible compartment toggled between +# wine/deli/drinks temperature presets (issue #20). Same shape as +# BEVERAGE_ZONE (a controllable named sub-zone with a mode + supported-modes +# list) but a distinct resource/field set -- x.com.samsung.da.mode / +# x.com.samsung.da.supportedOptions on /status/pantry/one/vs/0, rather than +# roomDesiredMode/roomSupportedModes on /specialzone/one/vs/0. Only a "one" +# instance has been seen; not generalized to a pattern cap until a second +# instance turns up. +# --------------------------------------------------------------------------- + +def _pantry_write(p, rep, href=None): + return ['status', 'pantry', 'one', 'vs', '0'], {'x.com.samsung.da.mode': p} + + +PANTRY_ZONE = Capability( + href='/status/pantry/one/vs/0', + poll_tier='warm', + entities=( + SelectDesc(key='pantry_zone_mode', field='x.com.samsung.da.mode', + name='Pantry zone mode', icon='mdi:glass-wine', + translation_key='pantry_zone_mode', + entity_category='config', + options_field='x.com.samsung.da.supportedOptions', + write_fn=_pantry_write), + ), +) + # --------------------------------------------------------------------------- # Flex zone (convertible drawer — /mode/vs/0 on RF9000-class fridges) # -# x.com.samsung.da.modes holds multiple orthogonal flags in one list. -# The flex zone entry is identified by the CV_TTYPE_RF9000A_ prefix. +# x.com.samsung.da.modes holds multiple orthogonal flags in one list; the +# flex-zone entry is whichever item is also a member of supportedOptions -- +# the other flags (WATERFILTER_*, DEFROST_BLOCK_*, the CVN_*_ZONE marker) +# never appear there. The prefix on that item varies by fridge family +# (CV_TTYPE_RF9000A_ on RF9000-class, CV_FDR_ on Bespoke-class -- issue #27 / +# #26, where the old CV_TTYPE_RF9000A_-only match left this entity bound but +# stuck on None), so match by list membership instead of a hardcoded prefix. # Write replaces only that item; other flags are preserved. # --------------------------------------------------------------------------- +def _flex_zone_current(rep): + modes = rep.get('x.com.samsung.da.modes') or [] + supported = set(rep.get('x.com.samsung.da.supportedOptions') or ()) + return next((m for m in modes if m in supported), None) + + def _flex_zone_write(p, rep, href=None): - modes = list(rep.get('x.com.samsung.da.modes') or []) - modes = [m for m in modes if not m.startswith('CV_TTYPE_RF9000A_')] + supported = set(rep.get('x.com.samsung.da.supportedOptions') or ()) + modes = [m for m in (rep.get('x.com.samsung.da.modes') or []) if m not in supported] modes.append(p) return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': modes} @@ -522,16 +561,13 @@ FLEX_ZONE = Capability( poll_tier='warm', entities=( SelectDesc(key='flex_zone_mode', - field='x.com.samsung.da.modes', name='Flex zone mode', icon='mdi:thermostat', translation_key='flex_zone_mode', entity_category='config', options_field='x.com.samsung.da.supportedOptions', exists_fn=lambda rep, resources: bool( rep.get('x.com.samsung.da.supportedOptions')), - value_fn=lambda modes: next( - (m for m in (modes or []) - if m.startswith('CV_TTYPE_RF9000A_')), None), + rep_fn=_flex_zone_current, write_fn=_flex_zone_write), ), ) diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index 08c7b31..2b2eca4 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -30,13 +30,25 @@ "sp_ttype_wine_dessert": "Wine and Dessert" } }, + "pantry_zone_mode": { + "state": { + "fdr_wine": "Wine", + "fdr_deli": "Deli", + "fdr_drinks": "Drinks" + } + }, "flex_zone_mode": { "state": { "cv_ttype_rf9000a_freeze": "Freeze", "cv_ttype_rf9000a_softfreeze": "Soft Freeze", "cv_ttype_rf9000a_meat_fish": "Meat/Fish", "cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies", - "cv_ttype_rf9000a_beverage": "Beverage" + "cv_ttype_rf9000a_beverage": "Beverage", + "cv_fdr_wine": "Wine", + "cv_fdr_deli": "Deli", + "cv_fdr_beverage": "Beverage", + "cv_fdr_meat": "Meat", + "cv_fdr_soft_freezer": "Soft Freezer" } }, "dishwasher_cycle": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 08c7b31..2b2eca4 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -30,13 +30,25 @@ "sp_ttype_wine_dessert": "Wine and Dessert" } }, + "pantry_zone_mode": { + "state": { + "fdr_wine": "Wine", + "fdr_deli": "Deli", + "fdr_drinks": "Drinks" + } + }, "flex_zone_mode": { "state": { "cv_ttype_rf9000a_freeze": "Freeze", "cv_ttype_rf9000a_softfreeze": "Soft Freeze", "cv_ttype_rf9000a_meat_fish": "Meat/Fish", "cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies", - "cv_ttype_rf9000a_beverage": "Beverage" + "cv_ttype_rf9000a_beverage": "Beverage", + "cv_fdr_wine": "Wine", + "cv_fdr_deli": "Deli", + "cv_fdr_beverage": "Beverage", + "cv_fdr_meat": "Meat", + "cv_fdr_soft_freezer": "Soft Freezer" } }, "dishwasher_cycle": { diff --git a/tests/fixtures/golden/airconditioner.json b/tests/fixtures/golden/airconditioner.json index afe8a6d..68abd0f 100644 --- a/tests/fixtures/golden/airconditioner.json +++ b/tests/fixtures/golden/airconditioner.json @@ -8,6 +8,7 @@ "climate", "diagnosis_status", "energy_kwh", + "energy_saved_kwh", "power_watts" ] } diff --git a/tests/fixtures/golden/dishwasher.json b/tests/fixtures/golden/dishwasher.json index 21ae55f..2a50531 100644 --- a/tests/fixtures/golden/dishwasher.json +++ b/tests/fixtures/golden/dishwasher.json @@ -8,6 +8,7 @@ "delay_start_hours", "diagnosis_status", "energy_kwh", + "energy_saved_kwh", "finish_time", "firmware_update", "heated_dry", @@ -39,6 +40,7 @@ "samsung_dishwasher_diagnosis_start", "samsung_dishwasher_diagnosis_status", "samsung_dishwasher_energy_kwh", + "samsung_dishwasher_energy_saved_kwh", "samsung_dishwasher_finish_time", "samsung_dishwasher_firmware_update", "samsung_dishwasher_heated_dry", diff --git a/tests/fixtures/golden/refrigerator.json b/tests/fixtures/golden/refrigerator.json index 96e4ac2..45ca68b 100644 --- a/tests/fixtures/golden/refrigerator.json +++ b/tests/fixtures/golden/refrigerator.json @@ -16,6 +16,9 @@ "door_cooler_open", "door_freezer_open", "energy_kwh", + "energy_last_month_kwh", + "energy_saved_kwh", + "energy_this_month_kwh", "filter_status", "filter_usage", "firmware_update", @@ -31,6 +34,7 @@ "icemaker_two_type", "night_end", "night_start", + "power_energy_kwh", "power_watts", "rapid_freezing", "rapid_fridge", @@ -56,6 +60,9 @@ "samsung_refrigerator_door_cooler_open", "samsung_refrigerator_door_freezer_open", "samsung_refrigerator_energy_kwh", + "samsung_refrigerator_energy_last_month_kwh", + "samsung_refrigerator_energy_saved_kwh", + "samsung_refrigerator_energy_this_month_kwh", "samsung_refrigerator_filter_status", "samsung_refrigerator_filter_usage", "samsung_refrigerator_firmware_update", @@ -71,6 +78,7 @@ "samsung_refrigerator_icemaker_two_type", "samsung_refrigerator_night_end", "samsung_refrigerator_night_start", + "samsung_refrigerator_power_energy_kwh", "samsung_refrigerator_power_watts", "samsung_refrigerator_rapid_freezing", "samsung_refrigerator_rapid_fridge", diff --git a/tests/fixtures/golden/refrigerator_artik051_ref_17k.json b/tests/fixtures/golden/refrigerator_artik051_ref_17k.json new file mode 100644 index 0000000..0a69dbb --- /dev/null +++ b/tests/fixtures/golden/refrigerator_artik051_ref_17k.json @@ -0,0 +1,28 @@ +{ + "state_keys": [ + "alarm_code", + "autofill", + "cooler_setpoint", + "cooler_temperature", + "defrost_active", + "defrost_delay", + "diagnosis_status", + "door_cooler_open", + "door_freezer_open", + "energy_last_month_kwh", + "energy_this_month_kwh", + "filter_status", + "filter_usage", + "firmware_update", + "freezer_setpoint", + "freezer_temperature", + "icemaker_one_enabled", + "icemaker_one_making_status", + "pantry_zone_mode", + "power_energy_kwh", + "power_watts", + "rapid_freezing", + "rapid_fridge", + "sabbath_mode" + ] +} diff --git a/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json index 962e01c..dd94692 100644 --- a/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json +++ b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json @@ -10,6 +10,9 @@ "door_cvroom_open", "door_freezer_open", "energy_kwh", + "energy_last_month_kwh", + "energy_saved_kwh", + "energy_this_month_kwh", "filter_status", "filter_usage", "firmware_update", @@ -20,6 +23,7 @@ "icemaker_one_making_status", "icemaker_two_enabled", "icemaker_two_making_status", + "power_energy_kwh", "power_watts", "rapid_freezing", "rapid_fridge", diff --git a/tests/fixtures/golden/refrigerator_tp2x_ref_20k.json b/tests/fixtures/golden/refrigerator_tp2x_ref_20k.json new file mode 100644 index 0000000..ba43462 --- /dev/null +++ b/tests/fixtures/golden/refrigerator_tp2x_ref_20k.json @@ -0,0 +1,29 @@ +{ + "state_keys": [ + "alarm_code", + "cooler_setpoint", + "cooler_temperature", + "defrost_active", + "defrost_delay", + "diagnosis_status", + "door_cooler_open", + "door_cvroom_open", + "door_freezer_open", + "energy_kwh", + "energy_last_month_kwh", + "energy_this_month_kwh", + "filter_status", + "filter_usage", + "firmware_update", + "flex_zone_mode", + "freezer_setpoint", + "freezer_temperature", + "icemaker_one_enabled", + "icemaker_one_making_status", + "power_energy_kwh", + "power_watts", + "rapid_freezing", + "rapid_fridge", + "sabbath_mode" + ] +} diff --git a/tests/fixtures/golden/washer.json b/tests/fixtures/golden/washer.json index e8beedc..69ac2a8 100644 --- a/tests/fixtures/golden/washer.json +++ b/tests/fixtures/golden/washer.json @@ -11,6 +11,7 @@ "drum_clean_cycles_remaining", "drum_clean_last_cleaned", "energy_kwh", + "energy_saved_kwh", "finish_time", "firmware_update", "job_beginning_status", diff --git a/tests/fixtures/golden/washer_dryer_combo.json b/tests/fixtures/golden/washer_dryer_combo.json new file mode 100644 index 0000000..a228067 --- /dev/null +++ b/tests/fixtures/golden/washer_dryer_combo.json @@ -0,0 +1,26 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "cycle", + "cycle_active", + "delay_start_hours", + "detergent_low", + "diagnosis_status", + "drum_clean_cycles_remaining", + "dry_level", + "energy_kwh", + "finish_time", + "firmware_update", + "job_beginning_status", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "rinse_cycles", + "softener_low", + "spin_speed", + "wash_temperature" + ] +} diff --git a/tests/fixtures/golden/washer_flexwash.json b/tests/fixtures/golden/washer_flexwash.json new file mode 100644 index 0000000..6db1e00 --- /dev/null +++ b/tests/fixtures/golden/washer_flexwash.json @@ -0,0 +1,22 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "cycle_active", + "delay_start_hours", + "detergent_low", + "diagnosis_status", + "drum_clean_cycles_remaining", + "energy_kwh", + "finish_time", + "job_beginning_status", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "rinse_cycles", + "spin_speed", + "wash_temperature" + ] +} diff --git a/tests/fixtures/refrigerator_artik051_ref_17k_device.json b/tests/fixtures/refrigerator_artik051_ref_17k_device.json new file mode 100644 index 0000000..49e6069 --- /dev/null +++ b/tests/fixtures/refrigerator_artik051_ref_17k_device.json @@ -0,0 +1,351 @@ +{ + "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": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Freezer", + "x.com.samsung.da.desired": "-21", + "x.com.samsung.da.current": "-21", + "x.com.samsung.da.maximum": "-15", + "x.com.samsung.da.minimum": "-23", + "x.com.samsung.da.unit": "Celsius" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Fridge", + "x.com.samsung.da.desired": "2", + "x.com.samsung.da.current": "2", + "x.com.samsung.da.maximum": "7", + "x.com.samsung.da.minimum": "1", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/temperature/current/freezer/0", + "rep": { + "range": [ + -23.0, + -15.0 + ], + "units": "C", + "temperature": -21.0 + } + }, + { + "href": "/temperature/desired/freezer/0", + "rep": { + "range": [ + -23.0, + -15.0 + ], + "units": "C", + "temperature": -21.0 + } + }, + { + "href": "/temperature/current/cooler/0", + "rep": { + "range": [ + 1.0, + 7.0 + ], + "units": "C", + "temperature": 2.0 + } + }, + { + "href": "/temperature/desired/cooler/0", + "rep": { + "range": [ + 1.0, + 7.0 + ], + "units": "C", + "temperature": 2.0 + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "0", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPower": "0", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.monthlyConsumption": "86000", + "x.com.samsung.da.thismonthlyConsumption": "64026" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2", + "SUPPORT_SABBATH_CONTROL" + ], + "x.com.samsung.da.modes": [ + "WATERFILTER_ENABLE" + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2", + "SUPPORT_SABBATH_CONTROL" + ], + "modes": [ + "WATERFILTER_ENABLE" + ] + } + }, + { + "href": "/defrost/block/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "DEFROST_BLOCK_ON", + "DEFROST_BLOCK_OFF" + ], + "x.com.samsung.da.modes": [ + "DEFROST_BLOCK_OFF" + ] + } + }, + { + "href": "/sabbath/vs/0", + "rep": { + "x.com.samsung.da.sabbathMode": "Off" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "ARTIK051_REF_17K|00117141|0002074E051311200103000010000000", + "x.com.samsung.da.description": "ARTIK051_REF_17K", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "WiFi Module", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02144A201124", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Micom", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "2003030C,FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "-04:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "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.id": "1", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close" + } + ] + } + }, + { + "href": "/door/freezer/0", + "rep": { + "openState": "Close" + } + }, + { + "href": "/door/cooler/0", + "rep": { + "openState": "Close" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.countryCode": "US", + "x.com.samsung.da.region": "" + } + }, + { + "href": "/defrost/delay/0", + "rep": { + "value": false + } + }, + { + "href": "/defrost/delay/vs/0", + "rep": { + "x.com.samsung.da.delayDefrost": "Off" + } + }, + { + "href": "/defrost/reservation/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Summer Season", + "x.com.samsung.da.startTime": "0000-05-01T15:00:00", + "x.com.samsung.da.period": "04:00:00", + "x.com.samsung.da.endTime": "0000-10-31T00:00:00" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Winter Season", + "x.com.samsung.da.startTime": "0000-11-01T06:00:00", + "x.com.samsung.da.period": "04:00:00", + "x.com.samsung.da.endTime": "0000-04-30T00:00:00" + } + ] + } + }, + { + "href": "/icemaker/status/0", + "rep": { + "status": "On" + } + }, + { + "href": "/icemaker/status/vs/0", + "rep": { + "x.com.samsung.da.iceMaker": "On" + } + }, + { + "href": "/refrigeration/0", + "rep": { + "defrost": false, + "rapidFreeze": false, + "rapidCool": false + } + }, + { + "href": "/refrigeration/vs/0", + "rep": { + "x.com.samsung.da.rapidFridge": "Off", + "x.com.samsung.da.rapidFreezing": "Off" + } + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 0, + "start": "00:00:00", + "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": "00:00:00", + "x.com.samsung.da.override": "Off" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "Micom", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/filter/waterfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "100", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterStatus": "replace", + "x.com.samsung.da.filterResetType": [ + "replaceable" + ] + } + }, + { + "href": "/autofill/vs/0", + "rep": { + "x.com.samsung.da.autofill": "Off" + } + }, + { + "href": "/icemaker/one/vs/0", + "rep": { + "x.com.samsung.da.iceMaker.name": "ICE_MAKER", + "x.com.samsung.da.iceMaker.state": "On", + "x.com.samsung.da.iceMaker.type": "toggle", + "x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP", + "x.com.samsung.da.iceType.desired": "NORMAL" + } + }, + { + "href": "/status/pantry/one/vs/0", + "rep": { + "x.com.samsung.da.controllable": "true", + "x.com.samsung.da.name": "COOL_SELECT_ZONE", + "x.com.samsung.da.mode": "FDR_DRINKS", + "x.com.samsung.da.supportedOptions": [ + "FDR_WINE", + "FDR_DELI", + "FDR_DRINKS" + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/refrigerator_tp2x_ref_20k_device.json b/tests/fixtures/refrigerator_tp2x_ref_20k_device.json new file mode 100644 index 0000000..be906eb --- /dev/null +++ b/tests/fixtures/refrigerator_tp2x_ref_20k_device.json @@ -0,0 +1,351 @@ +{ + "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": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Freezer", + "x.com.samsung.da.desired": "0", + "x.com.samsung.da.current": "0", + "x.com.samsung.da.maximum": "5", + "x.com.samsung.da.minimum": "-8", + "x.com.samsung.da.unit": "Fahrenheit" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Fridge", + "x.com.samsung.da.desired": "37", + "x.com.samsung.da.current": "37", + "x.com.samsung.da.maximum": "44", + "x.com.samsung.da.minimum": "34", + "x.com.samsung.da.unit": "Fahrenheit" + } + ] + } + }, + { + "href": "/temperature/current/freezer/0", + "rep": { + "range": [ + -8.0, + 5.0 + ], + "units": "F", + "temperature": 0.0 + } + }, + { + "href": "/temperature/desired/freezer/0", + "rep": { + "range": [ + -8.0, + 5.0 + ], + "units": "F", + "temperature": 0.0 + } + }, + { + "href": "/temperature/current/cooler/0", + "rep": { + "range": [ + 34.0, + 44.0 + ], + "units": "F", + "temperature": 37.0 + } + }, + { + "href": "/temperature/desired/cooler/0", + "rep": { + "range": [ + 34.0, + 44.0 + ], + "units": "F", + "temperature": 37.0 + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "288048", + "x.com.samsung.da.cumulativePower": "1797027", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPower": "115", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.monthlyConsumption": "55400", + "x.com.samsung.da.thismonthlyConsumption": "39737" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2", + "SUPPORT_SABBATH_CONTROL" + ], + "x.com.samsung.da.modes": [ + "CVN_CONVERTIBLE_ZONE", + "CV_FDR_BEVERAGE", + "WATERFILTER_DISABLE" + ], + "x.com.samsung.da.supportedOptions": [ + "CV_FDR_WINE", + "CV_FDR_DELI", + "CV_FDR_BEVERAGE", + "CV_FDR_MEAT" + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2", + "SUPPORT_SABBATH_CONTROL" + ], + "modes": [ + "CVN_CONVERTIBLE_ZONE", + "CV_FDR_BEVERAGE", + "WATERFILTER_DISABLE" + ] + } + }, + { + "href": "/defrost/block/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "DEFROST_BLOCK_ON", + "DEFROST_BLOCK_OFF" + ], + "x.com.samsung.da.modes": [ + "DEFROST_BLOCK_OFF" + ] + } + }, + { + "href": "/sabbath/vs/0", + "rep": { + "x.com.samsung.da.sabbathMode": "Off" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP2X_REF_20K|00113141|0002033F051311200103000010000000", + "x.com.samsung.da.description": "TP2X_REF_20K", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "WiFi Module", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02144A220110", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Micom", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "2011030E,FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+00:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "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.id": "1", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close" + } + ] + } + }, + { + "href": "/door/freezer/0", + "rep": { + "openState": "Close" + } + }, + { + "href": "/door/cooler/0", + "rep": { + "openState": "Close" + } + }, + { + "href": "/door/cvroom/0", + "rep": { + "openState": "Close" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.countryCode": "US", + "x.com.samsung.da.region": "" + } + }, + { + "href": "/defrost/delay/0", + "rep": { + "value": false + } + }, + { + "href": "/defrost/delay/vs/0", + "rep": { + "x.com.samsung.da.delayDefrost": "Off" + } + }, + { + "href": "/defrost/reservation/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Summer Season", + "x.com.samsung.da.startTime": "0000-05-01T15:00:00", + "x.com.samsung.da.period": "04:00:00", + "x.com.samsung.da.endTime": "0000-10-31T00:00:00" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Winter Season", + "x.com.samsung.da.startTime": "0000-11-01T06:00:00", + "x.com.samsung.da.period": "04:00:00", + "x.com.samsung.da.endTime": "0000-04-30T00:00:00" + } + ] + } + }, + { + "href": "/icemaker/status/0", + "rep": { + "status": "On" + } + }, + { + "href": "/icemaker/status/vs/0", + "rep": { + "x.com.samsung.da.iceMaker": "On" + } + }, + { + "href": "/refrigeration/0", + "rep": { + "defrost": false, + "rapidFreeze": false, + "rapidCool": false + } + }, + { + "href": "/refrigeration/vs/0", + "rep": { + "x.com.samsung.da.rapidFridge": "Off", + "x.com.samsung.da.rapidFreezing": "Off" + } + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 0, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.durationminutes": "0" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "Micom", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/filter/waterfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "0", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterResetType": [ + "replaceable" + ] + } + }, + { + "href": "/icemaker/one/vs/0", + "rep": { + "x.com.samsung.da.iceMaker.name": "ICE_MAKER", + "x.com.samsung.da.iceMaker.state": "On", + "x.com.samsung.da.iceMaker.type": "toggle", + "x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP", + "x.com.samsung.da.iceType.desired": "NORMAL" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/washer_dryer_combo_device.json b/tests/fixtures/washer_dryer_combo_device.json new file mode 100644 index 0000000..698aa12 --- /dev/null +++ b/tests/fixtures/washer_dryer_combo_device.json @@ -0,0 +1,325 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "1210200", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1784671200", + "x.com.samsung.da.cumulativeDateUTC": "1784667600" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_0167", + "UpdateAllow_NotAllowed", + "Course_1C", + "AiOption_On", + "LaundryOutTime_0", + "AddWashSet_0", + "AddWashAvailable_7", + "AddWashIndicator_On", + "SeamlessControl_Enable", + "KidsLockBypass_On", + "WashingTimes_25", + "DrumCleanProposal_40", + "DrumCleanLog_2024-07-10T22:36:33|2024-09-12T21:37:17|2024-12-06T02:13:28|2025-03-04T20:40:47|2025-05-25T11:02:07|2025-08-17T19:48:10|2025-10-30T20:20:13|2026-01-08T21:44:02|2026-04-02T21:17:40|2026-06-08T19:51:07", + "DetergentOnce_1", + "DetergentLeft_0", + "DetergentBase_5", + "DetergentAlarm_Off", + "DetergentType_2", + "DetergentTotal_49", + "SoftenerOnce_0", + "SoftenerLeft_0", + "SoftenerBase_0", + "SoftenerAlarm_Off", + "SoftenerType_0", + "SoftenerTotal_0", + "SpecialFunction_4", + "AvailableDelayTime_159", + "BubbleSoak_Off", + "LaundryPlannerUserSetTime_0", + "ProgressTimeSet_420BF48204D4A204F5", + "SendToDevice_Off", + "GMT_02", + "LaundryPlannerMicomSetTime_1704", + "PreWashSetting_Off", + "IntensiveSetting_Off", + "BubbleSoakSet_00F000F000F000F0F0F0F00000F000F0F00000F000000000", + "EnergyLevelSet_05030401040104020404020503020202030301010505050405", + "MostUsed_1C8410923FA67FB01F0000000000", + "PreWashAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000", + "IntensiveAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000", + "GeoFenceAlarm", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_2024-07-10T22:36:33|2024-09-12T21:37:17|2024-12-06T02:13:28|2025-03-04T20:40:47|2025-05-25T11:02:07|2025-08-17T19:48:10|2025-10-30T20:20:13|2026-01-08T21:44:02|2026-04-02T21:17:40|2026-06-08T19:51:07", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "41C8410923FA67FB01F1B847E923FA67FB01F1E811E933FA53FB01F1D841E923FA67FB01F1F8102923FA57FB01F25843E923FA57FB01F26841E920FA207B01F33857E933FA67FB00024841E930FA30FB00032833E923FA37FB00020857E943FA67FB01F22841E920FA30FB00023831E930FA57FB01F2F831E933FA30FB00021831E943FA57FB0002D841E923FA30FB00030843E923FA67FB000278000913FA67FB01F2880009000A67EB01F368410923FA640B11E3880009000A000B11E3980009000A000B11E3780009000A000B0002985209204A520B000" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": {} + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "02:39:00", + "x.com.samsung.da.progressPercentage": "1", + "x.com.samsung.da.progress": "None", + "x.com.samsung.da.delayEndTime": "00:00:00", + "x.com.samsung.da.supportedProgress": [ + "None", + "Weightsensing", + "Wash", + "Rinse", + "Spin", + "Finish" + ] + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "**REDACTED**", + "machineStates": "**REDACTED**", + "jobStates": [ + "None", + "Weightsensing", + "Wash", + "Rinse", + "Spin", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "02:39:00", + "progressPercentage": "1" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_WM_TP2_20_COMMON|20260041|20010102011411B402A3020700000000", + "x.com.samsung.da.description": "DA_WM_TP2_20_COMMON_WW6600R/DC92-02617A_5049", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "210", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_TP2_20_COMMON|20260041|20010102011411B402A3020700000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02674A250414(F546)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Firmware_1_DB_20260041200911390FFFFF202617412101203104FFFF(01672026004120261741_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "20091139,21012031", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Firmware_2_DB_2025984120072205042FFFFFFFFFFFFFFFFFFFFFFFFE(016720259841FFFFFFFF_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "20072205,FFFFFFFF" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+01:00" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.waterTemperature": "40", + "x.com.samsung.da.supportedWaterTemperature": [ + "None", + "Cold", + "20", + "30", + "40", + "60", + "90" + ], + "x.com.samsung.da.spinLevel": "1400", + "x.com.samsung.da.supportedSpinLevel": [ + "RinseHold", + "NoSpin", + "400", + "800", + "1000", + "1200", + "1400" + ], + "x.com.samsung.da.dryLevel": "None", + "x.com.samsung.da.supportedDryLevel": [ + "None", + "Cupboard", + "30", + "60", + "90", + "120", + "180", + "240" + ], + "x.com.samsung.da.rinseCycles": "2", + "x.com.samsung.da.supportedRinseCycles": [ + "0", + "1", + "2", + "3", + "4", + "5" + ] + } + }, + { + "href": "/st/washercourse/vs/0", + "rep": { + "x.com.samsung.da.st.washerMode": "Table_02_Course_1C", + "x.com.samsung.da.st.courseTable": "Table_02" + } + }, + { + "href": "/setting/vs/0", + "rep": { + "x.com.samsung.da.supportedSetLanguage": [ + "ko_KR", + "en_US" + ], + "x.com.samsung.da.setLanguage": "en_GB" + } + }, + { + "href": "/wm/editcourse/vs/0", + "rep": { + "x.com.samsung.da.editCourseList": "EditCourseList_1C3026241E293633281D27211B202F2223323825392D1F37", + "x.com.samsung.da.fixedCourseList": "FixedCourseList_1C353A29" + } + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "true", + "x.com.samsung.da.aiCourse": "true_formobile", + "x.com.samsung.da.isModelSettingPowerOnOff": "false" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": { + "x.com.samsung.da.currentStatus": "None" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "Micom", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": false + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.countryCode": "GB" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/washer_flexwash_device.json b/tests/fixtures/washer_flexwash_device.json new file mode 100644 index 0000000..61aec09 --- /dev/null +++ b/tests/fixtures/washer_flexwash_device.json @@ -0,0 +1,253 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.cumulativePower": "199200", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1784631600", + "x.com.samsung.da.cumulativeDateUTC": "1784646000" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_0146", + "UpdateAllow_NotAllowed", + "Course_A5", + "LaundryOutTime_0", + "AddGarmentIndicator_Off", + "SteamWash_Enable", + "SeamlessControl_Disable", + "KidsLockBypass_On", + "WashingTimes_14", + "DrumCleanProposal_40", + "DetergentOnce_0", + "DetergentLeft_0", + "DetergentBase_0", + "DetergentAlarm_Off", + "DetergentType_0", + "DetergentTotal_0", + "SpecialFunction_4", + "AvailableDelayTime_124", + "LaundryPlannerUserSetTime_0", + "ProgressTimeSet_4210E08209F6A2021C", + "EnergyLevelSet_0502040502030302030105", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_2023-08-11T19:17:11|2023-11-02T03:23:13|2023-12-22T22:35:54|2024-04-08T01:27:32|2024-04-22T01:07:26|2024-10-02T19:16:45|2025-02-03T02:01:33|2025-06-16T17:36:13|2026-01-04T18:34:46|2026-05-26T02:33:16", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "301833EA41BC20E9C843EA41FC30EA98520A41FC20EA2810EA207C20EAA831EA41FC20EA5830EA20FC20E9F831EA41FC10E9E830EA21FC20E9D8000A41FC000B18520A410C000" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/power/0", + "rep": { + "value": true + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": {} + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "02:04: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", + "Wash", + "Rinse", + "Spin", + "Finish" + ] + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "**REDACTED**", + "machineStates": "**REDACTED**", + "jobStates": [ + "None", + "Wash", + "Rinse", + "Spin", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "02:04:00", + "progressPercentage": "1" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20198042|20020001001111400203000000000000", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON_WV9600M/DC92-01980B_0014", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20198042|20020001001111400203000000000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02198A230708(E257)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "18030902,18090603", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "-04:00" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.waterTemperature": "Warm", + "x.com.samsung.da.supportedWaterTemperature": [ + "None", + "Cold", + "Cool", + "Warm", + "Hot", + "ExtraHot" + ], + "x.com.samsung.da.spinLevel": "Low", + "x.com.samsung.da.supportedSpinLevel": [ + "RinseHold", + "NoSpin", + "Low", + "Medium", + "High" + ], + "x.com.samsung.da.soilLevel": "Heavy", + "x.com.samsung.da.supportedSoilLevel": [ + "None", + "Light", + "Normal", + "Heavy" + ], + "x.com.samsung.da.rinseCycles": "3" + } + }, + { + "href": "/st/washercourse/vs/0", + "rep": { + "x.com.samsung.da.st.washerMode": "Table_00_Course_A5", + "x.com.samsung.da.st.courseTable": "Table_00" + } + }, + { + "href": "/setting/vs/0", + "rep": {} + }, + { + "href": "/wm/editcourse/vs/0", + "rep": {} + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "false", + "x.com.samsung.da.isModelSettingPowerOnOff": "false" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": {} + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "true" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": true + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.countryCode": "US" + } + } + ] +} \ No newline at end of file diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 511aca8..9eaf708 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -190,6 +190,16 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'washer' + def test_washer_wv_prefix(self): + """FlexWash twin washers use the WV consumer-model prefix -- issue #19.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'DA_WM_A51_20_COMMON|20198042|20020001001111400203000000000000', + 'DA_WM_A51_20_COMMON_WV9600M/DC92-01980B_0014', + ) + assert reg is not None + assert reg.name == 'washer' + def test_unknown_model_returns_none(self): from custom_components.localthings.registry.by_type import for_device_by_model reg = for_device_by_model('SOME-UNKNOWN-BOARD', 'SOME-UNKNOWN-BOARD') diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index 068956d..cc4daea 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -104,6 +104,43 @@ class TestRefrigerationFallback: assert desc.exists_fn({}, {'/refrigeration/0': {}}) is True +class TestFlexZone: + """Issue #32 (surfaced by #26/#27's TP2X/Bespoke dumps): the flex-zone + entry's prefix varies by fridge family (CV_TTYPE_RF9000A_ on RF9000-class, + CV_FDR_ on TP1X/Bespoke-class). The old hardcoded-prefix match left + CV_FDR_-family fridges with an always-unknown select, and a write would + have appended a duplicate flag instead of replacing the existing one.""" + + def test_reads_rf9000_prefix(self): + rep = { + 'x.com.samsung.da.modes': ['CV_TTYPE_RF9000A_BEVERAGE', 'WATERFILTER_DISABLE'], + 'x.com.samsung.da.supportedOptions': [ + 'CV_TTYPE_RF9000A_FREEZE', 'CV_TTYPE_RF9000A_BEVERAGE'], + } + assert fridge._flex_zone_current(rep) == 'CV_TTYPE_RF9000A_BEVERAGE' + + def test_reads_cv_fdr_prefix(self): + rep = { + 'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT', 'WATERFILTER_ENABLE'], + 'x.com.samsung.da.supportedOptions': [ + 'CV_FDR_WINE', 'CV_FDR_DELI', 'CV_FDR_BEVERAGE', 'CV_FDR_MEAT'], + } + assert fridge._flex_zone_current(rep) == 'CV_FDR_MEAT' + + def test_write_replaces_rather_than_duplicates(self): + rep = { + 'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT', 'WATERFILTER_ENABLE'], + 'x.com.samsung.da.supportedOptions': [ + 'CV_FDR_WINE', 'CV_FDR_DELI', 'CV_FDR_BEVERAGE', 'CV_FDR_MEAT'], + } + path, payload = fridge._flex_zone_write('CV_FDR_WINE', rep) + assert path == ['mode', 'vs', '0'] + modes = payload['x.com.samsung.da.modes'] + assert modes.count('CV_FDR_WINE') == 1 + assert 'CV_FDR_MEAT' not in modes + assert 'CVN_CONVERTIBLE_ZONE' in modes and 'WATERFILTER_ENABLE' in modes + + class TestTp1xNativeDuplicateResources: """The US TP1X_REF_21K publishes two native mirrors in addition to the richer vendor resources. They must count as covered without producing @@ -134,3 +171,60 @@ class TestTp1xNativeDuplicateResources: assert 'ice_maker_enabled' not in state assert state['icemaker_one_enabled'] is True assert state['icemaker_two_enabled'] is True + + +class TestPantryZone: + """Cool Select Zone pantry compartment on ARTIK051_REF_17K -- issue #20.""" + + def test_href(self): + assert fridge.PANTRY_ZONE.href == '/status/pantry/one/vs/0' + + def test_write(self): + desc = fridge.PANTRY_ZONE.entities[0] + path, body = desc.write_fn('FDR_WINE', {}) + assert path == ['status', 'pantry', 'one', 'vs', '0'] + assert body == {'x.com.samsung.da.mode': 'FDR_WINE'} + + +class TestArtik051AndTp2xFixturesHaveCompleteCoverage: + """issue #20 (ARTIK051_REF_17K) and #26 (TP2X_REF_20K) both triggered + the incomplete-capability-coverage repair; both must resolve to zero + unbound hrefs now that /diagnosis/vs/0 (dishwasher.DIAGNOSIS, reused for + fridges), /status/pantry/one/vs/0 (PANTRY_ZONE), and the OCF-native + defrost/icemaker mirrors are covered.""" + + def test_artik051_ref_17k(self): + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import refrigerator + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('refrigerator_artik051_ref_17k') + unbound = [] + bound = discover( + resources, + refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + state = flatten(bound, resources) + assert state['pantry_zone_mode'] == 'FDR_DRINKS' + + def test_tp2x_ref_20k(self): + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import refrigerator + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('refrigerator_tp2x_ref_20k') + unbound = [] + bound = discover( + resources, + refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + state = flatten(bound, resources) + assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE' diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index ee05af3..265f226 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -95,6 +95,64 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_us(): ) +def test_registry_reproduces_golden_state_keys_for_washer_flexwash(): + """FlexWash twin washers (WV-prefix consumer model, e.g. WV55M9600AW) + report no oneUiVersion and previously fell through for_device_by_model's + consumer-prefix map entirely -- issue #19.""" + from tests.conftest import _load_device + resources = _load_device('washer_flexwash') + golden = json.loads((GOLDEN / 'washer_flexwash.json').read_text()) + state_keys = _new_state_keys('washer_flexwash', 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_washer_dryer_combo(): + """Washer/dryer combo units carry a writable dryLevel field on + /washer/vs/0 itself, with no separate dryer resource -- issue #22.""" + from tests.conftest import _load_device + resources = _load_device('washer_dryer_combo') + golden = json.loads((GOLDEN / 'washer_dryer_combo.json').read_text()) + state_keys = _new_state_keys('washer_dryer_combo', 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_artik051_ref_17k(): + """ARTIK051_REF_17K's Cool Select Zone pantry compartment + (/status/pantry/one/vs/0) -- issue #20.""" + from tests.conftest import _load_device + resources = _load_device('refrigerator_artik051_ref_17k') + golden = json.loads((GOLDEN / 'refrigerator_artik051_ref_17k.json').read_text()) + state_keys = _new_state_keys('refrigerator_artik051_ref_17k', 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_tp2x_ref_20k(): + """TP2X_REF_20K -- CV_FDR_-prefixed flex zone (issue #32) plus the extra + energy fields (cumulativeConsumption/monthlyConsumption/ + thismonthlyConsumption) surfaced by issue #26.""" + from tests.conftest import _load_device + resources = _load_device('refrigerator_tp2x_ref_20k') + golden = json.loads((GOLDEN / 'refrigerator_tp2x_ref_20k.json').read_text()) + state_keys = _new_state_keys('refrigerator_tp2x_ref_20k', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index 1213789..580704d 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -40,6 +40,22 @@ class TestWasherSettings: assert body == {'x.com.samsung.da.rinseCycles': '3'} +class TestDryLevel: + """Washer/dryer combo units carry a writable dryLevel field on + /washer/vs/0 itself, self-gated off on plain washers -- issue #22.""" + + def test_exists_only_when_supported_dry_level_present(self): + desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'dry_level') + assert desc.exists_fn({'x.com.samsung.da.supportedDryLevel': ['None', '30']}, {}) is True + assert desc.exists_fn({}, {}) is False + + def test_write(self): + desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'dry_level') + path, body = desc.write_fn('Cupboard', {}) + assert path == ['washer', 'vs', '0'] + assert body == {'x.com.samsung.da.dryLevel': 'Cupboard'} + + class TestWasherCourse: def test_href(self): assert washer.WASHER_COURSE.href == '/course/vs/0' @@ -233,3 +249,45 @@ class TestDetergentSoftenerDosing: assert self._desc('detergent_low').exists_fn({'x.com.samsung.da.options': []}, {}) is False rep = {'x.com.samsung.da.options': _DOSING_OPTIONS} assert self._desc('detergent_low').exists_fn(rep, {}) is True + + +class TestFlexWashAndComboFixturesHaveCompleteCoverage: + """FlexWash (issue #19, previously unrecognized entirely) and + washer/dryer combo (issue #22, dry_level) dumps must both resolve to + zero unbound hrefs.""" + + def test_flexwash(self): + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import washer as washer_registry + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('washer_flexwash') + unbound = [] + bound = discover( + resources, + washer_registry.REGISTRY.capabilities, + washer_registry.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + flatten(bound, resources) # doesn't raise + + def test_dryer_combo(self): + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import washer as washer_registry + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('washer_dryer_combo') + unbound = [] + bound = discover( + resources, + washer_registry.REGISTRY.capabilities, + washer_registry.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + state = flatten(bound, resources) + # the device's own "off" sentinel is the literal string 'None', not absence + assert state['dry_level'] == 'None' From 43542b146f95c2dc7e76c05301d7d7954473a4ee Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 22 Jul 2026 06:11:16 +0000 Subject: [PATCH 08/29] fix: restore empty-stub carve-out on the new energy sensors; dedupe flex-zone set build Review follow-up (Opus + /simplify) on the previous commit: - power_energy_kwh/energy_saved_kwh/energy_last_month_kwh/ energy_this_month_kwh used a plain `field in rep` exists_fn, unlike their siblings power_watts/energy_kwh in the same capability. Per entity._is_included, an explicit exists_fn bypasses the stub carve-out entirely -- an empty {} rep at platform setup (device/0 returned a not-yet-fetched stub) would permanently drop these entities for the session instead of picking them up once a sub-poll populates the resource. Restore the same `not rep or ...` guard used above. - fridge._flex_zone_current/_flex_zone_write independently rebuilt the same supportedOptions set; factor into _flex_zone_supported. - note in a comment that the flex-zone match assumes at most one modes/supportedOptions overlap (true on every dump seen); add the missing negative assertion that dry_level self-gates off on a plain washer (was only positively asserted on the combo fixture). --- .../localthings/registry/capabilities/common.py | 17 ++++++++++++----- .../localthings/registry/capabilities/fridge.py | 12 ++++++++++-- tests/test_washer_capabilities.py | 3 ++- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 69a9c2c..edc3f6c 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -177,29 +177,36 @@ ENERGY_METER = Capability( not rep or 'x.com.samsung.da.cumulativePower' in rep)), # cumulativeConsumption is a second, independently-varying running # total alongside cumulativePower -- some fridges (issue #26) report - # both. Self-gates off where only cumulativePower is present. + # both. Self-gates off where only cumulativePower is present. `not + # rep or` keeps the same empty-{} stub carve-out as power_watts/ + # energy_kwh above -- without it, an exists_fn permanently drops the + # entity if setup happens to land on a not-yet-fetched stub. SensorDesc(key='power_energy_kwh', field='x.com.samsung.da.cumulativeConsumption', name='Power energy', device_class='energy', state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh, - exists_fn=lambda rep, resources: 'x.com.samsung.da.cumulativeConsumption' in rep), + exists_fn=lambda rep, resources: ( + not rep or 'x.com.samsung.da.cumulativeConsumption' in rep)), # AI Energy Mode's lifetime savings estimate vs. an unoptimized # baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/ # #27) and absent on others (issue #20/#26), unlike cumulativePower. SensorDesc(key='energy_saved_kwh', field='x.com.samsung.da.cumulativeSavedPower', name='Energy saved', device_class='energy', state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh, - exists_fn=lambda rep, resources: 'x.com.samsung.da.cumulativeSavedPower' in rep), + exists_fn=lambda rep, resources: ( + not rep or 'x.com.samsung.da.cumulativeSavedPower' in rep)), # Monthly billing-cycle totals -- the completed prior month and the # in-progress current month. Not ever-increasing (each resets at # month boundary), so no state_class. SensorDesc(key='energy_last_month_kwh', field='x.com.samsung.da.monthlyConsumption', name='Energy (last month)', device_class='energy', unit='kWh', value_fn=wh_to_kwh, - exists_fn=lambda rep, resources: 'x.com.samsung.da.monthlyConsumption' in rep), + exists_fn=lambda rep, resources: ( + not rep or 'x.com.samsung.da.monthlyConsumption' in rep)), SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption', name='Energy (this month)', device_class='energy', unit='kWh', value_fn=wh_to_kwh, - exists_fn=lambda rep, resources: 'x.com.samsung.da.thismonthlyConsumption' in rep), + exists_fn=lambda rep, resources: ( + not rep or 'x.com.samsung.da.thismonthlyConsumption' in rep)), ), ) diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 94fabd2..a258fa2 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -543,14 +543,22 @@ PANTRY_ZONE = Capability( # Write replaces only that item; other flags are preserved. # --------------------------------------------------------------------------- +def _flex_zone_supported(rep): + return set(rep.get('x.com.samsung.da.supportedOptions') or ()) + + def _flex_zone_current(rep): + # Every dump seen has at most one modes/supportedOptions overlap, so + # "first match" and "strip all matches" (in the write below) agree. If a + # future device ever reports two, this reads the first and the write + # would drop both -- revisit if that turns up. modes = rep.get('x.com.samsung.da.modes') or [] - supported = set(rep.get('x.com.samsung.da.supportedOptions') or ()) + supported = _flex_zone_supported(rep) return next((m for m in modes if m in supported), None) def _flex_zone_write(p, rep, href=None): - supported = set(rep.get('x.com.samsung.da.supportedOptions') or ()) + supported = _flex_zone_supported(rep) modes = [m for m in (rep.get('x.com.samsung.da.modes') or []) if m not in supported] modes.append(p) return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': modes} diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index 580704d..ac143d8 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -271,7 +271,8 @@ class TestFlexWashAndComboFixturesHaveCompleteCoverage: log=unbound.append, ) assert unbound == [] - flatten(bound, resources) # doesn't raise + state = flatten(bound, resources) + assert 'dry_level' not in state # plain washer -- no supportedDryLevel field def test_dryer_combo(self): from custom_components.localthings.registry.adapter import flatten From 0715eb08293dfa3ad7bed62ffbd8b814e3f9f295 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Tue, 21 Jul 2026 20:59:10 +0200 Subject: [PATCH 09/29] test: remove timing race from coordinator observe-mode tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The observe tests raced a background thread (5 ms wall-clock sleep) against the coordinator reaching its resubscribe. try_enter_observe_mode clears _notified before subscribing, so a notify only counts if it lands between that clear and the end of the grace sleep. When the intervening update cycle (reconnect, cache sweep, executor hops) took longer than 5 ms, every notify was cleared and the mode stayed 'poll' — test_reconnect_from_observe_mode_resubscribes_immediately failed roughly 1 in 7 runs. Replace the threads with FakeObserveSession.notify_on_subscribe, which delivers the rep synchronously from subscribe() the way a real device answers a subscription. That puts the notify inside the grace window by construction rather than by timing. Tests that require the resubscribe to fail now set it to None explicitly instead of relying on the absence of a racing thread. No production code changed; no assertion weakened, and no sleeps, retries or timeouts added. Claude-Session: https://claude.ai/code/session_01PUSU6tDjHjtbExtyXDPT3N --- tests/localthings/conftest.py | 13 ++++ tests/localthings/test_coordinator.py | 108 ++++++-------------------- 2 files changed, 35 insertions(+), 86 deletions(-) diff --git a/tests/localthings/conftest.py b/tests/localthings/conftest.py index 4674f3c..f4fcc56 100644 --- a/tests/localthings/conftest.py +++ b/tests/localthings/conftest.py @@ -5,6 +5,7 @@ import json from pathlib import Path from unittest.mock import patch +import cbor2 import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -150,12 +151,24 @@ class FakeObserveSession: self.subscribed: list[str] = [] self.fail_hrefs: set[str] = set() self.closed = False + # When set to a rep dict, subscribe() immediately delivers that rep + # as an OBSERVE notification for the href — what a real device does + # when it answers a subscription with the current representation. + # `try_enter_observe_mode` clears its notified set before it + # subscribes, so this is the only way to deliver a notify that + # reliably lands inside the grace period: a notify raced in from + # another thread can be wiped by that clear (or arrive after the + # grace period ends) depending on scheduling. Set to None to model + # a device that answers subscriptions but never notifies. + self.notify_on_subscribe: dict | None = None def subscribe(self, path_segs): href = '/' + '/'.join(path_segs) if href in self.fail_hrefs: raise ConnectionError("subscribe failed") self.subscribed.append(href) + if self.notify_on_subscribe is not None and self.on_notification is not None: + self.on_notification(href, cbor2.dumps(self.notify_on_subscribe)) return b'\x01' def refresh_observes(self, paths): diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 20c7c54..6b1572f 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -1,8 +1,6 @@ """Tests for the LocalThingsCoordinator.""" from __future__ import annotations -import threading -import time from datetime import timedelta from unittest.mock import AsyncMock, patch @@ -261,20 +259,15 @@ async def test_enters_observe_mode_when_hot_warm_hrefs_notify( hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs # try_enter_observe_mode clears prior notifications as soon as it - # starts, so notifies must land *during* its grace-period sleep (same - # pattern test_observe.py uses), not before the call. - def _notify_during_grace_period(): - time.sleep(0.005) - for href in hrefs: - fake.on_notification(href, cbor2.dumps({'notified': True})) - - notifier = threading.Thread(target=_notify_during_grace_period, daemon=True) - notifier.start() + # starts, so notifies must land *during* its grace-period sleep. The + # fake session delivers one synchronously from subscribe() (see + # FakeObserveSession.notify_on_subscribe in conftest.py), which puts + # them inside that window by construction rather than by timing. + fake.notify_on_subscribe = {'notified': True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, fake, hrefs, 0.02, 0.8, ) - notifier.join() assert entered is True assert coordinator._observe.mode == MODE_OBSERVE @@ -300,18 +293,11 @@ async def test_reconnect_while_observe_mode_downgrades_to_poll( # Get the coordinator into observe mode the same way # test_enters_observe_mode_when_hot_warm_hrefs_notify does. - def _notify_during_grace_period(): - time.sleep(0.005) - for href in hrefs: - fake.on_notification(href, cbor2.dumps({'notified': True})) - - notifier = threading.Thread(target=_notify_during_grace_period, daemon=True) - notifier.start() + fake.notify_on_subscribe = {'notified': True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, fake, hrefs, 0.02, 0.8, ) - notifier.join() assert entered is True assert coordinator.observe_mode == MODE_OBSERVE @@ -324,12 +310,12 @@ async def test_reconnect_while_observe_mode_downgrades_to_poll( # Simulate the existing "poll failed, reconnecting" branch: _poll_once # fails once (triggering the reconnect/backoff path), then succeeds. - # No fresh notifies are supplied for the immediate resubscribe attempt - # this now triggers, so its grace period (shortened for tests by the - # `_fast_coordinator_timers` autouse fixture — see conftest.py) times - # out and mode stays 'poll' — this test only asserts the tear-down half of - # the fix; test_reconnect_from_observe_mode_resubscribes_immediately - # covers the successful-immediate-resubscribe half. + # The device stops notifying on subscribe, so the immediate resubscribe + # attempt this now triggers gets nothing back and mode stays 'poll' — + # this test only asserts the tear-down half of the fix; + # test_reconnect_from_observe_mode_resubscribes_immediately covers the + # successful-immediate-resubscribe half. + fake.notify_on_subscribe = None with ( patch( 'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once', @@ -362,18 +348,11 @@ async def test_poll_timeout_skips_reconnect_when_push_is_healthy( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs - def _notify_during_grace_period(): - time.sleep(0.005) - for href in hrefs: - fake.on_notification(href, cbor2.dumps({'notified': True})) - - notifier = threading.Thread(target=_notify_during_grace_period, daemon=True) - notifier.start() + fake.notify_on_subscribe = {'notified': True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, fake, hrefs, 0.02, 0.8, ) - notifier.join() assert entered is True assert coordinator.observe_mode == MODE_OBSERVE @@ -408,18 +387,11 @@ async def test_poll_timeout_reconnects_after_consecutive_limit_even_with_push( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs - def _notify_during_grace_period(): - time.sleep(0.005) - for href in hrefs: - fake.on_notification(href, cbor2.dumps({'notified': True})) - - notifier = threading.Thread(target=_notify_during_grace_period, daemon=True) - notifier.start() + fake.notify_on_subscribe = {'notified': True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, fake, hrefs, 0.02, 0.8, ) - notifier.join() assert entered is True assert coordinator.observe_mode == MODE_OBSERVE @@ -441,7 +413,10 @@ async def test_poll_timeout_reconnects_after_consecutive_limit_even_with_push( assert coordinator.observe_mode == MODE_OBSERVE # The final consecutive timeout crosses the limit and triggers the - # existing reconnect path, which then succeeds and downgrades. + # existing reconnect path, which then succeeds and downgrades. The + # device stops notifying on subscribe, so the resubscribe attempt that + # follows the reconnect finds nothing and mode stays 'poll'. + fake.notify_on_subscribe = None with ( patch( 'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once', @@ -473,18 +448,11 @@ async def test_poll_timeout_counter_resets_when_push_is_healthy_again( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs - def _notify_during_grace_period(): - time.sleep(0.005) - for href in hrefs: - fake.on_notification(href, cbor2.dumps({'notified': True})) - - notifier = threading.Thread(target=_notify_during_grace_period, daemon=True) - notifier.start() + fake.notify_on_subscribe = {'notified': True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, fake, hrefs, 0.02, 0.8, ) - notifier.join() assert entered is True assert coordinator.observe_mode == MODE_OBSERVE @@ -526,21 +494,11 @@ async def test_reconnect_from_observe_mode_resubscribes_immediately( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs - def _notify(delay: float = 0.005) -> threading.Thread: - def _run(): - time.sleep(delay) - for href in hrefs: - fake.on_notification(href, cbor2.dumps({'notified': True})) - t = threading.Thread(target=_run, daemon=True) - t.start() - return t - - notifier = _notify() + fake.notify_on_subscribe = {'notified': True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, fake, hrefs, 0.02, 0.8, ) - notifier.join() assert entered is True assert coordinator.observe_mode == MODE_OBSERVE @@ -563,10 +521,8 @@ async def test_reconnect_from_observe_mode_resubscribes_immediately( new=AsyncMock(), ), ): - notifier = _notify() await coordinator.async_request_refresh() await hass.async_block_till_done() - notifier.join() assert coordinator.observe_mode == MODE_OBSERVE @@ -587,21 +543,11 @@ async def test_sweep_mismatch_never_downgrades_a_live_observe_session( hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs assert len(hrefs) >= 2 - def _notify(delay: float = 0.005) -> threading.Thread: - def _run(): - time.sleep(delay) - for href in hrefs: - fake.on_notification(href, cbor2.dumps({'notified': True})) - t = threading.Thread(target=_run, daemon=True) - t.start() - return t - - notifier = _notify() + fake.notify_on_subscribe = {'notified': True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, fake, hrefs, 0.02, 0.8, ) - notifier.join() assert entered is True assert coordinator.observe_mode == MODE_OBSERVE @@ -637,21 +583,11 @@ async def test_sweep_mismatch_forces_subpolls_on_a_live_observe_session( hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs assert len(hrefs) >= 2 - def _notify(delay: float = 0.005) -> threading.Thread: - def _run(): - time.sleep(delay) - for href in hrefs: - fake.on_notification(href, cbor2.dumps({'notified': True})) - t = threading.Thread(target=_run, daemon=True) - t.start() - return t - - notifier = _notify() + fake.notify_on_subscribe = {'notified': True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, fake, hrefs, 0.02, 0.8, ) - notifier.join() assert entered is True assert coordinator.observe_mode == MODE_OBSERVE From 5a9a002a7ff77a9c48c9cfb50dbc5c8db955996e Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 22 Jul 2026 06:12:17 +0000 Subject: [PATCH 10/29] chore: bump version to 0.8.0 Covers the #19-#27 fridge/washer coverage-gap batch: FlexWash (WV) and washer/dryer combo detection, the CV_FDR_ flex-zone fix (also closes #32), the Cool Select Zone pantry select, and the new energy sensors. --- custom_components/localthings/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/localthings/manifest.json b/custom_components/localthings/manifest.json index 1c39c15..ae994df 100644 --- a/custom_components/localthings/manifest.json +++ b/custom_components/localthings/manifest.json @@ -12,5 +12,5 @@ "pyOpenSSL>=23.0", "smartthings-local>=0.1.0" ], - "version": "0.7.0" + "version": "0.8.0" } From 8dadea6796d16bc37463cff07b9b05973f69e120 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Wed, 22 Jul 2026 07:48:43 +0200 Subject: [PATCH 11/29] test: address Copilot review on observe fixture typing and comment - Parameterize FakeObserveSession.notify_on_subscribe as dict[str, Any] | None to match the surrounding fully-typed attributes (the dict is delivered as the OBSERVE rep to on_notification); add the typing.Any import. - Reword the test_coordinator comment: subscribe() delivers a notification for every href it subscribes to (when notify_on_subscribe is set), not one. --- tests/localthings/conftest.py | 13 ++++++++----- tests/localthings/test_coordinator.py | 12 +++++++----- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/tests/localthings/conftest.py b/tests/localthings/conftest.py index f4fcc56..a5a5e62 100644 --- a/tests/localthings/conftest.py +++ b/tests/localthings/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations import json from pathlib import Path +from typing import Any from unittest.mock import patch import cbor2 @@ -156,11 +157,13 @@ class FakeObserveSession: # when it answers a subscription with the current representation. # `try_enter_observe_mode` clears its notified set before it # subscribes, so this is the only way to deliver a notify that - # reliably lands inside the grace period: a notify raced in from - # another thread can be wiped by that clear (or arrive after the - # grace period ends) depending on scheduling. Set to None to model - # a device that answers subscriptions but never notifies. - self.notify_on_subscribe: dict | None = None + # reliably counts: it lands synchronously via on_notification, + # after that clear and before the post-sleep fraction check — a + # notify raced in from another thread can be wiped by the clear + # (or arrive after the check) depending on scheduling. Set to + # None to model a device that answers subscriptions but never + # notifies. + self.notify_on_subscribe: dict[str, Any] | None = None def subscribe(self, path_segs): href = '/' + '/'.join(path_segs) diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 6b1572f..7739da5 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -258,11 +258,13 @@ async def test_enters_observe_mode_when_hot_warm_hrefs_notify( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs - # try_enter_observe_mode clears prior notifications as soon as it - # starts, so notifies must land *during* its grace-period sleep. The - # fake session delivers one synchronously from subscribe() (see - # FakeObserveSession.notify_on_subscribe in conftest.py), which puts - # them inside that window by construction rather than by timing. + # try_enter_observe_mode clears prior notifications before it + # subscribes, then checks the notified fraction after the grace-period + # sleep — a notify counts as long as it arrives in that window, not + # specifically during the sleep itself. The fake session delivers one + # synchronously from subscribe() for every href it subscribes to (see + # FakeObserveSession.notify_on_subscribe in conftest.py), which lands + # it in that window by construction rather than by timing. fake.notify_on_subscribe = {'notified': True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, From 2a0a9f32ee5457bcc411c513fb38d50fd20311e5 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Wed, 22 Jul 2026 08:30:20 +0200 Subject: [PATCH 12/29] feat: add AI energy level select and self-check error sensor for fridges Adds two small, additive fridge capabilities: - AI_ENERGY_LEVEL: a select on /energy/ailevel/vs/0 exposing the AI energy-saving level, gated behind supportedAiLevel actually offering more than one choice (previously globally ignored since every dump seen only reported a single supported level). - selfcheck_error: a diagnostic sensor on the existing SELF_CHECK capability surfacing x.com.samsung.da.error from the last self-check, for hardware that reports it. This does not touch FLEX_ZONE, /mode/vs/0, or /icemaker/status/0. --- .../registry/by_type/refrigerator.py | 1 + .../registry/capabilities/fridge.py | 33 +++++++ .../registry/capabilities/ignored.py | 9 +- custom_components/localthings/strings.json | 6 ++ .../localthings/translations/en.json | 6 ++ tests/fixtures/golden/refrigerator.json | 4 + .../golden/refrigerator_tp1x_ref_21k_us.json | 1 + tests/fixtures/refrigerator_device.json | 3 +- tests/test_fridge_capabilities.py | 91 +++++++++++++++++++ 9 files changed, 146 insertions(+), 8 deletions(-) diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index caddc51..02e7e44 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -20,6 +20,7 @@ REGISTRY = DeviceRegistry( dishwasher.DIAGNOSIS, fridge.ICEMAKER_NIGHTTIME, fridge.FLEX_ZONE, + fridge.AI_ENERGY_LEVEL, fridge.REFRIGERATION, fridge.AUTOFILL, fridge.WELCOME_LIGHTING, diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index a258fa2..89c7a31 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -242,6 +242,14 @@ SELF_CHECK = Capability( SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result', name='Self-check result', icon='mdi:clipboard-check-outline', entity_category='diagnostic'), + # List of error codes from the last self-check; joined for display. + # Not every fridge reports the field, hence the exists_fn. + SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error', + name='Self-check error', icon='mdi:alert-circle-outline', + entity_category='diagnostic', + exists_fn=lambda rep, resources: ( + not rep or 'x.com.samsung.da.error' in rep), + value_fn=lambda v: ', '.join(v) if isinstance(v, list) else v), ButtonDesc(key='selfcheck_start', field='', name='Start self-check', payload='Start', icon='mdi:play-circle-outline', entity_category='diagnostic', @@ -580,6 +588,31 @@ FLEX_ZONE = Capability( ), ) +# --------------------------------------------------------------------------- +# AI energy-saving level +# +# supportedAiLevel is a single-entry list ('1' only) on some hardware, where +# a select would offer no real choice — gated to >1 supported level. +# --------------------------------------------------------------------------- + +AI_ENERGY_LEVEL = Capability( + href='/energy/ailevel/vs/0', + poll_tier='cold', + entities=( + SelectDesc(key='ai_energy_level', field='aiLevel', + name='AI energy level', icon='mdi:leaf', + translation_key='ai_energy_level', + entity_category='config', + options_field='supportedAiLevel', + exists_fn=lambda rep, resources: ( + not rep or ( + isinstance((sl := rep.get('supportedAiLevel')), (list, tuple)) + and len(sl) > 1)), + write_fn=lambda p, rep, href=None: ( + ['energy', 'ailevel', 'vs', '0'], {'aiLevel': p})), + ), +) + # --------------------------------------------------------------------------- # Firmware update # --------------------------------------------------------------------------- diff --git a/custom_components/localthings/registry/capabilities/ignored.py b/custom_components/localthings/registry/capabilities/ignored.py index be54b62..b7e3cc1 100644 --- a/custom_components/localthings/registry/capabilities/ignored.py +++ b/custom_components/localthings/registry/capabilities/ignored.py @@ -85,14 +85,9 @@ IGNORED: list[Capability] = [ # Warranty/service-plan enrollment status — every field reads "Unknown" # on hardware not enrolled in a Samsung Care+ style program. Capability(href='/dginformation/vs/0'), - # AI energy-saving level; only one value ('1') is ever in - # supportedAiLevel on hardware seen so far, so there's no real choice - # to expose. Revisit if a device surfaces more than one supported level. - Capability(href='/energy/ailevel/vs/0'), # OCF-native vacation-mode flag (fridge). Only one value ('RVACATION_OFF') - # has ever been seen in `modes` (issue #7's dump), same "no real choice - # to expose yet" situation as /energy/ailevel/vs/0 above. Revisit if a - # device surfaces it toggled on. + # has ever been seen in `modes` (issue #7's dump) -- no real choice to + # expose yet. Revisit if a device surfaces it toggled on. Capability(href='/mode/0'), # Opaque integer with no supportedModes/options list to interpret it # against — meaning unclear from the raw resource alone. diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index 2b2eca4..b6cb7e6 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -143,6 +143,12 @@ "180": "3 hr", "240": "4 hr" } + }, + "ai_energy_level": { + "state": { + "1": "Level 1", + "2": "Level 2" + } } }, "sensor": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 2b2eca4..b6cb7e6 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -143,6 +143,12 @@ "180": "3 hr", "240": "4 hr" } + }, + "ai_energy_level": { + "state": { + "1": "Level 1", + "2": "Level 2" + } } }, "sensor": { diff --git a/tests/fixtures/golden/refrigerator.json b/tests/fixtures/golden/refrigerator.json index 45ca68b..0945c9a 100644 --- a/tests/fixtures/golden/refrigerator.json +++ b/tests/fixtures/golden/refrigerator.json @@ -1,5 +1,6 @@ { "state_keys": [ + "ai_energy_level", "alarm_code", "auto_door_opener", "autofill", @@ -39,11 +40,13 @@ "rapid_freezing", "rapid_fridge", "sabbath_mode", + "selfcheck_error", "selfcheck_result", "selfcheck_status", "welcome_lighting" ], "discovery_unique_ids": [ + "samsung_refrigerator_ai_energy_level", "samsung_refrigerator_alarm_code", "samsung_refrigerator_auto_door_opener", "samsung_refrigerator_autofill", @@ -83,6 +86,7 @@ "samsung_refrigerator_rapid_freezing", "samsung_refrigerator_rapid_fridge", "samsung_refrigerator_sabbath_mode", + "samsung_refrigerator_selfcheck_error", "samsung_refrigerator_selfcheck_result", "samsung_refrigerator_selfcheck_start", "samsung_refrigerator_selfcheck_status", diff --git a/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json index dd94692..ed004a4 100644 --- a/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json +++ b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json @@ -28,6 +28,7 @@ "rapid_freezing", "rapid_fridge", "sabbath_mode", + "selfcheck_error", "selfcheck_result", "selfcheck_status" ] diff --git a/tests/fixtures/refrigerator_device.json b/tests/fixtures/refrigerator_device.json index abf85c5..ca6d750 100644 --- a/tests/fixtures/refrigerator_device.json +++ b/tests/fixtures/refrigerator_device.json @@ -279,7 +279,8 @@ "rep": { "aiLevel": "1", "supportedAiLevel": [ - "1" + "1", + "2" ], "href": "/energy/ailevel/vs/0" } diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index cc4daea..b57f59e 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -171,6 +171,7 @@ class TestTp1xNativeDuplicateResources: assert 'ice_maker_enabled' not in state assert state['icemaker_one_enabled'] is True assert state['icemaker_two_enabled'] is True + assert state['selfcheck_error'] == 'DA_ERROR_NONE' class TestPantryZone: @@ -228,3 +229,93 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage: assert unbound == [] state = flatten(bound, resources) assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE' + + +class TestSelfCheckError: + """Self-check diagnostic error list -- surfaced on hardware that reports + x.com.samsung.da.error, joined into a single display string.""" + + def _desc(self): + return next(e for e in fridge.SELF_CHECK.entities if e.key == 'selfcheck_error') + + def test_exists_when_field_present(self): + desc = self._desc() + assert desc.exists_fn({'x.com.samsung.da.error': ['DA_ERROR_NONE']}, {}) is True + + def test_does_not_exist_when_field_absent(self): + desc = self._desc() + assert desc.exists_fn({'x.com.samsung.da.status': 'Ready'}, {}) is False + + def test_exists_for_empty_stub_rep(self): + """An empty {} rep is /device/0's not-yet-fetched-stub carve-out -- + must be included-for-now, same as ENERGY_METER's fields.""" + desc = self._desc() + assert desc.exists_fn({}, {}) is True + + def test_value_joins_list(self): + desc = self._desc() + assert desc.value_fn(['E1', 'E2']) == 'E1, E2' + + def test_value_passes_through_scalar(self): + desc = self._desc() + assert desc.value_fn('DA_ERROR_NONE') == 'DA_ERROR_NONE' + + +class TestAiEnergyLevel: + """AI energy-saving level select on /energy/ailevel/vs/0 -- only + exposed when the device actually offers more than one level; hardware + that only ever reports a single supported level keeps this ignored + (see capabilities/ignored.py).""" + + def test_href(self): + assert fridge.AI_ENERGY_LEVEL.href == '/energy/ailevel/vs/0' + + def test_hidden_with_single_supported_level(self): + desc = fridge.AI_ENERGY_LEVEL.entities[0] + assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is False + + def test_shown_with_multiple_supported_levels(self): + desc = fridge.AI_ENERGY_LEVEL.entities[0] + assert desc.exists_fn( + {'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is True + + def test_exists_for_empty_stub_rep(self): + """An empty {} rep is /device/0's not-yet-fetched-stub carve-out -- + must be included-for-now, same as ENERGY_METER's fields.""" + desc = fridge.AI_ENERGY_LEVEL.entities[0] + assert desc.exists_fn({}, {}) is True + + def test_hidden_when_supported_level_is_non_list_scalar(self): + """A stray scalar (e.g. a string) must not be len()-checked as if it + were a list -- a 5-char string would otherwise wrongly pass `> 1`.""" + desc = fridge.AI_ENERGY_LEVEL.entities[0] + assert desc.exists_fn( + {'aiLevel': '1', 'supportedAiLevel': '12'}, {}) is False + + def test_write(self): + desc = fridge.AI_ENERGY_LEVEL.entities[0] + path, body = desc.write_fn('2', {}) + assert path == ['energy', 'ailevel', 'vs', '0'] + assert body == {'aiLevel': '2'} + + def test_synthetic_fixture_has_complete_coverage(self): + """refrigerator_device.json's supportedAiLevel was extended to two + entries (['1', '2']) specifically to exercise this select -- the + real captured TP1X_REF_21K_US dump only ever reports one entry, so + this capability is synthetic-fixture-only for now.""" + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import refrigerator + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('refrigerator') + unbound = [] + bound = discover( + resources, + refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + state = flatten(bound, resources) + assert state['ai_energy_level'] == '1' From 0cc9d8e5119913b2eae01a905b1a98391316f736 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Wed, 22 Jul 2026 09:04:24 +0200 Subject: [PATCH 13/29] fix: render selfcheck_error as no-value on empty list; document ai_energy_level level labels --- .../localthings/registry/capabilities/fridge.py | 4 +++- tests/test_fridge_capabilities.py | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 89c7a31..0a44bea 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -249,7 +249,7 @@ SELF_CHECK = Capability( entity_category='diagnostic', exists_fn=lambda rep, resources: ( not rep or 'x.com.samsung.da.error' in rep), - value_fn=lambda v: ', '.join(v) if isinstance(v, list) else v), + value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v), ButtonDesc(key='selfcheck_start', field='', name='Start self-check', payload='Start', icon='mdi:play-circle-outline', entity_category='diagnostic', @@ -599,6 +599,8 @@ AI_ENERGY_LEVEL = Capability( href='/energy/ailevel/vs/0', poll_tier='cold', entities=( + # Only levels '1'/'2' have translated labels in strings.json/translations; + # a higher supportedAiLevel entry renders as its raw value until added. SelectDesc(key='ai_energy_level', field='aiLevel', name='AI energy level', icon='mdi:leaf', translation_key='ai_energy_level', diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index b57f59e..fa0f470 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -260,6 +260,12 @@ class TestSelfCheckError: desc = self._desc() assert desc.value_fn('DA_ERROR_NONE') == 'DA_ERROR_NONE' + def test_value_none_for_empty_list(self): + """An empty error list means no value to show -- None (unknown), + not an empty string.""" + desc = self._desc() + assert desc.value_fn([]) is None + class TestAiEnergyLevel: """AI energy-saving level select on /energy/ailevel/vs/0 -- only From bc9d4f4cf55ff877bb9b2f180f058b37e4fe4799 Mon Sep 17 00:00:00 2001 From: paddawan2002 <56457404+paddawan2002@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:26:01 +0200 Subject: [PATCH 14/29] Fix AC coverage for TP1X-class (oneUiVersion "7.0 Air conditioner") --- custom_components/localthings/climate.py | 45 +- .../registry/by_type/airconditioner.py | 1 + .../registry/capabilities/airconditioner.py | 38 +- ...nditioner_tp1x_da_ac_rac_01011_device.json | 595 ++++++++++++++++++ .../airconditioner_tp1x_da_ac_rac_01011.json | 14 + tests/test_airconditioner_capabilities.py | 55 +- tests/test_golden_regression.py | 18 + 7 files changed, 758 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/airconditioner_tp1x_da_ac_rac_01011_device.json create mode 100644 tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 08dbf3a..34b1bc3 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -41,6 +41,7 @@ from .registry.capabilities.airconditioner import ( HREF_TEMP_CURRENT as TEMP_CURRENT_HREF, HREF_TEMP_DESIRED as TEMP_DESIRED_HREF, HREF_TEMP_CONTROL as TEMP_CONTROL_HREF, + HREF_TEMPS_VS as TEMPS_VS_HREF, HREF_WIND_STRENGTH as WIND_STRENGTH_HREF, HREF_WIND_DIRECTION as WIND_DIRECTION_HREF, HREF_CONVENIENT as CONVENIENT_HREF, @@ -125,6 +126,22 @@ def _num(value): return None +def _temps_vs_item(rep: dict) -> dict: + """First item of the vendor `/temperatures/vs/0` items[] array. + + Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner", e.g. + model TP1X_DA-AC-RAC-01011) does NOT expose the OCF-standard + /temperature/current/0 + /temperature/desired/0 pair; it reports current + and target under a single `/temperatures/vs/0` resource whose + `x.com.samsung.da.items[0]` carries current/desired/minimum/maximum/ + increment/unit. Returns {} when absent, so callers fall through cleanly. + """ + items = rep.get('x.com.samsung.da.items') + if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict): + return items[0] + return {} + + class LocalThingsClimate(LocalThingsEntity, ClimateEntity): """Composite climate entity for a Samsung air conditioner.""" @@ -172,24 +189,41 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): # -- temperature -------------------------------------------------------- + def _temps_vs(self) -> dict: + """Vendor `/temperatures/vs/0` items[0] (empty {} when absent).""" + return _temps_vs_item(self._rep(TEMPS_VS_HREF)) + @property def temperature_unit(self) -> str: raw = self._rep(TEMP_DESIRED_HREF).get('units') + if raw is None: + raw = self._temps_vs().get('x.com.samsung.da.unit') return (UnitOfTemperature.FAHRENHEIT if normalize_temp_unit(raw, '°C') == '°F' else UnitOfTemperature.CELSIUS) @property def current_temperature(self): - return _num(self._rep(TEMP_CURRENT_HREF).get('temperature')) + v = _num(self._rep(TEMP_CURRENT_HREF).get('temperature')) + if v is None: + v = _num(self._temps_vs().get('x.com.samsung.da.current')) + return v @property def target_temperature(self): - return _num(self._rep(TEMP_DESIRED_HREF).get('temperature')) + v = _num(self._rep(TEMP_DESIRED_HREF).get('temperature')) + if v is None: + v = _num(self._temps_vs().get('x.com.samsung.da.desired')) + return v def _range(self) -> list | None: r = self._rep(TEMP_DESIRED_HREF).get('range') - return r if (isinstance(r, (list, tuple)) and len(r) == 2) else None + if isinstance(r, (list, tuple)) and len(r) == 2: + return r + item = self._temps_vs() + lo = _num(item.get('x.com.samsung.da.minimum')) + hi = _num(item.get('x.com.samsung.da.maximum')) + return [lo, hi] if (lo is not None and hi is not None) else None @property def min_temp(self) -> float: @@ -203,7 +237,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): @property def target_temperature_step(self) -> float: - return _num(self._rep(TEMP_CONTROL_HREF).get('increment')) or 1.0 + return (_num(self._rep(TEMP_CONTROL_HREF).get('increment')) + or _num(self._rep(TEMP_CONTROL_HREF).get('x.com.samsung.da.increment')) + or _num(self._temps_vs().get('x.com.samsung.da.increment')) + or 1.0) # -- hvac mode ---------------------------------------------------------- diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 7d27cc8..a7cbf36 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -24,6 +24,7 @@ REGISTRY = DeviceRegistry( airconditioner.AIR_PURIFY, airconditioner.AUTO_CLEAN, airconditioner.AIR_FILTER, + airconditioner.DISPLAY_LIGHT, *airconditioner.COVERAGE, ]), ) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 7454b09..0a43e3c 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -34,10 +34,12 @@ HREF_TEMP_CONTROL = '/temperature/control/vs/0' # target_temperature_step HREF_WIND_STRENGTH = '/wind/strength/vs/0' # fan_mode HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode +HREF_TEMPS_VS = '/temperatures/vs/0' # vendor temp fallback (items[] array) CLIMATE_CONSUMED_HREFS = [ HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED, - HREF_TEMP_CONTROL, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION, HREF_CONVENIENT, + HREF_TEMP_CONTROL, HREF_TEMPS_VS, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION, + HREF_CONVENIENT, ] @@ -146,6 +148,20 @@ AIR_FILTER = Capability( ), ) +DISPLAY_LIGHT = Capability( + href='/light/vs/0', + poll_tier='cold', + entities=( + SwitchDesc(key='display_light', field='mode', + name='Display light', icon='mdi:led-on', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['light', 'vs', '0'], + {'mode': 'On' if p == 'On' else 'Off'})), + ), +) + # --------------------------------------------------------------------------- # AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate # entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources. @@ -155,8 +171,6 @@ AIR_FILTER = Capability( # bound so discover() reports no coverage gap. # --------------------------------------------------------------------------- _AC_IGNORED = [ - # Vendor superset that duplicates the OCF /temperature/current+desired pair. - '/temperatures/vs/0', # 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', @@ -164,6 +178,24 @@ _AC_IGNORED = [ '/humidity/vs/0', # Presence-personalization plumbing (empty item list here). '/personality/presence/vs/0', + # --- TP1X-class (oneUiVersion "7.0 Air conditioner") housekeeping / opaque + # blobs. These carry no user-actionable state or no documented write + # contract, so per the 'don't guess' rule they are ignored rather than + # modeled. (Temperature for this class lives at /temperatures/vs/0, now + # consumed by the climate entity -- see CLIMATE_CONSUMED_HREFS.) + '/airlevelcheck/vs/0', # periodic air-quality sensing scheduler plumbing + '/aisleep/vs/0', # AI-sleep feedback state (no actionable control) + '/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap + '/da/softreset/vs/0', # soft-reset trigger plumbing + '/keepnormalstate/vs/0', # internal keep-normal flag + '/mds/absencemonitoring/vs/0', # motion-detection sensor plumbing (empty here) + '/mds/absencestate/vs/0', # motion-detection state (empty here) + '/option/muteonce/vs/0', # one-shot buzzer mute (no persistent state) + '/remotedatacontrol/vs/0', # remote data-control session status + '/remotetemperature/vs/0', # external temp-sensor feed (unset on this unit) + '/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob + '/selfcheck/vs/0', # self-diagnosis action (start/cancel, no simple state) + '/welcome/temperature/vs/0', # welcome-cooling plumbing ] # Built as bare no-entity caps; folded into the AC registry (not global). diff --git a/tests/fixtures/airconditioner_tp1x_da_ac_rac_01011_device.json b/tests/fixtures/airconditioner_tp1x_da_ac_rac_01011_device.json new file mode 100644 index 0000000..9960b19 --- /dev/null +++ b/tests/fixtures/airconditioner_tp1x_da_ac_rac_01011_device.json @@ -0,0 +1,595 @@ +{ + "meta": { + "note": "Samsung Room A/C, model TP1X_DA-AC-RAC-01011 (oneUiVersion '7.0 Air conditioner', Tizen Lite). Scrubbed real /device/0 dump. Adds temperature via /temperatures/vs/0 + display light. Issue #17 (newer model class).", + "issue": 17, + "model": "TP1X_DA-AC-RAC-01011", + "oneUiVersion": "7.0 Air conditioner" + }, + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/airlevelcheck/vs/0", + "rep": { + "x.com.samsung.da.periodicSensingInterval": "1800", + "x.com.samsung.da.sensingState": "NonProcessing", + "x.com.samsung.da.lastSensingTime": "1784508715", + "x.com.samsung.da.lastSensingLevel": "Kr1", + "x.com.samsung.da.periodicSensingSkipStatus": "Off", + "x.com.samsung.da.periodicSensingSkipTime": "00000000", + "x.com.samsung.da.periodicSensingActivationState": "On", + "x.com.samsung.da.autoExeState": "Alarm", + "x.com.samsung.da.supportedAutoExeState": [ + "Airpurify", + "Alarm", + "Sensing" + ], + "x.com.samsung.da.startSensingOnce": "Off", + "x.com.samsung.da.autoExeSetting": "Off", + "x.com.samsung.da.supportedAutoExeSetting": [ + "On", + "Off" + ] + } + }, + { + "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": { + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-22T07:13:19", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-22T07:13:19", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000000A0012C016102490F0F0000", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.airconOptionList": [ + "PRODUCT_GLOBAL", + "AI_GLOBAL_HEATPUMP_4.0", + "AI_3.0", + "Auto_To_AI", + "AI_Heat", + "ENERGY_2.0", + "HOMECARE_WIZARD_V2", + "DR", + "SingleCommand_1" + ] + } + }, + { + "href": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.start": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeDate": "1784704371", + "x.com.samsung.da.cumulativeSavedPower": "-1", + "x.com.samsung.da.cumulativePower": "75025", + "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": "+02:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterCapacity": "500", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable", + "washable" + ], + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterUsage": "42", + "x.com.samsung.da.filterDesiredUsage": "500", + "x.com.samsung.da.supportedFilterDesiredUsage": [ + "180", + "300", + "500", + "700" + ] + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "53" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01011_0000|10275643|60010523001911F58E0049320092E900", + "x.com.samsung.da.description": "TP1X_DA-AC-RAC-01011_0000", + "x.com.samsung.da.serialNum": "**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": "02762A260401", + "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.newVersionAvailable": "0", + "x.com.samsung.da.number": "02756C25082500,FFFFFFFFFFFFFF" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "02669A24092600,02636A10001200" + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "02672A10001000,FFFFFFFFFFFFFF" + } + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "AR4", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01" + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 1 + } + }, + { + "href": "/light/vs/0", + "rep": { + "mode": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/mds/absencemonitoring/vs/0", + "rep": {} + }, + { + "href": "/mds/absencestate/vs/0", + "rep": {} + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Smart", + "Speed", + "MotionIndirect", + "Nano", + "NanoSleep", + "DryComfort" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.modes": [ + "Heat" + ], + "workingMode": "Heat", + "x.com.samsung.da.supportedModes": [ + "Auto", + "Cool", + "Dry", + "Heat", + "Wind" + ], + "x.com.samsung.da.options": [ + "OptionCode_56378", + "Sleep_6", + "ArtificialWorking_Off", + "Off", + "OutdoorTemp_68", + "OutdoorConnection_Connected" + ] + } + }, + { + "href": "/option/airpurify/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.option.airpurify" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "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.supportedStatus": [ + "Start", + "Stop" + ], + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.settingStatus": "On", + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "Off" + ], + "defaultSettingStatus": "On" + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "href": "/personality/presence/vs/0", + "rep": {} + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off", + "operationNumber": "39", + "displayCondition": "Enable" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotedatacontrol/vs/0", + "rep": { + "x.com.samsung.da.status": "Off", + "x.com.samsung.da.connectionStatus": "Disconnected" + } + }, + { + "href": "/remotetemperature/vs/0", + "rep": { + "x.com.samsung.da.temperature": "", + "x.com.samsung.da.unit": "", + "x.com.samsung.da.error": "" + } + }, + { + "href": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "3EFFFFFFFF101E121EFFFF101EFFFF000001001F0001001F001F0000009CACFFFF0000", + "x.com.samsung.da.id": "RAC", + "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": { + "x.com.samsung.da.cleanLevel": "1", + "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": [ + "1" + ] + }, + { + "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": "/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.current": "17.0", + "x.com.samsung.da.desired": "25.0", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.increment": "1", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "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", + "Up_And_Low", + "Left_And_Right", + "All" + ] + } + }, + { + "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" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "0", + "otnCompleteDate": "noHistory", + "scheduledTime": "None", + "swVersionInfo": { + "platform": "Tizen Lite", + "oneUiVersion": "7.0 Air conditioner", + "osVersion": "4.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "ARA-KR-TP1-25-ARXX00", + "versions": [ + "11260401" + ], + "visVersion": "260401" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Africa/Johannesburg", + "offset": "+02:00", + "DST": "OFF" + } + }, + { + "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": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/dginformation/vs/0", + "rep": { + "enrolmentstatus": "Unknown", + "devicestate": "Unknown", + "lockstatus": "Normal", + "nextduedate": "", + "workingminutes": 0, + "paymentinfo": { + "emiplan": "Unknown", + "currency": "Unknown", + "totalemi": 0, + "totalemipaid": 0 + } + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json new file mode 100644 index 0000000..e640332 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json @@ -0,0 +1,14 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "air_purify", + "alarm_code", + "auto_clean", + "climate", + "display_light", + "energy_kwh", + "energy_saved_kwh", + "firmware_update" + ] +} \ No newline at end of file diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index efcdbce..328fbc7 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -6,7 +6,7 @@ climate entity itself lives in climate.py (imports homeassistant) and is not importable here -- consistent with how the other HA platform files are untested. """ from custom_components.localthings.registry.adapter import flatten -from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.by_type import for_device, for_device_by_model from custom_components.localthings.registry.capabilities import airconditioner from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.entities import ClimateDesc @@ -106,3 +106,56 @@ def test_climate_consumed_hrefs_declared_as_coverage(): caps = reg.capabilities.get(href) assert caps, href assert all(c.entities == () for c in caps), href + + +# --------------------------------------------------------------------------- +# TP1X_DA-AC-RAC-01011 (oneUiVersion "7.0 Air conditioner", Tizen Lite) -- a +# newer model class than the ARTIK051_PRAC dump above. It has no OCF-standard +# /temperature/current+desired pair (temperature lives on the vendor +# /temperatures/vs/0 items[] resource), exposes a /light/vs/0 display light, and +# carries 13 extra vendor housekeeping hrefs. Issue #17 for this class. +# --------------------------------------------------------------------------- + +def _ac_tp1x(): + resources = _load_device('airconditioner_tp1x_da_ac_rac_01011') + one_ui = resources['/otninformation/vs/0']['swVersionInfo']['oneUiVersion'] + return for_device(one_ui), resources + + +def test_tp1x_resolves_to_airconditioner_registry(): + reg, _ = _ac_tp1x() + assert reg is not None and reg.name == 'airconditioner' + + +def test_tp1x_no_unbound_hrefs(): + """Every resource in the TP1X dump binds or is covered -- including + /temperatures/vs/0, /light/vs/0 and the 13 housekeeping hrefs absent from + the ARTIK051 dump. Clears the coverage-gap repair.""" + reg, resources = _ac_tp1x() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_tp1x_display_light_switch_present(): + """/light/vs/0 (mode On/Off) surfaces as the display-light switch.""" + reg, resources = _ac_tp1x() + state = flatten( + discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state.get('display_light') is True # device reports mode == 'On' + + +def test_tp1x_vendor_temperature_and_light_covered(): + """The vendor temperature resource (read by the climate entity) and the + display-light resource both resolve in the registry -- no gap.""" + reg, _ = _ac_tp1x() + assert reg.capabilities.get('/temperatures/vs/0'), '/temperatures/vs/0' + assert reg.capabilities.get('/light/vs/0'), '/light/vs/0' + + +def test_tp1x_climate_entity_is_bound(): + """The composite climate entity still binds the primary /mode/vs/0.""" + reg, resources = _ac_tp1x() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + climate = [b for b in bound if isinstance(b.desc, ClimateDesc)] + assert len(climate) == 1 and climate[0].href == '/mode/vs/0' diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 265f226..85c1eb1 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -153,6 +153,24 @@ def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k(): ) +def test_registry_reproduces_golden_state_keys_for_ac_tp1x_da_ac_rac_01011(): + """Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner"; model + TP1X_DA-AC-RAC-01011) reports temperature via the vendor /temperatures/vs/0 + items[] resource and adds a /light/vs/0 display light, with 13 extra vendor + housekeeping hrefs -- issue #17 for this model class.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_tp1x_da_ac_rac_01011') + golden = json.loads( + (GOLDEN / 'airconditioner_tp1x_da_ac_rac_01011.json').read_text() + ) + state_keys = _new_state_keys('airconditioner_tp1x_da_ac_rac_01011', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { From e942c822a63ffcf278a3874e54ad257f4cf692e6 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 22 Jul 2026 13:47:57 +0000 Subject: [PATCH 15/29] feat: extend AC support for TP2X_RAC_20K and TP1X_DA-AC-RAC-01001_0000 Closes the coverage-gap repair for two more room-AC boards (issues #37, - TP2X_RAC_20K reports no oneUiVersion and no '_PRAC_' modelNum token, so its device type went unrecognized -- add a '_RAC_' fallback token to for_device_by_model. - Bind the resources both dumps actually populate: mute-once (switch), display light (switch), self-check (reused from fridge.SELF_CHECK), and the circuit-breaker current-limit setting (read-only diagnostic sensors, since its write contract isn't confirmed from the dump). - Ignore the rest as plumbing/opaque/unconfirmed: control-set descriptor, keepnormalstate flag, software-reset trigger, AI Sleep handshake, absence-monitoring stubs, remote-data-control, remote-temperature, reservation ruleset, and the welcome-temperature tracker. Adds scrubbed fixtures + goldens for both dumps and extends the AC capability/golden-regression tests to assert zero unbound hrefs. --- .../localthings/registry/by_type/__init__.py | 5 + .../registry/by_type/airconditioner.py | 3 + .../registry/capabilities/airconditioner.py | 52 +- .../airconditioner_tp1x_rac_device.json | 591 ++++++++++++++++++ .../airconditioner_tp2x_rac_20k_device.json | 380 +++++++++++ .../golden/airconditioner_tp1x_rac.json | 20 + .../golden/airconditioner_tp2x_rac_20k.json | 13 + tests/test_airconditioner_capabilities.py | 78 ++- tests/test_golden_regression.py | 32 +- 9 files changed, 1162 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/airconditioner_tp1x_rac_device.json create mode 100644 tests/fixtures/airconditioner_tp2x_rac_20k_device.json create mode 100644 tests/fixtures/golden/airconditioner_tp1x_rac.json create mode 100644 tests/fixtures/golden/airconditioner_tp2x_rac_20k.json diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index a8ae11f..2dcd79b 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -96,4 +96,9 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # a modelNum carrying the '_PRAC_' (Package Room Air Conditioner) token. if key is None and '_PRAC_' in (model_num or ''): key = 'airconditioner' + # Older/simpler RAC boards (e.g. TP2X_RAC_20K, issue #37) use the plain + # '_RAC_' token instead -- distinct from '_PRAC_' above (no overlap: the + # 'P' sits between the underscore and 'RAC' in that token). + if key is None and '_RAC_' in (model_num or ''): + key = 'airconditioner' return _REGISTRY_BY_KEY.get(key) if key else None diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index a7cbf36..0d5f050 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -19,12 +19,15 @@ REGISTRY = DeviceRegistry( common.ALARMS, common.ENERGY_METER, fridge.FIRMWARE_UPDATE, + fridge.SELF_CHECK, dishwasher.DIAGNOSIS, airconditioner.CLIMATE, airconditioner.AIR_PURIFY, airconditioner.AUTO_CLEAN, airconditioner.AIR_FILTER, airconditioner.DISPLAY_LIGHT, + airconditioner.MUTE_ONCE, + airconditioner.CURRENT_LIMIT, *airconditioner.COVERAGE, ]), ) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 0a43e3c..bfcb374 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -15,7 +15,7 @@ different schema (see capabilities/__init__.py). They live only in the AC by_type registry. """ from ..capability import Capability -from ..entities import ClimateDesc, SensorDesc, SwitchDesc +from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc # --------------------------------------------------------------------------- # Canonical AC resource hrefs. The climate entity (climate.py) binds the @@ -162,6 +162,43 @@ DISPLAY_LIGHT = Capability( ), ) +# Confirmed against issue #38's dump (TP1X_DA-AC-RAC-01001_0000): a single +# boolean field, no vendor prefix, mirroring the On/Off convention used +# throughout the rest of this API. +MUTE_ONCE = Capability( + href='/option/muteonce/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='mute_once', field='muteonce', + name='Mute once', icon='mdi:volume-mute', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['option', 'muteonce', 'vs', '0'], + {'muteonce': 'On' if p == 'On' else 'Off'})), + ), +) + +# Circuit-breaker current-limit setting (issue #38, TP1X board): `operation` +# toggles the limiter and `modes` picks a level out of `supportedModes` +# (seen as '3'..'9'). No vendor field-name prefix and no unit/label in the +# dump to confirm what the levels mean (amps vs. an abstract tier) -- +# exposed read-only per the 'don't guess' rule rather than risking an +# unverified write to live HVAC hardware. +CURRENT_LIMIT = Capability( + href='/electriccurrent/vs/0', + poll_tier='cold', + entities=( + BinarySensorDesc(key='current_limit_enabled', field='operation', + name='Current limit enabled', icon='mdi:current-ac', + entity_category='diagnostic', + value_fn=lambda v: v == 'On'), + SensorDesc(key='current_limit_level', field='modes', + name='Current limit level', icon='mdi:current-ac', + entity_category='diagnostic'), + ), +) + # --------------------------------------------------------------------------- # AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate # entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources. @@ -178,11 +215,12 @@ _AC_IGNORED = [ '/humidity/vs/0', # Presence-personalization plumbing (empty item list here). '/personality/presence/vs/0', - # --- TP1X-class (oneUiVersion "7.0 Air conditioner") housekeeping / opaque - # blobs. These carry no user-actionable state or no documented write - # contract, so per the 'don't guess' rule they are ignored rather than - # modeled. (Temperature for this class lives at /temperatures/vs/0, now - # consumed by the climate entity -- see CLIMATE_CONSUMED_HREFS.) + # --- TP1X/TP2X-class housekeeping / opaque blobs. These carry no + # user-actionable state or no documented write contract, so per the + # 'don't guess' rule they are ignored rather than modeled. + # /option/muteonce/vs/0 and /selfcheck/vs/0 are deliberately NOT here -- + # see MUTE_ONCE and fridge.SELF_CHECK above/in the by_type registry, + # both of which have a confirmed, cleanly modelable contract. '/airlevelcheck/vs/0', # periodic air-quality sensing scheduler plumbing '/aisleep/vs/0', # AI-sleep feedback state (no actionable control) '/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap @@ -190,11 +228,9 @@ _AC_IGNORED = [ '/keepnormalstate/vs/0', # internal keep-normal flag '/mds/absencemonitoring/vs/0', # motion-detection sensor plumbing (empty here) '/mds/absencestate/vs/0', # motion-detection state (empty here) - '/option/muteonce/vs/0', # one-shot buzzer mute (no persistent state) '/remotedatacontrol/vs/0', # remote data-control session status '/remotetemperature/vs/0', # external temp-sensor feed (unset on this unit) '/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob - '/selfcheck/vs/0', # self-diagnosis action (start/cancel, no simple state) '/welcome/temperature/vs/0', # welcome-cooling plumbing ] diff --git a/tests/fixtures/airconditioner_tp1x_rac_device.json b/tests/fixtures/airconditioner_tp1x_rac_device.json new file mode 100644 index 0000000..1eff68c --- /dev/null +++ b/tests/fixtures/airconditioner_tp1x_rac_device.json @@ -0,0 +1,591 @@ +{ + "meta": { + "model": "TP1X_DA-AC-RAC-01001_0000", + "device_type": "airconditioner", + "source": "issue #38 diagnostics (scrubbed)", + "note": "Newer TP1X RAC board (oneUiVersion '7.0 Air conditioner') with a fuller resource set than the ARTIK051/TP2X boards: display light, self-check, mute-once, and a circuit-breaker current-limit setting." + }, + "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": { + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-22T12:58:00", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-22T12:58:00", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "AEB700A0012C01610248040F0000", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.airconOptionList": [ + "PRODUCT_GLOBAL", + "AI_GLOBAL_HEATPUMP_4.0", + "AI_3.0", + "Auto_To_AI", + "AI_Heat", + "ENERGY_2.0", + "HOMECARE_WIZARD_V2", + "DR", + "SingleCommand_1" + ] + } + }, + { + "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": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "href": "/dginformation/vs/0", + "rep": { + "enrolmentstatus": "Unknown", + "devicestate": "Unknown", + "lockstatus": "Unknown", + "nextduedate": "", + "workingminutes": 0, + "paymentinfo": { + "emiplan": "Unknown", + "currency": "Unknown", + "totalemi": 0, + "totalemipaid": 0 + } + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.start": "2026-07-22T10:59:14Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.durationminutes": "1148", + "x.com.samsung.da.drlcLevel": "1", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/electriccurrent/vs/0", + "rep": { + "operation": "Off", + "modes": "3", + "supportedModes": [ + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeDate": "1784725078", + "x.com.samsung.da.cumulativeSavedPower": "-1", + "x.com.samsung.da.cumulativePower": "160922", + "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": "+03:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterCapacity": "500", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable", + "washable" + ], + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterUsage": "30", + "x.com.samsung.da.filterDesiredUsage": "500", + "x.com.samsung.da.supportedFilterDesiredUsage": [ + "180", + "300", + "500", + "700" + ] + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10267841|60010532001911000E0041220090A000", + "x.com.samsung.da.description": "TP1X_DA-AC-RAC-01001_0000", + "x.com.samsung.da.serialNum": "**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": "02762A260401", + "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.newVersionAvailable": "0", + "x.com.samsung.da.number": "02678A24092500,FFFFFFFFFFFFFF" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "02669A24092600,02636A10001200" + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "02672A10001000,FFFFFFFFFFFFFF" + } + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "AR2", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01" + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 1 + } + }, + { + "href": "/light/vs/0", + "rep": { + "mode": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/mds/absencemonitoring/vs/0", + "rep": {} + }, + { + "href": "/mds/absencestate/vs/0", + "rep": {} + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Smart", + "Speed" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.modes": [ + "Cool" + ], + "workingMode": "Cool", + "x.com.samsung.da.supportedModes": [ + "Auto", + "Cool", + "Dry", + "Fan", + "Heat" + ], + "x.com.samsung.da.options": [ + "OptionCode_54328", + "Sleep_0", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_240", + "Volume_100", + "StopAutoClean_Idle", + "DiagnosisAI_Off", + "ProgressDiagnosisAI_0", + "ResultDiagnosisAI_Normal", + "ExtendOptionCode_230024", + "UpdateAllow_NotAllowed", + "DurationOn_120", + "OutdoorTemp_92", + "CoolCapa_50", + "WarmCapa_60", + "Autoclean_Off", + "WelcomeCoolingState_Off", + "WelcomeHeatingState_Off", + "OutdoorConnection_Connected" + ] + } + }, + { + "href": "/option/airpurify/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.option.airpurify" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "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.supportedStatus": [ + "Start", + "Stop" + ], + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.settingStatus": "Off", + "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", + "otnStatus": "None", + "flashingProgress": "0", + "otnCompleteDate": "noHistory", + "scheduledTime": "None", + "swVersionInfo": { + "platform": "Tizen Lite", + "oneUiVersion": "7.0 Air conditioner", + "osVersion": "4.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "ARA-KR-TP1-25-ARXX00", + "versions": [ + "11260401" + ], + "visVersion": "260401" + }, + { + "type": "Micom", + "modelId": "845210267841FFFFFFFF", + "versions": [ + "24092500", + "FFFFFFFF" + ], + "visVersion": "240925" + }, + { + "type": "Micom", + "modelId": "045210267841FFFFFFFF", + "versions": [ + "24092500", + "FFFFFFFF" + ], + "visVersion": "240925" + }, + { + "type": "Micom", + "modelId": "04521026694110263641", + "versions": [ + "24092600", + "10001200" + ], + "visVersion": "240926" + }, + { + "type": "Micom", + "modelId": "FGCAN3", + "versions": [ + "", + "" + ], + "visVersion": "999999" + }, + { + "type": "Micom", + "modelId": "845210266941FFFFFFFF", + "versions": [ + "24092600", + "FFFFFFFF" + ], + "visVersion": "240926" + } + ] + } + }, + { + "href": "/personality/presence/vs/0", + "rep": {} + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On", + "operationNumber": "9" + } + }, + { + "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": "false", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotedatacontrol/vs/0", + "rep": { + "x.com.samsung.da.status": "Off", + "x.com.samsung.da.connectionStatus": "Disconnected" + } + }, + { + "href": "/remotetemperature/vs/0", + "rep": { + "x.com.samsung.da.temperature": "", + "x.com.samsung.da.unit": "", + "x.com.samsung.da.error": "" + } + }, + { + "href": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "AEFFFFFFFF101E121EFFFF101EFFFF000001001F00010000001F0000000000FFFF1E00", + "x.com.samsung.da.id": "RAC", + "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": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "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.current": "26.0", + "x.com.samsung.da.desired": "22.0", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.increment": "1", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Amman", + "offset": "+03:00", + "DST": "OFF" + } + }, + { + "href": "/welcome/temperature/vs/0", + "rep": { + "operatingStatus": "None", + "requestId": "0000" + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "All", + "x.com.samsung.da.supportedModes": [ + "Fix", + "Up_And_Low", + "Left_And_Right", + "All" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "1", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Low", + "Mid", + "High", + "Turbo" + ] + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "**REDACTED**" + } + } + ] +} diff --git a/tests/fixtures/airconditioner_tp2x_rac_20k_device.json b/tests/fixtures/airconditioner_tp2x_rac_20k_device.json new file mode 100644 index 0000000..1f4baaf --- /dev/null +++ b/tests/fixtures/airconditioner_tp2x_rac_20k_device.json @@ -0,0 +1,380 @@ +{ + "meta": { + "model": "TP2X_RAC_20K", + "device_type": "airconditioner", + "source": "issue #37 diagnostics (scrubbed)", + "note": "Older/simpler RAC board reporting no oneUiVersion -- resolved via the '_RAC_' modelNum token fallback in for_device_by_model (unlike issue #38's TP1X board, which self-reports oneUiVersion)." + }, + "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-22T12:42:46", + "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-22T12:42:46", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000000A0012C01610249040F0000", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "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_HEATPUMP_3.0", + "AI_3.0", + "Auto_To_AI", + "AI_Heat" + ] + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "power": 0.0 + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "0.000000", + "x.com.samsung.da.cumulativePower": "717060", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativePowerType": "total", + "x.com.samsung.da.instantaneousPowerUnit": "W" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+03:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "39", + "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/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "43" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP2X_RAC_20K|10229641|60010523001511014600003200800000", + "x.com.samsung.da.description": "TP2X_RAC_20K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "010", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.serialNumOption": "**REDACTED**", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02273A260324", + "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": "102296A23012700", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 0 + } + }, + { + "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": [ + "Auto", + "Cool", + "Dry", + "Wind", + "Heat", + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.modes": [ + "Cool" + ], + "x.com.samsung.da.options": [ + "Sleep_16", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "MotionState_Normal", + "AiTempChanged_Off", + "AiTemp_240", + "OutdoorTemp_85", + "CoolCapa_35", + "WarmCapa_35", + "Light_Off", + "Volume_100", + "StopAutoClean_Idle", + "Autoclean_Off", + "DiagnosisAI_Off", + "ProgressDiagnosisAI_1", + "ResultDiagnosisAI_Normal", + "MotionMode_PowerSave", + "OptionCode_56376", + "ExtendOptionCode_230157", + "RacInfo_None", + "UpdateAllow_NotAllowed", + "DurationOn_50", + "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": "TP2X_RAC_20K", + "versions": [ + "11260324" + ], + "visVersion": "260324" + }, + { + "type": "Micom", + "modelId": "045210229641FFFFFFFF", + "versions": [ + "23012700", + "FFFFFFFF" + ], + "visVersion": "230127" + }, + { + "type": "Micom", + "modelId": "04521022954110229941", + "versions": [ + "21092700", + "10000300" + ], + "visVersion": "210927" + } + ] + } + }, + { + "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": {} + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "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": "22.0", + "x.com.samsung.da.current": "26.0", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.increment": "1.0", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Amman", + "offset": "+03:00", + "DST": "OFF" + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Left_And_Right", + "x.com.samsung.da.supportedModes": [ + "Fix", + "All", + "Up_And_Low", + "Left_And_Right" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "2", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Low", + "Mid", + "High", + "Turbo" + ] + } + } + ] +} diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac.json b/tests/fixtures/golden/airconditioner_tp1x_rac.json new file mode 100644 index 0000000..b7ce7c3 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_tp1x_rac.json @@ -0,0 +1,20 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "air_purify", + "alarm_code", + "auto_clean", + "climate", + "current_limit_enabled", + "current_limit_level", + "display_light", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "mute_once", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status" + ] +} diff --git a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json new file mode 100644 index 0000000..4b50703 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json @@ -0,0 +1,13 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "alarm_code", + "auto_clean", + "climate", + "energy_kwh", + "firmware_update", + "mute_once", + "power_watts" + ] +} diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index 328fbc7..676f026 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -23,6 +23,22 @@ def _ac(): return reg, resources +def _resolve(name): + """Mirror the coordinator's detection order: oneUiVersion first, modelNum + fallback second (needed for issue #37's board, which reports neither + oneUiVersion nor a '_PRAC_' modelNum token).""" + resources = _load_device(name) + otn = resources.get('/otninformation/vs/0', {}) + one_ui = otn.get('swVersionInfo', {}).get('oneUiVersion', '') + info = resources['/information/vs/0'] + reg = for_device(one_ui) if one_ui else None + if reg is None: + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'], + ) + return reg, resources + + def _bound(): reg, resources = _ac() return discover(resources, reg.capabilities, reg.pattern_capabilities), resources @@ -113,7 +129,7 @@ def test_climate_consumed_hrefs_declared_as_coverage(): # newer model class than the ARTIK051_PRAC dump above. It has no OCF-standard # /temperature/current+desired pair (temperature lives on the vendor # /temperatures/vs/0 items[] resource), exposes a /light/vs/0 display light, and -# carries 13 extra vendor housekeeping hrefs. Issue #17 for this class. +# carries extra vendor housekeeping hrefs. Issue #17 for this class (PR #36). # --------------------------------------------------------------------------- def _ac_tp1x(): @@ -129,7 +145,7 @@ def test_tp1x_resolves_to_airconditioner_registry(): def test_tp1x_no_unbound_hrefs(): """Every resource in the TP1X dump binds or is covered -- including - /temperatures/vs/0, /light/vs/0 and the 13 housekeeping hrefs absent from + /temperatures/vs/0, /light/vs/0 and the housekeeping hrefs absent from the ARTIK051 dump. Clears the coverage-gap repair.""" reg, resources = _ac_tp1x() unbound = [] @@ -159,3 +175,61 @@ def test_tp1x_climate_entity_is_bound(): bound = discover(resources, reg.capabilities, reg.pattern_capabilities) climate = [b for b in bound if isinstance(b.desc, ClimateDesc)] assert len(climate) == 1 and climate[0].href == '/mode/vs/0' + + +def test_tp2x_rac_20k_model_resolves_via_model_fallback(): + """TP2X_RAC_20K (issue #37) reports no oneUiVersion and no '_PRAC_' token + -- resolved via the '_RAC_' modelNum fallback added for this device.""" + reg, _ = _resolve('airconditioner_tp2x_rac_20k') + assert reg is not None and reg.name == 'airconditioner' + + +def test_tp2x_rac_20k_no_unbound_hrefs(): + reg, resources = _resolve('airconditioner_tp2x_rac_20k') + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_tp1x_rac_model_resolves_via_one_ui_version(): + """TP1X_DA-AC-RAC-01001_0000 (issue #38) self-reports oneUiVersion + '7.0 Air conditioner' -- resolved via for_device(), not the modelNum + fallback.""" + reg, _ = _resolve('airconditioner_tp1x_rac') + assert reg is not None and reg.name == 'airconditioner' + + +def test_tp1x_rac_no_unbound_hrefs(): + reg, resources = _resolve('airconditioner_tp1x_rac') + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_tp1x_rac_expected_state_keys_present(): + reg, resources = _resolve('airconditioner_tp1x_rac') + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + for key in ('climate', 'display_light', 'mute_once', 'selfcheck_status', + 'selfcheck_result', 'current_limit_enabled', 'current_limit_level'): + assert key in state, key + + +def test_mute_once_write_target(): + write = airconditioner.MUTE_ONCE.entities[0].write_fn + assert write('On', {}) == (['option', 'muteonce', 'vs', '0'], {'muteonce': 'On'}) + assert write('Off', {}) == (['option', 'muteonce', 'vs', '0'], {'muteonce': 'Off'}) + + +def test_display_light_write_target(): + write = airconditioner.DISPLAY_LIGHT.entities[0].write_fn + assert write('On', {}) == (['light', 'vs', '0'], {'mode': 'On'}) + assert write('Off', {}) == (['light', 'vs', '0'], {'mode': 'Off'}) + + +def test_current_limit_is_read_only(): + """Meaning/write contract for the current-limit levels isn't confirmed + from the dump alone -- exposed as read-only diagnostic sensors rather + than a guessed writable control.""" + for desc in airconditioner.CURRENT_LIMIT.entities: + assert getattr(desc, 'write_fn', None) is None diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 85c1eb1..0dc0066 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -156,8 +156,8 @@ def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k(): def test_registry_reproduces_golden_state_keys_for_ac_tp1x_da_ac_rac_01011(): """Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner"; model TP1X_DA-AC-RAC-01011) reports temperature via the vendor /temperatures/vs/0 - items[] resource and adds a /light/vs/0 display light, with 13 extra vendor - housekeeping hrefs -- issue #17 for this model class.""" + items[] resource and adds a /light/vs/0 display light, with extra vendor + housekeeping hrefs -- issue #17 for this model class (PR #36).""" from tests.conftest import _load_device resources = _load_device('airconditioner_tp1x_da_ac_rac_01011') golden = json.loads( @@ -171,6 +171,34 @@ def test_registry_reproduces_golden_state_keys_for_ac_tp1x_da_ac_rac_01011(): ) +def test_registry_reproduces_golden_state_keys_for_tp2x_rac_20k(): + """TP2X_RAC_20K (issue #37) -- reports no oneUiVersion; resolved via the + '_RAC_' modelNum token fallback in for_device_by_model.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_tp2x_rac_20k') + golden = json.loads((GOLDEN / 'airconditioner_tp2x_rac_20k.json').read_text()) + state_keys = _new_state_keys('airconditioner_tp2x_rac_20k', 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 tests.conftest import _load_device + resources = _load_device('airconditioner_tp1x_rac') + golden = json.loads((GOLDEN / 'airconditioner_tp1x_rac.json').read_text()) + state_keys = _new_state_keys('airconditioner_tp1x_rac', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { From 8470853fb62de9fa43fc1246b046905aaeb80f1e Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 22 Jul 2026 14:18:15 +0000 Subject: [PATCH 16/29] fix: reconcile AC coverage with PR #36, bind selfcheck/mute-once for TP1X-01011 too Rebased onto PR #36's merge (temperature fallback for /temperatures/vs/0, display-light switch). Reconciliation, per Opus review: - Dropped our duplicate LIGHT capability in favor of PR #36's DISPLAY_LIGHT (same href/entity, cosmetic differences only). - Dropped /option/muteonce/vs/0 and /selfcheck/vs/0 from the ignore list PR #36 added -- both have confirmed, cleanly modelable contracts (a plain On/Off field, and a documented Start/Cancel self-check action already covered by the existing fridge.SELF_CHECK pattern), so keeping our bound versions instead of ignoring them. This also picks up mute_once/selfcheck_* on PR #36's own TP1X_DA-AC-RAC-01011 fixture -- regenerated its golden. - Deduped the ~10 housekeeping hrefs both branches independently ignored. - Added a unit test for climate.py's _temps_vs_item (the temperature- fallback selection logic), which had no coverage -- climate.py isn't importable in the registry-level AC tests, and the existing "no unbound hrefs" tests only exercise binding, not the fallback itself. --- .../airconditioner_tp1x_da_ac_rac_01011.json | 8 +++- tests/test_climate_temperature_fallback.py | 38 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 tests/test_climate_temperature_fallback.py 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 e640332..b9643bf 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json +++ b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json @@ -9,6 +9,10 @@ "display_light", "energy_kwh", "energy_saved_kwh", - "firmware_update" + "firmware_update", + "mute_once", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status" ] -} \ No newline at end of file +} diff --git a/tests/test_climate_temperature_fallback.py b/tests/test_climate_temperature_fallback.py new file mode 100644 index 0000000..41fb3ca --- /dev/null +++ b/tests/test_climate_temperature_fallback.py @@ -0,0 +1,38 @@ +"""Tests for the /temperatures/vs/0 fallback helper in climate.py (PR #36). + +`_temps_vs_item` is a pure module-level function (plain dict in, plain dict +out) with no coordinator/entity dependency, so -- like select.py's `_display` +in test_select_display.py -- it's testable directly even though the +LocalThingsClimate entity itself needs a coordinator/hass fixture to +exercise. This covers the actual fallback-selection logic that the +registry-level "no unbound hrefs" tests in test_airconditioner_capabilities.py +don't reach: TP1X-class firmware has no OCF-standard +/temperature/current+desired pair, so current_temperature/target_temperature +would silently be None without this fallback. +""" +from custom_components.localthings.climate import _temps_vs_item + + +def test_temps_vs_item_returns_first_item(): + rep = { + 'x.com.samsung.da.items': [ + {'x.com.samsung.da.current': '17.0', 'x.com.samsung.da.desired': '25.0'}, + ], + } + assert _temps_vs_item(rep) == { + 'x.com.samsung.da.current': '17.0', 'x.com.samsung.da.desired': '25.0', + } + + +def test_temps_vs_item_empty_when_items_missing(): + """OCF-pair devices don't populate /temperatures/vs/0's items at all -- + callers must fall through to the OCF hrefs cleanly, not raise.""" + assert _temps_vs_item({}) == {} + + +def test_temps_vs_item_empty_when_items_is_empty_list(): + assert _temps_vs_item({'x.com.samsung.da.items': []}) == {} + + +def test_temps_vs_item_empty_when_items_entry_is_not_a_dict(): + assert _temps_vs_item({'x.com.samsung.da.items': ['not-a-dict']}) == {} From 6611f8595881e195ed8f92938805b2fb2576dca6 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 22 Jul 2026 14:25:10 +0000 Subject: [PATCH 17/29] chore: bump version to 0.9.0 Covers PR #36's TP1X temperature-fallback fix and display-light switch, plus TP2X_RAC_20K device-type recognition and full TP1X_DA-AC-RAC-01001 capability coverage (issues #37, #38). --- custom_components/localthings/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/localthings/manifest.json b/custom_components/localthings/manifest.json index ae994df..dad4fc9 100644 --- a/custom_components/localthings/manifest.json +++ b/custom_components/localthings/manifest.json @@ -12,5 +12,5 @@ "pyOpenSSL>=23.0", "smartthings-local>=0.1.0" ], - "version": "0.8.0" + "version": "0.9.0" } From 7d011bfe89f3c05707fc4df8180ebfe4b60f4771 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 22 Jul 2026 18:34:41 +0000 Subject: [PATCH 18/29] fix: add missing washer cycle translations for combo units (#22) A washer/dryer combo user's editCourseList carries five Course_XX codes that weren't named in washer_cycle: 36 (Wash+Dry), 37 (Air Wash), 38 (Cotton Dry), 39 (Synthetics Dry), and 1F (Intense Cold, distinct from the existing 8F code used by non-combo models). --- .../localthings/registry/capabilities/washer.py | 9 ++++++++- custom_components/localthings/strings.json | 7 ++++++- custom_components/localthings/translations/en.json | 7 ++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 964a26c..e997afb 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -20,7 +20,7 @@ from ..entities import BinarySensorDesc, SelectDesc, SensorDesc from .laundry import cycle_select, hex_pairs, option_value, replace_in_options # --------------------------------------------------------------------------- -# Course_XX hex codes. The 23 codes named in strings.json/translations +# Course_XX hex codes. 23 of the codes named in strings.json/translations # under entity.select.washer_cycle.state. were captured # from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList # (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched @@ -35,6 +35,13 @@ from .laundry import cycle_select, hex_pairs, option_value, replace_in_options # you'd expect to be pinned (default cycle + maintenance cycle), # corroborating the positional match. # +# A further 5 codes -- '36' Wash+Dry, '37' Air Wash, '38' Cotton Dry, +# '39' Synthetics Dry, and a second, distinct '1F' Intense Cold (not the +# same code as '8F' above) -- came from a WD90T654DBN/S1 washer/dryer +# combo's editCourseList and were named from that user's app screenshot +# (issue #22). Combo units carry their own course set, so these codes +# don't imply anything about '1F' on a plain washer. +# # 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/strings.json b/custom_components/localthings/strings.json index b6cb7e6..4bba100 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -89,7 +89,12 @@ "66": "Denim", "2d": "Silent Wash", "8f": "Intense Cold", - "96": "Less Microfiber" + "96": "Less Microfiber", + "36": "Wash+Dry", + "37": "Air Wash", + "38": "Cotton Dry", + "39": "Synthetics Dry", + "1f": "Intense Cold" } }, "dryer_cycle": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index b6cb7e6..4bba100 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -89,7 +89,12 @@ "66": "Denim", "2d": "Silent Wash", "8f": "Intense Cold", - "96": "Less Microfiber" + "96": "Less Microfiber", + "36": "Wash+Dry", + "37": "Air Wash", + "38": "Cotton Dry", + "39": "Synthetics Dry", + "1f": "Intense Cold" } }, "dryer_cycle": { From e49b61e01ebad356505cc62875eba35d7581a98c Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 22 Jul 2026 19:10:12 +0000 Subject: [PATCH 19/29] feat: add bubble soak, pre-wash, and intensive switches for washers (#22) A follow-up dump confirmed these ride as plain BubbleSoak_On/Off, PreWashSetting_On/Off, and IntensiveSetting_On/Off tokens in the same /course/vs/0 options array as the cycle select, so they're exposed as self-gating config switches the same way other options-array fields are. Per-cycle availability (BubbleSoakSet/PreWashAvailableSet/IntensiveAvailableSet) lines up positionally with editCourseList but isn't used for gating, since exists_fn only runs once at setup against whatever course happened to be active then. --- .../registry/capabilities/washer.py | 56 ++++++++++++++++++- tests/fixtures/golden/washer.json | 3 + tests/fixtures/golden/washer_dryer_combo.json | 3 + tests/test_washer_capabilities.py | 53 ++++++++++++++++++ 4 files changed, 114 insertions(+), 1 deletion(-) diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index e997afb..28e3f99 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -16,7 +16,7 @@ array. from datetime import datetime, timezone from ..capability import Capability -from ..entities import BinarySensorDesc, SelectDesc, SensorDesc +from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc from .laundry import cycle_select, hex_pairs, option_value, replace_in_options # --------------------------------------------------------------------------- @@ -238,6 +238,45 @@ def _dosing_alarm_exists(prefix): rep.get('x.com.samsung.da.options'), prefix) is not None +# Bubble soak / pre-wash / intensive-wash toggles, from the same options[] +# array (issue #22 follow-up on a WD90T654DBN/S1 combo). Each rides as a +# plain '_On'/'_Off' token, confirmed by a dump taken with +# Bubble Soak switched on in the app (BubbleSoak_On) -- the same On/Off shape +# already used by AiOption and KidsLockBypass in this same array, so +# PreWashSetting/IntensiveSetting are assumed to follow suit. +# +# Each also has a same-shaped 'Set'/'AvailableSet' hex-pair +# string that lines up positionally with editCourseList -- e.g. on the combo +# dump, BubbleSoakSet's byte for the active course (Course_1C, position 0) +# is '00' while PreWashAvailableSet/IntensiveAvailableSet are both 'F0', +# suggesting F0/00 is an available/unavailable flag per course. That's not +# exposed here: exists_fn only runs once, against the setup-time snapshot +# (see entity._is_included), so gating on the *current* course would just +# make the entity's existence depend on whatever course happened to be +# selected when Home Assistant started, not on the device's real, static +# capability. Toggling one of these on a course the app would gray it out +# for is untested; treat it like any other write this integration doesn't +# validate against device-side state. +def _bool_option_write(prefix): + def write(p, rep, href=None): + opts = list(rep.get('x.com.samsung.da.options') or []) + if not opts: + return None + return ['course', 'vs', '0'], { + 'x.com.samsung.da.options': replace_in_options(opts, prefix, 'On' if p else 'Off'), + } + return write + + +def _bool_option_value(prefix): + return lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On' + + +def _bool_option_exists(prefix): + return lambda rep, resources: option_value( + rep.get('x.com.samsung.da.options'), prefix) is not None + + WASHER_COURSE = Capability( href='/course/vs/0', entities=( @@ -294,5 +333,20 @@ WASHER_COURSE = Capability( icon='mdi:alert-circle-outline', device_class='problem', exists_fn=_dosing_alarm_exists('SoftenerAlarm'), rep_fn=_dosing_low('SoftenerAlarm')), + SwitchDesc(key='bubble_soak', name='Bubble soak', icon='mdi:chart-bubble', + entity_category='config', + exists_fn=_bool_option_exists('BubbleSoak'), + rep_fn=_bool_option_value('BubbleSoak'), + write_fn=_bool_option_write('BubbleSoak')), + SwitchDesc(key='pre_wash', name='Pre wash', icon='mdi:washing-machine', + entity_category='config', + exists_fn=_bool_option_exists('PreWashSetting'), + rep_fn=_bool_option_value('PreWashSetting'), + write_fn=_bool_option_write('PreWashSetting')), + SwitchDesc(key='intensive', name='Intensive', icon='mdi:washing-machine', + entity_category='config', + exists_fn=_bool_option_exists('IntensiveSetting'), + rep_fn=_bool_option_value('IntensiveSetting'), + write_fn=_bool_option_write('IntensiveSetting')), ), ) diff --git a/tests/fixtures/golden/washer.json b/tests/fixtures/golden/washer.json index 69ac2a8..d3a99a1 100644 --- a/tests/fixtures/golden/washer.json +++ b/tests/fixtures/golden/washer.json @@ -1,6 +1,7 @@ { "state_keys": [ "alarm_code", + "bubble_soak", "buzzer_sound", "child_lock", "cycle", @@ -14,9 +15,11 @@ "energy_saved_kwh", "finish_time", "firmware_update", + "intensive", "job_beginning_status", "machine_state", "power_switch", + "pre_wash", "progress", "progress_percentage", "remote_control", diff --git a/tests/fixtures/golden/washer_dryer_combo.json b/tests/fixtures/golden/washer_dryer_combo.json index a228067..d2bab38 100644 --- a/tests/fixtures/golden/washer_dryer_combo.json +++ b/tests/fixtures/golden/washer_dryer_combo.json @@ -1,6 +1,7 @@ { "state_keys": [ "alarm_code", + "bubble_soak", "child_lock", "cycle", "cycle_active", @@ -12,9 +13,11 @@ "energy_kwh", "finish_time", "firmware_update", + "intensive", "job_beginning_status", "machine_state", "power_switch", + "pre_wash", "progress", "progress_percentage", "remote_control", diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index ac143d8..88c875d 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -251,6 +251,59 @@ class TestDetergentSoftenerDosing: assert self._desc('detergent_low').exists_fn(rep, {}) is True +class TestWashOptionToggles: + """Bubble soak / pre-wash / intensive-wash switches, from the same + options[] array as the cycle select (issue #22 follow-up). Confirmed + On/Off shape from a dump with Bubble Soak toggled on in the app.""" + + @staticmethod + def _desc(key): + return next(e for e in washer.WASHER_COURSE.entities if e.key == key) + + @staticmethod + def _keys(): + return ('bubble_soak', 'pre_wash', 'intensive') + + @staticmethod + def _prefix(key): + return {'bubble_soak': 'BubbleSoak', + 'pre_wash': 'PreWashSetting', + 'intensive': 'IntensiveSetting'}[key] + + def test_exists_only_when_field_present(self): + for key in self._keys(): + desc = self._desc(key) + assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_Off']} + assert desc.exists_fn(rep, {}) is True + + def test_reads_off(self): + for key in self._keys(): + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_Off']} + assert self._desc(key).rep_fn(rep) is False + + def test_reads_on(self): + for key in self._keys(): + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_On']} + assert self._desc(key).rep_fn(rep) is True + + def test_write_on_and_off(self): + for key in self._keys(): + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_Off', 'GMT_02']} + path, body = self._desc(key).write_fn(True, rep) + assert path == ['course', 'vs', '0'] + assert f'{prefix}_On' in body['x.com.samsung.da.options'] + assert 'GMT_02' in body['x.com.samsung.da.options'] + + rep = {'x.com.samsung.da.options': [f'{prefix}_On']} + path, body = self._desc(key).write_fn(False, rep) + assert f'{prefix}_Off' in body['x.com.samsung.da.options'] + + class TestFlexWashAndComboFixturesHaveCompleteCoverage: """FlexWash (issue #19, previously unrecognized entirely) and washer/dryer combo (issue #22, dry_level) dumps must both resolve to From f54daf3608a2bb87fabf54c01f15f04803fb1a68 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 22 Jul 2026 19:21:12 +0000 Subject: [PATCH 20/29] feat: reject bubble soak/pre-wash/intensive writes on unsupported courses Add a validate_fn hook to SwitchDesc, checked in switch.py before dispatch and surfaced as a ServiceValidationError so an unsupported write shows a real error in the UI instead of the coordinator's silent log-only rejection. Wired it into the three course-gated washer switches using their availability bitmaps (BubbleSoakSet/PreWashAvailableSet/IntensiveAvailableSet), which line up positionally with editCourseList. Turning a toggle off is never blocked, and the check fails open whenever the course or bitmap can't be resolved. Also fixes a bug in _bool_option_write: it took a `p and 'On' or 'Off'`-style truthy check, but switch.py always calls it with the string 'On' or 'Off' -- both truthy, so every write landed as 'On' regardless of intent. --- .../registry/capabilities/washer.py | 74 +++++++++++++---- .../localthings/registry/entities.py | 10 ++- custom_components/localthings/switch.py | 14 +++- tests/test_washer_capabilities.py | 79 ++++++++++++++++++- 4 files changed, 155 insertions(+), 22 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 28e3f99..e0fba1d 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -17,7 +17,7 @@ from datetime import datetime, timezone from ..capability import Capability from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc -from .laundry import cycle_select, hex_pairs, option_value, replace_in_options +from .laundry import cycle_options, cycle_select, hex_pairs, option_value, replace_in_options # --------------------------------------------------------------------------- # Course_XX hex codes. 23 of the codes named in strings.json/translations @@ -245,25 +245,27 @@ def _dosing_alarm_exists(prefix): # already used by AiOption and KidsLockBypass in this same array, so # PreWashSetting/IntensiveSetting are assumed to follow suit. # -# Each also has a same-shaped 'Set'/'AvailableSet' hex-pair -# string that lines up positionally with editCourseList -- e.g. on the combo -# dump, BubbleSoakSet's byte for the active course (Course_1C, position 0) -# is '00' while PreWashAvailableSet/IntensiveAvailableSet are both 'F0', -# suggesting F0/00 is an available/unavailable flag per course. That's not -# exposed here: exists_fn only runs once, against the setup-time snapshot -# (see entity._is_included), so gating on the *current* course would just -# make the entity's existence depend on whatever course happened to be -# selected when Home Assistant started, not on the device's real, static -# capability. Toggling one of these on a course the app would gray it out -# for is untested; treat it like any other write this integration doesn't -# validate against device-side state. +# Each also has a differently-named hex-pair availability field that lines up +# positionally with editCourseList: BubbleSoakSet, PreWashAvailableSet, +# IntensiveAvailableSet. On the reporter's dump (course '30' at position 1 of +# 24), all three read 'F0' at that position and the toggle was writable -- +# and the same dump's earlier state (course '1C' at position 0, 'BubbleSoak +# Off') decodes to '00' for that course, matching the app graying the +# control out there. 'F0'/'00' is treated as available/unavailable on that +# evidence. exists_fn (device-level presence) still only runs once, against +# the setup-time snapshot, so it isn't a fit for this per-course check -- +# validate_fn runs on every write attempt instead, rejecting an on-write for +# a course whose byte isn't 'F0' with a user-facing error rather than +# silently no-opping against the device. def _bool_option_write(prefix): def write(p, rep, href=None): + if p not in ('On', 'Off'): + return None opts = list(rep.get('x.com.samsung.da.options') or []) if not opts: return None return ['course', 'vs', '0'], { - 'x.com.samsung.da.options': replace_in_options(opts, prefix, 'On' if p else 'Off'), + 'x.com.samsung.da.options': replace_in_options(opts, prefix, p), } return write @@ -277,6 +279,41 @@ def _bool_option_exists(prefix): rep.get('x.com.samsung.da.options'), prefix) is not None +_AVAILABILITY_FIELD = { + 'BubbleSoak': 'BubbleSoakSet', + 'PreWashSetting': 'PreWashAvailableSet', + 'IntensiveSetting': 'IntensiveAvailableSet', +} + + +def _bool_option_validate(prefix, human_name): + """Reject turning `prefix` on when the selected course's byte in its + availability bitmap isn't 'F0'. Turning off is never blocked. Falls back + to allowing the write whenever the availability data can't be resolved + (unrecognized course, missing/mismatched-length bitmap) rather than + guessing -- a false rejection is worse than an occasional no-op write.""" + availability_field = _AVAILABILITY_FIELD[prefix] + + def validate(p, rep, resources): + if p != 'On': + return None + opts = rep.get('x.com.samsung.da.options') or [] + current = option_value(opts, 'Course') + courses = cycle_options(resources) + if not current or current not in courses: + return None + raw = option_value(opts, availability_field) + if raw is None: + return None + pairs = hex_pairs(raw) + if len(pairs) != len(courses): + return None + if pairs[courses.index(current)] != 'F0': + return f"{human_name} isn't available on the selected cycle." + return None + return validate + + WASHER_COURSE = Capability( href='/course/vs/0', entities=( @@ -337,16 +374,19 @@ WASHER_COURSE = Capability( entity_category='config', exists_fn=_bool_option_exists('BubbleSoak'), rep_fn=_bool_option_value('BubbleSoak'), - write_fn=_bool_option_write('BubbleSoak')), + write_fn=_bool_option_write('BubbleSoak'), + validate_fn=_bool_option_validate('BubbleSoak', 'Bubble soak')), SwitchDesc(key='pre_wash', name='Pre wash', icon='mdi:washing-machine', entity_category='config', exists_fn=_bool_option_exists('PreWashSetting'), rep_fn=_bool_option_value('PreWashSetting'), - write_fn=_bool_option_write('PreWashSetting')), + write_fn=_bool_option_write('PreWashSetting'), + validate_fn=_bool_option_validate('PreWashSetting', 'Pre wash')), SwitchDesc(key='intensive', name='Intensive', icon='mdi:washing-machine', entity_category='config', exists_fn=_bool_option_exists('IntensiveSetting'), rep_fn=_bool_option_value('IntensiveSetting'), - write_fn=_bool_option_write('IntensiveSetting')), + write_fn=_bool_option_write('IntensiveSetting'), + validate_fn=_bool_option_validate('IntensiveSetting', 'Intensive')), ), ) diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index a22d056..e1b98fc 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -2,7 +2,9 @@ Frozen dataclasses so the future native HA component can consume them as EntityDescription subclasses unchanged. Read transforms live in value_fn; -presence gating in exists_fn; write logic in write_fn on command platforms. +presence gating in exists_fn; write logic in write_fn on command platforms; +pre-write rejection (surfaced to the user, not just logged) in validate_fn +where a description declares one. """ from __future__ import annotations @@ -10,6 +12,11 @@ from dataclasses import dataclass, field from typing import Any, Callable, Optional WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]] +# (payload, rep, resources) -> a human-readable rejection message, or None to +# allow the write. resources is the coordinator's full href->rep snapshot, for +# the same cross-resource lookups exists_fn needs (e.g. reading a sibling +# href's live option list). +ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]] def _identity(v: Any) -> Any: @@ -61,6 +68,7 @@ class SelectDesc(SamsungEntityDescription): class SwitchDesc(SamsungEntityDescription): device_class: Optional[str] = None write_fn: WriteFn = None + validate_fn: ValidateFn = None @dataclass(frozen=True, kw_only=True) diff --git a/custom_components/localthings/switch.py b/custom_components/localthings/switch.py index 1961fbb..a1898db 100644 --- a/custom_components/localthings/switch.py +++ b/custom_components/localthings/switch.py @@ -4,6 +4,7 @@ from __future__ import annotations from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .registry.entities import SwitchDesc @@ -38,7 +39,16 @@ class LocalThingsSwitch(LocalThingsEntity, SwitchEntity): return (self.coordinator.data or {}).get(self._state_key) async def async_turn_on(self, **kwargs) -> None: - await self.coordinator.async_send_command(self._bound, 'On') + await self._send('On') async def async_turn_off(self, **kwargs) -> None: - await self.coordinator.async_send_command(self._bound, 'Off') + await self._send('Off') + + async def _send(self, payload: str) -> None: + desc: SwitchDesc = self._bound.desc + if desc.validate_fn is not None: + rep = self.coordinator.last_resources.get(self._bound.href) or {} + error = desc.validate_fn(payload, rep, self.coordinator.last_resources) + if error: + raise ServiceValidationError(error) + await self.coordinator.async_send_command(self._bound, payload) diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index 88c875d..0a1a3b4 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -291,17 +291,92 @@ class TestWashOptionToggles: assert self._desc(key).rep_fn(rep) is True def test_write_on_and_off(self): + """write_fn receives the same 'On'/'Off' string switch.py sends + (not a bool) -- covers a bug where an earlier `'On' if p else 'Off'` + implementation always wrote 'On', since any non-empty string + (including 'Off') is truthy.""" for key in self._keys(): prefix = self._prefix(key) rep = {'x.com.samsung.da.options': [f'{prefix}_Off', 'GMT_02']} - path, body = self._desc(key).write_fn(True, rep) + path, body = self._desc(key).write_fn('On', rep) assert path == ['course', 'vs', '0'] assert f'{prefix}_On' in body['x.com.samsung.da.options'] assert 'GMT_02' in body['x.com.samsung.da.options'] rep = {'x.com.samsung.da.options': [f'{prefix}_On']} - path, body = self._desc(key).write_fn(False, rep) + path, body = self._desc(key).write_fn('Off', rep) assert f'{prefix}_Off' in body['x.com.samsung.da.options'] + assert f'{prefix}_On' not in body['x.com.samsung.da.options'] + + def test_write_rejects_non_on_off_payload(self): + for key in self._keys(): + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_Off']} + assert self._desc(key).write_fn('bogus', rep) is None + + +# editCourseList and availability bitmaps from the reporter's issue #22 +# follow-up dump (WD90T654DBN/S1, course '30' selected, Bubble Soak just +# turned on in the app): 24 courses, course '30' at position 1 reads 'F0' +# (available) on all three bitmaps; course '1C' at position 0 reads '00' on +# BubbleSoakSet (matching the app graying that control out for Eco 40-60). +_EDIT_COURSE_RESOURCES = { + '/wm/editcourse/vs/0': { + 'x.com.samsung.da.editCourseList': + 'EditCourseList_1C301E26361B1D1F253324322022232F212D272838393729', + }, +} +_BUBBLE_SOAK_SET = 'BubbleSoakSet_00F000F000F000F0F0F0F00000F000F0F00000F000000000' +_PRE_WASH_AVAILABLE_SET = 'PreWashAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000' +_INTENSIVE_AVAILABLE_SET = 'IntensiveAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000' + + +class TestWashOptionToggleValidation: + """validate_fn rejects turning a toggle on for a course whose byte in + its availability bitmap isn't 'F0', with a user-facing message switch.py + raises as ServiceValidationError -- distinct from write_fn's silent + no-op for a malformed payload.""" + + @staticmethod + def _desc(key): + return next(e for e in washer.WASHER_COURSE.entities if e.key == key) + + def test_allowed_on_a_supported_course(self): + rep = {'x.com.samsung.da.options': ['Course_30', _BUBBLE_SOAK_SET]} + assert self._desc('bubble_soak').validate_fn( + 'On', rep, _EDIT_COURSE_RESOURCES) is None + + def test_rejected_on_an_unsupported_course(self): + rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]} + msg = self._desc('bubble_soak').validate_fn('On', rep, _EDIT_COURSE_RESOURCES) + assert msg == "Bubble soak isn't available on the selected cycle." + + def test_pre_wash_and_intensive_use_their_own_availableset_field(self): + rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]} + assert self._desc('pre_wash').validate_fn( + 'On', rep, _EDIT_COURSE_RESOURCES) is None + rep = {'x.com.samsung.da.options': ['Course_30', _INTENSIVE_AVAILABLE_SET]} + assert self._desc('intensive').validate_fn( + 'On', rep, _EDIT_COURSE_RESOURCES) is None + + def test_turning_off_is_never_blocked(self): + rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]} + assert self._desc('bubble_soak').validate_fn( + 'Off', rep, _EDIT_COURSE_RESOURCES) is None + + def test_allows_write_when_course_unresolvable(self): + """No editCourseList, no Course_ token, or a bitmap whose length + doesn't match editCourseList -- in every case, fail open rather than + block a write we can't actually verify.""" + desc = self._desc('bubble_soak') + rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]} + assert desc.validate_fn('On', rep, {}) is None + + rep = {'x.com.samsung.da.options': [_BUBBLE_SOAK_SET]} + assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None + + rep = {'x.com.samsung.da.options': ['Course_1C', 'BubbleSoakSet_00F0']} + assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None class TestFlexWashAndComboFixturesHaveCompleteCoverage: From 3469f570800a6e2a1825662ae6e03a651a1ad216 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 22 Jul 2026 19:33:58 +0000 Subject: [PATCH 21/29] refactor: simplify washer toggle switches, move validate_fn to coordinator /simplify pass on the bubble soak/pre-wash/intensive switches: - Move validate_fn dispatch from switch.py into coordinator.async_send_command, next to the existing write_fn getattr -- every platform gets validation for free instead of switch.py hand-rolling it alone, and it avoids building the full resources snapshot twice per write (switch.py was calling coordinator.last_resources twice; the coordinator now snapshots once, and only when a validate_fn is actually present). - Collapse the four per-switch factories (write/value/exists/validate) plus the _AVAILABILITY_FIELD side-table into one _bool_option_switch() that builds the SwitchDesc directly, so the three call sites read as one line each instead of six, and a typo'd prefix can no longer silently KeyError against a separate lookup table. - Rename _dosing_alarm_exists to _option_exists and reuse it for the new switches too -- it was already the exact same "is this token present" check the toggles need. No behavior change; re-verified write_fn/rep_fn/exists_fn/validate_fn against the same fixtures and golden state-key sets as before. --- custom_components/localthings/coordinator.py | 13 ++- .../registry/capabilities/washer.py | 80 +++++++------------ custom_components/localthings/switch.py | 14 +--- 3 files changed, 42 insertions(+), 65 deletions(-) diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index a7a9c6a..39c401b 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -12,6 +12,7 @@ import cbor2 from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.device_registry import DeviceInfo @@ -509,13 +510,23 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_send_command(self, bound_entity: BoundEntity, payload: Any) -> None: - """Write a value to the device. Fire-and-forget style.""" + """Write a value to the device. Fire-and-forget style. + + A description-level validate_fn (currently SwitchDesc only) runs + here rather than per-platform, so rejecting a write with a + user-facing message -- as opposed to write_fn's silent no-op below + -- is available to every platform for free.""" desc = bound_entity.desc write_fn = getattr(desc, 'write_fn', None) if write_fn is None: return href = bound_entity.href rep = self._cache.get(href or '') or {} + validate_fn = getattr(desc, 'validate_fn', None) + if validate_fn is not None: + error = validate_fn(payload, rep, self._cache.snapshot()) + if error: + raise ServiceValidationError(error) result = write_fn(payload, rep, href) if result is None: self._log.warning("write_fn rejected payload %r for %s", payload, href) diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index e0fba1d..b145e95 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -233,7 +233,7 @@ def _dosing_low(prefix): rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off') -def _dosing_alarm_exists(prefix): +def _option_exists(prefix): return lambda rep, resources: option_value( rep.get('x.com.samsung.da.options'), prefix) is not None @@ -254,10 +254,11 @@ def _dosing_alarm_exists(prefix): # control out there. 'F0'/'00' is treated as available/unavailable on that # evidence. exists_fn (device-level presence) still only runs once, against # the setup-time snapshot, so it isn't a fit for this per-course check -- -# validate_fn runs on every write attempt instead, rejecting an on-write for -# a course whose byte isn't 'F0' with a user-facing error rather than +# validate_fn runs on every write attempt instead (dispatched from +# coordinator.async_send_command, ahead of write_fn), rejecting an on-write +# for a course whose byte isn't 'F0' with a user-facing error rather than # silently no-opping against the device. -def _bool_option_write(prefix): +def _bool_option_switch(key, name, icon, prefix, availability_field): def write(p, rep, href=None): if p not in ('On', 'Off'): return None @@ -267,34 +268,14 @@ def _bool_option_write(prefix): return ['course', 'vs', '0'], { 'x.com.samsung.da.options': replace_in_options(opts, prefix, p), } - return write - - -def _bool_option_value(prefix): - return lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On' - - -def _bool_option_exists(prefix): - return lambda rep, resources: option_value( - rep.get('x.com.samsung.da.options'), prefix) is not None - - -_AVAILABILITY_FIELD = { - 'BubbleSoak': 'BubbleSoakSet', - 'PreWashSetting': 'PreWashAvailableSet', - 'IntensiveSetting': 'IntensiveAvailableSet', -} - - -def _bool_option_validate(prefix, human_name): - """Reject turning `prefix` on when the selected course's byte in its - availability bitmap isn't 'F0'. Turning off is never blocked. Falls back - to allowing the write whenever the availability data can't be resolved - (unrecognized course, missing/mismatched-length bitmap) rather than - guessing -- a false rejection is worse than an occasional no-op write.""" - availability_field = _AVAILABILITY_FIELD[prefix] def validate(p, rep, resources): + """Reject turning on when the selected course's byte in + `availability_field` isn't 'F0'. Turning off is never blocked. Falls + back to allowing the write whenever the availability data can't be + resolved (unrecognized course, missing/mismatched-length bitmap) + rather than guessing -- a false rejection is worse than an + occasional no-op write.""" if p != 'On': return None opts = rep.get('x.com.samsung.da.options') or [] @@ -309,9 +290,16 @@ def _bool_option_validate(prefix, human_name): if len(pairs) != len(courses): return None if pairs[courses.index(current)] != 'F0': - return f"{human_name} isn't available on the selected cycle." + return f"{name} isn't available on the selected cycle." return None - return validate + + return SwitchDesc( + key=key, name=name, icon=icon, entity_category='config', + exists_fn=_option_exists(prefix), + rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On', + write_fn=write, + validate_fn=validate, + ) WASHER_COURSE = Capability( @@ -364,29 +352,17 @@ WASHER_COURSE = Capability( write_fn=_level_write('SoftenerLevel2Ctrl')), BinarySensorDesc(key='detergent_low', name='Detergent low', icon='mdi:alert-circle-outline', device_class='problem', - exists_fn=_dosing_alarm_exists('DetergentAlarm'), + exists_fn=_option_exists('DetergentAlarm'), rep_fn=_dosing_low('DetergentAlarm')), BinarySensorDesc(key='softener_low', name='Softener low', icon='mdi:alert-circle-outline', device_class='problem', - exists_fn=_dosing_alarm_exists('SoftenerAlarm'), + exists_fn=_option_exists('SoftenerAlarm'), rep_fn=_dosing_low('SoftenerAlarm')), - SwitchDesc(key='bubble_soak', name='Bubble soak', icon='mdi:chart-bubble', - entity_category='config', - exists_fn=_bool_option_exists('BubbleSoak'), - rep_fn=_bool_option_value('BubbleSoak'), - write_fn=_bool_option_write('BubbleSoak'), - validate_fn=_bool_option_validate('BubbleSoak', 'Bubble soak')), - SwitchDesc(key='pre_wash', name='Pre wash', icon='mdi:washing-machine', - entity_category='config', - exists_fn=_bool_option_exists('PreWashSetting'), - rep_fn=_bool_option_value('PreWashSetting'), - write_fn=_bool_option_write('PreWashSetting'), - validate_fn=_bool_option_validate('PreWashSetting', 'Pre wash')), - SwitchDesc(key='intensive', name='Intensive', icon='mdi:washing-machine', - entity_category='config', - exists_fn=_bool_option_exists('IntensiveSetting'), - rep_fn=_bool_option_value('IntensiveSetting'), - write_fn=_bool_option_write('IntensiveSetting'), - validate_fn=_bool_option_validate('IntensiveSetting', 'Intensive')), + _bool_option_switch('bubble_soak', 'Bubble soak', 'mdi:chart-bubble', + 'BubbleSoak', 'BubbleSoakSet'), + _bool_option_switch('pre_wash', 'Pre wash', 'mdi:washing-machine', + 'PreWashSetting', 'PreWashAvailableSet'), + _bool_option_switch('intensive', 'Intensive', 'mdi:washing-machine', + 'IntensiveSetting', 'IntensiveAvailableSet'), ), ) diff --git a/custom_components/localthings/switch.py b/custom_components/localthings/switch.py index a1898db..1961fbb 100644 --- a/custom_components/localthings/switch.py +++ b/custom_components/localthings/switch.py @@ -4,7 +4,6 @@ from __future__ import annotations from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .registry.entities import SwitchDesc @@ -39,16 +38,7 @@ class LocalThingsSwitch(LocalThingsEntity, SwitchEntity): return (self.coordinator.data or {}).get(self._state_key) async def async_turn_on(self, **kwargs) -> None: - await self._send('On') + await self.coordinator.async_send_command(self._bound, 'On') async def async_turn_off(self, **kwargs) -> None: - await self._send('Off') - - async def _send(self, payload: str) -> None: - desc: SwitchDesc = self._bound.desc - if desc.validate_fn is not None: - rep = self.coordinator.last_resources.get(self._bound.href) or {} - error = desc.validate_fn(payload, rep, self.coordinator.last_resources) - if error: - raise ServiceValidationError(error) - await self.coordinator.async_send_command(self._bound, payload) + await self.coordinator.async_send_command(self._bound, 'Off') From 9800cd0aa894e6ae67ef6c99fc199c0beca06f17 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 22 Jul 2026 19:43:59 +0000 Subject: [PATCH 22/29] refactor: lift options[] boolean-toggle machinery into laundry.py Addresses the reuse finding skipped in the previous /simplify pass: washer's bubble soak/pre-wash/intensive switches and dishwasher's storm wash/auto release dry switches were two separate implementations of the same '_On'/'_Off' read-modify-write-on-options[] contract. Moved bool_option_write/bool_option_value/bool_option_exists/ bool_option_switch into laundry.py (same module that already owns cycle_write/cycle_select for the identical 'Course' token), and pointed both washer.py and dishwasher.py at it. washer.py keeps only its washer-specific per-course validate_fn, passed into the shared factory as a prebuilt callable -- the factory itself has no opinion on validation. No behavior change; re-verified every existing assertion (washer toggles, dishwasher storm_wash/auto_release_dry, dosing alarms) plus all golden state-key sets by hand against the refactored code. --- .../registry/capabilities/dishwasher.py | 45 +++------------- .../registry/capabilities/laundry.py | 53 +++++++++++++++++++ .../registry/capabilities/washer.py | 42 ++++++--------- 3 files changed, 76 insertions(+), 64 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/dishwasher.py b/custom_components/localthings/registry/capabilities/dishwasher.py index ca5c8d5..887d11d 100644 --- a/custom_components/localthings/registry/capabilities/dishwasher.py +++ b/custom_components/localthings/registry/capabilities/dishwasher.py @@ -8,7 +8,7 @@ wash, auto release dry) are read locally here. """ from ..capability import Capability from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc -from .laundry import cycle_select, option_value, replace_in_options +from .laundry import bool_option_switch, cycle_select # --------------------------------------------------------------------------- # /dishwasher/vs/0 — cycle wash/dry settings @@ -35,49 +35,20 @@ DISHWASHER_SETTINGS = Capability( # --------------------------------------------------------------------------- # /course/vs/0 — cycle selection (shared laundry.cycle_select) plus the # dishwasher-only StormWashZone / AutoDoorRelease toggles that ride in the -# same options array. Course display names live in translations under +# same options array (shared laundry.bool_option_switch, same options[] +# boolean-toggle contract washer's bubble-soak/pre-wash/intensive switches +# use). Course display names live in translations under # entity.select.dishwasher_cycle (see laundry.cycle_select). # --------------------------------------------------------------------------- - -def _storm_wash_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - opts = list(rep.get('x.com.samsung.da.options') or []) - if not opts: - return None - return ['course', 'vs', '0'], { - 'x.com.samsung.da.options': replace_in_options(opts, 'StormWashZone', p), - } - - -def _auto_release_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - opts = list(rep.get('x.com.samsung.da.options') or []) - if not opts: - return None - return ['course', 'vs', '0'], { - 'x.com.samsung.da.options': replace_in_options(opts, 'AutoDoorRelease', p), - } - - CYCLE_OPTIONS = Capability( href='/course/vs/0', entities=( cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'), - SwitchDesc(key='storm_wash', name='Storm Wash+', icon='mdi:weather-lightning-rainy', - rep_fn=lambda rep: option_value( - rep.get('x.com.samsung.da.options'), 'StormWashZone') == 'On', - write_fn=_storm_wash_write), - SwitchDesc(key='auto_release_dry', name='Auto release dry', icon='mdi:door-open', - exists_fn=lambda rep, resources: any( - isinstance(o, str) and o.startswith('AutoDoorRelease_') - for o in (rep.get('x.com.samsung.da.options') or []) - ), - rep_fn=lambda rep: option_value( - rep.get('x.com.samsung.da.options'), 'AutoDoorRelease') == 'On', - write_fn=_auto_release_write), + bool_option_switch('storm_wash', 'Storm Wash+', 'mdi:weather-lightning-rainy', + 'StormWashZone'), + bool_option_switch('auto_release_dry', 'Auto release dry', 'mdi:door-open', + 'AutoDoorRelease', gate_on_presence=True), ), ) diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index a84c8f2..820a2ad 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -217,6 +217,59 @@ def cycle_select(*, translation_key, icon): ) +# --------------------------------------------------------------------------- +# Plain boolean toggles over /course/vs/0's options[] array: a +# '_On'/'_Off' token, read-modify-written the same way as +# the 'Course' token above. Shared by washer (bubble soak, pre-wash, +# intensive -- issue #22) and dishwasher (storm wash, auto release dry) -- +# both families ride this exact contract, just with different prefixes and +# different presence/validation needs on top. +# --------------------------------------------------------------------------- + + +def bool_option_write(prefix): + def write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + opts = list(rep.get('x.com.samsung.da.options') or []) + if not opts: + return None + return ['course', 'vs', '0'], { + 'x.com.samsung.da.options': replace_in_options(opts, prefix, p), + } + return write + + +def bool_option_value(prefix): + return lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On' + + +def bool_option_exists(prefix): + return lambda rep, resources: option_value( + rep.get('x.com.samsung.da.options'), prefix) is not None + + +def bool_option_switch(key, name, icon, prefix, *, entity_category=None, + gate_on_presence=False, validate_fn=None): + """A SwitchDesc over a '_On'/'_Off' options[] token. + + gate_on_presence self-gates the entity off on models that never report + the token at all (washer's bubble soak/pre-wash/intensive); leave False + for a toggle every device in the family reports (dishwasher's storm + wash). validate_fn is passed straight through to SwitchDesc for callers + that need to reject a write against live device state (e.g. washer's + per-course availability check) -- this factory has no opinion on it and + building one, if needed, is the caller's job. + """ + return SwitchDesc( + key=key, name=name, icon=icon, entity_category=entity_category, + exists_fn=bool_option_exists(prefix) if gate_on_presence else None, + rep_fn=bool_option_value(prefix), + write_fn=bool_option_write(prefix), + validate_fn=validate_fn, + ) + + # --------------------------------------------------------------------------- # /wm/jobbeginingstatus/vs/0 -- the "why did the cycle not start" reason # (e.g. door open, no water). The vendor field is x.com.samsung.da.currentStatus diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index b145e95..04b52b5 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -16,8 +16,11 @@ array. from datetime import datetime, timezone from ..capability import Capability -from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc -from .laundry import cycle_options, cycle_select, hex_pairs, option_value, replace_in_options +from ..entities import BinarySensorDesc, SelectDesc, SensorDesc +from .laundry import ( + bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value, + replace_in_options, +) # --------------------------------------------------------------------------- # Course_XX hex codes. 23 of the codes named in strings.json/translations @@ -233,11 +236,6 @@ def _dosing_low(prefix): rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off') -def _option_exists(prefix): - return lambda rep, resources: option_value( - rep.get('x.com.samsung.da.options'), prefix) is not None - - # Bubble soak / pre-wash / intensive-wash toggles, from the same options[] # array (issue #22 follow-up on a WD90T654DBN/S1 combo). Each rides as a # plain '_On'/'_Off' token, confirmed by a dump taken with @@ -257,18 +255,12 @@ def _option_exists(prefix): # validate_fn runs on every write attempt instead (dispatched from # coordinator.async_send_command, ahead of write_fn), rejecting an on-write # for a course whose byte isn't 'F0' with a user-facing error rather than -# silently no-opping against the device. +# silently no-opping against the device. The read/write/presence machinery +# itself is laundry.bool_option_switch, shared with dishwasher's storm-wash/ +# auto-release-dry toggles -- only this per-course gating is washer-only, so +# it stays here rather than in laundry.py (see laundry.bool_option_switch's +# docstring: it takes a prebuilt validate_fn and has no opinion on it). def _bool_option_switch(key, name, icon, prefix, availability_field): - def write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - opts = list(rep.get('x.com.samsung.da.options') or []) - if not opts: - return None - return ['course', 'vs', '0'], { - 'x.com.samsung.da.options': replace_in_options(opts, prefix, p), - } - def validate(p, rep, resources): """Reject turning on when the selected course's byte in `availability_field` isn't 'F0'. Turning off is never blocked. Falls @@ -293,13 +285,9 @@ def _bool_option_switch(key, name, icon, prefix, availability_field): return f"{name} isn't available on the selected cycle." return None - return SwitchDesc( - key=key, name=name, icon=icon, entity_category='config', - exists_fn=_option_exists(prefix), - rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On', - write_fn=write, - validate_fn=validate, - ) + return bool_option_switch( + key, name, icon, prefix, + entity_category='config', gate_on_presence=True, validate_fn=validate) WASHER_COURSE = Capability( @@ -352,11 +340,11 @@ WASHER_COURSE = Capability( write_fn=_level_write('SoftenerLevel2Ctrl')), BinarySensorDesc(key='detergent_low', name='Detergent low', icon='mdi:alert-circle-outline', device_class='problem', - exists_fn=_option_exists('DetergentAlarm'), + exists_fn=bool_option_exists('DetergentAlarm'), rep_fn=_dosing_low('DetergentAlarm')), BinarySensorDesc(key='softener_low', name='Softener low', icon='mdi:alert-circle-outline', device_class='problem', - exists_fn=_option_exists('SoftenerAlarm'), + exists_fn=bool_option_exists('SoftenerAlarm'), rep_fn=_dosing_low('SoftenerAlarm')), _bool_option_switch('bubble_soak', 'Bubble soak', 'mdi:chart-bubble', 'BubbleSoak', 'BubbleSoakSet'), From d6639c99d4f41ba693f87e07dea2928fb47a2fae Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 22 Jul 2026 19:14:52 +0000 Subject: [PATCH 23/29] Bind AI energy level on washer; switch/select split, drop translations Issue #40: /energy/ailevel/vs/0 was unbound on a plain washer. The capability already existed for fridges but was gated off entirely on single-level hardware (the common case), so it's moved to common.py (cross-family, like fridge + washer now) and split into two entities: a switch when supportedAiLevel has exactly one entry (aiLevel is really just an on/off toggle there), and a select otherwise, with '0' (off) synthesized back into the select's options since supportedAiLevel never lists it but it's a real observed value. Also drops the translation_key/strings.json entries -- aiLevel's raw digit values already render fine untranslated, and translating a handful of levels can't cover devices with more. --- .../registry/by_type/refrigerator.py | 2 +- .../localthings/registry/by_type/washer.py | 1 + .../registry/capabilities/common.py | 62 +++++++++++- .../registry/capabilities/fridge.py | 27 ------ custom_components/localthings/strings.json | 6 -- .../localthings/translations/en.json | 6 -- .../golden/refrigerator_tp1x_ref_21k_us.json | 1 + tests/fixtures/golden/washer.json | 1 + tests/fixtures/washer_device.json | 15 +++ tests/test_common_capabilities.py | 97 +++++++++++++++++++ tests/test_fridge_capabilities.py | 47 ++------- tests/test_washer_capabilities.py | 27 ++++++ 12 files changed, 212 insertions(+), 80 deletions(-) diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index 02e7e44..ecd63f6 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -20,7 +20,7 @@ REGISTRY = DeviceRegistry( dishwasher.DIAGNOSIS, fridge.ICEMAKER_NIGHTTIME, fridge.FLEX_ZONE, - fridge.AI_ENERGY_LEVEL, + common.AI_ENERGY_LEVEL, fridge.REFRIGERATION, fridge.AUTOFILL, fridge.WELCOME_LIGHTING, diff --git a/custom_components/localthings/registry/by_type/washer.py b/custom_components/localthings/registry/by_type/washer.py index 2b84126..ac46cf4 100644 --- a/custom_components/localthings/registry/by_type/washer.py +++ b/custom_components/localthings/registry/by_type/washer.py @@ -19,6 +19,7 @@ REGISTRY = DeviceRegistry( common.ALARMS, common.ENERGY_METER, common.WATER_METER, + common.AI_ENERGY_LEVEL, operational.OPERATIONAL_STATE, dishwasher.DIAGNOSIS, fridge.FIRMWARE_UPDATE, diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index edc3f6c..a7660d2 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -11,7 +11,7 @@ against live device dumps: /filter/waterfilter/vs/0 -> x.com.samsung.da.filterUsage / filterStatus """ from ..capability import Capability -from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc +from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc def _num(v): @@ -231,3 +231,63 @@ WATER_FILTER = Capability( name='Filter status', icon='mdi:filter-check'), ), ) + +# AI energy-saving level -- '0' is off, and supportedAiLevel lists the +# additional level(s) the device offers ('1' meaning just "on" on most +# hardware, but multi-level boards have been reported). Verified cross-family: +# fridge (issue #21) and washer (issue #40) both expose this href. +# +# supportedAiLevel is a single-entry list on most captured hardware, where a +# select would offer only one real choice against an implicit "off" -- shown +# as a switch instead. '0' itself is never in supportedAiLevel but has been +# observed live as the off value of aiLevel, so the select synthesizes it +# back in as an explicit option rather than leaving no way to turn off. +# +# No translation_key: aiLevel's values are plain digit strings, and +# select.py's _display() already renders an untranslated numeric string +# as-is -- there's nothing a strings.json entry adds that's worth maintaining +# against an unknown, growing number of future levels. + + +def _ai_energy_supported_levels(rep): + """supportedAiLevel as a list -- a stray scalar (e.g. a string) must not + be len()-checked as if it were a list.""" + sl = rep.get('supportedAiLevel') + return list(sl) if isinstance(sl, (list, tuple)) else [] + + +def _ai_energy_level_options(resources): + rep = resources.get('/energy/ailevel/vs/0') or {} + return ['0', *_ai_energy_supported_levels(rep)] + + +def _ai_energy_level_write(p, rep, href=None): + return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': p} + + +def _ai_energy_level_switch_write(p, rep, href=None): + levels = _ai_energy_supported_levels(rep) + on_level = levels[0] if levels else '1' + return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': on_level if p == 'On' else '0'} + + +AI_ENERGY_LEVEL = Capability( + href='/energy/ailevel/vs/0', + poll_tier='cold', + entities=( + SwitchDesc(key='ai_energy_level', field='aiLevel', + name='AI energy level', icon='mdi:leaf', + entity_category='config', + value_fn=lambda v: v != '0', + exists_fn=lambda rep, resources: ( + len(_ai_energy_supported_levels(rep)) == 1), + write_fn=_ai_energy_level_switch_write), + SelectDesc(key='ai_energy_level', field='aiLevel', + name='AI energy level', icon='mdi:leaf', + entity_category='config', + options=_ai_energy_level_options, + exists_fn=lambda rep, resources: ( + not rep or len(_ai_energy_supported_levels(rep)) > 1), + write_fn=_ai_energy_level_write), + ), +) diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 0a44bea..5529526 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -588,33 +588,6 @@ FLEX_ZONE = Capability( ), ) -# --------------------------------------------------------------------------- -# AI energy-saving level -# -# supportedAiLevel is a single-entry list ('1' only) on some hardware, where -# a select would offer no real choice — gated to >1 supported level. -# --------------------------------------------------------------------------- - -AI_ENERGY_LEVEL = Capability( - href='/energy/ailevel/vs/0', - poll_tier='cold', - entities=( - # Only levels '1'/'2' have translated labels in strings.json/translations; - # a higher supportedAiLevel entry renders as its raw value until added. - SelectDesc(key='ai_energy_level', field='aiLevel', - name='AI energy level', icon='mdi:leaf', - translation_key='ai_energy_level', - entity_category='config', - options_field='supportedAiLevel', - exists_fn=lambda rep, resources: ( - not rep or ( - isinstance((sl := rep.get('supportedAiLevel')), (list, tuple)) - and len(sl) > 1)), - write_fn=lambda p, rep, href=None: ( - ['energy', 'ailevel', 'vs', '0'], {'aiLevel': p})), - ), -) - # --------------------------------------------------------------------------- # Firmware update # --------------------------------------------------------------------------- diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index 4bba100..3347a5c 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -148,12 +148,6 @@ "180": "3 hr", "240": "4 hr" } - }, - "ai_energy_level": { - "state": { - "1": "Level 1", - "2": "Level 2" - } } }, "sensor": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 4bba100..3347a5c 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -148,12 +148,6 @@ "180": "3 hr", "240": "4 hr" } - }, - "ai_energy_level": { - "state": { - "1": "Level 1", - "2": "Level 2" - } } }, "sensor": { diff --git a/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json index ed004a4..63b71e8 100644 --- a/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json +++ b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json @@ -1,5 +1,6 @@ { "state_keys": [ + "ai_energy_level", "alarm_code", "autofill", "cooler_setpoint", diff --git a/tests/fixtures/golden/washer.json b/tests/fixtures/golden/washer.json index d3a99a1..b48cd1b 100644 --- a/tests/fixtures/golden/washer.json +++ b/tests/fixtures/golden/washer.json @@ -1,5 +1,6 @@ { "state_keys": [ + "ai_energy_level", "alarm_code", "bubble_soak", "buzzer_sound", diff --git a/tests/fixtures/washer_device.json b/tests/fixtures/washer_device.json index 2f2eece..b76d18f 100644 --- a/tests/fixtures/washer_device.json +++ b/tests/fixtures/washer_device.json @@ -44,6 +44,21 @@ "href": "/energy/consumption/0", "rep": {} }, + { + "href": "/energy/ailevel/vs/0", + "rep": { + "aiLevel": "0", + "supportedAiLevel": [ + "1" + ], + "energySavingInfo": [ + { + "aiLevel": "1", + "maxEnergySavingRate": "70" + } + ] + } + }, { "href": "/course/vs/0", "rep": { diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index f535d54..adc1431 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -130,3 +130,100 @@ class TestEnergyMeter: phantom power sensor (the exists_fn replaces the field-presence gate).""" pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts') assert pw.exists_fn({'x.com.samsung.da.cumulativePower': '5'}, {}) is False + + +# --------------------------------------------------------------------------- +# AI energy-saving level. '0' is off; supportedAiLevel lists the additional +# level(s) on offer. A single-entry list (issue #21 fridge, issue #40 washer) +# is really a binary toggle, so it's exposed as a switch instead of a +# one-choice select; multiple entries get a select with '0' synthesized back +# in as the explicit off option (supportedAiLevel never lists '0' itself, but +# it's a real, observed value of aiLevel). +# --------------------------------------------------------------------------- + + +class TestAiEnergyLevelSwitch: + def _desc(self): + return next(e for e in common.AI_ENERGY_LEVEL.entities + if e.__class__.__name__ == 'SwitchDesc') + + def test_href(self): + assert common.AI_ENERGY_LEVEL.href == '/energy/ailevel/vs/0' + + def test_shown_only_with_single_supported_level(self): + desc = self._desc() + assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is True + assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is False + + def test_hidden_when_supported_level_is_non_list_scalar(self): + """A stray scalar (e.g. a string) must not be len()-checked as if it + were a list -- a 2-char string would otherwise wrongly pass `== 1` + style checks.""" + desc = self._desc() + assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': '1'}, {}) is False + + def test_hidden_on_empty_stub_rep(self): + """Unlike the select, the switch requires a confirmed single-entry + list -- an unfetched stub defaults to the select branch instead (see + TestAiEnergyLevelSelect.test_shown_for_empty_stub_rep).""" + desc = self._desc() + assert desc.exists_fn({}, {}) is False + + def test_value_fn(self): + desc = self._desc() + assert desc.value_fn('0') is False + assert desc.value_fn('1') is True + + def test_write_on_uses_the_single_supported_level(self): + """The on-value is whatever the device calls its one level, not a + hardcoded '1'.""" + desc = self._desc() + path, body = desc.write_fn('On', {'supportedAiLevel': ['2']}) + assert path == ['energy', 'ailevel', 'vs', '0'] + assert body == {'aiLevel': '2'} + + def test_write_off(self): + desc = self._desc() + path, body = desc.write_fn('Off', {'supportedAiLevel': ['1']}) + assert body == {'aiLevel': '0'} + + +class TestAiEnergyLevelSelect: + def _desc(self): + return next(e for e in common.AI_ENERGY_LEVEL.entities + if e.__class__.__name__ == 'SelectDesc') + + def test_shown_only_with_multiple_supported_levels(self): + desc = self._desc() + assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is True + assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is False + + def test_shown_for_empty_stub_rep(self): + """An empty {} rep is /device/0's not-yet-fetched-stub carve-out -- + defaults to the select so sub-polls can populate it either way.""" + desc = self._desc() + assert desc.exists_fn({}, {}) is True + + def test_no_translation_key(self): + """aiLevel's values are plain digits that render fine untranslated + (select.py's _display()) -- no strings.json entry to maintain + against an unknown number of future levels.""" + desc = self._desc() + assert desc.translation_key is None + + def test_options_synthesize_off(self): + """'0' is never in supportedAiLevel but is a real, observed aiLevel + value -- synthesized back in as the explicit off option.""" + desc = self._desc() + resources = {'/energy/ailevel/vs/0': {'supportedAiLevel': ['1', '2']}} + assert desc.options(resources) == ['0', '1', '2'] + + def test_options_empty_when_resource_missing(self): + desc = self._desc() + assert desc.options({}) == ['0'] + + def test_write(self): + desc = self._desc() + path, body = desc.write_fn('2', {}) + assert path == ['energy', 'ailevel', 'vs', '0'] + assert body == {'aiLevel': '2'} diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index fa0f470..c76ddfe 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -267,48 +267,17 @@ class TestSelfCheckError: assert desc.value_fn([]) is None -class TestAiEnergyLevel: - """AI energy-saving level select on /energy/ailevel/vs/0 -- only - exposed when the device actually offers more than one level; hardware - that only ever reports a single supported level keeps this ignored - (see capabilities/ignored.py).""" +class TestRefrigeratorAiEnergyLevelFixtureCoverage: + """AI energy-saving level (common.AI_ENERGY_LEVEL, see + test_common_capabilities.py) is exercised end-to-end here against the + refrigerator fixture/registry. - def test_href(self): - assert fridge.AI_ENERGY_LEVEL.href == '/energy/ailevel/vs/0' - - def test_hidden_with_single_supported_level(self): - desc = fridge.AI_ENERGY_LEVEL.entities[0] - assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is False - - def test_shown_with_multiple_supported_levels(self): - desc = fridge.AI_ENERGY_LEVEL.entities[0] - assert desc.exists_fn( - {'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is True - - def test_exists_for_empty_stub_rep(self): - """An empty {} rep is /device/0's not-yet-fetched-stub carve-out -- - must be included-for-now, same as ENERGY_METER's fields.""" - desc = fridge.AI_ENERGY_LEVEL.entities[0] - assert desc.exists_fn({}, {}) is True - - def test_hidden_when_supported_level_is_non_list_scalar(self): - """A stray scalar (e.g. a string) must not be len()-checked as if it - were a list -- a 5-char string would otherwise wrongly pass `> 1`.""" - desc = fridge.AI_ENERGY_LEVEL.entities[0] - assert desc.exists_fn( - {'aiLevel': '1', 'supportedAiLevel': '12'}, {}) is False - - def test_write(self): - desc = fridge.AI_ENERGY_LEVEL.entities[0] - path, body = desc.write_fn('2', {}) - assert path == ['energy', 'ailevel', 'vs', '0'] - assert body == {'aiLevel': '2'} + refrigerator_device.json's supportedAiLevel was extended to two entries + (['1', '2']) specifically to exercise the select branch -- the real + captured TP1X_REF_21K_US dump only ever reports one entry (switch + branch), so the select is synthetic-fixture-only for now.""" def test_synthetic_fixture_has_complete_coverage(self): - """refrigerator_device.json's supportedAiLevel was extended to two - entries (['1', '2']) specifically to exercise this select -- the - real captured TP1X_REF_21K_US dump only ever reports one entry, so - this capability is synthetic-fixture-only for now.""" from custom_components.localthings.registry.adapter import flatten from custom_components.localthings.registry.by_type import refrigerator from custom_components.localthings.registry.discovery import discover diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index 0a1a3b4..a06243f 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -379,6 +379,33 @@ class TestWashOptionToggleValidation: assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None +class TestAiEnergyLevel: + """Issue #40 -- /energy/ailevel/vs/0 was unbound on a plain washer. + + The capability itself (common.AI_ENERGY_LEVEL) is tested in + test_common_capabilities.py; this just confirms it's wired into the + washer registry and that the fixture's single-entry supportedAiLevel + (['1'], matching the issue's dump) surfaces as a switch, not a select.""" + + def test_fixture_has_complete_coverage(self): + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import washer as washer_registry + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('washer') + unbound = [] + bound = discover( + resources, + washer_registry.REGISTRY.capabilities, + washer_registry.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + state = flatten(bound, resources) + assert state['ai_energy_level'] is False # aiLevel '0' -- off + + class TestFlexWashAndComboFixturesHaveCompleteCoverage: """FlexWash (issue #19, previously unrecognized entirely) and washer/dryer combo (issue #22, dry_level) dumps must both resolve to From 3d5263d7928a280e170d4761d501c97540f8fb02 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 22 Jul 2026 19:51:37 +0000 Subject: [PATCH 24/29] Add common.UNIVERSAL/POWER bundles to stop hand-duplicating capabilities FIRMWARE_UPDATE and ALARMS were already copy-pasted into all 6 device-type registries by hand; POWER/KIDS_LOCK/REMOTE_CONTROL into 5 of 6. Consolidate into two bundles in common.py, unpacked via *common.UNIVERSAL / *common.POWER the same way ignored.IGNORED already is: - UNIVERSAL: ALARMS, ENERGY_METER, FIRMWARE_UPDATE (moved from fridge.py), SELF_CHECK (moved from fridge.py), AI_ENERGY_LEVEL, and the kids-lock/ remote-control pairs. Safe everywhere -- discover() only binds a href actually present in a device's dump, so a capability with no known conflicting family is a no-op where the href is absent and a real, wanted entity where it's present. This also broadens AI_ENERGY_LEVEL, ENERGY_METER, and SELF_CHECK to device types they weren't confirmed on before, on the same reasoning. - POWER: just POWER_GENERIC/POWER_VS_FALLBACK, applied to the 5 non-AC registries. Airconditioner keeps its own opt-out: its climate entity already owns /power/0 and /power/vs/0 via a bare, no-entity claim (airconditioner.COVERAGE), and a real power capability on the same href would make _build() raise (a href with multiple caps requires every cap to have rt_filter or match_fn; the bare COVERAGE cap has neither). Full test suite (327 tests, all 6 device-type golden fixtures) passes unchanged -- none of the newly-broadened capabilities bind on any existing fixture, confirming the no-op reasoning held in practice, not just theory. --- .../registry/by_type/airconditioner.py | 17 ++-- .../registry/by_type/dishwasher.py | 13 +-- .../localthings/registry/by_type/dryer.py | 13 +-- .../localthings/registry/by_type/oven.py | 12 +-- .../registry/by_type/refrigerator.py | 13 +-- .../localthings/registry/by_type/washer.py | 14 +-- .../registry/capabilities/airconditioner.py | 5 +- .../registry/capabilities/common.py | 86 ++++++++++++++++++- .../registry/capabilities/fridge.py | 51 +---------- tests/test_common_capabilities.py | 69 +++++++++++++++ tests/test_fridge_capabilities.py | 36 -------- 11 files changed, 180 insertions(+), 149 deletions(-) diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 0d5f050..9de96e0 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -3,23 +3,22 @@ The first device whose core controls surface as a single composite HA `climate` entity (see capabilities/airconditioner.py and climate.py). Power/mode/temp/wind are consumed by that entity rather than exposed as separate switches/selects, so -this registry deliberately does NOT include the common POWER caps -- on/off is -the climate entity's HVACMode.OFF / TURN_ON/OFF. +this registry includes *common.UNIVERSAL but deliberately NOT common.POWER -- +on/off is the climate entity's HVACMode.OFF / TURN_ON/OFF, and /power/0 + +/power/vs/0 are already pre-claimed (no-entity) by airconditioner.COVERAGE via +CLIMATE_CONSUMED_HREFS. A real POWER_GENERIC/POWER_VS_FALLBACK cap on either +href alongside that bare claim would make _build() raise (see its docstring). -Reuses common.ALARMS + common.ENERGY_METER, fridge.FIRMWARE_UPDATE (as every -registry does), and dishwasher.DIAGNOSIS for /diagnosis/vs/0. +Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0. """ -from ..capabilities import airconditioner, common, dishwasher, fridge, ignored +from ..capabilities import airconditioner, common, dishwasher, ignored from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( name='airconditioner', capabilities=_build([ *ignored.IGNORED, - common.ALARMS, - common.ENERGY_METER, - fridge.FIRMWARE_UPDATE, - fridge.SELF_CHECK, + *common.UNIVERSAL, dishwasher.DIAGNOSIS, airconditioner.CLIMATE, airconditioner.AIR_PURIFY, diff --git a/custom_components/localthings/registry/by_type/dishwasher.py b/custom_components/localthings/registry/by_type/dishwasher.py index e12dbd7..1f099f3 100644 --- a/custom_components/localthings/registry/by_type/dishwasher.py +++ b/custom_components/localthings/registry/by_type/dishwasher.py @@ -1,19 +1,13 @@ """Dishwasher device registry.""" -from ..capabilities import common, dishwasher, fridge, ignored, laundry, operational +from ..capabilities import common, dishwasher, ignored, laundry, operational from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( name='dishwasher', capabilities=_build([ *ignored.IGNORED, - common.POWER_GENERIC, - common.POWER_VS_FALLBACK, - common.KIDS_LOCK_GENERIC, - common.KIDS_LOCK_VS_FALLBACK, - common.REMOTE_CONTROL_GENERIC, - common.REMOTE_CONTROL_VS_FALLBACK, - common.ALARMS, - common.ENERGY_METER, + *common.UNIVERSAL, + *common.POWER, common.WATER_METER, common.WATER_FILTER, operational.OPERATIONAL_STATE, @@ -25,6 +19,5 @@ REGISTRY = DeviceRegistry( laundry.DOOR_LED, laundry.SOUND_MODE, laundry.SOUND_VOLUME, - fridge.FIRMWARE_UPDATE, ]), ) diff --git a/custom_components/localthings/registry/by_type/dryer.py b/custom_components/localthings/registry/by_type/dryer.py index 71122f1..81d2891 100644 --- a/custom_components/localthings/registry/by_type/dryer.py +++ b/custom_components/localthings/registry/by_type/dryer.py @@ -9,21 +9,15 @@ objects the washer registry uses -- washer and dryer expose the same DA_WM_-family surface, so they stay consistent instead of each carrying a bespoke variant. """ -from ..capabilities import common, dryer, fridge, ignored, laundry, operational +from ..capabilities import common, dryer, ignored, laundry, operational from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( name='dryer', capabilities=_build([ *ignored.IGNORED, - common.POWER_GENERIC, - common.POWER_VS_FALLBACK, - common.KIDS_LOCK_GENERIC, - common.KIDS_LOCK_VS_FALLBACK, - common.REMOTE_CONTROL_GENERIC, - common.REMOTE_CONTROL_VS_FALLBACK, - common.ALARMS, - common.ENERGY_METER, + *common.UNIVERSAL, + *common.POWER, operational.OPERATIONAL_STATE, laundry.DOOR_LED, laundry.SOUND_MODE, @@ -32,6 +26,5 @@ REGISTRY = DeviceRegistry( dryer.DRYER_SETTINGS, dryer.DRYER_COURSE, dryer.DRYER_DIAGNOSIS, - fridge.FIRMWARE_UPDATE, ]), ) diff --git a/custom_components/localthings/registry/by_type/oven.py b/custom_components/localthings/registry/by_type/oven.py index 69a0096..2ae3341 100644 --- a/custom_components/localthings/registry/by_type/oven.py +++ b/custom_components/localthings/registry/by_type/oven.py @@ -1,24 +1,18 @@ """Oven device registry.""" -from ..capabilities import common, fridge, ignored, oven +from ..capabilities import common, ignored, oven from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( name='oven', capabilities=_build([ *ignored.IGNORED, - common.POWER_GENERIC, - common.POWER_VS_FALLBACK, - common.KIDS_LOCK_GENERIC, - common.KIDS_LOCK_VS_FALLBACK, - common.REMOTE_CONTROL_GENERIC, - common.REMOTE_CONTROL_VS_FALLBACK, - common.ALARMS, + *common.UNIVERSAL, + *common.POWER, oven.OVEN_CAVITY, oven.OVEN_SETPOINT, oven.OVEN_MODE, oven.OVEN_OPERATIONAL_STATE, oven.OVEN_DOOR, oven.OVEN_CONNECTED, - fridge.FIRMWARE_UPDATE, ]), ) diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index ecd63f6..b893475 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -6,21 +6,14 @@ REGISTRY = DeviceRegistry( name='refrigerator', capabilities=_build([ *ignored.IGNORED, - common.POWER_GENERIC, - common.POWER_VS_FALLBACK, + *common.UNIVERSAL, + *common.POWER, fridge.STATUS_LOCK, fridge.DOOR_ALERT, - common.KIDS_LOCK_GENERIC, - common.KIDS_LOCK_VS_FALLBACK, - common.REMOTE_CONTROL_GENERIC, - common.REMOTE_CONTROL_VS_FALLBACK, - common.ALARMS, - common.ENERGY_METER, common.WATER_FILTER, dishwasher.DIAGNOSIS, fridge.ICEMAKER_NIGHTTIME, fridge.FLEX_ZONE, - common.AI_ENERGY_LEVEL, fridge.REFRIGERATION, fridge.AUTOFILL, fridge.WELCOME_LIGHTING, @@ -29,11 +22,9 @@ REGISTRY = DeviceRegistry( fridge.SABBATH, fridge.BEVERAGE_ZONE, fridge.PANTRY_ZONE, - fridge.FIRMWARE_UPDATE, fridge.DEFROST_DELAY, fridge.DEFROST_DELAY_NATIVE_DUPLICATE, fridge.DEFROST_BLOCK_STATUS, - fridge.SELF_CHECK, fridge.DOORS_FALLBACK, fridge.TEMPERATURES_FALLBACK, fridge.ICEMAKER_STATUS_FALLBACK, diff --git a/custom_components/localthings/registry/by_type/washer.py b/custom_components/localthings/registry/by_type/washer.py index ac46cf4..c7732d8 100644 --- a/custom_components/localthings/registry/by_type/washer.py +++ b/custom_components/localthings/registry/by_type/washer.py @@ -1,27 +1,19 @@ """Washer device registry.""" -from ..capabilities import common, dishwasher, fridge, ignored, laundry, operational, washer +from ..capabilities import common, dishwasher, ignored, laundry, operational, washer from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( name='washer', capabilities=_build([ *ignored.IGNORED, - common.POWER_GENERIC, - common.POWER_VS_FALLBACK, - common.KIDS_LOCK_GENERIC, - common.KIDS_LOCK_VS_FALLBACK, - common.REMOTE_CONTROL_GENERIC, - common.REMOTE_CONTROL_VS_FALLBACK, + *common.UNIVERSAL, + *common.POWER, washer.WASHER_SETTINGS, washer.WASHER_COURSE, laundry.BUZZER_SOUND, laundry.JOB_BEGINNING_STATUS, - common.ALARMS, - common.ENERGY_METER, common.WATER_METER, - common.AI_ENERGY_LEVEL, operational.OPERATIONAL_STATE, dishwasher.DIAGNOSIS, - fridge.FIRMWARE_UPDATE, ]), ) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index bfcb374..0abc0b7 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -219,8 +219,9 @@ _AC_IGNORED = [ # user-actionable state or no documented write contract, so per the # 'don't guess' rule they are ignored rather than modeled. # /option/muteonce/vs/0 and /selfcheck/vs/0 are deliberately NOT here -- - # see MUTE_ONCE and fridge.SELF_CHECK above/in the by_type registry, - # both of which have a confirmed, cleanly modelable contract. + # see MUTE_ONCE above and common.SELF_CHECK (via common.UNIVERSAL) in + # the by_type registry, both of which have a confirmed, cleanly + # modelable contract. '/airlevelcheck/vs/0', # periodic air-quality sensing scheduler plumbing '/aisleep/vs/0', # AI-sleep feedback state (no actionable control) '/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index a7660d2..d2b2ac5 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -11,7 +11,9 @@ against live device dumps: /filter/waterfilter/vs/0 -> x.com.samsung.da.filterUsage / filterStatus """ from ..capability import Capability -from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc +from ..entities import ( + BinarySensorDesc, ButtonDesc, SelectDesc, SensorDesc, SwitchDesc, +) def _num(v): @@ -291,3 +293,85 @@ AI_ENERGY_LEVEL = Capability( write_fn=_ai_energy_level_write), ), ) + +FIRMWARE_UPDATE = Capability( + href='/otninformation/vs/0', + poll_tier='cold', + entities=( + BinarySensorDesc( + key='firmware_update', + field='x.com.samsung.da.newVersionAvailable', + name='Firmware update available', + device_class='update', + entity_category='diagnostic', + value_fn=lambda v: str(v).lower() == 'true' if v is not None else None, + ), + ), +) + +SELF_CHECK = Capability( + href='/selfcheck/vs/0', + poll_tier='cold', + entities=( + SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status', + name='Self-check status', icon='mdi:stethoscope', + entity_category='diagnostic'), + SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result', + name='Self-check result', icon='mdi:clipboard-check-outline', + entity_category='diagnostic'), + # List of error codes from the last self-check; joined for display. + # Not every fridge reports the field, hence the exists_fn. + SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error', + name='Self-check error', icon='mdi:alert-circle-outline', + entity_category='diagnostic', + exists_fn=lambda rep, resources: ( + not rep or 'x.com.samsung.da.error' in rep), + value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v), + ButtonDesc(key='selfcheck_start', field='', name='Start self-check', + payload='Start', icon='mdi:play-circle-outline', + entity_category='diagnostic', + write_fn=lambda p, rep, href=None: ( + ['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})), + ), +) + +# --------------------------------------------------------------------------- +# Cross-family bundles, unpacked into every by_type registry's _build([...]) +# call the same way ignored.IGNORED is (*common.UNIVERSAL / *common.POWER). +# discover() only binds a capability whose href is actually present in a +# given device's resource dump, so listing one here for a family that +# doesn't expose the href is a no-op, not a phantom entity -- see the +# adding-device-support skill's coverage-discipline section. +# +# UNIVERSAL holds every capability with no known family that both (a) has +# the href and (b) needs to model it some other way -- broadening one of +# these to a new family is a safe, harmless guess (issue #40's AI energy +# level: 2 of 6 families confirmed, blanket-added everywhere else). +# +# POWER is kept separate: airconditioner deliberately does NOT include it. +# AC's climate entity (registry/capabilities/airconditioner.py) already +# owns /power/0 and /power/vs/0 -- they're pre-claimed there via bare, +# no-entity Capability objects (COVERAGE) so a second, real POWER_GENERIC/ +# POWER_VS_FALLBACK cap on the same href would make _build() raise +# (a href with >1 cap must have every cap discriminated by rt_filter/ +# match_fn, and the bare COVERAGE cap has neither). Kids-lock and remote- +# control don't have this conflict -- no AC dump has ever reported those +# hrefs -- so they stay in UNIVERSAL. +# --------------------------------------------------------------------------- + +UNIVERSAL = ( + ALARMS, + ENERGY_METER, + FIRMWARE_UPDATE, + SELF_CHECK, + AI_ENERGY_LEVEL, + KIDS_LOCK_GENERIC, + KIDS_LOCK_VS_FALLBACK, + REMOTE_CONTROL_GENERIC, + REMOTE_CONTROL_VS_FALLBACK, +) + +POWER = ( + POWER_GENERIC, + POWER_VS_FALLBACK, +) diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 5529526..b6d6dd1 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -19,7 +19,7 @@ import datetime from ..capability import Capability from ..entities import ( - BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc, + BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc, ) from .common import normalize_temp_unit @@ -228,36 +228,6 @@ DEFROST_BLOCK_STATUS = Capability( ), ) -# --------------------------------------------------------------------------- -# Self-check diagnostic -# --------------------------------------------------------------------------- - -SELF_CHECK = Capability( - href='/selfcheck/vs/0', - poll_tier='cold', - entities=( - SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status', - name='Self-check status', icon='mdi:stethoscope', - entity_category='diagnostic'), - SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result', - name='Self-check result', icon='mdi:clipboard-check-outline', - entity_category='diagnostic'), - # List of error codes from the last self-check; joined for display. - # Not every fridge reports the field, hence the exists_fn. - SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error', - name='Self-check error', icon='mdi:alert-circle-outline', - entity_category='diagnostic', - exists_fn=lambda rep, resources: ( - not rep or 'x.com.samsung.da.error' in rep), - value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v), - ButtonDesc(key='selfcheck_start', field='', name='Start self-check', - payload='Start', icon='mdi:play-circle-outline', - entity_category='diagnostic', - write_fn=lambda p, rep, href=None: ( - ['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})), - ), -) - # --------------------------------------------------------------------------- # Refrigeration modes (rapid cooling) # --------------------------------------------------------------------------- @@ -588,25 +558,6 @@ FLEX_ZONE = Capability( ), ) -# --------------------------------------------------------------------------- -# Firmware update -# --------------------------------------------------------------------------- - -FIRMWARE_UPDATE = Capability( - href='/otninformation/vs/0', - poll_tier='cold', - entities=( - BinarySensorDesc( - key='firmware_update', - field='x.com.samsung.da.newVersionAvailable', - name='Firmware update available', - device_class='update', - entity_category='diagnostic', - value_fn=lambda v: str(v).lower() == 'true' if v is not None else None, - ), - ), -) - # --------------------------------------------------------------------------- # Generic door pattern capability (href=None — use as pattern_cap only) # --------------------------------------------------------------------------- diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index adc1431..4a38654 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -227,3 +227,72 @@ class TestAiEnergyLevelSelect: path, body = desc.write_fn('2', {}) assert path == ['energy', 'ailevel', 'vs', '0'] assert body == {'aiLevel': '2'} + + +class TestSelfCheckError: + """Self-check diagnostic error list -- surfaced on hardware that reports + x.com.samsung.da.error, joined into a single display string.""" + + def _desc(self): + return next(e for e in common.SELF_CHECK.entities if e.key == 'selfcheck_error') + + def test_exists_when_field_present(self): + desc = self._desc() + assert desc.exists_fn({'x.com.samsung.da.error': ['DA_ERROR_NONE']}, {}) is True + + def test_does_not_exist_when_field_absent(self): + desc = self._desc() + assert desc.exists_fn({'x.com.samsung.da.status': 'Ready'}, {}) is False + + def test_exists_for_empty_stub_rep(self): + """An empty {} rep is /device/0's not-yet-fetched-stub carve-out -- + must be included-for-now, same as ENERGY_METER's fields.""" + desc = self._desc() + assert desc.exists_fn({}, {}) is True + + def test_value_joins_list(self): + desc = self._desc() + assert desc.value_fn(['E1', 'E2']) == 'E1, E2' + + def test_value_passes_through_scalar(self): + desc = self._desc() + assert desc.value_fn('DA_ERROR_NONE') == 'DA_ERROR_NONE' + + def test_value_none_for_empty_list(self): + """An empty error list means no value to show -- None (unknown), + not an empty string.""" + desc = self._desc() + assert desc.value_fn([]) is None + + +# --------------------------------------------------------------------------- +# Cross-family bundles (UNIVERSAL / POWER) -- unpacked into every by_type +# registry's _build([...]) call in place of the hand-duplicated capability +# lists that used to live there. +# --------------------------------------------------------------------------- + + +class TestUniversalAndPowerBundles: + def test_universal_contains_the_no_conflict_capabilities(self): + assert set(common.UNIVERSAL) == { + common.ALARMS, common.ENERGY_METER, common.FIRMWARE_UPDATE, + common.SELF_CHECK, common.AI_ENERGY_LEVEL, + common.KIDS_LOCK_GENERIC, common.KIDS_LOCK_VS_FALLBACK, + common.REMOTE_CONTROL_GENERIC, common.REMOTE_CONTROL_VS_FALLBACK, + } + + def test_power_kept_separate_for_airconditioners_sake(self): + """airconditioner.py deliberately doesn't unpack this bundle -- its + climate entity already owns /power/0 and /power/vs/0 via a bare, + no-entity claim (airconditioner.COVERAGE); a real POWER_GENERIC/ + POWER_VS_FALLBACK cap on the same href would make _build() raise.""" + assert set(common.POWER) == {common.POWER_GENERIC, common.POWER_VS_FALLBACK} + + def test_no_overlap_between_the_two_bundles(self): + assert not (set(common.UNIVERSAL) & set(common.POWER)) + + def test_airconditioner_registry_does_not_include_power(self): + from custom_components.localthings.registry.by_type import airconditioner + bound_caps = {c for caps in airconditioner.REGISTRY.capabilities.values() for c in caps} + assert common.POWER_GENERIC not in bound_caps + assert common.POWER_VS_FALLBACK not in bound_caps diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index c76ddfe..11dc501 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -231,42 +231,6 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage: assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE' -class TestSelfCheckError: - """Self-check diagnostic error list -- surfaced on hardware that reports - x.com.samsung.da.error, joined into a single display string.""" - - def _desc(self): - return next(e for e in fridge.SELF_CHECK.entities if e.key == 'selfcheck_error') - - def test_exists_when_field_present(self): - desc = self._desc() - assert desc.exists_fn({'x.com.samsung.da.error': ['DA_ERROR_NONE']}, {}) is True - - def test_does_not_exist_when_field_absent(self): - desc = self._desc() - assert desc.exists_fn({'x.com.samsung.da.status': 'Ready'}, {}) is False - - def test_exists_for_empty_stub_rep(self): - """An empty {} rep is /device/0's not-yet-fetched-stub carve-out -- - must be included-for-now, same as ENERGY_METER's fields.""" - desc = self._desc() - assert desc.exists_fn({}, {}) is True - - def test_value_joins_list(self): - desc = self._desc() - assert desc.value_fn(['E1', 'E2']) == 'E1, E2' - - def test_value_passes_through_scalar(self): - desc = self._desc() - assert desc.value_fn('DA_ERROR_NONE') == 'DA_ERROR_NONE' - - def test_value_none_for_empty_list(self): - """An empty error list means no value to show -- None (unknown), - not an empty string.""" - desc = self._desc() - assert desc.value_fn([]) is None - - class TestRefrigeratorAiEnergyLevelFixtureCoverage: """AI energy-saving level (common.AI_ENERGY_LEVEL, see test_common_capabilities.py) is exercised end-to-end here against the From 61ee2972dc683d24a12d3eb29eb704fdf154dc7b Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 22 Jul 2026 20:00:01 +0000 Subject: [PATCH 25/29] Trim duplicated comments from /simplify review Consolidate the AC/power-exclusion rationale (previously spelled out nearly verbatim in three places) down to one canonical explanation next to common.POWER, with one-line pointers elsewhere. Dedupe the switch/select test classes' identical _desc() lookup into one shared helper. --- .../registry/by_type/airconditioner.py | 6 ++---- .../registry/capabilities/common.py | 19 ++++++++++--------- tests/test_common_capabilities.py | 16 ++++++++-------- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 9de96e0..e218de5 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -4,10 +4,8 @@ The first device whose core controls surface as a single composite HA `climate` entity (see capabilities/airconditioner.py and climate.py). Power/mode/temp/wind are consumed by that entity rather than exposed as separate switches/selects, so this registry includes *common.UNIVERSAL but deliberately NOT common.POWER -- -on/off is the climate entity's HVACMode.OFF / TURN_ON/OFF, and /power/0 + -/power/vs/0 are already pre-claimed (no-entity) by airconditioner.COVERAGE via -CLIMATE_CONSUMED_HREFS. A real POWER_GENERIC/POWER_VS_FALLBACK cap on either -href alongside that bare claim would make _build() raise (see its docstring). +on/off is the climate entity's HVACMode.OFF / TURN_ON/OFF. See common.POWER's +own comment in capabilities/common.py for why it's excluded. Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0. """ diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index d2b2ac5..7a490a1 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -348,15 +348,16 @@ SELF_CHECK = Capability( # these to a new family is a safe, harmless guess (issue #40's AI energy # level: 2 of 6 families confirmed, blanket-added everywhere else). # -# POWER is kept separate: airconditioner deliberately does NOT include it. -# AC's climate entity (registry/capabilities/airconditioner.py) already -# owns /power/0 and /power/vs/0 -- they're pre-claimed there via bare, -# no-entity Capability objects (COVERAGE) so a second, real POWER_GENERIC/ -# POWER_VS_FALLBACK cap on the same href would make _build() raise -# (a href with >1 cap must have every cap discriminated by rt_filter/ -# match_fn, and the bare COVERAGE cap has neither). Kids-lock and remote- -# control don't have this conflict -- no AC dump has ever reported those -# hrefs -- so they stay in UNIVERSAL. +# POWER is kept separate -- airconditioner is the one family that opts out +# of it. Canonical reason (see by_type/airconditioner.py and its test for +# pointers back here, not restatements): AC's climate entity already owns +# /power/0 and /power/vs/0 via bare, no-entity Capability objects +# (airconditioner.COVERAGE), and a second, real POWER_GENERIC/ +# POWER_VS_FALLBACK cap on the same href would make _build() raise (a href +# with >1 cap must have every cap discriminated by rt_filter/match_fn, and +# the bare COVERAGE cap has neither). Kids-lock/remote-control don't have +# this conflict -- no AC dump has ever reported those hrefs -- so they stay +# in UNIVERSAL. # --------------------------------------------------------------------------- UNIVERSAL = ( diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index 4a38654..e852809 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -142,10 +142,14 @@ class TestEnergyMeter: # --------------------------------------------------------------------------- +def _ai_energy_level_desc(cls_name): + return next(e for e in common.AI_ENERGY_LEVEL.entities + if e.__class__.__name__ == cls_name) + + class TestAiEnergyLevelSwitch: def _desc(self): - return next(e for e in common.AI_ENERGY_LEVEL.entities - if e.__class__.__name__ == 'SwitchDesc') + return _ai_energy_level_desc('SwitchDesc') def test_href(self): assert common.AI_ENERGY_LEVEL.href == '/energy/ailevel/vs/0' @@ -190,8 +194,7 @@ class TestAiEnergyLevelSwitch: class TestAiEnergyLevelSelect: def _desc(self): - return next(e for e in common.AI_ENERGY_LEVEL.entities - if e.__class__.__name__ == 'SelectDesc') + return _ai_energy_level_desc('SelectDesc') def test_shown_only_with_multiple_supported_levels(self): desc = self._desc() @@ -282,10 +285,7 @@ class TestUniversalAndPowerBundles: } def test_power_kept_separate_for_airconditioners_sake(self): - """airconditioner.py deliberately doesn't unpack this bundle -- its - climate entity already owns /power/0 and /power/vs/0 via a bare, - no-entity claim (airconditioner.COVERAGE); a real POWER_GENERIC/ - POWER_VS_FALLBACK cap on the same href would make _build() raise.""" + """See common.POWER's own comment for why airconditioner opts out.""" assert set(common.POWER) == {common.POWER_GENERIC, common.POWER_VS_FALLBACK} def test_no_overlap_between_the_two_bundles(self): From 1493467c6313eba9c98335001ee3958f8bd22cdb Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 22 Jul 2026 20:16:20 +0000 Subject: [PATCH 26/29] Fix AI energy level platform-flip bug found in Opus review The switch and select shared a stub-time asymmetry: only the select had a `not rep` carve-out, so an unfetched-stub rep at the moment platforms are set up (entity creation runs once, ever) would instantiate a Select, while flatten() re-evaluates exists_fn every poll against live data -- once the resource populated to a single-level list, the switch's exists_fn would win instead and feed the already-created Select a bool through their shared 'ai_energy_level' key, which isn't a valid select option. Dropped the stub carve-out from the select's exists_fn so both sides require real, populated data to decide the platform -- on a device that stubs this cold-tier href on its very first poll, the entity now simply doesn't appear until a reload, instead of appearing as the wrong widget type. Added tests for the missing/empty-list supportedAiLevel shapes on both widgets and a regression test locking in the stub behavior. --- .../registry/capabilities/common.py | 18 ++++++- tests/test_common_capabilities.py | 47 ++++++++++++++++--- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 7a490a1..ef181fc 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -277,6 +277,22 @@ AI_ENERGY_LEVEL = Capability( href='/energy/ailevel/vs/0', poll_tier='cold', entities=( + # No `not rep` stub carve-out on either side, unlike most exists_fn + # gates in this file -- entity creation only ever runs once, against + # whichever snapshot happens to be current the moment platforms are + # set up (see entity._is_included / __init__.py's + # async_config_entry_first_refresh-before-forward-entry-setups + # ordering), while flatten() re-evaluates exists_fn every poll + # against live data. Both descriptors share key='ai_energy_level', + # so if a stub carve-out let one of them win at setup time while the + # other wins once real data lands, flatten() would feed the + # instantiated entity a value shaped for the other platform (e.g. a + # bool into a Select). Requiring real, populated data on both sides + # keeps the entity-creation decision and the live-value decision in + # permanent agreement -- the cost is this entity doesn't appear + # until a reload if the device's very first poll stubs this + # cold-tier href, the same reload already required to fix which + # platform got picked in that case. SwitchDesc(key='ai_energy_level', field='aiLevel', name='AI energy level', icon='mdi:leaf', entity_category='config', @@ -289,7 +305,7 @@ AI_ENERGY_LEVEL = Capability( entity_category='config', options=_ai_energy_level_options, exists_fn=lambda rep, resources: ( - not rep or len(_ai_energy_supported_levels(rep)) > 1), + len(_ai_energy_supported_levels(rep)) > 1), write_fn=_ai_energy_level_write), ), ) diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index e852809..fdd1150 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -166,10 +166,15 @@ class TestAiEnergyLevelSwitch: desc = self._desc() assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': '1'}, {}) is False + def test_hidden_when_supported_level_missing(self): + desc = self._desc() + assert desc.exists_fn({'aiLevel': '1'}, {}) is False + + def test_hidden_when_supported_level_empty_list(self): + desc = self._desc() + assert desc.exists_fn({'aiLevel': '0', 'supportedAiLevel': []}, {}) is False + def test_hidden_on_empty_stub_rep(self): - """Unlike the select, the switch requires a confirmed single-entry - list -- an unfetched stub defaults to the select branch instead (see - TestAiEnergyLevelSelect.test_shown_for_empty_stub_rep).""" desc = self._desc() assert desc.exists_fn({}, {}) is False @@ -201,11 +206,17 @@ class TestAiEnergyLevelSelect: assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is True assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is False - def test_shown_for_empty_stub_rep(self): - """An empty {} rep is /device/0's not-yet-fetched-stub carve-out -- - defaults to the select so sub-polls can populate it either way.""" + def test_hidden_when_supported_level_is_non_list_scalar(self): desc = self._desc() - assert desc.exists_fn({}, {}) is True + assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': '12'}, {}) is False + + def test_hidden_when_supported_level_missing(self): + desc = self._desc() + assert desc.exists_fn({'aiLevel': '1'}, {}) is False + + def test_hidden_when_supported_level_empty_list(self): + desc = self._desc() + assert desc.exists_fn({'aiLevel': '0', 'supportedAiLevel': []}, {}) is False def test_no_translation_key(self): """aiLevel's values are plain digits that render fine untranslated @@ -232,6 +243,28 @@ class TestAiEnergyLevelSelect: assert body == {'aiLevel': '2'} +class TestAiEnergyLevelStubDoesNotDecideThePlatform: + """Issue found in review: entity *creation* runs once, against whatever + snapshot is current when platforms are set up (see + __init__.py's async_config_entry_first_refresh-before-forward-entry-setups + ordering), while flatten() re-evaluates exists_fn every poll against live + data. Both descriptors share key='ai_energy_level' (see adapter._key), so + if a stub carve-out let one of them win at setup time while the other + wins once real data lands, flatten() would feed the already-instantiated + entity a value shaped for the other platform (e.g. a bool into a Select + expecting a string option). Neither side gets a `not rep` carve-out, so + an unfetched stub can never win entity creation for either platform -- + the entity simply doesn't appear until a reload happens with real data, + same as any other exists_fn-gated entity in this codebase that's unlucky + on first-poll timing, instead of appearing as the wrong widget type.""" + + def test_neither_widget_exists_on_empty_stub_rep(self): + switch = _ai_energy_level_desc('SwitchDesc') + select = _ai_energy_level_desc('SelectDesc') + assert switch.exists_fn({}, {}) is False + assert select.exists_fn({}, {}) is False + + class TestSelfCheckError: """Self-check diagnostic error list -- surfaced on hardware that reports x.com.samsung.da.error, joined into a single display string.""" From fb0f24a17554a5183cdd7c8ae3f5982e1474be18 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Thu, 23 Jul 2026 00:57:04 +0000 Subject: [PATCH 27/29] Fix AC climate state lag by promoting consumed hrefs to warm poll tier CLIMATE_CONSUMED_HREFS (power, current/target temp, fan, swing, preset) were bound as no-entity coverage capabilities with the Capability default poll_tier='cold'. The coordinator only OBSERVE-subscribes and sub-polls 'hot'/'warm' hrefs, so cold-tier state only refreshed on the ~30s full /device/0 summary sweep -- matching the 20-30s HA lag reported on issue #17 despite commands landing on the device instantly. Pin them to 'warm', same as CLIMATE's own primary href, so they get push notifications (or warm-tier sub-polling as a poll-only fallback). --- .../registry/capabilities/airconditioner.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 0abc0b7..c58cf03 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -206,6 +206,16 @@ CURRENT_LIMIT = Capability( # (/mode/vs/0 handled above, /temperatures/vs/0, /humidity/*) collide with # other families' schemas. A no-entity Capability still marks the href as # bound so discover() reports no coverage gap. +# +# CLIMATE_CONSUMED_HREFS carry the climate card's actual displayed state +# (power, current/target temp, fan, swing, preset) -- the coordinator only +# OBSERVE-subscribes and sub-polls 'hot'/'warm' hrefs (see coordinator.py), +# so leaving these at the Capability default of 'cold' meant every state +# change was invisible until the next full /device/0 summary sweep +# (~30s -- issue #17: instant device response, 20-30s HA lag). Pin them to +# 'warm' -- same tier as CLIMATE's own primary href -- so they get push +# notifications (or, in poll-only mode, the warm sub-poll cadence) instead +# of waiting on the summary sweep. # --------------------------------------------------------------------------- _AC_IGNORED = [ # All-zero and ambiguously encoded on this model (2-value arrays); the @@ -236,4 +246,8 @@ _AC_IGNORED = [ ] # Built as bare no-entity caps; folded into the AC registry (not global). -COVERAGE = [Capability(href=h) for h in (CLIMATE_CONSUMED_HREFS + _AC_IGNORED)] +COVERAGE = [ + Capability(href=h, poll_tier='warm') for h in CLIMATE_CONSUMED_HREFS +] + [ + Capability(href=h) for h in _AC_IGNORED +] From 10ab51c7f3365c79f62cad158c126985cd4bfb49 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Thu, 23 Jul 2026 01:10:48 +0000 Subject: [PATCH 28/29] fix(fridge,observe): merge partial cache updates, name ice makers dynamically Issue #27: the flex-zone/cooler-drawer select vanished after a device stopped including supportedOptions on an update for /mode/vs/0. ObserveManager.apply() handed reps straight to StateCache.apply_rep, which fully replaces the cached rep -- so a partial update (missing a field the select's exists_fn/options_field gate on) silently erased data a fuller update had previously supplied. apply() now merges incoming reps onto whatever's already cached instead. Also give ice-maker entities (and any future pattern-cap instance) a device-given display name instead of the href-derived "Icemaker One"/"Icemaker Two": Capability.name_field lets a pattern capability read and normalize an instance name (e.g. iceMaker.name's "CUBED_ICE") for use as the entity name prefix, independent of the stable key/unique_id. --- custom_components/localthings/entity.py | 10 ++++- custom_components/localthings/observe.py | 19 +++++++- .../registry/capabilities/fridge.py | 7 +++ .../localthings/registry/capability.py | 5 +++ .../localthings/registry/discovery.py | 21 ++++++++- tests/localthings/test_observe.py | 22 +++++++++ tests/test_discovery.py | 40 +++++++++++++++++ tests/test_entity_naming.py | 45 +++++++++++++++++++ 8 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 tests/test_entity_naming.py diff --git a/custom_components/localthings/entity.py b/custom_components/localthings/entity.py index 17962bf..c5ddf9e 100644 --- a/custom_components/localthings/entity.py +++ b/custom_components/localthings/entity.py @@ -57,7 +57,15 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]): self._bound = bound self._state_key = _key(bound) self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}" - self._attr_name = bound.desc.name if bound.desc.name is not None else _derive_name(self._state_key) + if bound.desc.name is not None: + self._attr_name = bound.desc.name + elif bound.instance_name: + # A device-given instance name (e.g. an ice maker's "Cubed + # Ice") takes the place of the href-derived instance label, + # keeping the same entity-specific suffix (issue #27). + self._attr_name = f"{bound.instance_name} {_derive_name(bound.desc.key)}".strip() + else: + self._attr_name = _derive_name(self._state_key) self._attr_translation_key = bound.desc.translation_key self._attr_icon = bound.desc.icon raw_cat = bound.desc.entity_category diff --git a/custom_components/localthings/observe.py b/custom_components/localthings/observe.py index acda4fe..b3b4544 100644 --- a/custom_components/localthings/observe.py +++ b/custom_components/localthings/observe.py @@ -92,11 +92,26 @@ class ObserveManager: return True def apply(self, href: str, rep: dict, source: str) -> bool: - """Gate a StateCache.apply_rep call through the write-settle guard.""" + """Gate a StateCache.apply_rep call through the write-settle guard. + + Merges the incoming rep onto whatever's already cached for this + href rather than handing it to StateCache.apply_rep verbatim -- + apply_rep does a full replace, and Samsung devices don't always + repeat every field on every update (issue #27: a /mode/vs/0 + OBSERVE notify -- and, on at least one Bespoke fridge, even the + /device/0 sweep entry for that href -- can carry just `modes`, + omitting `supportedOptions`/`supportedModes` entirely). A full + replace would silently wipe fields a select entity's + exists_fn/options_field gates on the moment one partial update + comes through, even though nothing about the device's actual + supported options changed. + """ if self._is_settling(href): self.log.debug("dropping %s update for %s (settling)", source, href) return False - return self.cache.apply_rep(href, rep, source=source) + prior = self.cache.get(href) + merged = {**prior, **rep} if prior else rep + return self.cache.apply_rep(href, merged, source=source) def on_notification(self, href: str, payload: bytes) -> None: """Wired as DtlsCoapSession.on_notification. Runs on the DTLS diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index b6d6dd1..2f2589c 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -100,6 +100,12 @@ ICEMAKER_NIGHTTIME = Capability( # Icemaker (generic — covers /icemaker/one/vs/0, /icemaker/two/vs/0) # /icemaker/status/vs/0 is kept as exact-href cap and binds first. # /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state). +# +# Entity names are derived from x.com.samsung.da.iceMaker.name ("CUBED_ICE", +# "ICE_BITES") via name_field, not the href's "one"/"two" segment -- these two +# ice makers are independent on/off toggles that can both be enabled at once +# (issue #27), so they stay separate entities rather than a single ice-type +# select, but users still want them labeled with the device's own names. # --------------------------------------------------------------------------- def _icemaker_write(field): @@ -112,6 +118,7 @@ ICEMAKER_GENERIC = Capability( href=None, href_prefix='/icemaker/', match_fn=lambda rep, resources: 'x.com.samsung.da.iceMaker.state' in rep, + name_field='x.com.samsung.da.iceMaker.name', poll_tier='warm', entities=( SensorDesc(key='making_status', diff --git a/custom_components/localthings/registry/capability.py b/custom_components/localthings/registry/capability.py index 05921f3..2c6b2d3 100644 --- a/custom_components/localthings/registry/capability.py +++ b/custom_components/localthings/registry/capability.py @@ -15,6 +15,11 @@ class Capability: rt_filter: Optional[str] = None # bind only if rt_filter in rep.get('rt', ()) href_prefix: Optional[str] = None # pattern caps only: bind only if href starts with this strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override + # Rep field holding this instance's device-given name (e.g. an ice + # maker's "CUBED_ICE"/"ICE_BITES"), normalized and used as the display + # name prefix in place of the href-derived instance label. Does not + # affect key_override/unique_id -- only what's shown in the UI. + name_field: Optional[str] = None match_fn: Optional[Callable[[dict, dict], bool]] = None # match_fn(rep, resources) -> bool # Rare optional hook — only operational-state-style resources use this. on_observation: Optional[Callable[[dict, dict], None]] = None diff --git a/custom_components/localthings/registry/discovery.py b/custom_components/localthings/registry/discovery.py index 61e27e3..f7c5392 100644 --- a/custom_components/localthings/registry/discovery.py +++ b/custom_components/localthings/registry/discovery.py @@ -27,6 +27,19 @@ class BoundEntity: desc: SamsungEntityDescription instance: str = '' key_override: Optional[str] = None + instance_name: Optional[str] = None + + +def _instance_name(cap: Capability, rep: dict) -> Optional[str]: + """Normalize `cap.name_field`'s raw value ("CUBED_ICE" -> "Cubed Ice") + for use as a display-name prefix, or None if the cap doesn't declare + one or the device didn't report it.""" + if not cap.name_field: + return None + raw = rep.get(cap.name_field) + if not isinstance(raw, str) or not raw: + return None + return raw.replace('_', ' ').title() def instance_suffix(href: str) -> str: @@ -58,9 +71,11 @@ def discover( if cap.match_fn is not None and not cap.match_fn(rep, resources): continue inst = instance_suffix(href) + inst_name = _instance_name(cap, rep) for desc in cap.entities: out.append(BoundEntity(href=href, capability=cap, - desc=desc, instance=inst)) + desc=desc, instance=inst, + instance_name=inst_name)) matched = True if matched: @@ -75,13 +90,15 @@ def discover( if cap.match_fn is not None and not cap.match_fn(rep, resources): continue inst = instance_suffix(href) + inst_name = _instance_name(cap, rep) # Auto-derive key prefix from href segments (skip digits and 'vs') src = href[len(cap.href_prefix):] if (cap.strip_prefix_in_key and cap.href_prefix) else href segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs'] for desc in cap.entities: key_override = '_'.join(segs) + '_' + desc.key out.append(BoundEntity(href=href, capability=cap, desc=desc, - instance=inst, key_override=key_override)) + instance=inst, key_override=key_override, + instance_name=inst_name)) matched = True break diff --git a/tests/localthings/test_observe.py b/tests/localthings/test_observe.py index a7e1aff..7d3fe9a 100644 --- a/tests/localthings/test_observe.py +++ b/tests/localthings/test_observe.py @@ -34,6 +34,28 @@ def test_apply_writes_through_when_not_settling(): assert mgr.cache.get('/oven/vs/0') == {'a': 1} +def test_apply_merges_partial_update_onto_prior_rep(): + """Regression test for issue #27: a Bespoke fridge's /mode/vs/0 notify + (and even a later sweep entry for that href) can carry only `modes`, + omitting `supportedOptions` entirely. A full replace would wipe + `supportedOptions` from the cache the moment that partial update + arrives, even though the device's supported options didn't change -- + which is exactly what made the flex-zone select disappear.""" + mgr = _manager() + full = { + 'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT'], + 'x.com.samsung.da.supportedOptions': ['CV_FDR_WINE', 'CV_FDR_MEAT'], + } + mgr.apply('/mode/vs/0', full, source='poll') + + partial = {'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'WATERFILTER_ENABLE']} + mgr.apply('/mode/vs/0', partial, source='observe') + + cached = mgr.cache.get('/mode/vs/0') + assert cached['x.com.samsung.da.modes'] == ['CVN_CONVERTIBLE_ZONE', 'WATERFILTER_ENABLE'] + assert cached['x.com.samsung.da.supportedOptions'] == ['CV_FDR_WINE', 'CV_FDR_MEAT'] + + def test_apply_drops_update_during_settle_window(): mgr = _manager() mgr.cache.apply_rep('/oven/vs/0', {'a': 1}, source='seed') diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 7b5a8d9..59702e2 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -158,6 +158,46 @@ def test_discover_match_fn_decline_is_not_logged_as_gap(): assert seen == [] +def test_discover_pattern_cap_reads_name_field(): + """A pattern cap's name_field normalizes a device-given instance name + (e.g. an ice maker's "CUBED_ICE") for use in the entity's display name, + independent of the href-derived key (issue #27).""" + cap = Capability( + href=None, + href_prefix='/icemaker/', + name_field='x.com.samsung.da.iceMaker.name', + entities=(BinarySensorDesc(key='enabled', field='x.com.samsung.da.iceMaker.state'),), + ) + resources = { + '/icemaker/one/vs/0': { + 'x.com.samsung.da.iceMaker.state': 'On', + 'x.com.samsung.da.iceMaker.name': 'CUBED_ICE', + }, + '/icemaker/two/vs/0': { + 'x.com.samsung.da.iceMaker.state': 'On', + 'x.com.samsung.da.iceMaker.name': 'ICE_BITES', + }, + } + bound = discover(resources, {}, pattern_caps=[cap]) + names = {b.href: b.instance_name for b in bound} + assert names == { + '/icemaker/one/vs/0': 'Cubed Ice', + '/icemaker/two/vs/0': 'Ice Bites', + } + + +def test_discover_pattern_cap_name_field_absent_leaves_instance_name_none(): + cap = Capability( + href=None, + href_prefix='/icemaker/', + name_field='x.com.samsung.da.iceMaker.name', + entities=(BinarySensorDesc(key='enabled', field='x.com.samsung.da.iceMaker.state'),), + ) + resources = {'/icemaker/one/vs/0': {'x.com.samsung.da.iceMaker.state': 'On'}} + bound = discover(resources, {}, pattern_caps=[cap]) + assert bound[0].instance_name is None + + def test_discover_rt_filter_gates_binding(): """Cap with rt_filter must not bind a rep whose rt list does not match.""" oven_mode_cap = Capability( diff --git a/tests/test_entity_naming.py b/tests/test_entity_naming.py new file mode 100644 index 0000000..1b9bacf --- /dev/null +++ b/tests/test_entity_naming.py @@ -0,0 +1,45 @@ +"""Tests for LocalThingsEntity's display-name derivation +(custom_components/localthings/entity.py) -- the explicit-name, +device-given-instance-name, and href-derived fallbacks. +""" +from custom_components.localthings.entity import LocalThingsEntity +from custom_components.localthings.registry.capability import Capability +from custom_components.localthings.registry.discovery import BoundEntity +from custom_components.localthings.registry.entities import BinarySensorDesc + + +class _FakeCoordinator: + device_serial = 'TEST-SERIAL' + + def __init__(self, last_resources=None): + self.last_resources = last_resources or {} + + +def _make_entity(desc, href='/x/vs/0', key_override=None, instance='', instance_name=None): + capability = Capability(href=href, entities=(desc,)) + bound = BoundEntity(href=href, capability=capability, desc=desc, + instance=instance, key_override=key_override, + instance_name=instance_name) + return LocalThingsEntity(_FakeCoordinator(), bound) + + +def test_explicit_name_wins_over_everything(): + desc = BinarySensorDesc(key='enabled', name='Explicit Name') + entity = _make_entity(desc, instance_name='Cubed Ice') + assert entity._attr_name == 'Explicit Name' + + +def test_instance_name_prefixes_the_derived_suffix(): + """Issue #27: an ice maker's device-given name ("Cubed Ice") replaces + the href-derived instance label ("Icemaker One") as the name prefix, + keeping the same entity-specific suffix.""" + desc = BinarySensorDesc(key='enabled') + entity = _make_entity(desc, key_override='icemaker_one_enabled', + instance_name='Cubed Ice') + assert entity._attr_name == 'Cubed Ice Enabled' + + +def test_no_instance_name_falls_back_to_derived_state_key(): + desc = BinarySensorDesc(key='enabled') + entity = _make_entity(desc, key_override='icemaker_one_enabled') + assert entity._attr_name == 'Icemaker One Enabled' From 36f7aa0b8cacb3751af93dbd21bb272b20479dde Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Thu, 23 Jul 2026 01:25:04 +0000 Subject: [PATCH 29/29] refactor: dedup naming helpers, close observe-cache merge race /simplify pass on the issue #27 fix: extract a shared _snake_to_title between entity.py and discovery.py, factor discover()'s two binding loops through one _bind() helper, and close a TOCTOU race the cache merge introduced -- apply() is the sole path StateCache mutations flow through, so the read-then-write is now serialized under one lock instead of two independently-locked calls. --- custom_components/localthings/entity.py | 4 +-- custom_components/localthings/observe.py | 16 +++++++-- .../localthings/registry/discovery.py | 33 ++++++++++++------- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/custom_components/localthings/entity.py b/custom_components/localthings/entity.py index c5ddf9e..8a8fc3d 100644 --- a/custom_components/localthings/entity.py +++ b/custom_components/localthings/entity.py @@ -8,7 +8,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.const import EntityCategory from .registry.adapter import _key -from .registry.discovery import BoundEntity +from .registry.discovery import BoundEntity, _snake_to_title from .const import DOMAIN from .coordinator import LocalThingsCoordinator @@ -44,7 +44,7 @@ def _derive_name(state_key: str) -> str: instance number with a space: "door_cooler_open1" → "Door Cooler Open 1". """ name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key) - return name.replace('_', ' ').title().strip() + return _snake_to_title(name).strip() class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]): diff --git a/custom_components/localthings/observe.py b/custom_components/localthings/observe.py index b3b4544..e3c5aad 100644 --- a/custom_components/localthings/observe.py +++ b/custom_components/localthings/observe.py @@ -69,6 +69,7 @@ class ObserveManager: self.last_mode_change_wall = time.time() self._settle_until: dict[str, float] = {} self._settle_lock = threading.Lock() + self._cache_lock = threading.Lock() self.subscribed_hrefs: set[str] = set() self._notified: set[str] = set() self._last_notify_ts: float | None = None @@ -105,13 +106,22 @@ class ObserveManager: exists_fn/options_field gates on the moment one partial update comes through, even though nothing about the device's actual supported options changed. + + `apply()` is the sole path StateCache mutations flow through in + this component (poll, sweep, and OBSERVE notify all funnel here), + so `_cache_lock` serializes the read-then-write across those + threads (DTLS reader for notifies, executor threads for poll/ + sweep) -- without it, two concurrent updates for the same href + could each read the same prior rep and the second writer would + silently lose the first's fields, reintroducing the exact bug + this merge fixes. """ if self._is_settling(href): self.log.debug("dropping %s update for %s (settling)", source, href) return False - prior = self.cache.get(href) - merged = {**prior, **rep} if prior else rep - return self.cache.apply_rep(href, merged, source=source) + with self._cache_lock: + merged = {**(self.cache.get(href) or {}), **rep} + return self.cache.apply_rep(href, merged, source=source) def on_notification(self, href: str, payload: bytes) -> None: """Wired as DtlsCoapSession.on_notification. Runs on the DTLS diff --git a/custom_components/localthings/registry/discovery.py b/custom_components/localthings/registry/discovery.py index f7c5392..891c2ae 100644 --- a/custom_components/localthings/registry/discovery.py +++ b/custom_components/localthings/registry/discovery.py @@ -30,6 +30,12 @@ class BoundEntity: instance_name: Optional[str] = None +def _snake_to_title(s: str) -> str: + """'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's + _derive_name, which applies the same transform to a state key.""" + return s.replace('_', ' ').title() + + def _instance_name(cap: Capability, rep: dict) -> Optional[str]: """Normalize `cap.name_field`'s raw value ("CUBED_ICE" -> "Cubed Ice") for use as a display-name prefix, or None if the cap doesn't declare @@ -39,7 +45,7 @@ def _instance_name(cap: Capability, rep: dict) -> Optional[str]: raw = rep.get(cap.name_field) if not isinstance(raw, str) or not raw: return None - return raw.replace('_', ' ').title() + return _snake_to_title(raw) def instance_suffix(href: str) -> str: @@ -50,6 +56,18 @@ def instance_suffix(href: str) -> str: return '' +def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str], + key_prefix: Optional[str] = None) -> list[BoundEntity]: + """Build one BoundEntity per entity on `cap`, sharing the instance/ + key-prefix/instance-name computed once by the caller.""" + return [ + BoundEntity(href=href, capability=cap, desc=desc, instance=inst, + key_override=f'{key_prefix}_{desc.key}' if key_prefix else None, + instance_name=inst_name) + for desc in cap.entities + ] + + def discover( resources: dict[str, dict], registry: dict[str, list[Capability]], @@ -71,11 +89,7 @@ def discover( if cap.match_fn is not None and not cap.match_fn(rep, resources): continue inst = instance_suffix(href) - inst_name = _instance_name(cap, rep) - for desc in cap.entities: - out.append(BoundEntity(href=href, capability=cap, - desc=desc, instance=inst, - instance_name=inst_name)) + out.extend(_bind(cap, href, inst, _instance_name(cap, rep))) matched = True if matched: @@ -90,15 +104,10 @@ def discover( if cap.match_fn is not None and not cap.match_fn(rep, resources): continue inst = instance_suffix(href) - inst_name = _instance_name(cap, rep) # Auto-derive key prefix from href segments (skip digits and 'vs') src = href[len(cap.href_prefix):] if (cap.strip_prefix_in_key and cap.href_prefix) else href segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs'] - for desc in cap.entities: - key_override = '_'.join(segs) + '_' + desc.key - out.append(BoundEntity(href=href, capability=cap, desc=desc, - instance=inst, key_override=key_override, - instance_name=inst_name)) + out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs))) matched = True break