fix(climate): keep optimistic state until DKN cloud catches up
The DKN cloud REST endpoint lags behind the unit for several seconds after a write, even though the unit itself and the DKN app reflect the change immediately. The previous 2.5s optimistic-overlay TTL expired well before the cloud caught up, so the next coordinator publish (REST poll or socket device-data push) carried stale values and the HA UI reverted to the previous setting. Extend the overlay TTL to 30s as a safety bound, and track the underlying device key + expected device value alongside each overlay. On every coordinator publish, clear overlays whose device key now reports the expected value (cloud has confirmed). The TTL still caps how long a silently-failed write can hold a wrong value. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -189,8 +189,15 @@ class DknClimateEntity(DknEntity, ClimateEntity):
|
|||||||
await self.coordinator.client.async_send_machine_event(
|
await self.coordinator.client.async_send_machine_event(
|
||||||
installation_id, self._command_mac, "power", False
|
installation_id, self._command_mac, "power", False
|
||||||
)
|
)
|
||||||
self._optimistic_set("power", False)
|
self._optimistic_set(
|
||||||
self._optimistic_set("hvac_mode", HVACMode.OFF)
|
"power", False, device_key="power", device_value=False
|
||||||
|
)
|
||||||
|
self._optimistic_set(
|
||||||
|
"hvac_mode",
|
||||||
|
HVACMode.OFF,
|
||||||
|
device_key="power",
|
||||||
|
device_value=False,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
mode = _HVAC_TO_MODE.get(hvac_mode)
|
mode = _HVAC_TO_MODE.get(hvac_mode)
|
||||||
if mode is None:
|
if mode is None:
|
||||||
@@ -201,8 +208,15 @@ class DknClimateEntity(DknEntity, ClimateEntity):
|
|||||||
await self.coordinator.client.async_send_machine_event(
|
await self.coordinator.client.async_send_machine_event(
|
||||||
installation_id, self._command_mac, "mode", mode
|
installation_id, self._command_mac, "mode", mode
|
||||||
)
|
)
|
||||||
self._optimistic_set("power", True)
|
self._optimistic_set(
|
||||||
self._optimistic_set("hvac_mode", hvac_mode)
|
"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
|
except Exception as err: # noqa: BLE001
|
||||||
raise HomeAssistantError(f"Failed to set HVAC mode: {err}") from err
|
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(
|
await self.coordinator.client.async_send_machine_event(
|
||||||
installation_id, self._command_mac, "mode", requested_mode_code
|
installation_id, self._command_mac, "mode", requested_mode_code
|
||||||
)
|
)
|
||||||
self._optimistic_set("power", True)
|
self._optimistic_set(
|
||||||
self._optimistic_set("hvac_mode", target_mode)
|
"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(
|
await self.coordinator.client.async_send_machine_event(
|
||||||
installation_id, self._command_mac, property_name, device_temp
|
installation_id, self._command_mac, property_name, device_temp
|
||||||
)
|
)
|
||||||
except Exception as err: # noqa: BLE001
|
except Exception as err: # noqa: BLE001
|
||||||
raise HomeAssistantError(f"Failed to set temperature: {err}") from err
|
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._schedule_refresh()
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
@@ -269,7 +295,9 @@ class DknClimateEntity(DknEntity, ClimateEntity):
|
|||||||
except Exception as err: # noqa: BLE001
|
except Exception as err: # noqa: BLE001
|
||||||
raise HomeAssistantError(f"Failed to set fan mode: {err}") from err
|
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._schedule_refresh()
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
@@ -289,7 +317,9 @@ class DknClimateEntity(DknEntity, ClimateEntity):
|
|||||||
except Exception as err: # noqa: BLE001
|
except Exception as err: # noqa: BLE001
|
||||||
raise HomeAssistantError(f"Failed to set swing mode: {err}") from err
|
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._schedule_refresh()
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,11 @@ DEFAULT_SCAN_INTERVAL = 60 # seconds
|
|||||||
MIN_SCAN_INTERVAL = 30
|
MIN_SCAN_INTERVAL = 30
|
||||||
MAX_SCAN_INTERVAL = 300
|
MAX_SCAN_INTERVAL = 300
|
||||||
|
|
||||||
# Optimistic overlay: how long to hold a locally-set value before trusting
|
# Optimistic overlay: safety bound for cloud-propagation lag. Overlays
|
||||||
# the next coordinator refresh. Must exceed the write→cloud→poll round-trip.
|
# normally clear earlier via reconciliation once the device echoes the
|
||||||
OPTIMISTIC_TTL_SEC: float = 2.5
|
# 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 coordinator refresh: coalesced delay after a device command.
|
||||||
POST_WRITE_REFRESH_DELAY_SEC: float = 1.0
|
POST_WRITE_REFRESH_DELAY_SEC: float = 1.0
|
||||||
|
|||||||
@@ -77,8 +77,20 @@ class DknEntity(CoordinatorEntity[DknCoordinator]):
|
|||||||
# Optimistic overlays
|
# Optimistic overlays
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _optimistic_set(self, key: str, value: Any) -> None:
|
def _optimistic_set(
|
||||||
"""Store a locally-set value with a TTL timestamp."""
|
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(
|
bucket = self.hass.data.setdefault(DOMAIN, {}).setdefault(
|
||||||
self.coordinator.entry_id, {}
|
self.coordinator.entry_id, {}
|
||||||
)
|
)
|
||||||
@@ -87,6 +99,8 @@ class DknEntity(CoordinatorEntity[DknCoordinator]):
|
|||||||
device_overlays[key] = {
|
device_overlays[key] = {
|
||||||
"value": value,
|
"value": value,
|
||||||
"expires": time.monotonic() + OPTIMISTIC_TTL_SEC,
|
"expires": time.monotonic() + OPTIMISTIC_TTL_SEC,
|
||||||
|
"device_key": device_key,
|
||||||
|
"device_value": device_value,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _optimistic_get(self, key: str, fallback: Any) -> Any:
|
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 = bucket.get("optimistic", {}).get(self._mac, {})
|
||||||
overlays.pop(key, None)
|
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)
|
# Post-write coordinator refresh (coalesced)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -125,22 +158,3 @@ class DknEntity(CoordinatorEntity[DknCoordinator]):
|
|||||||
await self.coordinator.async_request_refresh()
|
await self.coordinator.async_request_refresh()
|
||||||
|
|
||||||
bucket["pending_refresh"] = self.hass.async_create_task(_do_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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user