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()