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
4 changed files with 76 additions and 2 deletions
+6 -1
View File
@@ -26,13 +26,18 @@ async def async_get_config_entry_diagnostics(
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
integration = await async_get_integration(hass, DOMAIN)
# importlib.metadata.version() reads the installed package's metadata off
# disk (listdir + open + read_text), which trips HA's event-loop blocking
# detector when called inline here. Offload it to the executor.
stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local")
return {
"device_type": coordinator.device_type_name or "unknown",
"one_ui_version": coordinator.one_ui_version,
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
"resources": redact_resources(coordinator.last_resources),
"integration_version": integration.version,
"smartthings_local_version": pkg_version("smartthings-local"),
"smartthings_local_version": stl_version,
"observe_mode": coordinator.observe_mode,
"observe_subscribed_hrefs": sorted(coordinator._observe.subscribed_hrefs),
"observe_fallback_hrefs": sorted(coordinator._observe.fallback_hrefs),
+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:
+26
View File
@@ -2,10 +2,12 @@
from __future__ import annotations
import json
import threading
from pathlib import Path
from homeassistant.core import HomeAssistant
from custom_components.localthings import diagnostics as diagnostics_mod
from custom_components.localthings.const import DOMAIN
from custom_components.localthings.diagnostics import async_get_config_entry_diagnostics
from custom_components.localthings.registry.redact import REDACTED
@@ -49,3 +51,27 @@ async def test_diagnostics_include_observe_mode_fields(
assert diag['observe_fallback_hrefs'] == []
assert 'observe_last_mode_change' in diag
assert diag['observe_href_freshness_s'] == {}
async def test_dependency_version_read_off_the_event_loop(
hass: HomeAssistant, mock_entry, mock_coordinator_session, monkeypatch
) -> None:
"""importlib.metadata.version() does blocking disk I/O, so it must run in
the executor, not on the event loop (issue #9's logs flagged it)."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
loop_thread_id = threading.get_ident() # this coroutine runs on the loop
seen: dict[str, int] = {}
real_pkg_version = diagnostics_mod.pkg_version
def _spy(name: str) -> str:
seen['thread_id'] = threading.get_ident()
return real_pkg_version(name)
monkeypatch.setattr(diagnostics_mod, 'pkg_version', _spy)
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
assert diag['smartthings_local_version']
assert seen['thread_id'] != loop_thread_id
+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()