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 71f1dd4..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__) @@ -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: @@ -526,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: @@ -534,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: @@ -571,14 +584,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/custom_components/localthings/manifest.json b/custom_components/localthings/manifest.json index 7e53305..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.10.1" + "version": "0.11.1" } 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/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/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index 8b2762b..ce7ca0f 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -248,6 +248,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 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" + } + } + } + }, "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 8b2762b..ce7ca0f 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -248,6 +248,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 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" + } + } + } + }, "issues": { "device_gap": { "title": "Incomplete capability coverage for {device_name}", 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/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 ce4772d..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 ( @@ -749,6 +749,104 @@ 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} + + +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 @@ -881,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} 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) 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."""