Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad6c07a2a5 |
@@ -26,13 +26,18 @@ async def async_get_config_entry_diagnostics(
|
|||||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
|
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
integration = await async_get_integration(hass, DOMAIN)
|
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 {
|
return {
|
||||||
"device_type": coordinator.device_type_name or "unknown",
|
"device_type": coordinator.device_type_name or "unknown",
|
||||||
"one_ui_version": coordinator.one_ui_version,
|
"one_ui_version": coordinator.one_ui_version,
|
||||||
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
|
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
|
||||||
"resources": redact_resources(coordinator.last_resources),
|
"resources": redact_resources(coordinator.last_resources),
|
||||||
"integration_version": integration.version,
|
"integration_version": integration.version,
|
||||||
"smartthings_local_version": pkg_version("smartthings-local"),
|
"smartthings_local_version": stl_version,
|
||||||
"observe_mode": coordinator.observe_mode,
|
"observe_mode": coordinator.observe_mode,
|
||||||
"observe_subscribed_hrefs": sorted(coordinator._observe.subscribed_hrefs),
|
"observe_subscribed_hrefs": sorted(coordinator._observe.subscribed_hrefs),
|
||||||
"observe_fallback_hrefs": sorted(coordinator._observe.fallback_hrefs),
|
"observe_fallback_hrefs": sorted(coordinator._observe.fallback_hrefs),
|
||||||
|
|||||||
@@ -92,10 +92,25 @@ class ObserveManager:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def apply(self, href: str, rep: dict, source: str) -> bool:
|
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):
|
if self._is_settling(href):
|
||||||
self.log.debug("dropping %s update for %s (settling)", source, href)
|
self.log.debug("dropping %s update for %s (settling)", source, href)
|
||||||
return False
|
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)
|
return self.cache.apply_rep(href, rep, source=source)
|
||||||
|
|
||||||
def on_notification(self, href: str, payload: bytes) -> None:
|
def on_notification(self, href: str, payload: bytes) -> None:
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
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.const import DOMAIN
|
||||||
from custom_components.localthings.diagnostics import async_get_config_entry_diagnostics
|
from custom_components.localthings.diagnostics import async_get_config_entry_diagnostics
|
||||||
from custom_components.localthings.registry.redact import REDACTED
|
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 diag['observe_fallback_hrefs'] == []
|
||||||
assert 'observe_last_mode_change' in diag
|
assert 'observe_last_mode_change' in diag
|
||||||
assert diag['observe_href_freshness_s'] == {}
|
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
|
||||||
|
|||||||
@@ -76,6 +76,34 @@ class _FakeSession:
|
|||||||
return b'\x01'
|
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():
|
def test_try_enter_observe_mode_succeeds_when_all_hrefs_notify():
|
||||||
mgr = _manager()
|
mgr = _manager()
|
||||||
session = _FakeSession()
|
session = _FakeSession()
|
||||||
|
|||||||
Reference in New Issue
Block a user