diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index cb2f8b1..7494db2 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -545,11 +545,21 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): return path_segs, body = result + # Apply the write optimistically before starting the settle guard, + # not after -- mark_write_pending gates every source (poll, sweep, + # observe) through the same apply(), itself included, so flipping + # this order would have the guard drop the one update it exists to + # protect. Without an optimistic value in the cache for it to hold + # onto, the settle window was just delaying the real device + # confirmation for a few seconds on every write, which read exactly + # like the write being silently reverted (issue #27). + self._observe.apply(href, body, source='optimistic') + self._observe.mark_write_pending(href) + def _do_put(): sess = self._session if sess is None: raise RuntimeError("no session") - self._observe.mark_write_pending(href) code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=8.0) self._log.info("PUT %s → code %#04x", href, code) diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 0a5c2cb..943da68 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -640,6 +640,44 @@ async def test_write_marks_href_pending_before_post( assert coordinator._observe._settle_until.get('/test/vs/0') is not None +async def test_send_command_applies_write_optimistically_before_settling( + hass: HomeAssistant, mock_entry, mock_coordinator_observe_session +) -> None: + """The cache must reflect a write immediately, and stay put against a + stale echo for the rest of the settle window -- otherwise + mark_write_pending has nothing to protect and just delays the real + device confirmation instead, which reads to a user as the write being + silently reverted (issue #27).""" + from custom_components.localthings.registry.discovery import BoundEntity + from custom_components.localthings.registry.entities import NumberDesc + + fake = mock_coordinator_observe_session + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] + + def _write_fn(payload, rep, href): + return (['some', 'path'], {'value': payload}) + + desc = NumberDesc(key='test', field='value', write_fn=_write_fn) + bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc) + + with patch.object(fake, 'subscribe'): + fake.post = lambda *a, **k: (0x44, b'') + await coordinator.async_send_command(bound, 5) + + # The optimistic value is visible right away, without waiting for a + # device response. + assert coordinator._cache.get('/test/vs/0') == {'value': 5} + + # A stale update racing in behind the write (e.g. a poll/notify that + # was already in flight before the PUT) must not clobber it while the + # settle window is open. + applied = coordinator._observe.apply('/test/vs/0', {'value': 0}, source='observe') + assert applied is False + assert coordinator._cache.get('/test/vs/0') == {'value': 5} + + class TestRemoteControlEnabled: """remote_control_enabled (registry/capabilities/common.py) is the single source of truth for the /remotectrl on/off signal, shared by