Dynamic fan speeds

This commit is contained in:
Sebastien Lavoie
2026-03-30 20:35:13 -04:00
parent 0dab86d205
commit 04e1d488f9
5 changed files with 255 additions and 12 deletions
+32
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
from collections import deque
from collections.abc import Awaitable, Callable
from typing import Any
@@ -62,6 +63,8 @@ class DknCloudNaClient:
Callable[[str, dict[str, Any]], Awaitable[None]] | None
) = None
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)
def clear_password(self) -> None:
"""Discard password from memory after token exchange."""
@@ -241,10 +244,27 @@ class DknCloudNaClient:
namespace=namespace,
timeout=REQUEST_TIMEOUT,
)
self._last_command_ack = {
"namespace": namespace,
"payload": payload,
"ack": ack,
}
LOGGER.debug("DKN socket ack %s %s", namespace, ack)
except Exception as err: # noqa: BLE001
raise DknConnectionError(str(err) or type(err).__name__) from err
def pop_last_command_debug(self) -> dict[str, Any] | None:
"""Return and clear the latest command ack plus recent socket events."""
if self._last_command_ack is None and not self._recent_socket_events:
return None
debug = {
"last_command_ack": self._last_command_ack,
"recent_socket_events": list(self._recent_socket_events),
}
self._last_command_ack = None
self._recent_socket_events.clear()
return debug
async def _disconnect_socket_locked(self) -> None:
"""Disconnect the Socket.IO client while holding the socket lock."""
socket = self._socket
@@ -281,14 +301,19 @@ class DknCloudNaClient:
@sio.on("control-new-device", namespace=API_USERS_NAMESPACE)
async def _on_new_device(_: Any) -> None:
self._record_socket_event(API_USERS_NAMESPACE, "control-new-device", _)
await self._request_socket_refresh()
@sio.on("control-deleted-device", namespace=API_USERS_NAMESPACE)
async def _on_deleted_device(_: Any) -> None:
self._record_socket_event(API_USERS_NAMESPACE, "control-deleted-device", _)
await self._request_socket_refresh()
@sio.on("control-deleted-installation", namespace=API_USERS_NAMESPACE)
async def _on_deleted_installation(_: Any) -> None:
self._record_socket_event(
API_USERS_NAMESPACE, "control-deleted-installation", _
)
await self._request_socket_refresh()
for installation_id in installation_ids:
@@ -298,6 +323,7 @@ class DknCloudNaClient:
async def _on_device_data(
message: Any, *, _namespace: str = namespace
) -> None:
self._record_socket_event(_namespace, "device-data", message)
if not isinstance(message, dict):
return
mac = str(message.get("mac") or "").strip().lower()
@@ -326,6 +352,12 @@ class DknCloudNaClient:
"""Return the Socket.IO namespace for one installation."""
return f"/{installation_id}::dknUsa"
def _record_socket_event(self, namespace: str, event: str, payload: Any) -> None:
"""Track recent socket events for write debugging."""
self._recent_socket_events.append(
{"namespace": namespace, "event": event, "payload": payload}
)
async def _request(
self,
method: str,
+68 -11
View File
@@ -33,6 +33,7 @@ from .const import (
from .model import (
available_fan_speeds,
current_temperature as model_current_temperature,
fan_mode_labels,
inferred_hvac_action,
requested_mode,
supports_swing,
@@ -52,7 +53,6 @@ _MODE_TO_HVAC: dict[int, HVACMode] = {
}
_HVAC_TO_MODE: dict[HVACMode, int] = {v: k for k, v in _MODE_TO_HVAC.items()}
_FAN_MODES = ["auto", "20%", "40%", "60%", "80%", "100%"]
_SPEED_TO_FAN: dict[int, str] = {
SPEED_AUTO: "auto",
SPEED_20: "20%",
@@ -92,7 +92,6 @@ class DknClimateEntity(DknEntity, ClimateEntity):
HVACMode.DRY,
HVACMode.FAN_ONLY,
]
_attr_fan_modes = _FAN_MODES
_attr_swing_modes = ["off", "swing"]
_attr_min_temp = 16
_attr_max_temp = 32
@@ -114,6 +113,11 @@ class DknClimateEntity(DknEntity, ClimateEntity):
features |= ClimateEntityFeature.SWING_MODE
return features
@property
def fan_modes(self) -> list[str] | None:
labels = fan_mode_labels(self._device_data)
return labels or None
@property
def hvac_mode(self) -> HVACMode:
data = self._device_data
@@ -186,16 +190,10 @@ class DknClimateEntity(DknEntity, ClimateEntity):
)
self._optimistic_set("power", False)
self._optimistic_set("hvac_mode", HVACMode.OFF)
await self._wait_for_device_value("power", False)
else:
mode = _HVAC_TO_MODE.get(hvac_mode)
if mode is None:
raise HomeAssistantError(f"Unsupported HVAC mode: {hvac_mode}")
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", mode
)
await self._ensure_power_on()
await self._ensure_mode_synced(hvac_mode)
self._optimistic_set("power", True)
self._optimistic_set("hvac_mode", hvac_mode)
except Exception as err: # noqa: BLE001
@@ -225,9 +223,23 @@ class DknClimateEntity(DknEntity, ClimateEntity):
async with self._get_device_lock():
try:
await self._ensure_power_on()
await self._ensure_mode_synced(target_mode)
await self.coordinator.client.async_send_machine_event(
installation_id, self._command_mac, property_name, device_temp
)
if not await self._wait_for_device_value(property_name, device_temp):
await self.coordinator.async_request_refresh()
await self._ensure_mode_synced(target_mode)
await self.coordinator.client.async_send_machine_event(
installation_id, self._command_mac, property_name, device_temp
)
if not await self._wait_for_device_value(
property_name, device_temp
):
raise HomeAssistantError(
f"Device temperature did not sync to {temperature}"
)
except Exception as err: # noqa: BLE001
raise HomeAssistantError(f"Failed to set temperature: {err}") from err
@@ -245,6 +257,7 @@ class DknClimateEntity(DknEntity, ClimateEntity):
installation_id = self._installation_id
async with self._get_device_lock():
try:
await self._ensure_power_on()
await self.coordinator.client.async_send_machine_event(
installation_id, self._command_mac, "speed_state", speed
)
@@ -265,6 +278,7 @@ class DknClimateEntity(DknEntity, ClimateEntity):
slat = 9 if swing_mode == "swing" else 0
async with self._get_device_lock():
try:
await self._ensure_power_on()
await self.coordinator.client.async_send_machine_event(
installation_id, self._command_mac, "slats_vertical_1", slat
)
@@ -307,3 +321,46 @@ class DknClimateEntity(DknEntity, ClimateEntity):
def _to_device_temperature(self, temperature_c: float) -> float | int:
return to_device_temperature(temperature_c, self._device_data.get("units"))
async def _ensure_mode_synced(self, hvac_mode: HVACMode) -> int:
"""Ensure the device mode is synced before dependent writes."""
mode = _HVAC_TO_MODE.get(hvac_mode)
if mode is None:
raise HomeAssistantError(f"Unsupported HVAC mode: {hvac_mode}")
await self._ensure_power_on()
if self._device_data.get("mode") == mode:
return mode
installation_id = self._installation_id
await self.coordinator.client.async_send_machine_event(
installation_id, self._command_mac, "mode", mode
)
self._optimistic_set("hvac_mode", hvac_mode)
if not await self._wait_for_device_value("mode", mode):
await self.coordinator.async_request_refresh()
await self.coordinator.client.async_send_machine_event(
installation_id, self._command_mac, "mode", mode
)
if not await self._wait_for_device_value("mode", mode):
raise HomeAssistantError(f"Device mode did not sync to {hvac_mode}")
return mode
async def _ensure_power_on(self) -> None:
"""Ensure the device is powered on before sending dependent commands."""
if self._device_data.get("power") is True:
return
installation_id = self._installation_id
await self.coordinator.client.async_send_machine_event(
installation_id, self._command_mac, "power", True
)
self._optimistic_set("power", True)
if not await self._wait_for_device_value("power", True):
await self.coordinator.async_request_refresh()
await self.coordinator.client.async_send_machine_event(
installation_id, self._command_mac, "power", True
)
if not await self._wait_for_device_value("power", True):
raise HomeAssistantError("Device power did not sync to on")
+19
View File
@@ -125,3 +125,22 @@ 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
+18
View File
@@ -132,6 +132,24 @@ def available_fan_speeds(data: dict[str, Any]) -> list[int]:
return [SPEED_AUTO, SPEED_20, SPEED_40, SPEED_60, SPEED_80, SPEED_100]
def fan_mode_labels(data: dict[str, Any]) -> list[str]:
"""Return the supported HA fan mode labels for the device."""
mapping = {
SPEED_AUTO: "auto",
SPEED_20: "20%",
SPEED_40: "40%",
SPEED_60: "60%",
SPEED_80: "80%",
SPEED_100: "100%",
}
labels: list[str] = []
for speed in available_fan_speeds(data):
label = mapping.get(speed)
if label is not None:
labels.append(label)
return labels
def supports_swing(data: dict[str, Any]) -> bool:
"""Return whether the device appears to support vertical swing control."""
return "slats_vertical_1" in data or as_int(data.get("slats_vnum")) not in (None, 0)