From 8194ea9e87680f81a0b352cce9e79ddd204d2bf5 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 00:39:33 +0000 Subject: [PATCH 1/2] Fix ARTIK051_DONGLE_REF freezer type detection and door sensor (issue #77) RZ32M713EWW/EE (an ARTIK051-dongle standalone freezer) reports no oneUiVersion and a pipe-delimited modelNum ('ARTIK051_DONGLE_REF|') -- REF is the last underscore segment before the pipe, not wrapped in underscores on both sides like the '..._REF_...' shape for_device_by_model's substring check expected, so the device fell through to 'unknown' with only common capabilities: no door sensor, no temperature sensors, nothing fridge-specific. Replace the substring check with a segment-based one (_model_num_segments splits the pipe-delimited prefix on '_') that catches both shapes. Same root cause, same fix, and same device family independently reported and root-caused in issue #83 (which also covers two further bugs -- config-flow/coordinator serial collisions on a second symptom of this firmware, 'Nothing(SVC)' as a literal serial -- not needed here since this reporter has a single unit; left for #83). Once routed to the refrigerator registry, fridge.py's existing pattern capabilities pick up /temperature/current/freezer/0 and /temperature/desired/freezer/0 for free -- they were never the actual problem, just unreachable under the 'unknown' fallback (which never tries pattern capabilities at all). The door sensor needed one more fix: /door/onedoorfreezer/vs/0 reports the vendor-prefixed x.com.samsung.da.openState, not the bare openState DOOR_GENERIC read, so the entity existed but stayed permanently unavailable. Check both field names via rep_fn. --- .../localthings/registry/by_type/__init__.py | 18 +- .../registry/capabilities/fridge.py | 18 +- .../refrigerator_artik051_dongle_ref.json | 22 +++ ...frigerator_artik051_dongle_ref_device.json | 184 ++++++++++++++++++ tests/test_by_type.py | 33 ++++ tests/test_fridge_capabilities.py | 56 ++++++ tests/test_golden_regression.py | 21 ++ 7 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/golden/refrigerator_artik051_dongle_ref.json create mode 100644 tests/fixtures/refrigerator_artik051_dongle_ref_device.json diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 7f6b864..4ef5e10 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -87,6 +87,22 @@ _CONSUMER_PREFIX_TO_KEY: dict[str, str] = { } +def _model_num_segments(model_num: str) -> list[str]: + """Underscore-delimited segments of modelNum's pipe-prefix. + + Most boards wrap a token in underscores on both sides ('..._REF_...'), + which a plain substring check catches fine. But the ARTIK051_DONGLE_REF + family (issues #77, #83) reports modelNum as + '_DONGLE_REF|' -- REF is the *last* segment before the + pipe, with no trailing underscore, so '_REF_' never matches and the + device silently fell back to 'unknown'. Splitting on '_' and checking + segment membership catches both shapes without caring which side (if + either) has a delimiter. + """ + prefix = (model_num or '').split('|', 1)[0] + return prefix.split('_') + + def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]: """Fallback device-type detection for hardware that never reports oneUiVersion (confirmed for washers -- their /otninformation/vs/0 has @@ -102,7 +118,7 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi """ token = (description or '').split('/', 1)[0].rsplit('_', 1)[-1] key = _CONSUMER_PREFIX_TO_KEY.get(token[:2].upper()) - if key is None and '_REF_' in (model_num or ''): + if key is None and 'REF' in _model_num_segments(model_num): key = 'refrigerator' # Room air conditioners (e.g. ARTIK051_PRAC_20K) report no oneUiVersion and # a modelNum carrying the '_PRAC_' (Package Room Air Conditioner) token. diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 72bf573..13bbc4b 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -573,15 +573,27 @@ FLEX_ZONE = Capability( # Generic door pattern capability (href=None — use as pattern_cap only) # --------------------------------------------------------------------------- +def _door_open_state(rep): + """Most /door/* resources report bare `openState`, but the + ARTIK051_DONGLE_REF family's /door/onedoorfreezer/vs/0 (issues #77, #83) + reports the vendor-prefixed `x.com.samsung.da.openState` instead. This + capability still binds either way (href_prefix match doesn't care about + field names), but a plain `field=` lookup against the wrong key means + the entity exists and is permanently unavailable -- check both.""" + v = rep.get('openState') + if v is None: + v = rep.get('x.com.samsung.da.openState') + return v == 'Open' + + DOOR_GENERIC = Capability( href=None, href_prefix='/door/', poll_tier='hot', entities=( - BinarySensorDesc(key='open', field='openState', + BinarySensorDesc(key='open', rep_fn=_door_open_state, translation_key='instance_open', - use_instance_name=True, device_class='door', - value_fn=lambda v: v == 'Open'), + use_instance_name=True, device_class='door'), ), ) diff --git a/tests/fixtures/golden/refrigerator_artik051_dongle_ref.json b/tests/fixtures/golden/refrigerator_artik051_dongle_ref.json new file mode 100644 index 0000000..6174968 --- /dev/null +++ b/tests/fixtures/golden/refrigerator_artik051_dongle_ref.json @@ -0,0 +1,22 @@ +{ + "state_keys": [ + "alarm_code", + "defrost_active", + "defrost_delay", + "diagnosis_status", + "door_onedoorfreezer_open", + "energy_kwh", + "energy_last_month_kwh", + "energy_saved_kwh", + "energy_this_month_kwh", + "firmware_update", + "freezer_setpoint", + "freezer_temperature", + "ice_maker_enabled", + "power_energy_kwh", + "power_watts", + "rapid_freezing", + "rapid_fridge", + "sabbath_mode" + ] +} diff --git a/tests/fixtures/refrigerator_artik051_dongle_ref_device.json b/tests/fixtures/refrigerator_artik051_dongle_ref_device.json new file mode 100644 index 0000000..cca5915 --- /dev/null +++ b/tests/fixtures/refrigerator_artik051_dongle_ref_device.json @@ -0,0 +1,184 @@ +{ + "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": "/bespoke/vs/0", + "rep": { + "x.com.samsung.da.BespokeProduct": "Off" + } + }, + { + "href": "/defrost/delay/0", + "rep": {} + }, + { + "href": "/defrost/delay/vs/0", + "rep": {} + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/door/onedoorfreezer/vs/0", + "rep": { + "x.com.samsung.da.openState": "Close" + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close" + } + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": {} + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+02:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/icemaker/status/0", + "rep": {} + }, + { + "href": "/icemaker/status/vs/0", + "rep": {} + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "ARTIK051_DONGLE_REF|00127641|00080020001430300100000000000000", + "x.com.samsung.da.description": "ARTIK_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": "011120190711", + "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": "2103120d,ffffffff", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "Micom", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/refrigeration/0", + "rep": { + "defrost": false, + "rapidFreeze": false, + "rapidCool": false + } + }, + { + "href": "/refrigeration/vs/0", + "rep": { + "x.com.samsung.da.rapidFreezing": "Off" + } + }, + { + "href": "/sabbath/vs/0", + "rep": {} + }, + { + "href": "/temperature/current/freezer/0", + "rep": { + "range": [ + -23.0, + -15.0 + ], + "units": "C", + "temperature": -20.0 + } + }, + { + "href": "/temperature/desired/freezer/0", + "rep": { + "range": [ + -23.0, + -15.0 + ], + "units": "C", + "temperature": -20.0 + } + }, + { + "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": "-20", + "x.com.samsung.da.current": "-20", + "x.com.samsung.da.maximum": "-15", + "x.com.samsung.da.minimum": "-23", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 656f937..9617878 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -111,6 +111,25 @@ class TestWasherRegistry: assert href in registry.capabilities, f"{href} missing from washer registry" +class TestModelNumSegments: + def test_splits_pipe_prefix_on_underscore(self): + from custom_components.localthings.registry.by_type import _model_num_segments + assert _model_num_segments('ARTIK051_DONGLE_REF|00127641|000800200014') == [ + 'ARTIK051', 'DONGLE', 'REF', + ] + + def test_ignores_everything_after_first_pipe(self): + from custom_components.localthings.registry.by_type import _model_num_segments + assert _model_num_segments('TP2X_RAC_20K|abc|REF_should_not_appear') == [ + 'TP2X', 'RAC', '20K', + ] + + def test_empty_for_none_or_empty_input(self): + from custom_components.localthings.registry.by_type import _model_num_segments + assert _model_num_segments('') == [''] + assert _model_num_segments(None) == [''] + + class TestForDeviceByModel: """Fallback device-type detection for hardware without oneUiVersion.""" @@ -178,6 +197,20 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'refrigerator' + def test_refrigerator_dongle_ref_pipe_delimited_modelnum(self): + """Issues #77/#83: the ARTIK051_DONGLE_REF family's modelNum is + '_DONGLE_REF|' -- REF is the last underscore segment + before the pipe, with no trailing underscore, so the plain '_REF_' + substring check used to miss it entirely and the device fell back + to 'unknown' with only common capabilities.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'ARTIK051_DONGLE_REF|00127641|00080020001430300100000000000000', + 'ARTIK_REF_17K', + ) + assert reg is not None + assert reg.name == 'refrigerator' + def test_airconditioner_via_prac_token(self): """Issue #17: a room AC (ARTIK051_PRAC_20K) reports no oneUiVersion and an unrecognized consumer token ('20K'); it falls back to the '_PRAC_' diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index d35f5d4..bbd7d54 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -20,6 +20,29 @@ class TestTempCurrentGeneric: assert desc.unit_fn({'temperature': 5.0}) == '°F' +class TestDoorGeneric: + """Issues #77/#83: DOOR_GENERIC used to read only the bare `openState` + field. Most /door/* resources use that, but the ARTIK051_DONGLE_REF + family's /door/onedoorfreezer/vs/0 reports the vendor-prefixed + `x.com.samsung.da.openState` instead -- the pattern capability still + bound the href either way, but the entity's value was always None.""" + + def test_reads_bare_open_state(self): + desc = fridge.DOOR_GENERIC.entities[0] + assert desc.rep_fn({'openState': 'Open'}) is True + assert desc.rep_fn({'openState': 'Close'}) is False + + def test_reads_vendor_prefixed_open_state(self): + desc = fridge.DOOR_GENERIC.entities[0] + assert desc.rep_fn({'x.com.samsung.da.openState': 'Open'}) is True + assert desc.rep_fn({'x.com.samsung.da.openState': 'Close'}) is False + + def test_prefers_bare_field_when_both_present(self): + desc = fridge.DOOR_GENERIC.entities[0] + rep = {'openState': 'Open', 'x.com.samsung.da.openState': 'Close'} + assert desc.rep_fn(rep) is True + + class TestTempSetpointGeneric: def test_unit_reads_celsius(self): desc = fridge.TEMP_SETPOINT_GENERIC.entities[0] @@ -231,6 +254,39 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage: assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE' +class TestArtik051DongleRefFixtureCoverage: + """Issues #77/#83: the ARTIK051_DONGLE_REF standalone freezer resolves + to the refrigerator registry (segment-based modelNum detection) with + zero unbound hrefs, and its door/temperature entities -- which only + bind through pattern capabilities the 'unknown' fallback never tried -- + now carry real values.""" + + def test_no_unbound_hrefs_and_expected_entities(self): + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import ( + for_device_by_model, refrigerator, + ) + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('refrigerator_artik051_dongle_ref') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + assert reg is not None and reg.name == 'refrigerator' + + unbound = [] + bound = discover( + resources, refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, log=unbound.append, + ) + assert unbound == [] + state = flatten(bound, resources) + assert state['door_onedoorfreezer_open'] is False # rep reports 'Close' + assert state['freezer_temperature'] == -20.0 + assert state['freezer_setpoint'] == -20.0 + + class TestRefrigeratorAiEnergyLevelFixtureCoverage: """AI energy-saving level (common.AI_ENERGY_LEVEL, see test_common_capabilities.py) is exercised end-to-end here against the diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 56ba878..d3b2b8f 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -166,6 +166,27 @@ def test_registry_reproduces_golden_state_keys_for_artik051_ref_17k(): ) +def test_registry_reproduces_golden_state_keys_for_artik051_dongle_ref(): + """ARTIK051_DONGLE_REF standalone freezer (issues #77/#83) -- reports no + oneUiVersion and a pipe-delimited modelNum ('..._DONGLE_REF|') + that the old '_REF_' substring check missed entirely; resolved via the + segment-based check in for_device_by_model. Its door + (/door/onedoorfreezer/vs/0) and temperature + (/temperature/{current,desired}/freezer/0) hrefs only bind through + fridge.py's pattern capabilities, which the 'unknown' fallback never + tries -- so this also regression-tests that those resources bind at + all once routed to the right registry.""" + from tests.conftest import _load_device + resources = _load_device('refrigerator_artik051_dongle_ref') + golden = json.loads((GOLDEN / 'refrigerator_artik051_dongle_ref.json').read_text()) + state_keys = _new_state_keys('refrigerator_artik051_dongle_ref', 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/ From 643aac92f80641ff305c08efa97cd928133874c6 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 00:41:25 +0000 Subject: [PATCH 2/2] Lock in the same ARTIK051_DONGLE_REF fix for the fridge half (issue #78) RR40M7165WW is the fridge half of the same household dongle setup as issue #77's freezer -- identical pipe-delimited ARTIK051_DONGLE_REF modelNum, so the previous commit's detection and door-sensor fixes already cover it with no further code changes. Add its fixture as a second, independent regression case: it exercises the 'cooler' instance segment instead of 'freezer' for both the temperature pattern caps and DOOR_GENERIC, and notably reports /door/onedoorfreezer/vs/0 despite being a single-door fridge (shared firmware naming across the product line, not an actual second compartment) -- worth having its own golden so that stays working too. --- ...frigerator_artik051_dongle_ref_cooler.json | 23 ++ ...tor_artik051_dongle_ref_cooler_device.json | 201 ++++++++++++++++++ tests/test_fridge_capabilities.py | 36 ++++ tests/test_golden_regression.py | 18 ++ 4 files changed, 278 insertions(+) create mode 100644 tests/fixtures/golden/refrigerator_artik051_dongle_ref_cooler.json create mode 100644 tests/fixtures/refrigerator_artik051_dongle_ref_cooler_device.json diff --git a/tests/fixtures/golden/refrigerator_artik051_dongle_ref_cooler.json b/tests/fixtures/golden/refrigerator_artik051_dongle_ref_cooler.json new file mode 100644 index 0000000..984569c --- /dev/null +++ b/tests/fixtures/golden/refrigerator_artik051_dongle_ref_cooler.json @@ -0,0 +1,23 @@ +{ + "state_keys": [ + "alarm_code", + "cooler_setpoint", + "cooler_temperature", + "defrost_active", + "defrost_delay", + "diagnosis_status", + "door_cooler_open", + "door_onedoorfreezer_open", + "energy_kwh", + "energy_last_month_kwh", + "energy_saved_kwh", + "energy_this_month_kwh", + "firmware_update", + "ice_maker_enabled", + "power_energy_kwh", + "power_watts", + "rapid_freezing", + "rapid_fridge", + "sabbath_mode" + ] +} diff --git a/tests/fixtures/refrigerator_artik051_dongle_ref_cooler_device.json b/tests/fixtures/refrigerator_artik051_dongle_ref_cooler_device.json new file mode 100644 index 0000000..de8e022 --- /dev/null +++ b/tests/fixtures/refrigerator_artik051_dongle_ref_cooler_device.json @@ -0,0 +1,201 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/bespoke/vs/0", + "rep": { + "x.com.samsung.da.BespokeProduct": "Off" + } + }, + { + "href": "/defrost/delay/0", + "rep": {} + }, + { + "href": "/defrost/delay/vs/0", + "rep": {} + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/door/cooler/0", + "rep": { + "openState": "Close" + } + }, + { + "href": "/door/onedoorfreezer/vs/0", + "rep": { + "x.com.samsung.da.openState": "Close" + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close" + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close" + } + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": {} + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+01:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/icemaker/status/0", + "rep": {} + }, + { + "href": "/icemaker/status/vs/0", + "rep": {} + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "ARTIK051_DONGLE_REF|00090741|00090028001130300100000000000000", + "x.com.samsung.da.description": "ARTIK_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": "011120190711", + "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": "14040703,ffffffff", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "HOMECARE_WIZARD_V2", + "RVACATION_ON", + "RVACATION_OFF" + ], + "modes": [ + "RVACATION_OFF" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "RVACATION_ON", + "RVACATION_OFF" + ], + "x.com.samsung.da.modes": [ + "RVACATION_OFF" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "Micom", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/refrigeration/0", + "rep": { + "defrost": false, + "rapidFreeze": false, + "rapidCool": false + } + }, + { + "href": "/refrigeration/vs/0", + "rep": { + "x.com.samsung.da.rapidFridge": "Off" + } + }, + { + "href": "/sabbath/vs/0", + "rep": {} + }, + { + "href": "/temperature/current/cooler/0", + "rep": { + "range": [ + 1.0, + 7.0 + ], + "units": "C", + "temperature": 3.0 + } + }, + { + "href": "/temperature/desired/cooler/0", + "rep": { + "range": [ + 1.0, + 7.0 + ], + "units": "C", + "temperature": 3.0 + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Fridge", + "x.com.samsung.da.desired": "3", + "x.com.samsung.da.current": "3", + "x.com.samsung.da.maximum": "7", + "x.com.samsung.da.minimum": "1", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index bbd7d54..1d3634b 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -287,6 +287,42 @@ class TestArtik051DongleRefFixtureCoverage: assert state['freezer_setpoint'] == -20.0 +class TestArtik051DongleRefCoolerFixtureCoverage: + """Issue #78: RR40M7165WW, the fridge half of the same household + ARTIK051_DONGLE_REF dongle setup as issue #77's freezer. Notably reports + *two* door hrefs (/door/cooler/0 and /door/onedoorfreezer/vs/0) despite + being a single-door fridge, not a fridge/freezer combo -- apparently + shared firmware naming across the product line, not a real second + compartment. Both must still resolve to zero unbound hrefs and real + values with the same fix as #77.""" + + def test_no_unbound_hrefs_and_expected_entities(self): + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import ( + for_device_by_model, refrigerator, + ) + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('refrigerator_artik051_dongle_ref_cooler') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + assert reg is not None and reg.name == 'refrigerator' + + unbound = [] + bound = discover( + resources, refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, log=unbound.append, + ) + assert unbound == [] + state = flatten(bound, resources) + assert state['door_cooler_open'] is False + assert state['door_onedoorfreezer_open'] is False + assert state['cooler_temperature'] == 3.0 + assert state['cooler_setpoint'] == 3.0 + + class TestRefrigeratorAiEnergyLevelFixtureCoverage: """AI energy-saving level (common.AI_ENERGY_LEVEL, see test_common_capabilities.py) is exercised end-to-end here against the diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index d3b2b8f..8769adf 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -166,6 +166,24 @@ def test_registry_reproduces_golden_state_keys_for_artik051_ref_17k(): ) +def test_registry_reproduces_golden_state_keys_for_artik051_dongle_ref_cooler(): + """RR40M7165WW (issue #78) -- the same ARTIK051_DONGLE_REF household + dongle family as issue #77's freezer, but the fridge half: reports + /door/cooler/0 *and* /door/onedoorfreezer/vs/0 (the latter apparently + shared firmware naming, not an actual second freezer compartment) plus + /temperature/{current,desired}/cooler/0. Same pipe-delimited modelNum + detection gap and DOOR_GENERIC field-name gap as #77, same fix.""" + from tests.conftest import _load_device + resources = _load_device('refrigerator_artik051_dongle_ref_cooler') + golden = json.loads((GOLDEN / 'refrigerator_artik051_dongle_ref_cooler.json').read_text()) + state_keys = _new_state_keys('refrigerator_artik051_dongle_ref_cooler', 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_dongle_ref(): """ARTIK051_DONGLE_REF standalone freezer (issues #77/#83) -- reports no oneUiVersion and a pipe-delimited modelNum ('..._DONGLE_REF|')