From 020402c82fe752df4a2bee7efe1da158ad1b4bfd Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 24 Jul 2026 03:07:37 +0000 Subject: [PATCH 1/8] fix: size the write-settle window to outlast a slow-settling device (issue #9) async_send_command's settle guard used DEFAULT_SETTLE_S's fixed few seconds, which is long enough for fields that update instantly on the device but not for ones that visibly take a few seconds of internal validation or hardware movement to catch up. Issue #9's washer packs cycle/detergent/softener selection into /course/vs/0's shared options[] array, and that settling time regularly outlasted the fixed window -- same device, same integration, but /washer/vs/0's temperature/spin fields (plain flags) confirmed instantly while these didn't. The short window expired before the confirm poll (or the device itself) caught up, so a stale read landed unprotected and reverted the optimistic value, only to self-correct again once a later poll saw the real change -- reading to the user as the write reverting and then reapplying itself a few seconds later. Size the window to always outlast the PUT and the confirming refresh's poll combined, as issues #17/#53 also needed, but without that fix's early-release mechanism (reverted previously for its own races around overlapping writes) -- just hold the guard for the full window and let it expire on its own. --- custom_components/localthings/coordinator.py | 44 ++++++++++++++- tests/localthings/test_coordinator.py | 59 ++++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 71f1dd4..db8ff6c 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -77,6 +77,14 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): # fine session shouldn't tear down a working OBSERVE subscription. _POLL_TIMEOUT_LIMIT: int = 3 + # Timeouts for the two network round trips a write triggers: the PUT + # itself (_do_put), then the confirming full /device/0 summary poll + # async_send_command requests right after (_poll_once). Named here + # (rather than left as inline literals) so the write-settle window + # below can be sized to always outlast both — see async_send_command. + _POST_TIMEOUT_S: float = 8.0 + _POLL_TIMEOUT_S: float = 35.0 + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: # Per-device logger (module logger scoped to this device's host) so # every log line — including the base DataUpdateCoordinator's own @@ -211,7 +219,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): # would have succeeded, not a dead session. 35s gives a slow # blockwise transfer room to actually finish instead of # generating a TimeoutError every cycle. - code, payload = sess.get(_SEED_PATH, timeout=35.0) + code, payload = sess.get(_SEED_PATH, timeout=self._POLL_TIMEOUT_S) except TimeoutError: raise except Exception as e: @@ -571,14 +579,44 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): # 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). + # + # settle_s must outlast the PUT and the async_request_refresh() + # below combined, not just DEFAULT_SETTLE_S's fixed few seconds -- + # that refresh is a full /device/0 summary poll, which + # _POLL_TIMEOUT_S itself admits can legitimately take tens of + # seconds on these devices (see _poll_once), and some writes settle + # on the device itself well after that: issue #9's washer packs + # cycle/detergent/softener selection into the same /course/vs/0 + # options[] array, and picking a new value there visibly needs a + # few seconds of internal validation/dispenser movement before the + # device's own state agrees -- while /washer/vs/0's temperature/ + # spin fields (plain flags, no device-side settling) confirm + # instantly on the same device. A short fixed window expired while + # the confirm poll was still in flight (or before the device had + # caught up internally), so that stale read landed unprotected and + # reverted the optimistic value, self-correcting again only once a + # later poll finally saw the real change -- read by the user as the + # write "reverting, then re-applying itself" a few seconds later. + # + # An earlier attempt at this also released the guard early, right + # after the confirming refresh completed, to avoid shutting out + # unrelated real updates (another automation, the physical remote) + # for the rest of settle_s. That was reverted: releasing the guard + # the moment one round trip finishes doesn't mean the device has + # actually caught up (exactly the slow-settling case above), and it + # introduced its own races around overlapping writes to the same + # href. Simpler and safer to just hold the guard for the full, + # generously-sized window and let it expire on its own. self._observe.apply(write_href, body, source='optimistic') - self._observe.mark_write_pending(write_href) + self._observe.mark_write_pending( + write_href, settle_s=self._POST_TIMEOUT_S + self._POLL_TIMEOUT_S + ) def _do_put(): sess = self._session if sess is None: raise RuntimeError("no session") - code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=8.0) + code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=self._POST_TIMEOUT_S) self._log.info("PUT %s → code %#04x", write_href, code) try: diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index ce4772d..72a406e 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -749,6 +749,65 @@ async def test_climate_power_write_applies_to_its_own_href_not_bound_href( assert coordinator._observe._settle_until.get('/power/0') is not None +async def test_send_command_survives_stale_confirm_poll( + hass: HomeAssistant, mock_entry, mock_coordinator_observe_session, fridge_resources, +) -> None: + """The settle window must outlast the confirm poll async_send_command + triggers via async_request_refresh(), not just DEFAULT_SETTLE_S's fixed + few seconds (issue #9). Some devices settle a write internally slower + than that refresh's own round trip -- issue #9's washer packs cycle/ + detergent/softener selection into /course/vs/0's shared options[] + array, which visibly takes a few seconds of validation/dispenser + movement to catch up, unlike /washer/vs/0's plain temperature/spin + fields on the same device, which confirm instantly. A confirm poll that + lands before the device has caught up is stale, and a settle window + sized only to the fixed default (rather than the PUT+poll round trip) + expires before that poll even returns, so the stale read lands + unprotected and reverts the optimistic value -- self-correcting again + only once a later poll finally sees the real change. That reads to a + user as the write reverting, then reapplying itself, a few seconds + later.""" + 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 (['test', 'vs', '0'], {'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) + + def _stale_confirm_poll(): + # Stand-in for the write's own confirming /device/0 poll racing + # against a stale read for the same href -- the device's internal + # state hasn't caught up to the write yet, even though it will a + # little later. + coordinator._observe.apply('/test/vs/0', {'value': 0}, source='poll') + return fridge_resources + + with ( + patch.object(fake, 'subscribe'), + patch.object(LocalThingsCoordinator, '_poll_once', side_effect=_stale_confirm_poll), + ): + fake.post = lambda *a, **k: (0x44, b'') + await coordinator.async_send_command(bound, 5) + + # The optimistic value is visible right away and survived the stale + # confirm-poll race triggered by the refresh above. + assert coordinator._cache.get('/test/vs/0') == {'value': 5} + + # The settle window is still open afterward -- sized to outlast the + # whole PUT + confirm-poll round trip, not released the moment that + # round trip happens to finish. + 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 85cca70b6b43bbb76283973af8182653712a63ce Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 24 Jul 2026 03:14:58 +0000 Subject: [PATCH 2/8] fix: don't let an in-progress settle window block a second write's own optimistic apply Widening the settle window to tens of seconds (previous commit) made a real gap much more likely to bite: apply() gated every source, including 'optimistic', on _is_settling. /course/vs/0 backs several independent washer selects (cycle, detergent quantity, softener quantity, ...), so picking a second one while the first write's window was still open -- routine within a ~43s window -- had its own optimistic value silently dropped from the cache instead of shown, recreating the exact "write doesn't seem to apply" symptom the guard exists to prevent, just for whichever write lost the race. Let source='optimistic' always bypass the gate; poll/sweep/observe stay gated as before. mark_write_pending still re-arms the window right after, so the newer write is protected going forward. --- custom_components/localthings/observe.py | 16 +++++++++- tests/localthings/test_coordinator.py | 39 ++++++++++++++++++++++++ tests/localthings/test_observe.py | 24 +++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/custom_components/localthings/observe.py b/custom_components/localthings/observe.py index e3c5aad..7d51109 100644 --- a/custom_components/localthings/observe.py +++ b/custom_components/localthings/observe.py @@ -115,8 +115,22 @@ class ObserveManager: could each read the same prior rep and the second writer would silently lose the first's fields, reintroducing the exact bug this merge fixes. + + `source == 'optimistic'` always bypasses the settle gate below, + even while this href is already settling from an earlier write. + Several independent selects can share one href -- issue #9's washer + packs cycle, detergent, and softener settings onto the same + /course/vs/0 -- so a second, different write to the same href + while the first's settle window is still open (up to + _POST_TIMEOUT_S + _POLL_TIMEOUT_S, tens of seconds -- see + coordinator.async_send_command) is an expected, normal sequence, + not a stale echo of the first write. Gating it the same as a + poll/sweep/observe update would silently drop the user's own + second selection from the cache until the first write's guard + happened to expire, i.e. the exact symptom this guard exists to + prevent, just relocated to whichever write loses the race. """ - if self._is_settling(href): + if source != 'optimistic' and self._is_settling(href): self.log.debug("dropping %s update for %s (settling)", source, href) return False with self._cache_lock: diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 72a406e..a396815 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -808,6 +808,45 @@ async def test_send_command_survives_stale_confirm_poll( assert coordinator._cache.get('/test/vs/0') == {'value': 5} +async def test_second_write_to_same_href_lands_during_first_writes_settle_window( + hass: HomeAssistant, mock_entry, mock_coordinator_observe_session, +) -> None: + """Regression for issue #9: /course/vs/0 backs several independent + washer selects (cycle, detergent quantity, softener quantity, ...) + sharing one href. The settle window is now sized to tens of seconds + (see test_send_command_survives_stale_confirm_poll), which makes a + second, different write to that href landing while the first write's + window is still open a routine occurrence -- e.g. a user picking a + cycle and then adjusting detergent quantity within the same minute -- + not a rare edge case. The second write's own optimistic value must + still show up immediately; a guard meant to protect optimistic writes + from stale device echoes must not itself suppress a later one.""" + 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] + + desc_a = NumberDesc(key='cycle', field='cycle', + write_fn=lambda p, rep, href: (['test', 'vs', '0'], {'cycle': p})) + desc_b = NumberDesc(key='detergent', field='detergent', + write_fn=lambda p, rep, href: (['test', 'vs', '0'], {'detergent': p})) + bound_a = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc_a) + bound_b = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc_b) + + with patch.object(fake, 'subscribe'): + fake.post = lambda *a, **k: (0x44, b'') + await coordinator.async_send_command(bound_a, 'Eco') + assert coordinator._cache.get('/test/vs/0') == {'cycle': 'Eco'} + + # Still well within the first write's settle window. + await coordinator.async_send_command(bound_b, 'High') + + assert coordinator._cache.get('/test/vs/0') == {'cycle': 'Eco', 'detergent': 'High'} + + class TestRemoteControlEnabled: """remote_control_enabled (registry/capabilities/common.py) is the single source of truth for the /remotectrl on/off signal, shared by diff --git a/tests/localthings/test_observe.py b/tests/localthings/test_observe.py index 7d3fe9a..4ea057d 100644 --- a/tests/localthings/test_observe.py +++ b/tests/localthings/test_observe.py @@ -67,6 +67,30 @@ def test_apply_drops_update_during_settle_window(): assert mgr.cache.get('/oven/vs/0') == {'a': 1} +def test_apply_optimistic_bypasses_an_in_progress_settle_window(): + """Regression for issue #9: /course/vs/0 backs several independent + washer selects (cycle, detergent quantity, softener quantity, ...). + Picking a second one while the first's settle window is still open + (now sized to tens of seconds -- see coordinator._POST_TIMEOUT_S/ + _POLL_TIMEOUT_S) is a normal sequence, not a stale echo of the first + write, and must land in the cache immediately -- not get silently + dropped by a guard that exists to protect optimistic writes, not + suppress them.""" + mgr = _manager() + mgr.cache.apply_rep('/course/vs/0', {'Course': '1C', 'Detergent': '1'}, source='seed') + mgr.mark_write_pending('/course/vs/0', settle_s=30.0) + + result = mgr.apply('/course/vs/0', {'Detergent': '2'}, source='optimistic') + + assert result is True + assert mgr.cache.get('/course/vs/0') == {'Course': '1C', 'Detergent': '2'} + + # A poll/sweep/observe update racing in right behind it is still + # gated -- the second write's own guard (re-armed by mark_write_pending, + # not exercised directly here) is what protects it going forward. + assert mgr.apply('/course/vs/0', {'Detergent': '1'}, source='poll') is False + + def test_apply_accepts_update_after_settle_window_elapses(): mgr = _manager() mgr.mark_write_pending('/oven/vs/0', settle_s=0.05) From f7faf566898853fb7a723425103761f84aee116e Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 24 Jul 2026 03:21:16 +0000 Subject: [PATCH 3/8] Bump version to 0.10.2 --- 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 7e53305..3d358d7 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.1" + "version": "0.10.2" } From 49bd4ef0cb811e29602c1d2172f66fec4c582546 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 24 Jul 2026 03:22:59 +0000 Subject: [PATCH 4/8] Bump version to 0.11.0 --- 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 3d358d7..889e8ef 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.2" + "version": "0.11.0" } From 037dc9722f9ab706e7f10877a7d09bfb5497f890 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 24 Jul 2026 03:32:49 +0000 Subject: [PATCH 5/8] feat: add options flow to bypass the remote-control write block per device (issue #54) The remote-control-off write block was device-wide and unconditional: whenever a device reports remote control off, every write is rejected with a user-facing error, on the assumption the device would reject it anyway. Issue #54 reports a washer where that assumption doesn't hold -- default detergent/softener dosing writes through even with remote control off, since they apply to the built-in programs too, not just a custom remote-controlled cycle. Add a per-device options flow (Settings > Devices & Services > this device > Configure) with a single toggle, stored in entry.options (not entry.data) so it doesn't affect the device's identity/unique_id. coordinator.async_send_command reads it ahead of the existing remote_control_enabled() check; defaults to False everywhere, so devices that don't touch this option see no change in behavior. --- custom_components/localthings/config_flow.py | 37 +++++++++++++++ custom_components/localthings/const.py | 10 +++++ custom_components/localthings/coordinator.py | 11 +++-- custom_components/localthings/strings.json | 11 +++++ .../localthings/translations/en.json | 11 +++++ tests/localthings/test_config_flow.py | 45 ++++++++++++++++++- tests/localthings/test_coordinator.py | 43 +++++++++++++++++- 7 files changed, 163 insertions(+), 5 deletions(-) diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 14644d1..fe72327 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -12,6 +12,7 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.selector import ( TextSelector, @@ -24,6 +25,7 @@ from .const import ( CONF_HOST, CONF_PORT, CONF_CA_CERT_PEM, CONF_CA_KEY_PEM, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, + CONF_BYPASS_REMOTE_CONTROL, PROBE_PORT_RANGE, PREFERRED_PROBE_PORTS, LIVENESS_PROBE_TIMEOUT_S, PROBE_GET_TIMEOUT_S, ) @@ -300,6 +302,13 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._ca_key_pem: str = "" self._pending_info: dict | None = None + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> LocalThingsOptionsFlow: + return LocalThingsOptionsFlow() + def _create_entry(self, info: dict) -> FlowResult: return self.async_create_entry( title=f"Samsung Appliance ({self._host})", @@ -384,3 +393,31 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): "one_ui_version": self._pending_info["one_ui_version"] or "(none reported)", }, ) + + +class LocalThingsOptionsFlow(config_entries.OptionsFlow): + """Per-device override of the remote-control-off write block (issue + #54). The block exists because most devices reject writes outright + while remote control is off and a clear error beats a silent + device-side rejection -- but not every model actually enforces that, + so this lets a user who's confirmed their device accepts writes anyway + turn the block off for just that device rather than it being + hardcoded on for everyone.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + if user_input is not None: + return self.async_create_entry(data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema({ + vol.Required( + CONF_BYPASS_REMOTE_CONTROL, + default=self.config_entry.options.get( + CONF_BYPASS_REMOTE_CONTROL, False + ), + ): bool, + }), + ) diff --git a/custom_components/localthings/const.py b/custom_components/localthings/const.py index bad8b37..63bffbd 100644 --- a/custom_components/localthings/const.py +++ b/custom_components/localthings/const.py @@ -12,6 +12,16 @@ CONF_CA_KEY_PEM = "ca_key_pem" CONF_LEAF_CERT_PEM = "leaf_cert_pem" CONF_LEAF_KEY_PEM = "leaf_key_pem" +# Options-flow key (entry.options, not entry.data): lets a user override the +# device-wide remote-control-off write block for a specific device (issue +# #54). Some devices report remote control off yet still accept certain +# writes (e.g. default detergent/softener dosing on a washer, applied even +# to the built-in programs) -- the block exists to give a clear error +# instead of a silent device-side rejection, but that assumption doesn't +# hold for every model. Defaults to False (block stays on) everywhere it's +# read, so devices this doesn't apply to see no behavior change. +CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock" + # The DTLS/CoAP local API binds somewhere in this ephemeral range; which port # depends on firmware. Newer builds answer on 49154/49155, but older ones have # been seen as low as 49153, so we sweep the whole range for a live UDP port diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index db8ff6c..94e8fc8 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -31,7 +31,7 @@ from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S from .const import ( DOMAIN, CONF_HOST, CONF_PORT, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, - DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S, + CONF_BYPASS_REMOTE_CONTROL, DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S, ) _LOGGER = logging.getLogger(__name__) @@ -534,7 +534,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): user-facing message -- as opposed to write_fn's silent no-op below -- is available to every platform for free. The remote-control check runs first and applies to every platform unconditionally, - ahead of any description-specific validate_fn.""" + ahead of any description-specific validate_fn -- unless the user has + opted this device out of it via CONF_BYPASS_REMOTE_CONTROL (issue + #54: some devices accept certain writes, e.g. a washer's default + dosing levels, even while reporting remote control off, so the + block's assumption doesn't hold for every model).""" desc = bound_entity.desc write_fn = getattr(desc, 'write_fn', None) if write_fn is None: @@ -542,7 +546,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): href = bound_entity.href rep = self._cache.get(href or '') or {} resources = self._cache.snapshot() - if not remote_control_enabled(resources): + bypass_remote_control = self._entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False) + if not bypass_remote_control and not remote_control_enabled(resources): raise ServiceValidationError(_REMOTE_CONTROL_DISABLED_MESSAGE) validate_fn = getattr(desc, 'validate_fn', None) if validate_fn is not None: diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index b3e0026..4142012 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -246,6 +246,17 @@ "already_configured": "This device is already configured." } }, + "options": { + "step": { + "init": { + "title": "Local Things Options", + "description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only turn this off if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.", + "data": { + "bypass_remote_control_lock": "Allow writes even when remote control is reported off" + } + } + } + }, "issues": { "device_gap": { "title": "Incomplete capability coverage for {device_name}", diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index b3e0026..4142012 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -246,6 +246,17 @@ "already_configured": "This device is already configured." } }, + "options": { + "step": { + "init": { + "title": "Local Things Options", + "description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only turn this off if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.", + "data": { + "bypass_remote_control_lock": "Allow writes even when remote control is reported off" + } + } + } + }, "issues": { "device_gap": { "title": "Incomplete capability coverage for {device_name}", diff --git a/tests/localthings/test_config_flow.py b/tests/localthings/test_config_flow.py index 294e780..f21ee54 100644 --- a/tests/localthings/test_config_flow.py +++ b/tests/localthings/test_config_flow.py @@ -9,7 +9,8 @@ from homeassistant.data_entry_flow import FlowResultType from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.localthings.const import ( - CONF_CA_CERT_PEM, CONF_CA_KEY_PEM, CONF_HOST, CONF_PORT, DOMAIN, + CONF_BYPASS_REMOTE_CONTROL, CONF_CA_CERT_PEM, CONF_CA_KEY_PEM, + CONF_HOST, CONF_PORT, DOMAIN, ) from .conftest import ( @@ -276,3 +277,45 @@ def test_probe_marks_washer_as_recognized(monkeypatch): ) is not None ) assert recognized is True + + +async def test_options_flow_default_is_off(hass: HomeAssistant) -> None: + """The bypass defaults to False, so devices that never touch this + option see no change in the remote-control write block.""" + entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}') + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result['type'] == FlowResultType.FORM + assert result['step_id'] == 'init' + assert result['data_schema']({})[CONF_BYPASS_REMOTE_CONTROL] is False + + +async def test_options_flow_can_enable_bypass(hass: HomeAssistant) -> None: + """Submitting the form with the toggle on stores it in entry.options, + where coordinator.async_send_command reads it (issue #54).""" + entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}') + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result['flow_id'], user_input={CONF_BYPASS_REMOTE_CONTROL: True} + ) + + assert result['type'] == FlowResultType.CREATE_ENTRY + assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True + + +async def test_options_flow_reflects_previously_saved_value(hass: HomeAssistant) -> None: + """Reopening the form shows the currently-saved choice as the default, + not always False.""" + entry = MockConfigEntry( + domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}', + options={CONF_BYPASS_REMOTE_CONTROL: True}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result['data_schema']({})[CONF_BYPASS_REMOTE_CONTROL] is True diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index a396815..cd137e3 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -11,7 +11,7 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir from custom_components.localthings.const import ( - CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S, + CONF_BYPASS_REMOTE_CONTROL, CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S, ) from custom_components.localthings.coordinator import LocalThingsCoordinator from custom_components.localthings.registry.capabilities.common import ( @@ -979,3 +979,44 @@ async def test_send_command_remote_control_check_precedes_validate_fn( with pytest.raises(ServiceValidationError, match="Remote control is turned off"): await coordinator.async_send_command(bound, 'On') + + +async def test_send_command_bypasses_remote_control_when_option_enabled( + hass: HomeAssistant, mock_coordinator_observe_session +) -> None: + """Issue #54: some devices accept certain writes even while reporting + remote control off, so a user who's confirmed that for their device can + opt it out of the block entirely via the options flow + (CONF_BYPASS_REMOTE_CONTROL, entry.options -- not entry.data).""" + from pytest_homeassistant_custom_component.common import MockConfigEntry + + from custom_components.localthings.registry.discovery import BoundEntity + from custom_components.localthings.registry.entities import NumberDesc + + entry = MockConfigEntry( + domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}', + options={CONF_BYPASS_REMOTE_CONTROL: True}, + ) + entry.add_to_hass(hass) + + fake = mock_coordinator_observe_session + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator._cache.apply_rep( + '/remotectrl/vs/0', + {'x.com.samsung.da.remoteControlEnabled': 'false'}, + source='test', + ) + + 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) + + assert coordinator._cache.get('/some/path') == {'value': 5} From dccd6e12091e17c6d4d625de568cc8871ad4d379 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 24 Jul 2026 03:38:10 +0000 Subject: [PATCH 6/8] fix: correct backwards options-flow description wording The bypass toggle is off by default and turned ON to allow writes with remote control reported off, but the description said "only turn this off if..." -- backwards from the actual control. Caught in Opus review. --- custom_components/localthings/strings.json | 2 +- custom_components/localthings/translations/en.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index 4142012..9166e74 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -250,7 +250,7 @@ "step": { "init": { "title": "Local Things Options", - "description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only turn this off if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.", + "description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.", "data": { "bypass_remote_control_lock": "Allow writes even when remote control is reported off" } diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 4142012..9166e74 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -250,7 +250,7 @@ "step": { "init": { "title": "Local Things Options", - "description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only turn this off if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.", + "description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.", "data": { "bypass_remote_control_lock": "Allow writes even when remote control is reported off" } From 8e25e6dc717524df378320eedbd50fefd7f289ba Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 24 Jul 2026 01:11:58 +0000 Subject: [PATCH 7/8] Recognize A-CAWW-TP2-20-COMMON system air conditioners (issue #52) New '-CAWW-' modelNum token for multi-indoor-unit commercial AC installs -- these report no oneUiVersion, same as the other RAC/PRAC boards. Once routed to the existing airconditioner registry, every resource in the reporter's dump already binds except one new SAC-specific installation-topology blob, now ignored. --- .../localthings/registry/by_type/__init__.py | 9 + .../registry/capabilities/airconditioner.py | 5 + .../airconditioner_caww_tp2_device.json | 486 ++++++++++++++++++ .../golden/airconditioner_caww_tp2.json | 15 + tests/test_airconditioner_capabilities.py | 24 + tests/test_golden_regression.py | 18 + 6 files changed, 557 insertions(+) create mode 100644 tests/fixtures/airconditioner_caww_tp2_device.json create mode 100644 tests/fixtures/golden/airconditioner_caww_tp2.json diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 4fa49d9..7f6b864 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -113,6 +113,15 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # 'P' sits between the underscore and 'RAC' in that token). if key is None and '_RAC_' in (model_num or ''): key = 'airconditioner' + # System air conditioners (multi-indoor-unit commercial installs, e.g. + # A-CAWW-TP2-20-COMMON, issue #52) report no oneUiVersion either and + # carry the '-CAWW-' board-family token instead of '_RAC_'/'_PRAC_'. + # Same TP1X/TP2X-class resource surface as the room-AC models above + # (confirmed by the issue #52 dump binding cleanly against the existing + # airconditioner registry once routed here), plus one new SAC-specific + # resource (see airconditioner.py's _AC_IGNORED). + if key is None and '-CAWW-' in (model_num or '').upper(): + key = 'airconditioner' # Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no # oneUiVersion either, and carry the '_TVTL_' board-family token. if key is None and '_TVTL_' in (model_num or ''): diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index c58cf03..0137163 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -243,6 +243,11 @@ _AC_IGNORED = [ '/remotetemperature/vs/0', # external temp-sensor feed (unset on this unit) '/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob '/welcome/temperature/vs/0', # welcome-cooling plumbing + # System-AC-only (multi-indoor-unit commercial installs, e.g. + # A-CAWW-TP2-20-COMMON, issue #52): opaque hex-encoded installation + # topology -- indoor/outdoor unit pairing, per-unit serials, MCU info. + # Commissioning-time plumbing, not user-actionable appliance state. + '/sac/installationinfo/vs/0', ] # Built as bare no-entity caps; folded into the AC registry (not global). diff --git a/tests/fixtures/airconditioner_caww_tp2_device.json b/tests/fixtures/airconditioner_caww_tp2_device.json new file mode 100644 index 0000000..481110a --- /dev/null +++ b/tests/fixtures/airconditioner_caww_tp2_device.json @@ -0,0 +1,486 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/aisleep/vs/0", + "rep": { + "x.com.samsung.da.displayNightMode": "Off", + "x.com.samsung.da.elapsedTime": "0", + "x.com.samsung.da.requestFeedback": "Off", + "x.com.samsung.da.resultFeedback": "0", + "x.com.samsung.da.statusFeedback": "Idle", + "x.com.samsung.da.sleepTime": "14002200", + "x.com.samsung.da.sleepMode": "Off" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-23T05:13:21", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-23T05:13:21", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000100B4012C0000120000000000", + "x.com.samsung.da.id": "CAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "4131000000", + "x.com.samsung.da.airconOptionList": [ + "DR", + "SingleCommand_1.0", + "AI_2.0" + ] + } + }, + { + "href": "/diagnosis/vs/0", + "rep": {} + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.start": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "153.000000", + "x.com.samsung.da.cumulativePower": "239209", + "x.com.samsung.da.cumulativeSavedPower": "7201", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePowerType": "total", + "x.com.samsung.da.saveLocation": "/mnt/usage.db" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "85", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterCapacity": "1000", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable", + "washable" + ] + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0.000000", + "x.com.samsung.da.fivepercentHumidity": "83" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "A-CAWW-TP2-20-COMMON|10241941|600301170016110B40000F2000F2AD00", + "x.com.samsung.da.description": "A-CAWW-TP2-20-COMMON", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "023", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02274A260512", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "02419A25051901,FFFFFFFFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Outdoor", + "x.com.samsung.da.number": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF" + } + ] + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 0 + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Nano", + "LongWind", + "Speed", + "Sleep", + "NanoSleep" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.workingMode": "Auto", + "x.com.samsung.da.supportedModes": [ + "Auto", + "Cool", + "Dry", + "Fan", + "AIComfort" + ], + "x.com.samsung.da.modes": [ + "Auto" + ], + "x.com.samsung.da.options": [ + "Sleep_0", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_240", + "OutdoorTemp_82", + "Light_Off", + "Volume_100", + "KeyInputPermit_On", + "ModePermit_NoLimit", + "UpdateAllow_NotAllowed", + "DurationOn_419", + "WelcomeCoolingState_Off" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.settingStatus": "On", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.supportedStatus": [ + "Start", + "Stop" + ], + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "Off" + ] + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.newVersionNo": "00000000", + "x.com.samsung.da.currentVersionInfo": "00000000", + "otnStatus": "None", + "flashingProgress": "", + "otnTarget": "main", + "otnCompleteDate": "noHistory", + "otnList": [ + { + "type": "WIFI", + "modelId": "A-CAWW-TP2-20-COMMON", + "versions": [ + "14260512" + ], + "visVersion": "260512" + }, + { + "type": "Micom", + "modelId": "040010241941FFFFFFFF", + "versions": [ + "25051901", + "FFFFFFFF" + ], + "visVersion": "250519" + }, + { + "type": "Micom", + "modelId": "0400FFFFFFFFFFFFFFFF", + "versions": [ + "FFFFFFFF", + "FFFFFFFF" + ], + "visVersion": "" + } + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "1F121E121E121E121EFFFFFFFF010001000F0001000E0000001C", + "x.com.samsung.da.id": "CAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/sac/installationinfo/vs/0", + "rep": { + "installationOptions": [ + { + "id": "0", + "option": "17444980C80171700010" + }, + { + "id": "1", + "option": "28010000000000000100" + }, + { + "id": "2", + "option": "50000000000000000000" + }, + { + "id": "3", + "option": "30000000000000000000" + } + ], + "installationDeviceNum": [ + { + "id": "0", + "number": "01" + }, + { + "id": "1", + "number": "04" + }, + { + "id": "2", + "number": "00" + } + ], + "outdoorInfo": [ + { + "id": "0", + "info": "01900A230418", + "serial": "**REDACTED**" + }, + { + "id": "1", + "info": "000000000000", + "serial": "**REDACTED**" + }, + { + "id": "2", + "info": "000000000000", + "serial": "**REDACTED**" + }, + { + "id": "3", + "info": "000000000000", + "serial": "**REDACTED**" + } + ], + "mcuInfo": [ + { + "id": "0", + "info": "" + }, + { + "id": "1", + "info": "" + }, + { + "id": "2", + "info": "" + }, + { + "id": "3", + "info": "" + }, + { + "id": "4", + "info": "" + }, + { + "id": "5", + "info": "" + }, + { + "id": "6", + "info": "" + }, + { + "id": "7", + "info": "" + }, + { + "id": "8", + "info": "" + }, + { + "id": "9", + "info": "" + }, + { + "id": "10", + "info": "" + }, + { + "id": "11", + "info": "" + }, + { + "id": "12", + "info": "" + }, + { + "id": "13", + "info": "" + }, + { + "id": "14", + "info": "" + }, + { + "id": "15", + "info": "" + } + ] + } + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "0.5" + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "27.0", + "x.com.samsung.da.current": "29.0", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "18", + "x.com.samsung.da.increment": "0.5", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Fix", + "x.com.samsung.da.supportedModes": [ + "Up_And_Low", + "Fix", + "Left_And_Right", + "All" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "0", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Low", + "Mid", + "High" + ] + } + } + ] +} diff --git a/tests/fixtures/golden/airconditioner_caww_tp2.json b/tests/fixtures/golden/airconditioner_caww_tp2.json new file mode 100644 index 0000000..290a269 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_caww_tp2.json @@ -0,0 +1,15 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "alarm_code", + "auto_clean", + "climate", + "diagnosis_status", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "mute_once", + "power_watts" + ] +} diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index 676f026..981dc5d 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -215,6 +215,30 @@ def test_tp1x_rac_expected_state_keys_present(): assert key in state, key +def test_caww_tp2_model_resolves_via_model_fallback(): + """A-CAWW-TP2-20-COMMON (issue #52, System AC) reports no oneUiVersion + and no '_RAC_'/'_PRAC_' token -- resolved via the '-CAWW-' modelNum + fallback added for this device.""" + reg, _ = _resolve('airconditioner_caww_tp2') + assert reg is not None and reg.name == 'airconditioner' + + +def test_caww_tp2_no_unbound_hrefs(): + """Every resource in the issue #52 dump binds or is ignored -- clears + the coverage-gap repair. Only new href beyond the existing RAC/PRAC + surface is /sac/installationinfo/vs/0 (opaque SAC installation topology, + ignored).""" + reg, resources = _resolve('airconditioner_caww_tp2') + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_caww_tp2_sac_installationinfo_is_ignored(): + ignored_hrefs = {cap.href for cap in airconditioner.COVERAGE} + assert '/sac/installationinfo/vs/0' in ignored_hrefs + + def test_mute_once_write_target(): write = airconditioner.MUTE_ONCE.entities[0].write_fn assert write('On', {}) == (['option', 'muteonce', 'vs', '0'], {'muteonce': 'On'}) diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index e3159cb..56ba878 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -213,6 +213,24 @@ def test_registry_reproduces_golden_state_keys_for_tp2x_rac_20k(): ) +def test_registry_reproduces_golden_state_keys_for_caww_tp2(): + """A-CAWW-TP2-20-COMMON (issue #52, System AC / multi-indoor-unit + commercial install) reports no oneUiVersion and no '_RAC_'/'_PRAC_' + token; resolved via the '-CAWW-' modelNum fallback in + for_device_by_model. Otherwise binds cleanly against the existing + airconditioner registry -- same TP1X/TP2X-class resource surface, plus + one SAC-only installation-topology resource (ignored).""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_caww_tp2') + golden = json.loads((GOLDEN / 'airconditioner_caww_tp2.json').read_text()) + state_keys = _new_state_keys('airconditioner_caww_tp2', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_registry_reproduces_golden_state_keys_for_tp1x_rac(): """TP1X_DA-AC-RAC-01001_0000 (issue #38) -- fuller RAC board with display light, self-check, mute-once, and a current-limit setting.""" From efe87381b78f2cfa9b4a306c68cda35ab20a4940 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 24 Jul 2026 04:04:08 +0000 Subject: [PATCH 8/8] Bump version to 0.11.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 889e8ef..323af73 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.11.0" + "version": "0.11.1" }