diff --git a/custom_components/dkncloudna/api.py b/custom_components/dkncloudna/api.py index a3b9b52..ab56c8e 100644 --- a/custom_components/dkncloudna/api.py +++ b/custom_components/dkncloudna/api.py @@ -65,6 +65,10 @@ class DknCloudNaClient: self._socket_refresh_callback: Callable[[], Awaitable[None]] | None = None self._last_command_ack: dict[str, Any] | None = None self._recent_socket_events: deque[dict[str, Any]] = deque(maxlen=20) + # Incremented on every successful Socket.IO connection so consumers can + # tell that live state accumulated from the previous session is no + # longer trustworthy and must be re-synced from REST. + self.socket_session = 0 def clear_password(self) -> None: """Discard password from memory after token exchange.""" @@ -194,6 +198,7 @@ class DknCloudNaClient: self._socket = sio self._socket_installations = installation_ids self._socket_token = self.token + self.socket_session += 1 return True async def _ensure_socket_ready_for_write_locked(self, installation_id: str) -> None: diff --git a/custom_components/dkncloudna/climate.py b/custom_components/dkncloudna/climate.py index efa1bac..fd3a705 100644 --- a/custom_components/dkncloudna/climate.py +++ b/custom_components/dkncloudna/climate.py @@ -133,10 +133,16 @@ class DknClimateEntity(DknEntity, ClimateEntity): @property def hvac_action(self) -> HVACAction | None: - if self.hvac_mode == HVACMode.OFF: + mode = self.hvac_mode + if mode == HVACMode.OFF: return HVACAction.OFF - action = inferred_hvac_action(self._device_data) + action = inferred_hvac_action( + self._device_data, + mode=_HVAC_TO_MODE.get(mode), + target=self.target_temperature, + power=True, + ) if action == "heating": return HVACAction.HEATING if action == "cooling": @@ -158,7 +164,12 @@ class DknClimateEntity(DknEntity, ClimateEntity): mode = self.hvac_mode if mode in _NO_TARGET_TEMP_MODES: return None - fallback = model_target_temperature(self._device_data) + # Read the setpoint for the effective mode so the value shown matches + # the key a write would target, even while a mode change is still + # propagating through the cloud. + fallback = model_target_temperature(self._device_data, _HVAC_TO_MODE.get(mode)) + if fallback is None: + fallback = model_target_temperature(self._device_data) if fallback is None: return None return self._optimistic_get("target_temp", fallback) @@ -239,31 +250,22 @@ class DknClimateEntity(DknEntity, ClimateEntity): ) installation_id = self._installation_id - property_name = self._writable_temperature_property_for_mode(target_mode) - device_temp = self._to_device_temperature(float(temperature)) + requested_mode_code = _HVAC_TO_MODE.get(target_mode) async with self._get_device_lock(): + # A mode change made moments ago (either above, or by a separate + # set_hvac_mode call from the UI) may not have landed in the cloud + # yet. Give it a bounded window to be echoed back before writing the + # setpoint, so the setpoint is not applied against the old mode. + if ( + requested_mode_code is not None + and self._device_data.get("mode") != requested_mode_code + ): + await self._async_wait_for_device_value("mode", requested_mode_code) + + property_name = self._writable_temperature_property_for_mode(target_mode) + device_temp = self._to_device_temperature(float(temperature)) try: - requested_mode_code = _HVAC_TO_MODE.get(target_mode) - if ( - requested_mode_code is not None - and self._device_data.get("mode") != requested_mode_code - ): - await self.coordinator.client.async_send_machine_event( - installation_id, self._command_mac, "power", True - ) - await self.coordinator.client.async_send_machine_event( - installation_id, self._command_mac, "mode", requested_mode_code - ) - 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 ) @@ -354,10 +356,14 @@ class DknClimateEntity(DknEntity, ClimateEntity): return key def _writable_temperature_property_for_mode(self, hvac_mode: HVACMode) -> str: - preferred = writable_target_temperature_key(self._device_data) - if preferred is not None: - return preferred - return self._temperature_property_for_mode(hvac_mode) + # The key must follow the mode being requested, not whatever mode the + # cached device payload still reports; that payload lags behind a mode + # change and would send e.g. a heat setpoint to setpoint_air_cool. + requested = self._temperature_property_for_mode(hvac_mode) + preferred = writable_target_temperature_key( + self._device_data, _HVAC_TO_MODE.get(hvac_mode) + ) + return preferred or requested def _to_device_temperature(self, temperature_c: float) -> float | int: return to_device_temperature(temperature_c, self._device_data.get("units")) diff --git a/custom_components/dkncloudna/const.py b/custom_components/dkncloudna/const.py index 8b0e04f..9971075 100644 --- a/custom_components/dkncloudna/const.py +++ b/custom_components/dkncloudna/const.py @@ -48,6 +48,12 @@ OPTIMISTIC_TTL_SEC: float = 30.0 # Post-write coordinator refresh: coalesced delay after a device command. POST_WRITE_REFRESH_DELAY_SEC: float = 1.0 +# Bounded wait for the cloud to echo a dependent write (e.g. confirming a mode +# change before the matching setpoint is sent) and the poll step used while +# waiting. +DEVICE_ECHO_TIMEOUT_SEC: float = 8.0 +DEVICE_ECHO_POLL_SEC: float = 0.25 + # Device modes (from homebridge plugin src/types.ts) # DeviceMode: 1=Auto, 2=Cool, 3=Heat, 4=Fan, 5=Dry DEVICE_MODE_AUTO = 1 diff --git a/custom_components/dkncloudna/coordinator.py b/custom_components/dkncloudna/coordinator.py index f1afa6c..3944745 100644 --- a/custom_components/dkncloudna/coordinator.py +++ b/custom_components/dkncloudna/coordinator.py @@ -44,6 +44,11 @@ class DknCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): self.client = client self._entry = entry self.entry_id = entry.entry_id + # Values pushed over Socket.IO during the current socket session, keyed + # by mac. The REST /installations snapshot can lag behind reality, so + # these take precedence over it until the socket reconnects. + self._live_state: dict[str, dict[str, Any]] = {} + self._live_state_session: int = 0 async def _async_update_data(self) -> dict[str, dict[str, Any]]: """Fetch all installations and flatten into {mac: device_dict}.""" @@ -65,6 +70,7 @@ class DknCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): raise UpdateFailed(f"Unexpected error: {type(err).__name__}") from err self._persist_tokens_if_changed() + self._discard_stale_live_state() devices: dict[str, dict[str, Any]] = {} existing = self.data or {} @@ -77,11 +83,27 @@ class DknCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): devices[mac] = { **existing.get(mac, {}), **device, + # Socket.IO pushes are the live source of truth; the REST + # snapshot is frequently a few minutes behind and would + # otherwise revert state the socket already reported. + **self._live_state.get(mac, {}), "_installation_id": inst_id, } return devices + def _discard_stale_live_state(self) -> None: + """Drop accumulated live state when the socket session has changed. + + A new socket session means updates may have been missed while it was + down, so the REST snapshot becomes authoritative again until fresh + pushes arrive. + """ + session = self.client.socket_session + if session != self._live_state_session: + self._live_state_session = session + self._live_state = {} + def _persist_tokens_if_changed(self) -> None: """Save refreshed tokens back to the config entry so they survive restarts.""" opts = self._entry.options @@ -105,6 +127,8 @@ class DknCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): self, mac: str, data: dict[str, Any] ) -> None: """Merge live device-data from Socket.IO into coordinator state.""" + self._discard_stale_live_state() + self._live_state[mac] = {**self._live_state.get(mac, {}), **data} current = dict(self.data or {}) current[mac] = {**current.get(mac, {}), **data} self.async_set_updated_data(current) diff --git a/custom_components/dkncloudna/entity.py b/custom_components/dkncloudna/entity.py index 0038ec4..0388b26 100644 --- a/custom_components/dkncloudna/entity.py +++ b/custom_components/dkncloudna/entity.py @@ -10,6 +10,8 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( + DEVICE_ECHO_POLL_SEC, + DEVICE_ECHO_TIMEOUT_SEC, DOMAIN, MANUFACTURER, OPTIMISTIC_TTL_SEC, @@ -137,6 +139,37 @@ class DknEntity(CoordinatorEntity[DknCoordinator]): self._reconcile_optimistic() super()._handle_coordinator_update() + # ------------------------------------------------------------------ + # Waiting for the cloud to echo a write + # ------------------------------------------------------------------ + + async def _async_wait_for_device_value( + self, + device_key: str, + expected: Any, + timeout: float = DEVICE_ECHO_TIMEOUT_SEC, + ) -> bool: + """Wait until the device reports ``expected`` for ``device_key``. + + Socket.IO pushes land in coordinator data, so polling it is enough. A + single mid-window refresh covers the case where no push arrives. + Returns whether the value was observed before the timeout. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + refreshed = False + while True: + if self._device_data.get(device_key) == expected: + return True + remaining = deadline - loop.time() + if remaining <= 0: + return False + if not refreshed and remaining < timeout / 2: + refreshed = True + await self.coordinator.async_request_refresh() + continue + await asyncio.sleep(min(DEVICE_ECHO_POLL_SEC, remaining)) + # ------------------------------------------------------------------ # Post-write coordinator refresh (coalesced) # ------------------------------------------------------------------ diff --git a/custom_components/dkncloudna/manifest.json b/custom_components/dkncloudna/manifest.json index 6597ae5..2784fb7 100644 --- a/custom_components/dkncloudna/manifest.json +++ b/custom_components/dkncloudna/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/lavoiesl/homeassistant-dkncloudna/issues", "requirements": ["python-socketio>=4.6.1,<5"], - "version": "0.2.0" + "version": "0.2.1" } diff --git a/custom_components/dkncloudna/model.py b/custom_components/dkncloudna/model.py index 1a40ad4..f3a8861 100644 --- a/custom_components/dkncloudna/model.py +++ b/custom_components/dkncloudna/model.py @@ -84,19 +84,35 @@ def target_temperature_key(mode: int | None) -> str | None: return None -def target_temperature(data: dict[str, Any]) -> float | None: - """Return the requested target temperature in Celsius.""" - key = target_temperature_key(requested_mode(data)) +def target_temperature(data: dict[str, Any], mode: int | None = None) -> float | None: + """Return the target temperature in Celsius for ``mode``. + + ``mode`` defaults to the device's requested mode. Callers that already know + the effective mode (for example while an optimistic mode change is still + propagating) should pass it so the read uses the same setpoint key the + write used. + """ + key = target_temperature_key(mode if mode is not None else requested_mode(data)) if key is None: return None return to_celsius(data.get(key), data.get("units")) -def writable_target_temperature_key(data: dict[str, Any]) -> str | None: - """Return the setpoint key most likely to be writable for the current state.""" +def writable_target_temperature_key( + data: dict[str, Any], mode: int | None = None +) -> str | None: + """Return the setpoint key to write for ``mode``. + + ``mode`` is the mode the write is intended for and takes priority over + anything in ``data``, which may still describe the pre-change state while a + mode change propagates through the cloud. The device-reported modes are + only consulted as a fallback for payloads that do not expose the requested + mode's setpoint at all. + """ candidates: list[str] = [] - for mode in (live_mode(data), requested_mode(data), DEVICE_MODE_AUTO): - key = target_temperature_key(mode) + preference = (mode, live_mode(data), requested_mode(data), DEVICE_MODE_AUTO) + for candidate_mode in preference: + key = target_temperature_key(candidate_mode) if key is not None and key not in candidates: candidates.append(key) @@ -107,15 +123,28 @@ def writable_target_temperature_key(data: dict[str, Any]) -> str | None: return candidates[0] if candidates else None -def inferred_hvac_action(data: dict[str, Any]) -> str: - """Infer the active HVAC action from requested mode and temperatures.""" - power = as_bool(data.get("power")) +def inferred_hvac_action( + data: dict[str, Any], + mode: int | None = None, + target: float | None = None, + power: bool | None = None, +) -> str: + """Infer the active HVAC action from the effective mode and temperatures. + + ``mode``, ``target`` and ``power`` let callers supply the effective + (possibly still optimistic) values so the reported action matches the + reported mode. + """ + if power is None: + power = as_bool(data.get("power")) if not power: return "off" - mode = requested_mode(data) + if mode is None: + mode = requested_mode(data) current = current_temperature(data) - target = target_temperature(data) + if target is None: + target = target_temperature(data, mode) if mode == DEVICE_MODE_HEAT: if current is not None and target is not None and current < target: