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:
1 parent
d2699f0f5b
commit
5d1a32aa9a
13 files changed
+279
-23
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user