Map remaining coverage-gap hrefs, fix discover() false-gap bug, improve device naming

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 <Type> (<model>)".
This commit is contained in:
Marc Billow
2026-07-08 15:24:34 -05:00
parent d2699f0f5b
commit 5d1a32aa9a
13 changed files with 279 additions and 23 deletions
+6 -4
View File
@@ -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(
@@ -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,
@@ -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,
@@ -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'),
),
)
@@ -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'})),
),
)
@@ -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'),
]
@@ -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
+7
View File
@@ -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",
+9
View File
@@ -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"
]
}
+12 -8
View File
@@ -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(
+1 -1
View File
@@ -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"]
+20
View File
@@ -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(
+7 -6
View File
@@ -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()