From 0dab86d2056a90a25d5df8f74bb911ebd2107b83 Mon Sep 17 00:00:00 2001 From: Sebastien Lavoie Date: Mon, 30 Mar 2026 10:07:01 -0400 Subject: [PATCH] Send + ack --- custom_components/dkncloudna/api.py | 8 +++- custom_components/dkncloudna/climate.py | 12 +++--- custom_components/dkncloudna/entity.py | 28 +++++++++---- smoke-test/smoke_test.py | 52 +++++++++++++++++++++---- 4 files changed, 77 insertions(+), 23 deletions(-) diff --git a/custom_components/dkncloudna/api.py b/custom_components/dkncloudna/api.py index a872e91..c5bfc41 100644 --- a/custom_components/dkncloudna/api.py +++ b/custom_components/dkncloudna/api.py @@ -235,7 +235,13 @@ class DknCloudNaClient: payload = {"mac": mac, "property": property_name, "value": value} LOGGER.debug("DKN socket send %s %s", namespace, payload) try: - await socket.emit("create-machine-event", payload, namespace=namespace) + ack = await socket.call( + "create-machine-event", + payload, + namespace=namespace, + timeout=REQUEST_TIMEOUT, + ) + 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 diff --git a/custom_components/dkncloudna/climate.py b/custom_components/dkncloudna/climate.py index 65bb1de..70c854d 100644 --- a/custom_components/dkncloudna/climate.py +++ b/custom_components/dkncloudna/climate.py @@ -182,7 +182,7 @@ class DknClimateEntity(DknEntity, ClimateEntity): try: if hvac_mode == HVACMode.OFF: await self.coordinator.client.async_send_machine_event( - installation_id, self._mac, "power", False + installation_id, self._command_mac, "power", False ) self._optimistic_set("power", False) self._optimistic_set("hvac_mode", HVACMode.OFF) @@ -191,10 +191,10 @@ class DknClimateEntity(DknEntity, ClimateEntity): if mode is None: raise HomeAssistantError(f"Unsupported HVAC mode: {hvac_mode}") await self.coordinator.client.async_send_machine_event( - installation_id, self._mac, "power", True + installation_id, self._command_mac, "power", True ) await self.coordinator.client.async_send_machine_event( - installation_id, self._mac, "mode", mode + installation_id, self._command_mac, "mode", mode ) self._optimistic_set("power", True) self._optimistic_set("hvac_mode", hvac_mode) @@ -226,7 +226,7 @@ class DknClimateEntity(DknEntity, ClimateEntity): async with self._get_device_lock(): try: await self.coordinator.client.async_send_machine_event( - installation_id, self._mac, property_name, device_temp + installation_id, self._command_mac, property_name, device_temp ) except Exception as err: # noqa: BLE001 raise HomeAssistantError(f"Failed to set temperature: {err}") from err @@ -246,7 +246,7 @@ class DknClimateEntity(DknEntity, ClimateEntity): async with self._get_device_lock(): try: await self.coordinator.client.async_send_machine_event( - installation_id, self._mac, "speed_state", speed + installation_id, self._command_mac, "speed_state", speed ) except Exception as err: # noqa: BLE001 raise HomeAssistantError(f"Failed to set fan mode: {err}") from err @@ -266,7 +266,7 @@ class DknClimateEntity(DknEntity, ClimateEntity): async with self._get_device_lock(): try: await self.coordinator.client.async_send_machine_event( - installation_id, self._mac, "slats_vertical_1", slat + installation_id, self._command_mac, "slats_vertical_1", slat ) except Exception as err: # noqa: BLE001 raise HomeAssistantError(f"Failed to set swing mode: {err}") from err diff --git a/custom_components/dkncloudna/entity.py b/custom_components/dkncloudna/entity.py index ca7fd92..b645aea 100644 --- a/custom_components/dkncloudna/entity.py +++ b/custom_components/dkncloudna/entity.py @@ -9,7 +9,12 @@ from typing import Any from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, MANUFACTURER, OPTIMISTIC_TTL_SEC, POST_WRITE_REFRESH_DELAY_SEC +from .const import ( + DOMAIN, + MANUFACTURER, + OPTIMISTIC_TTL_SEC, + POST_WRITE_REFRESH_DELAY_SEC, +) from .coordinator import DknCoordinator @@ -35,6 +40,14 @@ class DknEntity(CoordinatorEntity[DknCoordinator]): """Return raw device dict from coordinator, or empty dict if unavailable.""" return (self.coordinator.data or {}).get(self._mac, {}) + @property + def _command_mac(self) -> str: + """Return the device MAC in the form expected by the cloud API.""" + mac = self._device_data.get("mac") + if isinstance(mac, str) and mac.strip(): + return mac.strip() + return self._mac.upper() + @property def device_info(self) -> DeviceInfo: data = self._device_data @@ -71,13 +84,14 @@ class DknEntity(CoordinatorEntity[DknCoordinator]): ) overlays: dict[str, dict[str, Any]] = bucket.setdefault("optimistic", {}) device_overlays = overlays.setdefault(self._mac, {}) - device_overlays[key] = {"value": value, "expires": time.monotonic() + OPTIMISTIC_TTL_SEC} + device_overlays[key] = { + "value": value, + "expires": time.monotonic() + OPTIMISTIC_TTL_SEC, + } def _optimistic_get(self, key: str, fallback: Any) -> Any: """Return the optimistic value if still fresh, else fallback.""" - bucket = self.hass.data.get(DOMAIN, {}).get( - self.coordinator.entry_id, {} - ) + bucket = self.hass.data.get(DOMAIN, {}).get(self.coordinator.entry_id, {}) overlays = bucket.get("optimistic", {}).get(self._mac, {}) entry = overlays.get(key) if entry and time.monotonic() < entry["expires"]: @@ -86,9 +100,7 @@ class DknEntity(CoordinatorEntity[DknCoordinator]): def _optimistic_clear(self, key: str) -> None: """Expire an optimistic overlay immediately.""" - bucket = self.hass.data.get(DOMAIN, {}).get( - self.coordinator.entry_id, {} - ) + bucket = self.hass.data.get(DOMAIN, {}).get(self.coordinator.entry_id, {}) overlays = bucket.get("optimistic", {}).get(self._mac, {}) overlays.pop(key, None) diff --git a/smoke-test/smoke_test.py b/smoke-test/smoke_test.py index 07d0b13..3cffcba 100644 --- a/smoke-test/smoke_test.py +++ b/smoke-test/smoke_test.py @@ -209,6 +209,7 @@ async def _main() -> None: "login": False, "discovery": None, "socket_connect": False, + "write_debug": {}, "writes": {}, } @@ -232,8 +233,11 @@ async def _main() -> None: for device in installation.get("devices", []): device_mac = str(device.get("mac") or "").strip().lower() if device_mac == mac: - current_device = dict(device) - current_device["_installation_id"] = installation_id + current_device = { + **current_device, + **dict(device), + "_installation_id": installation_id, + } return current_device raise RuntimeError("Selected device disappeared during smoke test") @@ -270,6 +274,7 @@ async def _main() -> None: installation_id = str(selected_installation.get("_id") or "") mac = str(selected_device.get("mac") or "").strip().lower() + command_mac = str(selected_device.get("mac") or "").strip() or mac.upper() current_device = dict(selected_device) current_device["_installation_id"] = installation_id @@ -301,6 +306,7 @@ async def _main() -> None: "installation_id": installation_id, "device_name": selected_device.get("name"), "mac": mac, + "command_mac": command_mac, **_effective_state(current_device), } @@ -322,9 +328,24 @@ async def _main() -> None: return True return False + async def collect_socket_deltas(window: float = 5.0) -> list[dict[str, Any]]: + deltas: list[dict[str, Any]] = [] + loop = asyncio.get_running_loop() + deadline = loop.time() + window + while loop.time() < deadline: + remaining = max(0.05, deadline - loop.time()) + try: + payload = await asyncio.wait_for( + queue.get(), timeout=min(0.5, remaining) + ) + except asyncio.TimeoutError: + continue + deltas.append(payload) + return deltas + async def send(property_name: str, value: Any) -> None: await client.async_send_machine_event( - installation_id, mac, property_name, value + installation_id, command_mac, property_name, value ) async def send_and_verify( @@ -332,8 +353,18 @@ async def _main() -> None: ) -> bool | str: if not _supports_write_verification(current_device, property_name): return "unsupported_by_payload" + baseline = current_device.get(property_name) await send(property_name, value) - return await wait_for_property(property_name, value, timeout=timeout) + verified = await wait_for_property(property_name, value, timeout=timeout) + refreshed = await fetch_current_device() + results["write_debug"][property_name] = { + "requested": value, + "before": baseline, + "after": refreshed.get(property_name), + "acknowledged": True, + "socket_deltas": await collect_socket_deltas(), + } + return verified restore_actions: list[tuple[str, Any]] = [] try: @@ -367,13 +398,18 @@ async def _main() -> None: else: original_temp = current_device.get(property_name) low, high = _temp_bounds(current_device, mode) - if original_temp is None or low is None or high is None: - results["writes"]["temperature"] = "skipped_missing_bounds" + if original_temp is None: + results["writes"]["temperature"] = "skipped_missing_setpoint" else: test_temp = original_temp - if original_temp < high: + if low is not None and high is not None: + if original_temp < high: + test_temp = original_temp + 1 + elif original_temp > low: + test_temp = original_temp - 1 + elif original_temp < 31: test_temp = original_temp + 1 - elif original_temp > low: + elif original_temp > 17: test_temp = original_temp - 1 if test_temp == original_temp: results["writes"]["temperature"] = "skipped_no_room"