From 5d1a32aa9a9bd28af57f0abc710006af8a308c9b Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 8 Jul 2026 15:24:34 -0500 Subject: [PATCH] Map remaining coverage-gap hrefs, fix discover() false-gap bug, improve device naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps the fridge/dishwasher hrefs flagged by last session's coverage-gap Repairs issue: defrost delay (switch) + block status (diagnostic binary sensor), master ice-maker enable, self-check trigger/status, dishwasher diagnosis trigger/status, and last-operation-source. Adds fallback capabilities for /doors, /temperatures, and /icemaker/status that only bind when the richer per-instance hrefs they duplicate are absent, so simpler devices without those hrefs still get the data. Everything else in the original gap list moves to capabilities.ignored (Bixby audio feedback, inert DR/energy-planner resources, redundant metadata). Fixes discover() reporting a known href as an unregistered gap whenever its match_fn declined to bind for that device (e.g. a filter capability on hardware without that filter) — it's now only a gap when no capability is registered for the href at all. Device naming now uses x.com.samsung.da.modelNum (not the OCF /oic/p,d metadata, which produced inconsistent names like "[dishwasher] Samsung") to build "Samsung ()". --- custom_components/localthings/coordinator.py | 10 +- .../registry/by_type/dishwasher.py | 5 +- .../registry/by_type/refrigerator.py | 6 + .../registry/capabilities/dishwasher.py | 31 +++- .../registry/capabilities/fridge.py | 141 +++++++++++++++++- .../registry/capabilities/ignored.py | 30 ++++ .../localthings/registry/discovery.py | 8 +- tests/fixtures/golden/dishwasher.json | 7 + tests/fixtures/golden/refrigerator.json | 9 ++ tests/localthings/test_coordinator.py | 20 ++- tests/localthings/test_diagnostics.py | 2 +- tests/test_discovery.py | 20 +++ tests/test_registry_ignored.py | 13 +- 13 files changed, 279 insertions(+), 23 deletions(-) diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 01997a3..d454bc2 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -202,15 +202,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.bound = bound self._unbound_hrefs = unbound - serial = (resources.get('/information/vs/0', {}) - .get('x.com.samsung.da.serialNum', '')) + info = resources.get('/information/vs/0', {}) + serial = info.get('x.com.samsung.da.serialNum', '') if not serial: serial = self._entry.data[CONF_HOST] self.device_serial = serial ident = self._identity - model = (ident.model if ident else '') or (reg.name.title() if reg else 'Appliance') - name = (ident.name if ident else '') or f"Samsung {model}" + device_type = reg.name.title() if reg else 'Appliance' + model_num = info.get('x.com.samsung.da.modelNum', '') + model = model_num.split('|', 1)[0] if model_num else (ident.model if ident else '') + name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}" mfr = (ident.manufacturer if ident else '') or 'Samsung' self.device_info = DeviceInfo( diff --git a/custom_components/localthings/registry/by_type/dishwasher.py b/custom_components/localthings/registry/by_type/dishwasher.py index bd5252e..ba969d1 100644 --- a/custom_components/localthings/registry/by_type/dishwasher.py +++ b/custom_components/localthings/registry/by_type/dishwasher.py @@ -1,5 +1,5 @@ """Dishwasher device registry.""" -from ..capabilities import common, dishwasher, fridge, ignored, laundry, operational +from ..capabilities import common, dishwasher, dryer, fridge, ignored, laundry, operational from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( @@ -16,6 +16,9 @@ REGISTRY = DeviceRegistry( operational.OPERATIONAL_STATE, dishwasher.CYCLE_OPTIONS, dishwasher.DISHWASHER_SETTINGS, + dishwasher.DIAGNOSIS, + dishwasher.OPERATION_ORIGIN, + dryer.JOB_BEGINNING_STATUS, laundry.DOOR_LED, laundry.SOUND_MODE, laundry.SOUND_VOLUME, diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index 8449082..89de9de 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -24,6 +24,12 @@ REGISTRY = DeviceRegistry( fridge.SABBATH, fridge.BEVERAGE_ZONE, fridge.FIRMWARE_UPDATE, + fridge.DEFROST_DELAY, + fridge.DEFROST_BLOCK_STATUS, + fridge.SELF_CHECK, + fridge.DOORS_FALLBACK, + fridge.TEMPERATURES_FALLBACK, + fridge.ICEMAKER_STATUS_FALLBACK, ]), pattern_capabilities=[ fridge.TEMP_CURRENT_GENERIC, diff --git a/custom_components/localthings/registry/capabilities/dishwasher.py b/custom_components/localthings/registry/capabilities/dishwasher.py index bf02b95..860bf06 100644 --- a/custom_components/localthings/registry/capabilities/dishwasher.py +++ b/custom_components/localthings/registry/capabilities/dishwasher.py @@ -3,7 +3,7 @@ Resources verified against the live device dump at 10.0.0.129. """ from ..capability import Capability -from ..entities import SelectDesc, SwitchDesc +from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc # --------------------------------------------------------------------------- # /dishwasher/vs/0 — cycle wash/dry settings @@ -119,3 +119,32 @@ CYCLE_OPTIONS = Capability( write_fn=_auto_release_write), ), ) + +# --------------------------------------------------------------------------- +# Self-diagnostic trigger and last-operation-source sensor +# --------------------------------------------------------------------------- + +DIAGNOSIS = Capability( + href='/diagnosis/vs/0', + poll_tier='cold', + entities=( + SensorDesc(key='diagnosis_status', field='x.com.samsung.da.diagnosisStart', + name='Diagnosis status', icon='mdi:stethoscope', + entity_category='diagnostic'), + ButtonDesc(key='diagnosis_start', field='', name='Start diagnosis', + payload='Start', icon='mdi:play-circle-outline', + entity_category='diagnostic', + write_fn=lambda p, rep, href=None: ( + ['diagnosis', 'vs', '0'], {'x.com.samsung.da.diagnosisStart': p})), + ), +) + +OPERATION_ORIGIN = Capability( + href='/operation/origin/vs/0', + poll_tier='cold', + entities=( + SensorDesc(key='operation_origin', field='origin', + name='Last operation source', icon='mdi:remote', + entity_category='diagnostic'), + ), +) diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index dc291e3..4de4582 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -15,7 +15,8 @@ import datetime from ..capability import Capability from ..entities import ( - BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc, + BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc, + SwitchDesc, TimeDesc, ) # Beverage zone flex modes. @@ -164,6 +165,62 @@ STATUS_LOCK = Capability( ), ) +# --------------------------------------------------------------------------- +# Defrost delay +# +# /defrost/delay/vs/0 is the writable toggle. /defrost/block/vs/0 just +# reports whether the defrost cycle is currently blocked as a result (a +# derived status, not an independent control) — exposed as a read-only +# diagnostic binary sensor. +# --------------------------------------------------------------------------- + +DEFROST_DELAY = Capability( + href='/defrost/delay/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='defrost_delay', field='x.com.samsung.da.delayDefrost', + name='Defrost delay', icon='mdi:snowflake-off', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['defrost', 'delay', 'vs', '0'], + {'x.com.samsung.da.delayDefrost': 'On' if p else 'Off'})), + ), +) + +DEFROST_BLOCK_STATUS = Capability( + href='/defrost/block/vs/0', + poll_tier='warm', + entities=( + BinarySensorDesc(key='defrost_blocked', field='x.com.samsung.da.modes', + name='Defrost blocked', icon='mdi:snowflake-off', + entity_category='diagnostic', + value_fn=lambda modes: bool(modes) and modes[0] == 'DEFROST_BLOCK_ON'), + ), +) + +# --------------------------------------------------------------------------- +# 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'), + 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) # --------------------------------------------------------------------------- @@ -484,3 +541,85 @@ DOOR_GENERIC = Capability( value_fn=lambda v: v == 'Open'), ), ) + +# --------------------------------------------------------------------------- +# Aggregate-resource fallbacks +# +# /doors/vs/0, /temperatures/vs/0, and /icemaker/status/vs/0 each duplicate +# information exposed more precisely by per-instance hrefs (DOOR_GENERIC, +# TEMP_CURRENT_GENERIC/TEMP_SETPOINT_GENERIC, ICEMAKER_GENERIC) on hardware +# that has them. Not every fridge does — a simpler model may only ever +# advertise the aggregate resource. Each fallback's match_fn checks the +# full resource set for the richer sibling hrefs and only binds when +# they're absent, so it's a no-op (not a gap — see discovery.py) wherever +# the richer hrefs exist, and a real (if coarser) source of the same data +# where they don't. +# --------------------------------------------------------------------------- + +def _any_door_generic(resources): + return any(h.startswith('/door/') for h in resources) + + +DOORS_FALLBACK = Capability( + href='/doors/vs/0', + match_fn=lambda rep, resources: not _any_door_generic(resources), + poll_tier='hot', + entities=( + BinarySensorDesc(key='door_open', field='x.com.samsung.da.items', + name='Door', device_class='door', + value_fn=lambda items: any( + i.get('x.com.samsung.da.openState') == 'Open' + for i in (items or []))), + ), +) + + +def _any_temperature_generic(resources): + return any(h.startswith('/temperature/') for h in resources) + + +def _temp_item_value(items, keyword): + for item in (items or []): + if keyword.lower() in (item.get('x.com.samsung.da.description') or '').lower(): + return _int(item.get('x.com.samsung.da.current')) + return None + + +TEMPERATURES_FALLBACK = Capability( + href='/temperatures/vs/0', + match_fn=lambda rep, resources: not _any_temperature_generic(resources), + poll_tier='warm', + entities=( + SensorDesc(key='freezer_temperature', field='x.com.samsung.da.items', + name='Freezer temperature', icon='mdi:thermometer', + device_class='temperature', unit='°F', state_class='measurement', + value_fn=lambda items: _temp_item_value(items, 'Freezer')), + SensorDesc(key='fridge_temperature', field='x.com.samsung.da.items', + name='Fridge temperature', icon='mdi:thermometer', + device_class='temperature', unit='°F', state_class='measurement', + value_fn=lambda items: _temp_item_value(items, 'Fridge')), + ), +) + + +def _any_icemaker_unit_generic(resources): + return any( + h.startswith('/icemaker/') and isinstance(r, dict) + and 'x.com.samsung.da.iceMaker.state' in r + for h, r in resources.items() + ) + + +ICEMAKER_STATUS_FALLBACK = Capability( + href='/icemaker/status/vs/0', + match_fn=lambda rep, resources: not _any_icemaker_unit_generic(resources), + poll_tier='warm', + entities=( + SwitchDesc(key='ice_maker_enabled', field='x.com.samsung.da.iceMaker', + name='Ice maker', icon='mdi:cube-outline', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['icemaker', 'status', 'vs', '0'], + {'x.com.samsung.da.iceMaker': 'On' if p else 'Off'})), + ), +) diff --git a/custom_components/localthings/registry/capabilities/ignored.py b/custom_components/localthings/registry/capabilities/ignored.py index d5dd0bc..824da36 100644 --- a/custom_components/localthings/registry/capabilities/ignored.py +++ b/custom_components/localthings/registry/capabilities/ignored.py @@ -64,4 +64,34 @@ IGNORED: list[Capability] = [ # /wm/editcourse/vs/0 is the raw encoded course-list byte string already # decoded and exposed via /course/vs/0 (dishwasher.CYCLE_OPTIONS). Capability(href='/wm/editcourse/vs/0'), + + # Bixby audio feedback (chime + volume played when Bixby starts/stops + # listening) — only meaningful with Bixby enabled, which this + # integration has no local path to configure or use. + Capability(href='/sec/networkaudio/audio/vs/0'), + + # Static Bespoke-product-line flag, not appliance state. + Capability(href='/bespoke/vs/0'), + # Empty resource on every dump seen so far — nothing to expose. + Capability(href='/defrost/prediction/vs/0'), + # Seasonal defrost schedule (start/period/end per season). Automating + # this cleanly would need a multi-field schedule editor; the practical + # on/off control is fridge.DEFROST_DELAY. + Capability(href='/defrost/reservation/vs/0'), + # 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'), + # Opaque integer with no supportedModes/options list to interpret it + # against — meaning unclear from the raw resource alone. + Capability(href='/runningmode/vs/0'), + + # Demand-response energy planner — same utility-program dependency as + # /drlc/vs/0 above; every dump seen so far is inert (plan: 'none'). + Capability(href='/energy/planner/vs/0'), + # Temperature-unit display preference, redundant with HA's own units. + Capability(href='/wm/submode/vs/0'), ] diff --git a/custom_components/localthings/registry/discovery.py b/custom_components/localthings/registry/discovery.py index b6eb040..61e27e3 100644 --- a/custom_components/localthings/registry/discovery.py +++ b/custom_components/localthings/registry/discovery.py @@ -4,6 +4,12 @@ A device advertises a set of OCF resources keyed by href. For every href present in the registry, emit the capability's entities bound to that resource. Unknown hrefs are a coverage gap; each one is passed to the optional `log` callback (as the raw href, not a formatted message) and otherwise skipped. + +An href with a registered capability whose rt_filter/match_fn declines for +this particular device (e.g. a filter capability sitting on hardware that +doesn't have that filter) is *not* a gap — a maintainer already looked at +that href and decided how to handle it. Only hrefs absent from the registry +entirely are reported. """ from __future__ import annotations @@ -79,7 +85,7 @@ def discover( matched = True break - if not matched and log is not None: + if not matched and not caps and log is not None: log(href) return out diff --git a/tests/fixtures/golden/dishwasher.json b/tests/fixtures/golden/dishwasher.json index 891c848..da3f5d1 100644 --- a/tests/fixtures/golden/dishwasher.json +++ b/tests/fixtures/golden/dishwasher.json @@ -6,16 +6,19 @@ "cycle", "cycle_active", "delay_start_time", + "diagnosis_status", "energy_kwh", "finish_time", "firmware_update", "heated_dry", + "job_beginning_status", "led_brightness", "led_night_brightness", "led_night_end", "led_night_light", "led_night_start", "machine_state", + "operation_origin", "power_switch", "power_watts", "progress", @@ -34,16 +37,20 @@ "samsung_dishwasher_cycle", "samsung_dishwasher_cycle_active", "samsung_dishwasher_delay_start_time", + "samsung_dishwasher_diagnosis_start", + "samsung_dishwasher_diagnosis_status", "samsung_dishwasher_energy_kwh", "samsung_dishwasher_finish_time", "samsung_dishwasher_firmware_update", "samsung_dishwasher_heated_dry", + "samsung_dishwasher_job_beginning_status", "samsung_dishwasher_led_brightness", "samsung_dishwasher_led_night_brightness", "samsung_dishwasher_led_night_end", "samsung_dishwasher_led_night_light", "samsung_dishwasher_led_night_start", "samsung_dishwasher_machine_state", + "samsung_dishwasher_operation_origin", "samsung_dishwasher_pause", "samsung_dishwasher_power_switch", "samsung_dishwasher_power_watts", diff --git a/tests/fixtures/golden/refrigerator.json b/tests/fixtures/golden/refrigerator.json index 65ed3aa..cd6eef6 100644 --- a/tests/fixtures/golden/refrigerator.json +++ b/tests/fixtures/golden/refrigerator.json @@ -10,6 +10,8 @@ "cooler_setpoint", "cooler_temperature", "day_brightness", + "defrost_blocked", + "defrost_delay", "door_alert", "door_cooler_open", "door_freezer_open", @@ -33,6 +35,8 @@ "rapid_freezing", "rapid_fridge", "sabbath_mode", + "selfcheck_result", + "selfcheck_status", "welcome_lighting" ], "discovery_unique_ids": [ @@ -46,6 +50,8 @@ "samsung_refrigerator_cooler_setpoint", "samsung_refrigerator_cooler_temperature", "samsung_refrigerator_day_brightness", + "samsung_refrigerator_defrost_blocked", + "samsung_refrigerator_defrost_delay", "samsung_refrigerator_door_alert", "samsung_refrigerator_door_cooler_open", "samsung_refrigerator_door_freezer_open", @@ -69,6 +75,9 @@ "samsung_refrigerator_rapid_freezing", "samsung_refrigerator_rapid_fridge", "samsung_refrigerator_sabbath_mode", + "samsung_refrigerator_selfcheck_result", + "samsung_refrigerator_selfcheck_start", + "samsung_refrigerator_selfcheck_status", "samsung_refrigerator_welcome_lighting" ] } diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index e11b353..fb273d6 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -79,30 +79,34 @@ async def test_update_failed_on_persistent_poll_error( # Coverage-gap detection and the Repairs issue it raises # --------------------------------------------------------------------------- -async def test_discovery_populates_device_type_and_unbound_hrefs( +async def test_discovery_populates_device_type_with_no_unbound_hrefs( hass: HomeAssistant, mock_entry, mock_coordinator_session ) -> None: - """The real refrigerator fixture is a recognized type with some gaps left.""" + """The real refrigerator fixture is a recognized type, fully covered. + + Every href in this fixture is either mapped to a capability or listed + in capabilities.ignored — see tests/test_registry_ignored.py for the + noise-suppression checks. If this starts failing because a genuinely + new gap appeared, map it or ignore it rather than deleting the assertion. + """ await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] assert coordinator.device_type_name == 'refrigerator' assert coordinator.one_ui_version == '7.0 Refrigerator' - # Real, currently-unmodeled hrefs on this fixture (not noise — see - # tests/test_registry_ignored.py for the noise-suppression checks). - assert '/bespoke/vs/0' in coordinator._unbound_hrefs + assert coordinator._unbound_hrefs == [] -async def test_coverage_gap_issue_created_for_real_fixture_gaps( +async def test_coverage_gap_issue_absent_for_fully_covered_real_fixture( hass: HomeAssistant, mock_entry, mock_coordinator_session ) -> None: - """A recognized device with leftover unbound hrefs still raises the issue.""" + """A recognized device with no unbound hrefs does not raise the issue.""" await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() issue_id = f"device_gap_{mock_entry.entry_id}" - assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None + assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None def test_update_coverage_gap_issue_creates_issue_for_unknown_type( diff --git a/tests/localthings/test_diagnostics.py b/tests/localthings/test_diagnostics.py index e345a57..0c010af 100644 --- a/tests/localthings/test_diagnostics.py +++ b/tests/localthings/test_diagnostics.py @@ -18,7 +18,7 @@ async def test_diagnostics_shape_and_redaction( assert diagnostics["device_type"] == 'refrigerator' assert diagnostics["one_ui_version"] == '7.0 Refrigerator' - assert '/bespoke/vs/0' in diagnostics["unbound_hrefs"] + assert diagnostics["unbound_hrefs"] == [] assert diagnostics["integration_version"] == '0.1.0' assert diagnostics["smartthings_local_version"] diff --git a/tests/test_discovery.py b/tests/test_discovery.py index ac6227a..7b5a8d9 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -138,6 +138,26 @@ def test_discover_match_fn_filters_wrong_device(): assert bound == [] +def test_discover_match_fn_decline_is_not_logged_as_gap(): + """A registered href whose match_fn declines is known, not a gap. + + Distinguishes "no capability registered for this href" (a real + coverage gap) from "a capability is registered but chose not to bind + for this device" (e.g. a filter capability on hardware that doesn't + have that filter) — only the former should be reported via `log`. + """ + seen = [] + cap = Capability( + href='/filter/vs/0', + match_fn=lambda rep, resources: False, + entities=(BinarySensorDesc(key='filter', field='x.com.samsung.da.filterStatus'),), + ) + resources = {'/filter/vs/0': {'x.com.samsung.da.filterStatus': 'notused'}} + bound = discover(resources, {'/filter/vs/0': [cap]}, log=seen.append) + assert bound == [] + assert seen == [] + + 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_registry_ignored.py b/tests/test_registry_ignored.py index d38d582..2fd5a85 100644 --- a/tests/test_registry_ignored.py +++ b/tests/test_registry_ignored.py @@ -45,14 +45,15 @@ def test_ignored_hrefs_absent_from_refrigerator_gaps(): assert not present_ignored, f"ignored hrefs leaked into gap report: {present_ignored}" -def test_dishwasher_fixture_still_has_genuine_gaps(): +def test_dishwasher_fixture_has_no_remaining_gaps(): """Sanity check that the gap mechanism isn't silencing everything. - If this starts failing because the fixture is now fully covered, that's - good news — just trim the expected set down (or to empty) rather than - deleting the test. + Every href in the dishwasher fixture is now either mapped to a + capability or listed in capabilities.ignored — if this starts failing + because a genuinely new gap appeared, that's the interesting case to + look at (add the href to ignored.py or map it, don't just delete this + assertion). """ resources = _load('dishwasher_device.json') unbound = set(_unbound_hrefs(resources, dishwasher.REGISTRY)) - assert '/wm/jobbeginingstatus/vs/0' in unbound - assert '/diagnosis/vs/0' in unbound + assert unbound == set()