Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad6c07a2a5 |
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user