Merge pull request #46 from mbillow/claude/issue-27-investigation-ff60pw

fix(fridge,observe): merge partial cache updates, name ice makers dynamically
This commit is contained in:
Marc Billow
2026-07-22 20:31:19 -05:00
committed by GitHub
8 changed files with 190 additions and 12 deletions
+11 -3
View File
@@ -8,7 +8,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.const import EntityCategory
from .registry.adapter import _key
from .registry.discovery import BoundEntity
from .registry.discovery import BoundEntity, _snake_to_title
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
@@ -44,7 +44,7 @@ def _derive_name(state_key: str) -> str:
instance number with a space: "door_cooler_open1" → "Door Cooler Open 1".
"""
name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key)
return name.replace('_', ' ').title().strip()
return _snake_to_title(name).strip()
class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
@@ -57,7 +57,15 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
self._bound = bound
self._state_key = _key(bound)
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
self._attr_name = bound.desc.name if bound.desc.name is not None else _derive_name(self._state_key)
if bound.desc.name is not None:
self._attr_name = bound.desc.name
elif bound.instance_name:
# A device-given instance name (e.g. an ice maker's "Cubed
# Ice") takes the place of the href-derived instance label,
# keeping the same entity-specific suffix (issue #27).
self._attr_name = f"{bound.instance_name} {_derive_name(bound.desc.key)}".strip()
else:
self._attr_name = _derive_name(self._state_key)
self._attr_translation_key = bound.desc.translation_key
self._attr_icon = bound.desc.icon
raw_cat = bound.desc.entity_category
+27 -2
View File
@@ -69,6 +69,7 @@ class ObserveManager:
self.last_mode_change_wall = time.time()
self._settle_until: dict[str, float] = {}
self._settle_lock = threading.Lock()
self._cache_lock = threading.Lock()
self.subscribed_hrefs: set[str] = set()
self._notified: set[str] = set()
self._last_notify_ts: float | None = None
@@ -92,11 +93,35 @@ class ObserveManager:
return True
def apply(self, href: str, rep: dict, source: str) -> bool:
"""Gate a StateCache.apply_rep call through the write-settle guard."""
"""Gate a StateCache.apply_rep call through the write-settle guard.
Merges the incoming rep onto whatever's already cached for this
href rather than handing it to StateCache.apply_rep verbatim --
apply_rep does a full replace, and Samsung devices don't always
repeat every field on every update (issue #27: a /mode/vs/0
OBSERVE notify -- and, on at least one Bespoke fridge, even the
/device/0 sweep entry for that href -- can carry just `modes`,
omitting `supportedOptions`/`supportedModes` entirely). A full
replace would silently wipe fields a select entity's
exists_fn/options_field gates on the moment one partial update
comes through, even though nothing about the device's actual
supported options changed.
`apply()` is the sole path StateCache mutations flow through in
this component (poll, sweep, and OBSERVE notify all funnel here),
so `_cache_lock` serializes the read-then-write across those
threads (DTLS reader for notifies, executor threads for poll/
sweep) -- without it, two concurrent updates for the same href
could each read the same prior rep and the second writer would
silently lose the first's fields, reintroducing the exact bug
this merge fixes.
"""
if self._is_settling(href):
self.log.debug("dropping %s update for %s (settling)", source, href)
return False
return self.cache.apply_rep(href, rep, source=source)
with self._cache_lock:
merged = {**(self.cache.get(href) or {}), **rep}
return self.cache.apply_rep(href, merged, source=source)
def on_notification(self, href: str, payload: bytes) -> None:
"""Wired as DtlsCoapSession.on_notification. Runs on the DTLS
@@ -100,6 +100,12 @@ ICEMAKER_NIGHTTIME = Capability(
# Icemaker (generic — covers /icemaker/one/vs/0, /icemaker/two/vs/0)
# /icemaker/status/vs/0 is kept as exact-href cap and binds first.
# /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state).
#
# Entity names are derived from x.com.samsung.da.iceMaker.name ("CUBED_ICE",
# "ICE_BITES") via name_field, not the href's "one"/"two" segment -- these two
# ice makers are independent on/off toggles that can both be enabled at once
# (issue #27), so they stay separate entities rather than a single ice-type
# select, but users still want them labeled with the device's own names.
# ---------------------------------------------------------------------------
def _icemaker_write(field):
@@ -112,6 +118,7 @@ ICEMAKER_GENERIC = Capability(
href=None,
href_prefix='/icemaker/',
match_fn=lambda rep, resources: 'x.com.samsung.da.iceMaker.state' in rep,
name_field='x.com.samsung.da.iceMaker.name',
poll_tier='warm',
entities=(
SensorDesc(key='making_status',
@@ -15,6 +15,11 @@ class Capability:
rt_filter: Optional[str] = None # bind only if rt_filter in rep.get('rt', ())
href_prefix: Optional[str] = None # pattern caps only: bind only if href starts with this
strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override
# Rep field holding this instance's device-given name (e.g. an ice
# maker's "CUBED_ICE"/"ICE_BITES"), normalized and used as the display
# name prefix in place of the href-derived instance label. Does not
# affect key_override/unique_id -- only what's shown in the UI.
name_field: Optional[str] = None
match_fn: Optional[Callable[[dict, dict], bool]] = None # match_fn(rep, resources) -> bool
# Rare optional hook — only operational-state-style resources use this.
on_observation: Optional[Callable[[dict, dict], None]] = None
@@ -27,6 +27,25 @@ class BoundEntity:
desc: SamsungEntityDescription
instance: str = ''
key_override: Optional[str] = None
instance_name: Optional[str] = None
def _snake_to_title(s: str) -> str:
"""'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's
_derive_name, which applies the same transform to a state key."""
return s.replace('_', ' ').title()
def _instance_name(cap: Capability, rep: dict) -> Optional[str]:
"""Normalize `cap.name_field`'s raw value ("CUBED_ICE" -> "Cubed Ice")
for use as a display-name prefix, or None if the cap doesn't declare
one or the device didn't report it."""
if not cap.name_field:
return None
raw = rep.get(cap.name_field)
if not isinstance(raw, str) or not raw:
return None
return _snake_to_title(raw)
def instance_suffix(href: str) -> str:
@@ -37,6 +56,18 @@ def instance_suffix(href: str) -> str:
return ''
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
key_prefix: Optional[str] = None) -> list[BoundEntity]:
"""Build one BoundEntity per entity on `cap`, sharing the instance/
key-prefix/instance-name computed once by the caller."""
return [
BoundEntity(href=href, capability=cap, desc=desc, instance=inst,
key_override=f'{key_prefix}_{desc.key}' if key_prefix else None,
instance_name=inst_name)
for desc in cap.entities
]
def discover(
resources: dict[str, dict],
registry: dict[str, list[Capability]],
@@ -58,9 +89,7 @@ def discover(
if cap.match_fn is not None and not cap.match_fn(rep, resources):
continue
inst = instance_suffix(href)
for desc in cap.entities:
out.append(BoundEntity(href=href, capability=cap,
desc=desc, instance=inst))
out.extend(_bind(cap, href, inst, _instance_name(cap, rep)))
matched = True
if matched:
@@ -78,10 +107,7 @@ def discover(
# Auto-derive key prefix from href segments (skip digits and 'vs')
src = href[len(cap.href_prefix):] if (cap.strip_prefix_in_key and cap.href_prefix) else href
segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs']
for desc in cap.entities:
key_override = '_'.join(segs) + '_' + desc.key
out.append(BoundEntity(href=href, capability=cap, desc=desc,
instance=inst, key_override=key_override))
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs)))
matched = True
break
+22
View File
@@ -34,6 +34,28 @@ def test_apply_writes_through_when_not_settling():
assert mgr.cache.get('/oven/vs/0') == {'a': 1}
def test_apply_merges_partial_update_onto_prior_rep():
"""Regression test for issue #27: a Bespoke fridge's /mode/vs/0 notify
(and even a later sweep entry for that href) can carry only `modes`,
omitting `supportedOptions` entirely. A full replace would wipe
`supportedOptions` from the cache the moment that partial update
arrives, even though the device's supported options didn't change --
which is exactly what made the flex-zone select disappear."""
mgr = _manager()
full = {
'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT'],
'x.com.samsung.da.supportedOptions': ['CV_FDR_WINE', 'CV_FDR_MEAT'],
}
mgr.apply('/mode/vs/0', full, source='poll')
partial = {'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'WATERFILTER_ENABLE']}
mgr.apply('/mode/vs/0', partial, source='observe')
cached = mgr.cache.get('/mode/vs/0')
assert cached['x.com.samsung.da.modes'] == ['CVN_CONVERTIBLE_ZONE', 'WATERFILTER_ENABLE']
assert cached['x.com.samsung.da.supportedOptions'] == ['CV_FDR_WINE', 'CV_FDR_MEAT']
def test_apply_drops_update_during_settle_window():
mgr = _manager()
mgr.cache.apply_rep('/oven/vs/0', {'a': 1}, source='seed')
+40
View File
@@ -158,6 +158,46 @@ def test_discover_match_fn_decline_is_not_logged_as_gap():
assert seen == []
def test_discover_pattern_cap_reads_name_field():
"""A pattern cap's name_field normalizes a device-given instance name
(e.g. an ice maker's "CUBED_ICE") for use in the entity's display name,
independent of the href-derived key (issue #27)."""
cap = Capability(
href=None,
href_prefix='/icemaker/',
name_field='x.com.samsung.da.iceMaker.name',
entities=(BinarySensorDesc(key='enabled', field='x.com.samsung.da.iceMaker.state'),),
)
resources = {
'/icemaker/one/vs/0': {
'x.com.samsung.da.iceMaker.state': 'On',
'x.com.samsung.da.iceMaker.name': 'CUBED_ICE',
},
'/icemaker/two/vs/0': {
'x.com.samsung.da.iceMaker.state': 'On',
'x.com.samsung.da.iceMaker.name': 'ICE_BITES',
},
}
bound = discover(resources, {}, pattern_caps=[cap])
names = {b.href: b.instance_name for b in bound}
assert names == {
'/icemaker/one/vs/0': 'Cubed Ice',
'/icemaker/two/vs/0': 'Ice Bites',
}
def test_discover_pattern_cap_name_field_absent_leaves_instance_name_none():
cap = Capability(
href=None,
href_prefix='/icemaker/',
name_field='x.com.samsung.da.iceMaker.name',
entities=(BinarySensorDesc(key='enabled', field='x.com.samsung.da.iceMaker.state'),),
)
resources = {'/icemaker/one/vs/0': {'x.com.samsung.da.iceMaker.state': 'On'}}
bound = discover(resources, {}, pattern_caps=[cap])
assert bound[0].instance_name is None
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(
+45
View File
@@ -0,0 +1,45 @@
"""Tests for LocalThingsEntity's display-name derivation
(custom_components/localthings/entity.py) -- the explicit-name,
device-given-instance-name, and href-derived fallbacks.
"""
from custom_components.localthings.entity import LocalThingsEntity
from custom_components.localthings.registry.capability import Capability
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import BinarySensorDesc
class _FakeCoordinator:
device_serial = 'TEST-SERIAL'
def __init__(self, last_resources=None):
self.last_resources = last_resources or {}
def _make_entity(desc, href='/x/vs/0', key_override=None, instance='', instance_name=None):
capability = Capability(href=href, entities=(desc,))
bound = BoundEntity(href=href, capability=capability, desc=desc,
instance=instance, key_override=key_override,
instance_name=instance_name)
return LocalThingsEntity(_FakeCoordinator(), bound)
def test_explicit_name_wins_over_everything():
desc = BinarySensorDesc(key='enabled', name='Explicit Name')
entity = _make_entity(desc, instance_name='Cubed Ice')
assert entity._attr_name == 'Explicit Name'
def test_instance_name_prefixes_the_derived_suffix():
"""Issue #27: an ice maker's device-given name ("Cubed Ice") replaces
the href-derived instance label ("Icemaker One") as the name prefix,
keeping the same entity-specific suffix."""
desc = BinarySensorDesc(key='enabled')
entity = _make_entity(desc, key_override='icemaker_one_enabled',
instance_name='Cubed Ice')
assert entity._attr_name == 'Cubed Ice Enabled'
def test_no_instance_name_falls_back_to_derived_state_key():
desc = BinarySensorDesc(key='enabled')
entity = _make_entity(desc, key_override='icemaker_one_enabled')
assert entity._attr_name == 'Icemaker One Enabled'