diff --git a/custom_components/dkncloudna/climate.py b/custom_components/dkncloudna/climate.py index c452a4d..efa1bac 100644 --- a/custom_components/dkncloudna/climate.py +++ b/custom_components/dkncloudna/climate.py @@ -189,8 +189,15 @@ class DknClimateEntity(DknEntity, ClimateEntity): await self.coordinator.client.async_send_machine_event( installation_id, self._command_mac, "power", False ) - self._optimistic_set("power", False) - self._optimistic_set("hvac_mode", HVACMode.OFF) + self._optimistic_set( + "power", False, device_key="power", device_value=False + ) + self._optimistic_set( + "hvac_mode", + HVACMode.OFF, + device_key="power", + device_value=False, + ) else: mode = _HVAC_TO_MODE.get(hvac_mode) if mode is None: @@ -201,8 +208,15 @@ class DknClimateEntity(DknEntity, ClimateEntity): await self.coordinator.client.async_send_machine_event( installation_id, self._command_mac, "mode", mode ) - self._optimistic_set("power", True) - self._optimistic_set("hvac_mode", hvac_mode) + self._optimistic_set( + "power", True, device_key="power", device_value=True + ) + self._optimistic_set( + "hvac_mode", + hvac_mode, + device_key="mode", + device_value=mode, + ) except Exception as err: # noqa: BLE001 raise HomeAssistantError(f"Failed to set HVAC mode: {err}") from err @@ -241,15 +255,27 @@ class DknClimateEntity(DknEntity, ClimateEntity): await self.coordinator.client.async_send_machine_event( installation_id, self._command_mac, "mode", requested_mode_code ) - self._optimistic_set("power", True) - self._optimistic_set("hvac_mode", target_mode) + self._optimistic_set( + "power", True, device_key="power", device_value=True + ) + self._optimistic_set( + "hvac_mode", + target_mode, + device_key="mode", + device_value=requested_mode_code, + ) await self.coordinator.client.async_send_machine_event( installation_id, self._command_mac, property_name, device_temp ) except Exception as err: # noqa: BLE001 raise HomeAssistantError(f"Failed to set temperature: {err}") from err - self._optimistic_set("target_temp", float(temperature)) + self._optimistic_set( + "target_temp", + float(temperature), + device_key=property_name, + device_value=device_temp, + ) self._schedule_refresh() self.async_write_ha_state() @@ -269,7 +295,9 @@ class DknClimateEntity(DknEntity, ClimateEntity): except Exception as err: # noqa: BLE001 raise HomeAssistantError(f"Failed to set fan mode: {err}") from err - self._optimistic_set("fan_mode", fan_mode) + self._optimistic_set( + "fan_mode", fan_mode, device_key="speed_state", device_value=speed + ) self._schedule_refresh() self.async_write_ha_state() @@ -289,7 +317,9 @@ class DknClimateEntity(DknEntity, ClimateEntity): except Exception as err: # noqa: BLE001 raise HomeAssistantError(f"Failed to set swing mode: {err}") from err - self._optimistic_set("swing_mode", swing_mode) + self._optimistic_set( + "swing_mode", swing_mode, device_key="slats_vertical_1", device_value=slat + ) self._schedule_refresh() self.async_write_ha_state() diff --git a/custom_components/dkncloudna/const.py b/custom_components/dkncloudna/const.py index 82eeee5..8b0e04f 100644 --- a/custom_components/dkncloudna/const.py +++ b/custom_components/dkncloudna/const.py @@ -39,9 +39,11 @@ DEFAULT_SCAN_INTERVAL = 60 # seconds MIN_SCAN_INTERVAL = 30 MAX_SCAN_INTERVAL = 300 -# Optimistic overlay: how long to hold a locally-set value before trusting -# the next coordinator refresh. Must exceed the write→cloud→poll round-trip. -OPTIMISTIC_TTL_SEC: float = 2.5 +# Optimistic overlay: safety bound for cloud-propagation lag. Overlays +# normally clear earlier via reconciliation once the device echoes the +# requested value; this TTL guarantees the UI cannot get stuck on a +# locally-set value indefinitely if the write was silently rejected. +OPTIMISTIC_TTL_SEC: float = 30.0 # Post-write coordinator refresh: coalesced delay after a device command. POST_WRITE_REFRESH_DELAY_SEC: float = 1.0 diff --git a/custom_components/dkncloudna/entity.py b/custom_components/dkncloudna/entity.py index d60a1b6..0038ec4 100644 --- a/custom_components/dkncloudna/entity.py +++ b/custom_components/dkncloudna/entity.py @@ -77,8 +77,20 @@ class DknEntity(CoordinatorEntity[DknCoordinator]): # Optimistic overlays # ------------------------------------------------------------------ - def _optimistic_set(self, key: str, value: Any) -> None: - """Store a locally-set value with a TTL timestamp.""" + def _optimistic_set( + self, + key: str, + value: Any, + *, + device_key: str | None = None, + device_value: Any = None, + ) -> None: + """Store a locally-set value with a TTL timestamp. + + When ``device_key`` is provided, ``_reconcile_optimistic`` will clear + the overlay as soon as the device data reports ``device_value`` for + that key — i.e. as soon as the cloud confirms the write. + """ bucket = self.hass.data.setdefault(DOMAIN, {}).setdefault( self.coordinator.entry_id, {} ) @@ -87,6 +99,8 @@ class DknEntity(CoordinatorEntity[DknCoordinator]): device_overlays[key] = { "value": value, "expires": time.monotonic() + OPTIMISTIC_TTL_SEC, + "device_key": device_key, + "device_value": device_value, } def _optimistic_get(self, key: str, fallback: Any) -> Any: @@ -104,6 +118,25 @@ class DknEntity(CoordinatorEntity[DknCoordinator]): overlays = bucket.get("optimistic", {}).get(self._mac, {}) overlays.pop(key, None) + def _reconcile_optimistic(self) -> None: + """Clear overlays whose tracked device key now reports the expected value.""" + bucket = self.hass.data.get(DOMAIN, {}).get(self.coordinator.entry_id, {}) + overlays = bucket.get("optimistic", {}).get(self._mac) + if not overlays: + return + device = self._device_data + for overlay_key in list(overlays): + entry = overlays[overlay_key] + device_key = entry.get("device_key") + if device_key is None: + continue + if device.get(device_key) == entry["device_value"]: + overlays.pop(overlay_key, None) + + def _handle_coordinator_update(self) -> None: + self._reconcile_optimistic() + super()._handle_coordinator_update() + # ------------------------------------------------------------------ # Post-write coordinator refresh (coalesced) # ------------------------------------------------------------------ @@ -125,22 +158,3 @@ class DknEntity(CoordinatorEntity[DknCoordinator]): await self.coordinator.async_request_refresh() bucket["pending_refresh"] = self.hass.async_create_task(_do_refresh()) - - async def _wait_for_device_value( - self, - key: str, - expected: Any, - timeout: float = 10.0, - interval: float = 0.5, - ) -> bool: - """Wait for a device property to match the expected value.""" - deadline = time.monotonic() + timeout - next_refresh = time.monotonic() + 2.0 - while time.monotonic() < deadline: - if self._device_data.get(key) == expected: - return True - if time.monotonic() >= next_refresh: - await self.coordinator.async_request_refresh() - next_refresh = time.monotonic() + 2.0 - await asyncio.sleep(interval) - return self._device_data.get(key) == expected