fix(climate): write mode-correct setpoints and stop REST reverting live state #5

Merged
thatguygriff merged 1 commits from fix/setpoint-key-and-stale-rest-state into main 2026-09-19 12:53:51 +00:00
7 changed files with 145 additions and 42 deletions
Showing only changes of commit 3929546ea5 - Show all commits
+5
View File
@@ -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:
+35 -29
View File
@@ -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"))
+6
View File
@@ -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
@@ -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)
+33
View File
@@ -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)
# ------------------------------------------------------------------
+1 -1
View File
@@ -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"
}
+41 -12
View File
@@ -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: