Files
localthings/tests/localthings/test_observe.py
T
Marc Billow 569029c116 refactor(observe): stop tearing down live sessions on data-drift or poll hiccups
Replace the breadth/silence downgrade heuristic with a simpler rule: a
still-live OBSERVE session is never torn down on a sweep/cache mismatch
(the 30s sweep already corrects the cache regardless of mode) -- only a
proven reconnect invalidates subscriptions. A mismatch instead triggers
extra hot/warm subpolls this cycle as a bounded fallback.

Also stop treating a summary-poll block-level ACK timeout as session
death: distinguish TimeoutError (transfer was progressing, session
likely alive) from ConnectionError (session actually closed), backed by
a consecutive-timeout counter so a genuinely dead channel still
recovers. This was causing a flaky/slow device (e.g. a dishwasher) to
flap observe<->poll every ~45s on nothing but a slow blockwise GET.

Also remove the dead is_active/active_when scaffolding (never wired
into the coordinator) and add DEBUG logging for each OBSERVE notify
received, including its href.
2026-07-10 19:50:40 -05:00

409 lines
13 KiB
Python

"""Tests for ObserveManager: write-settle guard and mode defaults."""
from __future__ import annotations
import threading
import time
import cbor2
import pytest
from smartthings_local.ocf.state_cache import StateCache
from custom_components.localthings.observe import (
ObserveManager, MODE_POLL, _rep_diff,
)
class _NullDescriptor:
def on_observation(self, state, href, rep):
return None
def _manager() -> ObserveManager:
return ObserveManager(StateCache(_NullDescriptor()))
def test_starts_in_poll_mode():
mgr = _manager()
assert mgr.mode == MODE_POLL
def test_apply_writes_through_when_not_settling():
mgr = _manager()
assert mgr.apply('/oven/vs/0', {'a': 1}, source='poll') is True
assert mgr.cache.get('/oven/vs/0') == {'a': 1}
def test_apply_drops_update_during_settle_window():
mgr = _manager()
mgr.cache.apply_rep('/oven/vs/0', {'a': 1}, source='seed')
mgr.mark_write_pending('/oven/vs/0', settle_s=1.0)
result = mgr.apply('/oven/vs/0', {'a': 2}, source='poll')
assert result is False
assert mgr.cache.get('/oven/vs/0') == {'a': 1}
def test_apply_accepts_update_after_settle_window_elapses():
mgr = _manager()
mgr.mark_write_pending('/oven/vs/0', settle_s=0.05)
time.sleep(0.1)
result = mgr.apply('/oven/vs/0', {'a': 2}, source='poll')
assert result is True
assert mgr.cache.get('/oven/vs/0') == {'a': 2}
class _FakeSession:
"""Minimal stand-in for DtlsCoapSession.subscribe(), for observe tests.
`notify_after_subscribe` maps href -> cbor-encodable rep. Call
`subscribe()` records the href; the test then calls
`mgr.on_notification(href, payload)` itself to simulate delivery,
since real notify delivery is async/threaded in production.
"""
def __init__(self):
self.subscribed: list[str] = []
self.fail_hrefs: set[str] = set()
def subscribe(self, path_segs):
href = '/' + '/'.join(path_segs)
if href in self.fail_hrefs:
raise ConnectionError("subscribe failed")
self.subscribed.append(href)
return b'\x01'
def test_try_enter_observe_mode_succeeds_when_all_hrefs_notify():
mgr = _manager()
session = _FakeSession()
hrefs = ['/oven/vs/0', '/power/vs/0']
def _notify_during_grace_period():
# Simulate notifications arriving during the grace period
time.sleep(0.005) # Let the sleep start, then notify partway through
for href in hrefs:
mgr.on_notification(href, cbor2.dumps({'x': 1}))
# Start background thread to deliver notifications during grace period
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
try:
entered = mgr.try_enter_observe_mode(session, hrefs, grace_period_s=0.02)
notifier.join()
assert entered is True
assert mgr.mode == 'observe'
assert mgr.subscribed_hrefs == set(hrefs)
finally:
mgr.close()
def test_try_enter_observe_mode_falls_back_when_no_notifies_arrive():
mgr = _manager()
session = _FakeSession()
hrefs = ['/oven/vs/0', '/power/vs/0']
entered = mgr.try_enter_observe_mode(session, hrefs, grace_period_s=0.01)
assert entered is False
assert mgr.mode == 'poll'
assert mgr.subscribed_hrefs == set()
def test_try_enter_observe_mode_falls_back_when_subscribe_fails_for_all():
mgr = _manager()
session = _FakeSession()
session.fail_hrefs = {'/oven/vs/0', '/power/vs/0'}
hrefs = ['/oven/vs/0', '/power/vs/0']
entered = mgr.try_enter_observe_mode(session, hrefs, grace_period_s=0.01)
assert entered is False
assert mgr.mode == 'poll'
def test_try_enter_observe_mode_meets_success_fraction_with_partial_notifies():
mgr = _manager()
session = _FakeSession()
hrefs = ['/a/vs/0', '/b/vs/0', '/c/vs/0', '/d/vs/0']
def _notify_partial():
# Simulate partial notifications arriving during grace period
time.sleep(0.005)
for href in hrefs[:3]: # 3/4 = 0.75
mgr.on_notification(href, cbor2.dumps({'x': 1}))
notifier = threading.Thread(target=_notify_partial, daemon=True)
notifier.start()
try:
entered = mgr.try_enter_observe_mode(
session, hrefs, grace_period_s=0.02, success_fraction=0.7,
)
notifier.join()
assert entered is True
assert mgr.mode == 'observe'
finally:
mgr.close()
def test_on_notification_ignores_malformed_cbor():
mgr = _manager()
mgr.on_notification('/oven/vs/0', b'\xff\xff\xff not cbor')
assert mgr.cache.get('/oven/vs/0') is None
def test_recently_notified_false_before_any_notify():
mgr = _manager()
assert mgr.recently_notified() is False
def test_recently_notified_true_just_after_a_notify():
mgr = _manager()
mgr.on_notification('/oven/vs/0', cbor2.dumps({'a': 1}))
assert mgr.recently_notified() is True
def test_recently_notified_false_after_window_elapses():
mgr = _manager()
mgr.on_notification('/oven/vs/0', cbor2.dumps({'a': 1}))
assert mgr.recently_notified(window_s=0.01) is True
time.sleep(0.02)
assert mgr.recently_notified(window_s=0.01) is False
def test_try_enter_observe_mode_clears_stale_notifications_on_retry():
"""Regression test: verify second call to try_enter_observe_mode() doesn't
get polluted by notifications from the first call. This happens in real
usage when poll-only mode periodically retries entering observe mode."""
mgr = _manager()
session = _FakeSession()
hrefs = ['/a/vs/0', '/b/vs/0']
try:
# First call: all hrefs notify -> succeeds and enters observe mode
def _notify_all_first():
time.sleep(0.005)
for href in hrefs:
mgr.on_notification(href, cbor2.dumps({'x': 1}))
notifier1 = threading.Thread(target=_notify_all_first, daemon=True)
notifier1.start()
entered = mgr.try_enter_observe_mode(session, hrefs, grace_period_s=0.02)
notifier1.join()
assert entered is True
assert mgr.mode == 'observe'
# Second call: retry with same hrefs, but only one notifies.
# Without the fix (missing self._notified.clear()), the old notifications
# would leak in, making this appear successful (2/2 instead of 1/2).
# With the fix, it should fail because only 1/2 < 0.8.
session.subscribed = [] # reset for clean test
def _notify_partial_second():
time.sleep(0.005)
mgr.on_notification(hrefs[0], cbor2.dumps({'x': 2})) # only one notifies
notifier2 = threading.Thread(target=_notify_partial_second, daemon=True)
notifier2.start()
entered = mgr.try_enter_observe_mode(session, hrefs, grace_period_s=0.02)
notifier2.join()
assert entered is False
assert mgr.mode == 'poll'
assert mgr.subscribed_hrefs == set()
finally:
mgr.close()
def test_log_sweep_discrepancies_noop_when_not_in_observe_mode(caplog):
mgr = _manager()
with caplog.at_level('DEBUG'):
mgr.log_sweep_discrepancies({'/oven/vs/0': {'a': 2}})
assert 'observe missed a change' not in caplog.text
def test_log_sweep_discrepancies_never_changes_mode():
"""A sweep/cache mismatch is diagnostic-only — a still-live OBSERVE
session is never torn down over a data-drift inference. The 30s sweep
already re-applies the authoritative state to the cache regardless of
mode, so there's nothing a downgrade would fix; it would only throw
away working push coverage on every other subscribed href."""
mgr = _manager()
session = _FakeSession()
hrefs = ['/oven/vs/0', '/power/vs/0']
def _notify_during_grace_period():
time.sleep(0.005)
for href in hrefs:
mgr.on_notification(href, cbor2.dumps({'a': 1}))
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
try:
mgr.try_enter_observe_mode(session, hrefs, grace_period_s=0.02)
notifier.join()
assert mgr.mode == 'observe'
# Sweep sees values the cache never got via notify on BOTH hrefs.
mgr.log_sweep_discrepancies({hrefs[0]: {'a': 2}, hrefs[1]: {'a': 2}})
assert mgr.mode == 'observe'
finally:
mgr.close()
def test_rep_diff_reports_only_shared_fields_that_differ():
"""Fields present on only one side (a shape difference, e.g. batch
sweep vs. individual GET/notify returning different fields for the
same href) must not be reported — only shared fields with different
values."""
diff = _rep_diff({'a': 1, 'b': 2, 'c': 3}, {'a': 1, 'b': 5, 'd': 9})
assert diff == {'b': (2, 5)}
def test_log_sweep_discrepancies_ignores_shape_only_differences(caplog):
"""A sweep rep missing/adding fields the cache doesn't have (or vice
versa) is a representation-shape difference, not a missed change —
must not be logged as a discrepancy."""
mgr = _manager()
session = _FakeSession()
href = '/oven/vs/0'
def _notify_during_grace_period():
time.sleep(0.005)
mgr.on_notification(href, cbor2.dumps({'a': 1, 'rt': ['x'], 'if': ['y']}))
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
try:
mgr.try_enter_observe_mode(session, [href], grace_period_s=0.02)
notifier.join()
assert mgr.mode == 'observe'
# Sweep rep has the same 'a' value but lacks rt/if and adds 'href' —
# a shape difference (batch interface), not a missed content change.
with caplog.at_level('DEBUG'):
mgr.log_sweep_discrepancies({href: {'a': 1, 'href': href}})
finally:
mgr.close()
assert 'observe missed a change' not in caplog.text
def test_log_sweep_discrepancies_includes_the_diff(caplog):
"""The log line must include what actually differed, not just that it
did."""
mgr = _manager()
session = _FakeSession()
hrefs = ['/oven/vs/0', '/power/vs/0']
def _notify_during_grace_period():
time.sleep(0.005)
for href in hrefs:
mgr.on_notification(href, cbor2.dumps({'a': 1, 'b': 2}))
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
try:
mgr.try_enter_observe_mode(session, hrefs, grace_period_s=0.02)
notifier.join()
assert mgr.mode == 'observe'
with caplog.at_level('DEBUG'):
mgr.log_sweep_discrepancies(
{hrefs[0]: {'a': 1, 'b': 5}, hrefs[1]: {'a': 1, 'b': 9}}
)
assert "{'b': (2, 5)}" in caplog.text
assert mgr.mode == 'observe'
finally:
mgr.close()
def test_log_sweep_discrepancies_ignores_href_during_settle_window(caplog):
mgr = _manager()
session = _FakeSession()
href = '/oven/vs/0'
mgr.on_notification(href, cbor2.dumps({'a': 1}))
mgr.try_enter_observe_mode(session, [href], grace_period_s=0.01)
mgr.mark_write_pending(href, settle_s=5.0)
with caplog.at_level('DEBUG'):
mgr.log_sweep_discrepancies({href: {'a': 2}})
assert 'observe missed a change' not in caplog.text
def test_downgrade_to_poll_moves_subscribed_to_fallback():
mgr = _manager()
session = _FakeSession()
href = '/oven/vs/0'
def _notify_during_grace_period():
time.sleep(0.005)
mgr.on_notification(href, cbor2.dumps({'a': 1}))
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
mgr.try_enter_observe_mode(session, [href], grace_period_s=0.02)
notifier.join()
mgr.downgrade_to_poll()
assert mgr.mode == 'poll'
assert mgr.subscribed_hrefs == set()
assert mgr.fallback_hrefs == {href}
def test_start_refresh_task_spawns_a_daemon_thread():
mgr = _manager()
mgr.subscribed_hrefs = {'/oven/vs/0'}
session = _FakeSession()
mgr.start_refresh_task(session)
try:
assert mgr._refresh_thread is not None
assert mgr._refresh_thread.is_alive()
assert mgr._refresh_thread.daemon is True
finally:
mgr.close()
def test_close_stops_the_refresh_thread():
mgr = _manager()
mgr.subscribed_hrefs = {'/oven/vs/0'}
session = _FakeSession()
mgr.start_refresh_task(session)
thread = mgr._refresh_thread
mgr.close()
thread.join(timeout=2.0)
assert not thread.is_alive()
assert mgr._refresh_thread is None
def test_downgrade_to_poll_stops_refresh_task():
mgr = _manager()
mgr.subscribed_hrefs = {'/oven/vs/0'}
session = _FakeSession()
mgr.start_refresh_task(session)
thread = mgr._refresh_thread
mgr.downgrade_to_poll()
thread.join(timeout=2.0)
assert not thread.is_alive()
assert mgr._refresh_thread is None