diff --git a/custom_components/dkncloudna/api.py b/custom_components/dkncloudna/api.py index c5bfc41..3935711 100644 --- a/custom_components/dkncloudna/api.py +++ b/custom_components/dkncloudna/api.py @@ -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, diff --git a/custom_components/dkncloudna/climate.py b/custom_components/dkncloudna/climate.py index 70c854d..b72cce7 100644 --- a/custom_components/dkncloudna/climate.py +++ b/custom_components/dkncloudna/climate.py @@ -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") diff --git a/custom_components/dkncloudna/entity.py b/custom_components/dkncloudna/entity.py index b645aea..d60a1b6 100644 --- a/custom_components/dkncloudna/entity.py +++ b/custom_components/dkncloudna/entity.py @@ -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 diff --git a/custom_components/dkncloudna/model.py b/custom_components/dkncloudna/model.py index a2dabcf..4c36432 100644 --- a/custom_components/dkncloudna/model.py +++ b/custom_components/dkncloudna/model.py @@ -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) diff --git a/smoke-test/smoke_test.py b/smoke-test/smoke_test.py index 3cffcba..50f958f 100644 --- a/smoke-test/smoke_test.py +++ b/smoke-test/smoke_test.py @@ -75,6 +75,17 @@ def _next_fan_speed(current: int) -> int: return speeds[(speeds.index(current) + 1) % len(speeds)] +def _next_available_fan_speed(device: dict[str, Any], current: int) -> int: + available = device.get("speed_available") + if isinstance(available, list): + speeds = [int(value) for value in available if isinstance(value, int)] + if current in speeds and len(speeds) > 1: + return speeds[(speeds.index(current) + 1) % len(speeds)] + if speeds: + return speeds[0] + return _next_fan_speed(current) + + def _bool_or_none(value: Any) -> bool | None: if isinstance(value, bool): return value @@ -208,6 +219,7 @@ async def _main() -> None: results: dict[str, Any] = { "login": False, "discovery": None, + "probes": {}, "socket_connect": False, "write_debug": {}, "writes": {}, @@ -348,6 +360,25 @@ async def _main() -> None: installation_id, command_mac, property_name, value ) + async def ensure_mode(mode_value: int, timeout: float = 20.0) -> bool: + if current_device.get("mode") == mode_value: + return True + await send("mode", mode_value) + return await wait_for_property("mode", mode_value, timeout=timeout) + + async def ensure_power_on(timeout: float = 20.0) -> bool: + if current_device.get("power") is True: + return True + await send("power", True) + return await wait_for_property("power", True, timeout=timeout) + + async def recover_known_good_state() -> dict[str, Any]: + recovery = {"power_on": False, "heat_mode": False} + recovery["power_on"] = await ensure_power_on() + if recovery["power_on"]: + recovery["heat_mode"] = await ensure_mode(3) + return recovery + async def send_and_verify( property_name: str, value: Any, timeout: float = 20.0 ) -> bool | str: @@ -357,17 +388,99 @@ async def _main() -> None: await send(property_name, value) verified = await wait_for_property(property_name, value, timeout=timeout) refreshed = await fetch_current_device() + command_debug = client.pop_last_command_debug() or {} results["write_debug"][property_name] = { "requested": value, "before": baseline, "after": refreshed.get(property_name), "acknowledged": True, "socket_deltas": await collect_socket_deltas(), + **command_debug, } return verified + async def probe_setpoint_keys() -> dict[str, Any]: + mode = int(current_device.get("mode", 1) or 1) + original_values = { + key: current_device.get(key) + for key in ( + "setpoint_air_auto", + "setpoint_air_cool", + "setpoint_air_heat", + ) + } + probe_results: dict[str, Any] = { + "mode": mode, + "original": original_values, + "attempts": {}, + } + + target_key = _temp_property(mode) + if target_key is None or original_values.get(target_key) is None: + probe_results["status"] = "skipped_missing_target" + return probe_results + + original_value = original_values[target_key] + test_value = ( + original_value + 1 if original_value < 31 else original_value - 1 + ) + if test_value == original_value: + probe_results["status"] = "skipped_no_room" + return probe_results + + for key in ("setpoint_air_auto", "setpoint_air_cool", "setpoint_air_heat"): + if current_device.get(key) is None: + probe_results["attempts"][key] = "missing" + continue + await send(key, test_value) + accepted = await wait_for_property(key, test_value, timeout=8.0) + refreshed = await fetch_current_device() + probe_results["attempts"][key] = { + "requested": test_value, + "accepted": accepted, + "after": refreshed.get(key), + "command_debug": client.pop_last_command_debug() or {}, + } + await send(key, original_values[key]) + await wait_for_property(key, original_values[key], timeout=8.0) + + probe_results["status"] = "completed" + return probe_results + + async def probe_mode_transitions() -> dict[str, Any]: + original_mode = int(current_device.get("mode", 1) or 1) + original_power = bool(current_device.get("power", False)) + attempts: dict[str, Any] = {} + + for label, value in (("auto", 1), ("cool", 2), ("heat", 3), ("fan", 4)): + await send("mode", value) + accepted = await wait_for_property("mode", value, timeout=8.0) + refreshed = await fetch_current_device() + attempts[label] = { + "requested": value, + "accepted": accepted, + "after": refreshed.get("mode"), + "power": refreshed.get("power"), + "command_debug": client.pop_last_command_debug() or {}, + } + + await send("mode", original_mode) + await wait_for_property("mode", original_mode, timeout=8.0) + if not original_power: + await send("power", False) + await wait_for_property("power", False, timeout=8.0) + + return { + "original_mode": original_mode, + "attempts": attempts, + } + restore_actions: list[tuple[str, Any]] = [] try: + results["probes"]["recovery"] = await recover_known_good_state() + results["probes"]["setpoint_keys"] = await probe_setpoint_keys() + results["probes"]["mode_transitions"] = await probe_mode_transitions() + await send("power", current_device.get("power", False)) results["writes"]["power_emit"] = "sent_same_value" @@ -382,7 +495,7 @@ async def _main() -> None: restore_actions.append(("slats_vertical_1", original_swing)) original_fan = int(current_device.get("speed_state", 0) or 0) - test_fan = _next_fan_speed(original_fan) + test_fan = _next_available_fan_speed(current_device, original_fan) if test_fan == original_fan: results["writes"]["fan_speed"] = "skipped_no_alternate" else: @@ -414,6 +527,10 @@ async def _main() -> None: if test_temp == original_temp: results["writes"]["temperature"] = "skipped_no_room" else: + mode_ready = await ensure_mode(mode) + results["write_debug"].setdefault(property_name, {})[ + "mode_ready" + ] = mode_ready results["writes"]["temperature"] = await send_and_verify( property_name, test_temp )