From f805015b749a9dfa4488ba8e5ffc89bcca4b47b6 Mon Sep 17 00:00:00 2001 From: Sebastien Lavoie Date: Sun, 29 Mar 2026 09:52:40 -0400 Subject: [PATCH] Implement write actions --- custom_components/dkncloudna/api.py | 25 ++++ custom_components/dkncloudna/climate.py | 191 +++++++++++++++++++----- 2 files changed, 182 insertions(+), 34 deletions(-) diff --git a/custom_components/dkncloudna/api.py b/custom_components/dkncloudna/api.py index 31b63cd..f71016f 100644 --- a/custom_components/dkncloudna/api.py +++ b/custom_components/dkncloudna/api.py @@ -190,6 +190,31 @@ class DknCloudNaClient: async with self._socket_lock: await self._disconnect_socket_locked() + async def async_send_machine_event( + self, + installation_id: str, + mac: str, + property_name: str, + value: Any, + ) -> None: + """Send a device control event over Socket.IO.""" + async with self._socket_lock: + socket = self._socket + namespace = self._installation_namespace(installation_id) + if socket is None or not socket.connected: + raise DknConnectionError("Socket not connected") + if namespace not in socket.namespaces: + raise DknConnectionError( + f"Socket namespace unavailable for installation {installation_id}" + ) + + 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) + except Exception as err: # noqa: BLE001 + raise DknConnectionError(str(err) or type(err).__name__) from err + async def _disconnect_socket_locked(self) -> None: """Disconnect the Socket.IO client while holding the socket lock.""" socket = self._socket diff --git a/custom_components/dkncloudna/climate.py b/custom_components/dkncloudna/climate.py index c8c2ca0..bed0a06 100644 --- a/custom_components/dkncloudna/climate.py +++ b/custom_components/dkncloudna/climate.py @@ -9,10 +9,12 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.const import PRECISION_WHOLE, UnitOfTemperature -from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( DEVICE_MODE_AUTO, @@ -21,18 +23,17 @@ from .const import ( DEVICE_MODE_FAN, DEVICE_MODE_HEAT, DOMAIN, - LOGGER, SPEED_20, SPEED_40, SPEED_60, SPEED_80, SPEED_100, SPEED_AUTO, + TEMP_FAHRENHEIT, ) from .coordinator import DknCoordinator from .entity import DknEntity -# Map DKN device mode integers to HA HVACMode _MODE_TO_HVAC: dict[int, HVACMode] = { DEVICE_MODE_AUTO: HVACMode.AUTO, DEVICE_MODE_COOL: HVACMode.COOL, @@ -42,7 +43,6 @@ _MODE_TO_HVAC: dict[int, HVACMode] = { } _HVAC_TO_MODE: dict[HVACMode, int] = {v: k for k, v in _MODE_TO_HVAC.items()} -# Fan speed labels _FAN_MODES = ["auto", "20%", "40%", "60%", "80%", "100%"] _SPEED_TO_FAN: dict[int, str] = { SPEED_AUTO: "auto", @@ -54,7 +54,6 @@ _SPEED_TO_FAN: dict[int, str] = { } _FAN_TO_SPEED: dict[str, int] = {v: k for k, v in _SPEED_TO_FAN.items()} -# Modes where a temperature target makes no sense _NO_TARGET_TEMP_MODES = {HVACMode.FAN_ONLY, HVACMode.DRY, HVACMode.OFF} @@ -73,7 +72,7 @@ async def async_setup_entry( class DknClimateEntity(DknEntity, ClimateEntity): """Climate entity representing one DKN Cloud NA AC unit.""" - _attr_name = None # entity name = device name + _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_precision = PRECISION_WHOLE _attr_hvac_modes = [ @@ -98,40 +97,51 @@ class DknClimateEntity(DknEntity, ClimateEntity): def supported_features(self) -> ClimateEntityFeature: """Return feature flags appropriate for the current HVAC mode.""" features = ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF - mode = self.hvac_mode - if mode not in _NO_TARGET_TEMP_MODES: + if self.hvac_mode not in _NO_TARGET_TEMP_MODES: features |= ClimateEntityFeature.TARGET_TEMPERATURE - features |= ClimateEntityFeature.FAN_MODE - features |= ClimateEntityFeature.SWING_MODE - return features + return ( + features | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE + ) @property def hvac_mode(self) -> HVACMode: data = self._device_data - if not data.get("power", False): + power = self._optimistic_get("power", data.get("power", False)) + if not power: return HVACMode.OFF + optimistic_mode = self._optimistic_get("hvac_mode", None) + if optimistic_mode is not None: + return optimistic_mode mode_int = data.get("real_mode") or data.get("mode", DEVICE_MODE_AUTO) return _MODE_TO_HVAC.get(int(mode_int), HVACMode.AUTO) @property def current_temperature(self) -> float | None: - return self._device_data.get("work_temp") + value = self._device_data.get("work_temp") + if value is None: + return None + return self._from_device_temperature(float(value)) @property def target_temperature(self) -> float | None: - if self.hvac_mode in _NO_TARGET_TEMP_MODES: + mode = self.hvac_mode + if mode in _NO_TARGET_TEMP_MODES: return None data = self._device_data - mode = data.get("real_mode") or data.get("mode", DEVICE_MODE_AUTO) - if int(mode) == DEVICE_MODE_HEAT: - return self._optimistic_get("target_temp", data.get("setpoint_air_heat")) - if int(mode) == DEVICE_MODE_COOL: - return self._optimistic_get("target_temp", data.get("setpoint_air_cool")) - return self._optimistic_get("target_temp", data.get("setpoint_air_auto")) + if mode == HVACMode.HEAT: + value = data.get("setpoint_air_heat") + elif mode == HVACMode.COOL: + value = data.get("setpoint_air_cool") + else: + value = data.get("setpoint_air_auto") + if value is None: + return None + fallback = self._from_device_temperature(float(value)) + return self._optimistic_get("target_temp", fallback) @property def target_temperature_high(self) -> float | None: - return None # no range mode + return None @property def target_temperature_low(self) -> float | None: @@ -147,28 +157,141 @@ class DknClimateEntity(DknEntity, ClimateEntity): slat = self._device_data.get("slats_vertical_1", 0) return self._optimistic_get("swing_mode", "swing" if int(slat) == 9 else "off") - # ------------------------------------------------------------------ - # Service handlers — all stub (raise NotImplementedError for now) - # ------------------------------------------------------------------ - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - LOGGER.debug("set_hvac_mode(%s) stub for %s", hvac_mode, self._mac) - raise NotImplementedError("Device control not yet implemented") + installation_id = self._installation_id + async with self._get_device_lock(): + try: + if hvac_mode == HVACMode.OFF: + await self.coordinator.client.async_send_machine_event( + installation_id, self._mac, "power", False + ) + self._optimistic_set("power", False) + self._optimistic_set("hvac_mode", HVACMode.OFF) + 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._mac, "power", True + ) + await self.coordinator.client.async_send_machine_event( + installation_id, self._mac, "mode", mode + ) + self._optimistic_set("power", True) + self._optimistic_set("hvac_mode", hvac_mode) + except Exception as err: # noqa: BLE001 + raise HomeAssistantError(f"Failed to set HVAC mode: {err}") from err + + self._schedule_refresh() + self.async_write_ha_state() async def async_set_temperature(self, **kwargs: Any) -> None: - LOGGER.debug("set_temperature(%s) stub for %s", kwargs, self._mac) - raise NotImplementedError("Device control not yet implemented") + hvac_mode = kwargs.get("hvac_mode") + if hvac_mode is not None: + await self.async_set_hvac_mode(hvac_mode) + + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + + target_mode = hvac_mode or self.hvac_mode + if target_mode in _NO_TARGET_TEMP_MODES: + raise HomeAssistantError( + f"Target temperature is not supported in {target_mode} mode" + ) + + installation_id = self._installation_id + property_name = self._temperature_property_for_mode(target_mode) + device_temp = self._to_device_temperature(float(temperature)) + + async with self._get_device_lock(): + try: + await self.coordinator.client.async_send_machine_event( + installation_id, self._mac, property_name, device_temp + ) + except Exception as err: # noqa: BLE001 + raise HomeAssistantError(f"Failed to set temperature: {err}") from err + + self._optimistic_set("target_temp", float(temperature)) + self._schedule_refresh() + self.async_write_ha_state() async def async_set_fan_mode(self, fan_mode: str) -> None: - LOGGER.debug("set_fan_mode(%s) stub for %s", fan_mode, self._mac) - raise NotImplementedError("Device control not yet implemented") + speed = _FAN_TO_SPEED.get(fan_mode) + if speed is None: + raise HomeAssistantError(f"Unsupported fan mode: {fan_mode}") + + installation_id = self._installation_id + async with self._get_device_lock(): + try: + await self.coordinator.client.async_send_machine_event( + installation_id, self._mac, "speed_state", speed + ) + except Exception as err: # noqa: BLE001 + raise HomeAssistantError(f"Failed to set fan mode: {err}") from err + + self._optimistic_set("fan_mode", fan_mode) + self._schedule_refresh() + self.async_write_ha_state() async def async_set_swing_mode(self, swing_mode: str) -> None: - LOGGER.debug("set_swing_mode(%s) stub for %s", swing_mode, self._mac) - raise NotImplementedError("Device control not yet implemented") + if swing_mode not in {"off", "swing"}: + raise HomeAssistantError(f"Unsupported swing mode: {swing_mode}") + + installation_id = self._installation_id + slat = 9 if swing_mode == "swing" else 0 + async with self._get_device_lock(): + try: + await self.coordinator.client.async_send_machine_event( + installation_id, self._mac, "slats_vertical_1", slat + ) + except Exception as err: # noqa: BLE001 + raise HomeAssistantError(f"Failed to set swing mode: {err}") from err + + self._optimistic_set("swing_mode", swing_mode) + self._schedule_refresh() + self.async_write_ha_state() async def async_turn_on(self) -> None: await self.async_set_hvac_mode(HVACMode.AUTO) async def async_turn_off(self) -> None: await self.async_set_hvac_mode(HVACMode.OFF) + + @property + def _installation_id(self) -> str: + installation_id = str(self._device_data.get("_installation_id") or "").strip() + if not installation_id: + raise HomeAssistantError("Missing installation id for device") + return installation_id + + def _temperature_property_for_mode(self, hvac_mode: HVACMode) -> str: + if hvac_mode == HVACMode.HEAT: + return "setpoint_air_heat" + if hvac_mode == HVACMode.COOL: + return "setpoint_air_cool" + if hvac_mode not in {HVACMode.AUTO, HVACMode.HEAT, HVACMode.COOL}: + raise HomeAssistantError( + f"Target temperature is not supported in {hvac_mode} mode" + ) + return "setpoint_air_auto" + + def _to_device_temperature(self, temperature_c: float) -> float | int: + if int(self._device_data.get("units", 0)) != TEMP_FAHRENHEIT: + return temperature_c + fahrenheit = TemperatureConverter.convert( + temperature_c, + UnitOfTemperature.CELSIUS, + UnitOfTemperature.FAHRENHEIT, + ) + return round(fahrenheit) + + def _from_device_temperature(self, value: float) -> float: + if int(self._device_data.get("units", 0)) != TEMP_FAHRENHEIT: + return value + celsius = TemperatureConverter.convert( + value, + UnitOfTemperature.FAHRENHEIT, + UnitOfTemperature.CELSIUS, + ) + return round(celsius, 1)