From f783aa72b99828621f987721e2bf0e418ee29c14 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Thu, 23 Jul 2026 02:47:12 +0000 Subject: [PATCH 1/3] Add range/cooktop-oven combo to supported appliance types --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d269429..0e89c5c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi | Air conditioner | `by_type/airconditioner.py` | | Dryer | `by_type/dryer.py` | | Oven | `by_type/oven.py` | +| Range / cooktop-oven combo | `by_type/range.py` | | Dishwasher | `by_type/dishwasher.py` | | Refrigerator | `by_type/refrigerator.py` | | Washer | `by_type/washer.py` | From 94551da4a0d5f690d7e67e2e9c512a608dc1ab01 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Thu, 23 Jul 2026 03:26:00 +0000 Subject: [PATCH 2/3] fix: apply writes optimistically before the settle guard (issue #27) mark_write_pending's settle window was dropping every update for a just-written href, including the coordinator's own post-write refresh, because nothing ever wrote the optimistic value into the cache for it to protect. The write reflected on the device immediately but reverted in HA until the next 30s summary sweep. --- custom_components/localthings/coordinator.py | 12 ++++++- tests/localthings/test_coordinator.py | 38 ++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) 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 From 760d797c1220f2ed622cb4110d8ab9880c9b8081 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Thu, 23 Jul 2026 03:30:16 +0000 Subject: [PATCH 3/3] Bump version to 0.10.1 --- custom_components/localthings/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/localthings/manifest.json b/custom_components/localthings/manifest.json index 4342ff1..7e53305 100644 --- a/custom_components/localthings/manifest.json +++ b/custom_components/localthings/manifest.json @@ -12,5 +12,5 @@ "pyOpenSSL>=23.0", "smartthings-local>=0.1.0" ], - "version": "0.10.0" + "version": "0.10.1" }