From 9582cb066549a800dc9ce8f30bc3f7bafee54220 Mon Sep 17 00:00:00 2001 From: Sebastien Lavoie Date: Mon, 30 Mar 2026 08:43:37 -0400 Subject: [PATCH] Fix incorrect data --- custom_components/dkncloudna/climate.py | 96 +++++++++-------- custom_components/dkncloudna/model.py | 137 ++++++++++++++++++++++++ custom_components/dkncloudna/sensor.py | 6 ++ smoke-test/smoke_test.py | 95 ++++++++++++---- 4 files changed, 272 insertions(+), 62 deletions(-) create mode 100644 custom_components/dkncloudna/model.py diff --git a/custom_components/dkncloudna/climate.py b/custom_components/dkncloudna/climate.py index bed0a06..65bb1de 100644 --- a/custom_components/dkncloudna/climate.py +++ b/custom_components/dkncloudna/climate.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Any from homeassistant.components.climate import ( + HVACAction, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -14,7 +15,6 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTempera 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, @@ -29,7 +29,16 @@ from .const import ( SPEED_80, SPEED_100, SPEED_AUTO, - TEMP_FAHRENHEIT, +) +from .model import ( + available_fan_speeds, + current_temperature as model_current_temperature, + inferred_hvac_action, + requested_mode, + supports_swing, + target_temperature as model_target_temperature, + target_temperature_key, + to_device_temperature, ) from .coordinator import DknCoordinator from .entity import DknEntity @@ -99,9 +108,11 @@ class DknClimateEntity(DknEntity, ClimateEntity): features = ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF if self.hvac_mode not in _NO_TARGET_TEMP_MODES: features |= ClimateEntityFeature.TARGET_TEMPERATURE - return ( - features | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE - ) + if available_fan_speeds(self._device_data): + features |= ClimateEntityFeature.FAN_MODE + if supports_swing(self._device_data): + features |= ClimateEntityFeature.SWING_MODE + return features @property def hvac_mode(self) -> HVACMode: @@ -112,31 +123,39 @@ class DknClimateEntity(DknEntity, ClimateEntity): 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) + mode_int = requested_mode(data) or DEVICE_MODE_AUTO return _MODE_TO_HVAC.get(int(mode_int), HVACMode.AUTO) + @property + def hvac_action(self) -> HVACAction | None: + if self.hvac_mode == HVACMode.OFF: + return HVACAction.OFF + + action = inferred_hvac_action(self._device_data) + if action == "heating": + return HVACAction.HEATING + if action == "cooling": + return HVACAction.COOLING + if action == "idle": + return HVACAction.IDLE + if action == "fan": + return HVACAction.FAN + if action == "drying": + return HVACAction.DRYING + return None + @property def current_temperature(self) -> float | None: - value = self._device_data.get("work_temp") - if value is None: - return None - return self._from_device_temperature(float(value)) + return model_current_temperature(self._device_data) @property def target_temperature(self) -> float | None: mode = self.hvac_mode if mode in _NO_TARGET_TEMP_MODES: return None - data = self._device_data - 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: + fallback = model_target_temperature(self._device_data) + if fallback is None: return None - fallback = self._from_device_temperature(float(value)) return self._optimistic_get("target_temp", fallback) @property @@ -220,6 +239,8 @@ class DknClimateEntity(DknEntity, ClimateEntity): speed = _FAN_TO_SPEED.get(fan_mode) if speed is None: raise HomeAssistantError(f"Unsupported fan mode: {fan_mode}") + if speed not in available_fan_speeds(self._device_data): + raise HomeAssistantError(f"Fan mode not supported by device: {fan_mode}") installation_id = self._installation_id async with self._get_device_lock(): @@ -237,6 +258,8 @@ class DknClimateEntity(DknEntity, ClimateEntity): async def async_set_swing_mode(self, swing_mode: str) -> None: if swing_mode not in {"off", "swing"}: raise HomeAssistantError(f"Unsupported swing mode: {swing_mode}") + if not supports_swing(self._device_data): + raise HomeAssistantError("Swing mode not supported by device") installation_id = self._installation_id slat = 9 if swing_mode == "swing" else 0 @@ -266,32 +289,21 @@ class DknClimateEntity(DknEntity, ClimateEntity): 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" + requested = { + HVACMode.AUTO: DEVICE_MODE_AUTO, + HVACMode.COOL: DEVICE_MODE_COOL, + HVACMode.HEAT: DEVICE_MODE_HEAT, + }[hvac_mode] + key = target_temperature_key(requested) + if key is None: + raise HomeAssistantError( + f"Target temperature is not supported in {hvac_mode} mode" + ) + return key 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) + return to_device_temperature(temperature_c, self._device_data.get("units")) diff --git a/custom_components/dkncloudna/model.py b/custom_components/dkncloudna/model.py new file mode 100644 index 0000000..a2dabcf --- /dev/null +++ b/custom_components/dkncloudna/model.py @@ -0,0 +1,137 @@ +"""Shared device-state helpers for DKN Cloud NA.""" + +from __future__ import annotations + +from typing import Any + +from .const import ( + DEVICE_MODE_AUTO, + DEVICE_MODE_COOL, + DEVICE_MODE_DRY, + DEVICE_MODE_FAN, + DEVICE_MODE_HEAT, + SPEED_20, + SPEED_40, + SPEED_60, + SPEED_80, + SPEED_100, + SPEED_AUTO, + TEMP_FAHRENHEIT, +) + + +def as_bool(value: Any) -> bool | None: + """Return a boolean for a real boolean value, else None.""" + if isinstance(value, bool): + return value + return None + + +def as_int(value: Any) -> int | None: + """Return an integer for int-like values, else None.""" + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + return None + + +def to_celsius(value: Any, units: Any) -> float | None: + """Convert a device temperature to Celsius if needed.""" + if value is None: + return None + temp = float(value) + if as_int(units) != TEMP_FAHRENHEIT: + return temp + return round((temp - 32) * 5 / 9, 1) + + +def to_device_temperature(value_c: float, units: Any) -> float | int: + """Convert a Celsius temperature to the device units.""" + if as_int(units) != TEMP_FAHRENHEIT: + return value_c + return round((value_c * 9 / 5) + 32) + + +def requested_mode(data: dict[str, Any]) -> int | None: + """Return the requested device mode.""" + return as_int(data.get("mode")) + + +def live_mode(data: dict[str, Any]) -> int | None: + """Return the live device mode, when reported.""" + return as_int(data.get("real_mode")) + + +def current_temperature(data: dict[str, Any]) -> float | None: + """Return the indoor temperature in Celsius.""" + return to_celsius(data.get("work_temp", data.get("local_temp")), data.get("units")) + + +def exterior_temperature(data: dict[str, Any]) -> float | None: + """Return the exterior temperature in Celsius.""" + return to_celsius(data.get("ext_temp"), data.get("units")) + + +def target_temperature_key(mode: int | None) -> str | None: + """Return the setpoint key for the requested mode.""" + if mode == DEVICE_MODE_HEAT: + return "setpoint_air_heat" + if mode == DEVICE_MODE_COOL: + return "setpoint_air_cool" + if mode == DEVICE_MODE_AUTO: + return "setpoint_air_auto" + 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)) + if key is None: + return None + return to_celsius(data.get(key), data.get("units")) + + +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")) + if not power: + return "off" + + mode = requested_mode(data) + current = current_temperature(data) + target = target_temperature(data) + + if mode == DEVICE_MODE_HEAT: + if current is not None and target is not None and current < target: + return "heating" + return "idle" + if mode == DEVICE_MODE_COOL: + if current is not None and target is not None and current > target: + return "cooling" + return "idle" + if mode == DEVICE_MODE_AUTO: + if current is not None and target is not None: + if current < target: + return "heating" + if current > target: + return "cooling" + return "idle" + if mode == DEVICE_MODE_FAN: + return "fan" + if mode == DEVICE_MODE_DRY: + return "drying" + return "on" + + +def available_fan_speeds(data: dict[str, Any]) -> list[int]: + """Return the supported fan speed codes for the device.""" + raw = data.get("speed_available") + if isinstance(raw, list): + return [speed for speed in (as_int(item) for item in raw) if speed is not None] + return [SPEED_AUTO, SPEED_20, SPEED_40, SPEED_60, SPEED_80, SPEED_100] + + +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/custom_components/dkncloudna/sensor.py b/custom_components/dkncloudna/sensor.py index 65a7899..7656394 100644 --- a/custom_components/dkncloudna/sensor.py +++ b/custom_components/dkncloudna/sensor.py @@ -20,11 +20,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import DknCoordinator from .entity import DknEntity +from .model import current_temperature, exterior_temperature @dataclass(frozen=True, kw_only=True) class DknSensorEntityDescription(SensorEntityDescription): """Extend SensorEntityDescription with a device_data key.""" + data_key: str = "" @@ -95,4 +97,8 @@ class DknSensorEntity(DknEntity, SensorEntity): @property def native_value(self) -> Any: + if self.entity_description.data_key == "work_temp": + return current_temperature(self._device_data) + if self.entity_description.data_key == "ext_temp": + return exterior_temperature(self._device_data) return self._device_data.get(self.entity_description.data_key) diff --git a/smoke-test/smoke_test.py b/smoke-test/smoke_test.py index 8b67e9e..07d0b13 100644 --- a/smoke-test/smoke_test.py +++ b/smoke-test/smoke_test.py @@ -97,21 +97,57 @@ def _effective_state(device: dict[str, Any]) -> dict[str, Any]: is_connected = _bool_or_none(device.get("isConnected")) machine_ready = _bool_or_none(device.get("machineready")) - hvac_state = "off" - active_mode = real_mode if real_mode is not None else mode + hvac_mode = "off" + hvac_action = "off" if power: - if active_mode == 3: - hvac_state = "heating" - elif active_mode == 2: - hvac_state = "cooling" - elif active_mode == 1: - hvac_state = "auto" - elif active_mode == 4: - hvac_state = "fan_only" - elif active_mode == 5: - hvac_state = "dry" + if mode == 3: + hvac_mode = "heat" + elif mode == 2: + hvac_mode = "cool" + elif mode == 1: + hvac_mode = "auto" + elif mode == 4: + hvac_mode = "fan_only" + elif mode == 5: + hvac_mode = "dry" else: - hvac_state = "on" + hvac_mode = "on" + + current = device.get("work_temp") + target = None + if mode == 3: + target = device.get("setpoint_air_heat") + elif mode == 2: + target = device.get("setpoint_air_cool") + elif mode == 1: + target = device.get("setpoint_air_auto") + + if mode == 3: + if current is not None and target is not None and current < target: + hvac_action = "heating" + else: + hvac_action = "idle" + elif mode == 2: + if current is not None and target is not None and current > target: + hvac_action = "cooling" + else: + hvac_action = "idle" + elif mode == 1: + if current is not None and target is not None: + if current < target: + hvac_action = "heating" + elif current > target: + hvac_action = "cooling" + else: + hvac_action = "idle" + else: + hvac_action = "idle" + elif mode == 4: + hvac_action = "fan" + elif mode == 5: + hvac_action = "drying" + else: + hvac_action = "on" return { "connected": is_connected, @@ -120,7 +156,8 @@ def _effective_state(device: dict[str, Any]) -> dict[str, Any]: "mode": mode, "real_mode": real_mode, "units": units, - "hvac_state": hvac_state, + "hvac_mode": hvac_mode, + "hvac_action": hvac_action, "work_temp": device.get("work_temp"), "setpoint_air_auto": device.get("setpoint_air_auto"), "setpoint_air_cool": device.get("setpoint_air_cool"), @@ -130,6 +167,19 @@ def _effective_state(device: dict[str, Any]) -> dict[str, Any]: } +def _supports_write_verification(device: dict[str, Any], property_name: str) -> bool: + if property_name == "slats_vertical_1": + return "slats_vertical_1" in device or _int_or_none( + device.get("slats_vnum") + ) not in (None, 0) + if property_name == "speed_state": + available = device.get("speed_available") + return isinstance(available, list) and len(available) > 0 + if property_name.startswith("setpoint_air_"): + return device.get(property_name) is not None + return True + + def _has_live_state(device: dict[str, Any]) -> bool: state = _effective_state(device) return any( @@ -277,6 +327,14 @@ async def _main() -> None: installation_id, mac, property_name, value ) + async def send_and_verify( + property_name: str, value: Any, timeout: float = 20.0 + ) -> bool | str: + if not _supports_write_verification(current_device, property_name): + return "unsupported_by_payload" + await send(property_name, value) + return await wait_for_property(property_name, value, timeout=timeout) + restore_actions: list[tuple[str, Any]] = [] try: await send("power", current_device.get("power", False)) @@ -287,8 +345,7 @@ async def _main() -> None: original_swing = int(current_device.get("slats_vertical_1", 0) or 0) test_swing = 0 if original_swing == 9 else 9 - await send("slats_vertical_1", test_swing) - results["writes"]["swing_toggle"] = await wait_for_property( + results["writes"]["swing_toggle"] = await send_and_verify( "slats_vertical_1", test_swing ) restore_actions.append(("slats_vertical_1", original_swing)) @@ -298,8 +355,7 @@ async def _main() -> None: if test_fan == original_fan: results["writes"]["fan_speed"] = "skipped_no_alternate" else: - await send("speed_state", test_fan) - results["writes"]["fan_speed"] = await wait_for_property( + results["writes"]["fan_speed"] = await send_and_verify( "speed_state", test_fan ) restore_actions.append(("speed_state", original_fan)) @@ -322,8 +378,7 @@ async def _main() -> None: if test_temp == original_temp: results["writes"]["temperature"] = "skipped_no_room" else: - await send(property_name, test_temp) - results["writes"]["temperature"] = await wait_for_property( + results["writes"]["temperature"] = await send_and_verify( property_name, test_temp ) restore_actions.append((property_name, original_temp))