diff --git a/custom_components/localthings/diagnostics.py b/custom_components/localthings/diagnostics.py index 8d75794..b42f821 100644 --- a/custom_components/localthings/diagnostics.py +++ b/custom_components/localthings/diagnostics.py @@ -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), diff --git a/custom_components/localthings/observe.py b/custom_components/localthings/observe.py index acda4fe..6837926 100644 --- a/custom_components/localthings/observe.py +++ b/custom_components/localthings/observe.py @@ -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: diff --git a/tests/localthings/test_diagnostics.py b/tests/localthings/test_diagnostics.py index 214d7ff..13fde6b 100644 --- a/tests/localthings/test_diagnostics.py +++ b/tests/localthings/test_diagnostics.py @@ -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 diff --git a/tests/localthings/test_observe.py b/tests/localthings/test_observe.py index a7e1aff..ad618eb 100644 --- a/tests/localthings/test_observe.py +++ b/tests/localthings/test_observe.py @@ -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()