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/