Compare commits

..
Author SHA1 Message Date
Marc Billow ad6c07a2a5 fix: keep last state on empty sweep reps; stop blocking the loop in diagnostics (#9)
Two fixes for issue #9 (WW90T634DHE washer):

- observe: a busy device (e.g. a washer mid-cycle) intermittently returns an
  empty/stub rep for a resource in the batch /device/0 sweep, which the batch
  parser surfaces as {}. apply_rep would then replace the cached fields with
  nothing, sending every entity on that resource -- the wash-setting and
  detergent/softener dosing selects -- to "unknown" until the next good sweep.
  A Samsung OCF resource never reports a genuinely empty state, so
  ObserveManager.apply now drops an empty rep when the cache already holds
  real data, keeping the last known-good value; a later sweep/notify with
  real fields updates it normally. A first-ever stub is still applied so the
  resource is known to exist.

- diagnostics: pkg_version("smartthings-local") reads the installed package's
  metadata off disk (listdir + open + read_text), tripping HA's event-loop
  blocking-call detector. Offload it to the executor. Audited the rest of the
  package: config_flow's socket/crypto work and every coordinator DTLS call
  are already offloaded via async_add_executor_job -- this was the only
  blocking call left on the loop.

Adds regression tests for both.
2026-07-21 15:12:00 +00:00
8 changed files with 115 additions and 153 deletions
+3 -4
View File
@@ -16,7 +16,6 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi
| Type | Registry |
|---|---|
| Air conditioner | `by_type/airconditioner.py` |
| Dryer | `by_type/dryer.py` |
| Oven | `by_type/oven.py` |
| Dishwasher | `by_type/dishwasher.py` |
@@ -100,7 +99,7 @@ custom_components/localthings/
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
const.py Domain, config keys, probe ports
entity.py Base entity wiring capability registry -> HA entity
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / climate.py
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py
One module per HA platform
strings.json / translations/ Config-flow copy + entity state translations
registry/
@@ -111,8 +110,8 @@ custom_components/localthings/
adapter.py Flattens bound entities into HA-ready state
identity.py Reads device identity for type detection
redact.py Strips account/identity data before diagnostics leave HA
capabilities/ Shared + per-family Capability definitions (common, airconditioner,
dryer, oven, dishwasher, fridge, washer, laundry, operational, ignored)
capabilities/ Shared + per-family Capability definitions (common, dryer, oven,
dishwasher, fridge, washer, laundry, operational, ignored)
by_type/ One DeviceRegistry per appliance type, composed from capabilities/
tests/ Registry composition, discovery, entity descriptors, coordinator/observe
behavior, and golden-file regression against captured device dumps
+33 -43
View File
@@ -30,27 +30,24 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import ClimateDesc
# The AC's canonical resource hrefs live in the capability module (the single
# source of truth shared with its COVERAGE caps); power prefers the OCF-standard
# href, falling back to the vendor one, mirroring common.POWER_GENERIC /
# POWER_VS_FALLBACK.
from .registry.capabilities.airconditioner import (
HREF_MODE as MODE_HREF,
HREF_POWER as POWER_HREF,
HREF_POWER_VS as POWER_VS_HREF,
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
HREF_TEMP_DESIRED as TEMP_DESIRED_HREF,
HREF_TEMP_CONTROL as TEMP_CONTROL_HREF,
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
HREF_CONVENIENT as CONVENIENT_HREF,
)
from .registry.capabilities.common import normalize_temp_unit
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
# Sibling OCF resources the climate entity reads (the primary bound href is
# /mode/vs/0). Power prefers the OCF-standard href, falling back to the vendor
# one, mirroring common.POWER_GENERIC / POWER_VS_FALLBACK.
POWER_HREF = '/power/0'
POWER_VS_HREF = '/power/vs/0'
MODE_HREF = '/mode/vs/0'
TEMP_CURRENT_HREF = '/temperature/current/0'
TEMP_DESIRED_HREF = '/temperature/desired/0'
TEMP_CONTROL_HREF = '/temperature/control/vs/0'
WIND_STRENGTH_HREF = '/wind/strength/vs/0'
WIND_DIRECTION_HREF = '/wind/direction/vs/0'
CONVENIENT_HREF = '/mode/convenient/vs/0'
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
@@ -162,22 +159,12 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
def _supported(self, href: str) -> list[str]:
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
def _read_mode(self, href: str, mapping: dict):
"""Current mode of a wind/convenient resource, mapped to its HA value."""
return mapping.get(_first(self._rep(href).get(_MODES_FIELD)))
def _read_modes(self, href: str, mapping: dict) -> list[str]:
"""Supported modes of a resource, mapped to HA values (unknowns dropped)."""
return [mapping[c] for c in self._supported(href) if c in mapping]
# -- temperature --------------------------------------------------------
@property
def temperature_unit(self) -> str:
raw = self._rep(TEMP_DESIRED_HREF).get('units')
return (UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, '°C') == '°F'
else UnitOfTemperature.CELSIUS)
units = str(self._rep(TEMP_DESIRED_HREF).get('units', 'C')).upper()
return UnitOfTemperature.FAHRENHEIT if units.startswith('F') else UnitOfTemperature.CELSIUS
@property
def current_temperature(self):
@@ -227,27 +214,30 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@property
def fan_mode(self):
return self._read_mode(WIND_STRENGTH_HREF, _DEVICE_TO_FAN)
return _DEVICE_TO_FAN.get(_first(self._rep(WIND_STRENGTH_HREF).get(_MODES_FIELD)))
@property
def fan_modes(self) -> list[str]:
return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN)
return [_DEVICE_TO_FAN[c] for c in self._supported(WIND_STRENGTH_HREF)
if c in _DEVICE_TO_FAN]
@property
def swing_mode(self):
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
return _DEVICE_TO_SWING.get(_first(self._rep(WIND_DIRECTION_HREF).get(_MODES_FIELD)))
@property
def swing_modes(self) -> list[str]:
return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
return [_DEVICE_TO_SWING[c] for c in self._supported(WIND_DIRECTION_HREF)
if c in _DEVICE_TO_SWING]
@property
def preset_mode(self):
return self._read_mode(CONVENIENT_HREF, _DEVICE_TO_PRESET)
return _DEVICE_TO_PRESET.get(_first(self._rep(CONVENIENT_HREF).get(_MODES_FIELD)))
@property
def preset_modes(self) -> list[str]:
return self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET)
return [_DEVICE_TO_PRESET[c] for c in self._supported(CONVENIENT_HREF)
if c in _DEVICE_TO_PRESET]
# -- writes -------------------------------------------------------------
@@ -273,17 +263,17 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
async def async_turn_off(self) -> None:
await self.coordinator.async_send_command(self._bound, ('power', False))
async def _set_mapped(self, kind: str, mapping: dict, value: str) -> None:
"""Map an HA fan/swing/preset value back to its device code and write it."""
device = mapping.get(value)
if device is not None:
await self.coordinator.async_send_command(self._bound, (kind, device))
async def async_set_fan_mode(self, fan_mode: str) -> None:
await self._set_mapped('fan', _FAN_TO_DEVICE, fan_mode)
device = _FAN_TO_DEVICE.get(fan_mode)
if device is not None:
await self.coordinator.async_send_command(self._bound, ('fan', device))
async def async_set_swing_mode(self, swing_mode: str) -> None:
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
device = _SWING_TO_DEVICE.get(swing_mode)
if device is not None:
await self.coordinator.async_send_command(self._bound, ('swing', device))
async def async_set_preset_mode(self, preset_mode: str) -> None:
await self._set_mapped('preset', _PRESET_TO_DEVICE, preset_mode)
device = _PRESET_TO_DEVICE.get(preset_mode)
if device is not None:
await self.coordinator.async_send_command(self._bound, ('preset', device))
+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.0"
],
"version": "0.7.0"
"version": "0.6.0"
}
+16 -1
View File
@@ -92,10 +92,25 @@ 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 and
the empty-rep guard."""
if self._is_settling(href):
self.log.debug("dropping %s update for %s (settling)", source, href)
return False
if not rep and self.cache.get(href):
# A busy device (e.g. a washer mid-cycle) intermittently returns an
# empty/stub rep for a resource in the batch /device/0 sweep -- the
# batch parser surfaces those as {} (issue #9). apply_rep would then
# replace the cached fields with nothing, sending every entity on
# that resource (the wash-setting selects, dosing selects, etc.) to
# "unknown" until the next good sweep. A Samsung OCF resource never
# reports a *genuinely* empty state -- empty always means "no data
# this cycle" -- so keep the last known-good rep instead of
# clobbering it. A later sweep/notify carrying real fields updates
# it normally.
self.log.debug(
"keeping last state for %s (%s update had no data)", href, source)
return False
return self.cache.apply_rep(href, rep, source=source)
def on_notification(self, href: str, payload: bytes) -> None:
@@ -17,29 +17,6 @@ by_type registry.
from ..capability import Capability
from ..entities import ClimateDesc, SensorDesc, SwitchDesc
# ---------------------------------------------------------------------------
# Canonical AC resource hrefs. The climate entity (climate.py) binds the
# primary HREF_MODE via CLIMATE below and reads the CLIMATE_CONSUMED_HREFS
# siblings off the coordinator snapshot; those siblings are marked covered
# (no-entity caps) so discover() reports no gap. Declared once here and
# imported by climate.py, so a new sibling read can't drift out of sync with
# its coverage entry.
# ---------------------------------------------------------------------------
HREF_MODE = '/mode/vs/0' # primary (bound by CLIMATE)
HREF_POWER = '/power/0' # on/off -> HVACMode.OFF / TURN_ON/OFF
HREF_POWER_VS = '/power/vs/0' # vendor fallback for on/off
HREF_TEMP_CURRENT = '/temperature/current/0' # current_temperature
HREF_TEMP_DESIRED = '/temperature/desired/0' # target_temperature (write target)
HREF_TEMP_CONTROL = '/temperature/control/vs/0' # target_temperature_step
HREF_WIND_STRENGTH = '/wind/strength/vs/0' # fan_mode
HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode
HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode
CLIMATE_CONSUMED_HREFS = [
HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED,
HREF_TEMP_CONTROL, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION, HREF_CONVENIENT,
]
def _num(v):
try:
@@ -94,7 +71,7 @@ def _climate_write(payload, rep, href=None):
CLIMATE = Capability(
href=HREF_MODE,
href='/mode/vs/0',
poll_tier='warm',
entities=(
ClimateDesc(key='climate', translation_key='airconditioner',
@@ -147,13 +124,24 @@ AIR_FILTER = Capability(
)
# ---------------------------------------------------------------------------
# AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate
# entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources.
# These are NOT in the global ignored.IGNORED because several of them
# (/mode/vs/0 handled above, /temperatures/vs/0, /humidity/*) collide with
# other families' schemas. A no-entity Capability still marks the href as
# bound so discover() reports no coverage gap.
# AC-scoped coverage: hrefs consumed by the composite climate entity, and
# vendor duplicates / all-zero-ambiguous / plumbing resources. These are NOT in
# the global ignored.IGNORED because several of them (/mode/vs/0 handled above,
# /temperatures/vs/0, /humidity/*) collide with other families' schemas. A
# no-entity Capability still marks the href as bound so discover() reports no
# coverage gap.
# ---------------------------------------------------------------------------
_CONSUMED_BY_CLIMATE = [
'/power/0', # on/off -> climate HVACMode.OFF / TURN_ON/OFF
'/power/vs/0', # vendor fallback for on/off
'/temperature/current/0', # climate current_temperature
'/temperature/desired/0', # climate target_temperature (write target)
'/temperature/control/vs/0', # climate target_temperature_step
'/wind/strength/vs/0', # climate fan_mode
'/wind/direction/vs/0', # climate swing_mode
'/mode/convenient/vs/0', # climate preset_mode
]
_AC_IGNORED = [
# Vendor superset that duplicates the OCF /temperature/current+desired pair.
'/temperatures/vs/0',
@@ -167,4 +155,4 @@ _AC_IGNORED = [
]
# Built as bare no-entity caps; folded into the AC registry (not global).
COVERAGE = [Capability(href=h) for h in (CLIMATE_CONSUMED_HREFS + _AC_IGNORED)]
COVERAGE = [Capability(href=h) for h in (_CONSUMED_BY_CLIMATE + _AC_IGNORED)]
@@ -160,50 +160,13 @@ def _level_options(prefix):
return lambda resources: _supported_level_options(resources, prefix)
def _dosing_level(prefix):
"""Current dose code, normalized to the `Supported<prefix>` code format.
The device reports the selected level as `<prefix>_<code>` with the code
un-padded (e.g. '3'), but the valid codes -- which are also this select's
options and its translation keys -- come from `Supported<prefix>_<hexpairs>`
as zero-padded hex pairs (e.g. '03'). Left as '3', the current value sits
outside the select's own option list, so HA renders it 'unknown' (issue #9).
Resolve it to the supported code with the same integer value so
current_option matches an option (and its translation)."""
def fn(rep):
opts = rep.get('x.com.samsung.da.options')
raw = option_value(opts, prefix)
if raw is None:
return None
supported_raw = option_value(opts, f'Supported{prefix}')
try:
target = int(raw, 16)
except (TypeError, ValueError):
return raw
for code in hex_pairs(supported_raw) if supported_raw else []:
try:
if int(code, 16) == target:
return code
except (TypeError, ValueError):
continue
return raw
return fn
def _level_write(prefix):
def write(p, rep, href=None):
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
# `p` is the zero-padded supported code the UI selected (e.g. '03');
# the device stores the level un-padded (e.g. '3'), matching how it
# reports it, so write it back in that native shape.
try:
native = format(int(p, 16), 'X')
except (TypeError, ValueError):
native = p
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': replace_in_options(opts, prefix, native),
'x.com.samsung.da.options': replace_in_options(opts, prefix, p),
}
return write
@@ -238,7 +201,8 @@ WASHER_COURSE = Capability(
options=_level_options('DetergentLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevelCtrl')(resources)),
rep_fn=_dosing_level('DetergentLevelCtrl'),
rep_fn=lambda rep: option_value(
rep.get('x.com.samsung.da.options'), 'DetergentLevelCtrl'),
write_fn=_level_write('DetergentLevelCtrl')),
SelectDesc(key='detergent_water_hardness', name='Detergent water hardness',
icon='mdi:water-opacity',
@@ -247,7 +211,8 @@ WASHER_COURSE = Capability(
options=_level_options('DetergentLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevel2Ctrl')(resources)),
rep_fn=_dosing_level('DetergentLevel2Ctrl'),
rep_fn=lambda rep: option_value(
rep.get('x.com.samsung.da.options'), 'DetergentLevel2Ctrl'),
write_fn=_level_write('DetergentLevel2Ctrl')),
SelectDesc(key='softener_quantity', name='Softener quantity', icon='mdi:flask-outline',
translation_key='washer_dosing_quantity',
@@ -255,7 +220,8 @@ WASHER_COURSE = Capability(
options=_level_options('SoftenerLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevelCtrl')(resources)),
rep_fn=_dosing_level('SoftenerLevelCtrl'),
rep_fn=lambda rep: option_value(
rep.get('x.com.samsung.da.options'), 'SoftenerLevelCtrl'),
write_fn=_level_write('SoftenerLevelCtrl')),
SelectDesc(key='softener_concentration', name='Softener concentration',
icon='mdi:flask-plus-outline',
@@ -264,7 +230,8 @@ WASHER_COURSE = Capability(
options=_level_options('SoftenerLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevel2Ctrl')(resources)),
rep_fn=_dosing_level('SoftenerLevel2Ctrl'),
rep_fn=lambda rep: option_value(
rep.get('x.com.samsung.da.options'), 'SoftenerLevel2Ctrl'),
write_fn=_level_write('SoftenerLevel2Ctrl')),
BinarySensorDesc(key='detergent_low', name='Detergent low',
icon='mdi:alert-circle-outline', device_class='problem',
+28
View File
@@ -76,6 +76,34 @@ class _FakeSession:
return b'\x01'
def test_apply_keeps_last_state_on_empty_then_resumes_on_real_data():
"""A busy device (e.g. a washer mid-cycle) intermittently returns an
empty/stub rep in the batch sweep (issue #9). That empty update must be
rejected so the resource's entities (the wash-setting / dosing selects)
keep their last known-good value instead of going to 'unknown'; a later
sweep carrying real fields updates normally."""
mgr = _manager()
mgr.cache.apply_rep('/washer/vs/0', {'lvl': '2'}, source='seed')
# Empty stub during a busy cycle is rejected, last state kept.
assert mgr.apply('/washer/vs/0', {}, source='sweep') is False
assert mgr.cache.get('/washer/vs/0') == {'lvl': '2'}
# A later sweep with real data updates normally.
assert mgr.apply('/washer/vs/0', {'lvl': '3'}, source='sweep') is True
assert mgr.cache.get('/washer/vs/0') == {'lvl': '3'}
def test_apply_allows_empty_rep_before_any_data_exists():
"""The empty-rep guard only protects existing data -- a first-ever stub is
not short-circuited, so a resource with no data yet is still handled the
same as before (the entity exists, pending real data)."""
mgr = _manager()
# No prior data for this href -> guard does not reject; apply_rep runs.
mgr.apply('/washer/vs/0', {}, source='sweep')
assert mgr.cache.get('/washer/vs/0') in (None, {})
def test_try_enter_observe_mode_succeeds_when_all_hrefs_notify():
mgr = _manager()
session = _FakeSession()
+6 -31
View File
@@ -151,32 +151,11 @@ class TestDetergentSoftenerDosing:
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
def test_quantity_and_hardness_read(self):
"""The device reports the level un-padded ('3'), but the options and
translation keys are zero-padded supported codes ('03'); rep_fn
normalizes to the supported code so the value is a valid option
(issue #9 -- otherwise HA renders the select 'unknown')."""
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
assert self._desc('detergent_quantity').rep_fn(rep) == '03'
assert self._desc('detergent_water_hardness').rep_fn(rep) == '02'
assert self._desc('softener_quantity').rep_fn(rep) == '03'
assert self._desc('softener_concentration').rep_fn(rep) == '02'
def test_current_value_is_a_valid_option_for_every_dosing_select(self):
"""The core regression: HA shows a select 'unknown' when current_option
is not in options. Each dosing select's value must be one of its own
options."""
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
for key in ('detergent_quantity', 'detergent_water_hardness',
'softener_quantity', 'softener_concentration'):
desc = self._desc(key)
assert desc.rep_fn(rep) in desc.options(_DOSING_RESOURCES), key
def test_read_passes_through_when_no_supported_match(self):
"""A value with no matching supported code is returned as-is rather than
dropped, so an unexpected device stays visible instead of blank."""
rep = {'x.com.samsung.da.options': ['DetergentLevelCtrl_7',
'SupportedDetergentLevelCtrl_00010203']}
assert self._desc('detergent_quantity').rep_fn(rep) == '7'
assert self._desc('detergent_quantity').rep_fn(rep) == '3'
assert self._desc('detergent_water_hardness').rep_fn(rep) == '2'
assert self._desc('softener_quantity').rep_fn(rep) == '3'
assert self._desc('softener_concentration').rep_fn(rep) == '2'
def test_translation_keys(self):
"""detergent_quantity and softener_quantity share one translation_key
@@ -202,21 +181,17 @@ class TestDetergentSoftenerDosing:
assert desc.exists_fn({}, _DOSING_RESOURCES) is True
def test_quantity_write(self):
"""The UI selects a padded supported code ('01'); the write posts the
un-padded device code ('1'), mirroring how the device reports it."""
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
path, body = self._desc('detergent_quantity').write_fn('01', rep)
assert path == ['course', 'vs', '0']
assert 'DetergentLevelCtrl_1' in body['x.com.samsung.da.options']
assert 'DetergentLevelCtrl_01' in body['x.com.samsung.da.options']
assert 'DetergentLevelCtrl_3' not in body['x.com.samsung.da.options']
# untouched siblings survive the read-modify-write
assert 'SoftenerLevelCtrl_3' in body['x.com.samsung.da.options']
def test_hardness_write(self):
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
path, body = self._desc('softener_concentration').write_fn('03', rep)
assert path == ['course', 'vs', '0']
assert 'SoftenerLevel2Ctrl_3' in body['x.com.samsung.da.options']
assert 'SoftenerLevel2Ctrl_03' in body['x.com.samsung.da.options']
def test_low_reservoir_off_when_alarm_off(self):
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}