From 09f21c4ea42f2772855d39fa64f476ccd80f29af Mon Sep 17 00:00:00 2001 From: Sebastien Lavoie Date: Sun, 29 Mar 2026 08:38:23 -0400 Subject: [PATCH] docs: add scaffolding implementation plan Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/2026-03-29-scaffolding.md | 1547 ++++++++++++++++++++++++++ 1 file changed, 1547 insertions(+) create mode 100644 docs/plans/2026-03-29-scaffolding.md diff --git a/docs/plans/2026-03-29-scaffolding.md b/docs/plans/2026-03-29-scaffolding.md new file mode 100644 index 0000000..c7802e6 --- /dev/null +++ b/docs/plans/2026-03-29-scaffolding.md @@ -0,0 +1,1547 @@ +# DKN Cloud NA — Scaffolding Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Create a complete, HACS-valid integration scaffolding with stub entities, working config flow, and README — no live Daikin API calls yet. + +**Architecture:** Cloud-polling integration under `custom_components/dkncloudna/`. A `DataUpdateCoordinator` drives all entity updates. Config flow collects email+password, exchanges for tokens (stub), displays tokens, and stores only tokens+settings in `entry.options`. Per-device `asyncio.Lock` + optimistic overlays handle write latency. + +**Tech Stack:** Python 3.12+, Home Assistant 2024.1+, HACS 2.0+, `aiohttp` (via HA's client session), `voluptuous` for schema validation. + +--- + +## Reference Files + +- Homebridge plugin (NA API): `/Users/slavoie2/src/github.com/plecong/homebridge-dkncloudna/src/` +- EU HA reference: `/Users/slavoie2/src/github.com/eXPerience83/DKNCloud-HASS/custom_components/airzoneclouddaikin/` +- Design doc: `docs/plans/2026-03-29-hacs-plugin-design.md` + +--- + +## Task 1: Repo skeleton + CI + +**Files:** +- Create: `hacs.json` +- Create: `.github/workflows/validate.yml` +- Create: `custom_components/dkncloudna/` (directory) + +**Step 1: Create `hacs.json`** + +```json +{ + "name": "DKN Cloud NA", + "homeassistant": "2024.1.0", + "hacs": "2.0.0" +} +``` + +**Step 2: Create `.github/workflows/validate.yml`** + +```yaml +name: Validate + +on: + workflow_dispatch: + schedule: + - cron: "0 0 * * *" + push: + branches: [main] + pull_request: + branches: [main] + +permissions: {} + +jobs: + hassfest: + name: Hassfest validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: home-assistant/actions/hassfest@master + + hacs: + name: HACS validation + runs-on: ubuntu-latest + steps: + - uses: hacs/action@main + with: + category: integration + ignore: brands +``` + +**Step 3: Commit** + +```bash +git add hacs.json .github/workflows/validate.yml +git commit -m "feat: add HACS and hassfest CI validation" +``` + +--- + +## Task 2: `manifest.json` + `const.py` + +**Files:** +- Create: `custom_components/dkncloudna/manifest.json` +- Create: `custom_components/dkncloudna/const.py` + +**Step 1: Create `manifest.json`** + +```json +{ + "domain": "dkncloudna", + "name": "DKN Cloud NA", + "codeowners": ["@lavoiesl"], + "config_flow": true, + "documentation": "https://github.com/lavoiesl/homeassistant-dkncloudna", + "integration_type": "hub", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/lavoiesl/homeassistant-dkncloudna/issues", + "version": "0.1.0" +} +``` + +Notes: +- No `"dependencies"` key — we don't use `persistent_notification`. +- `"integration_type": "hub"` because one config entry manages multiple devices. +- `"requirements"` omitted (empty) — no extra pip packages yet. + +**Step 2: Create `const.py`** + +```python +"""Constants for DKN Cloud NA integration.""" + +from __future__ import annotations + +import logging + +DOMAIN = "dkncloudna" +LOGGER = logging.getLogger(__package__) +MANUFACTURER = "Daikin" + +# API +BASE_URL = "https://dkncloudna.com/api/v1" +API_LOGIN = "/auth/login/dknUsa" +API_IS_LOGGED_IN = "/users/isLoggedIn/dknUsa" +API_REFRESH_TOKEN = "/auth/refreshToken/{refresh_token}/dknUsa" +API_INSTALLATIONS = "/installations/dknUsa" + +# The DKN Cloud NA API requires a mobile-like User-Agent. +# This matches what the official DKN Cloud NA iOS app sends. +USER_AGENT = ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148" +) + +REQUEST_TIMEOUT = 30 # seconds + +# Config/options keys +CONF_SCAN_INTERVAL = "scan_interval" +CONF_EXPOSE_PII = "expose_pii" +CONF_USER_TOKEN = "user_token" +CONF_REFRESH_TOKEN = "refresh_token" + +# Defaults +DEFAULT_SCAN_INTERVAL = 60 # seconds +MIN_SCAN_INTERVAL = 30 +MAX_SCAN_INTERVAL = 300 + +# Optimistic overlay: how long to hold a locally-set value before trusting +# the next coordinator refresh. Must exceed the write→cloud→poll round-trip. +OPTIMISTIC_TTL_SEC: float = 2.5 + +# Post-write coordinator refresh: coalesced delay after a device command. +POST_WRITE_REFRESH_DELAY_SEC: float = 1.0 + +# Device modes (from homebridge plugin src/types.ts) +# DeviceMode: 1=Auto, 2=Cool, 3=Heat, 4=Fan, 5=Dry +DEVICE_MODE_AUTO = 1 +DEVICE_MODE_COOL = 2 +DEVICE_MODE_HEAT = 3 +DEVICE_MODE_FAN = 4 +DEVICE_MODE_DRY = 5 + +# Fan speeds (SpeedState) +SPEED_AUTO = 0 +SPEED_20 = 2 +SPEED_40 = 3 +SPEED_60 = 4 +SPEED_80 = 5 +SPEED_100 = 6 + +# Temperature units +TEMP_CELSIUS = 0 +TEMP_FAHRENHEIT = 1 +``` + +**Step 3: Commit** + +```bash +git add custom_components/dkncloudna/manifest.json custom_components/dkncloudna/const.py +git commit -m "feat: add manifest.json and constants" +``` + +--- + +## Task 3: API client stub (`api.py`) + +**Files:** +- Create: `custom_components/dkncloudna/api.py` + +This is a stub — methods raise `NotImplementedError` or return hardcoded test data. The real HTTP calls come in a future phase. + +**Step 1: Create `api.py`** + +```python +"""DKN Cloud NA API client stub. + +Real HTTP calls are implemented in a future phase. This stub defines the +interface and returns hardcoded data so the rest of the integration can be +developed and tested without live credentials. +""" + +from __future__ import annotations + +from typing import Any + +from aiohttp import ClientSession + +from .const import LOGGER + + +class DknAuthError(Exception): + """Raised when authentication fails (401).""" + + +class DknConnectionError(Exception): + """Raised when the API cannot be reached.""" + + +class DknCloudNaClient: + """Async client for the DKN Cloud NA REST API. + + Instantiated once per config entry. Password is cleared after login and + never stored beyond the initial token exchange. + """ + + def __init__( + self, + username: str, + session: ClientSession, + *, + password: str | None = None, + token: str | None = None, + refresh_token: str | None = None, + ) -> None: + self._username = username + self._session = session + self._password = password + self.token = token + self.refresh_token = refresh_token + + def clear_password(self) -> None: + """Discard password from memory after token exchange.""" + self._password = None + + async def login(self) -> None: + """Exchange email+password for access and refresh tokens. + + Sets self.token and self.refresh_token on success. + Raises DknAuthError on bad credentials, DknConnectionError on network failure. + """ + # TODO: implement real POST to API_LOGIN + LOGGER.debug("DknCloudNaClient.login() stub called for %s", self._username) + raise NotImplementedError("login() not yet implemented") + + async def is_logged_in(self) -> bool: + """Return True if the current token is still valid.""" + # TODO: implement real GET to API_IS_LOGGED_IN + raise NotImplementedError("is_logged_in() not yet implemented") + + async def refresh_access_token(self) -> None: + """Use the refresh token to obtain a new access token. + + Updates self.token on success. + Raises DknAuthError if the refresh token is also expired. + """ + # TODO: implement real GET to API_REFRESH_TOKEN + raise NotImplementedError("refresh_access_token() not yet implemented") + + async def fetch_installations(self) -> list[dict[str, Any]]: + """Return all installations and their devices. + + Each installation contains a list of DeviceInfo dicts. + Returns stub data for scaffolding. + """ + LOGGER.debug("DknCloudNaClient.fetch_installations() stub — returning fake data") + return [ + { + "_id": "stub-installation-1", + "name": "My Home", + "devices": [ + { + "mac": "aa:bb:cc:dd:ee:ff", + "name": "Living Room AC", + "power": False, + "mode": 1, + "real_mode": 1, + "work_temp": 22.0, + "ext_temp": 18.0, + "units": 0, + "setpoint_air_auto": 22.0, + "setpoint_air_cool": 24.0, + "setpoint_air_heat": 20.0, + "range_sp_auto_air_min": 16, + "range_sp_auto_air_max": 32, + "range_sp_cool_air_min": 16, + "range_sp_cool_air_max": 32, + "range_sp_hot_air_min": 16, + "range_sp_hot_air_max": 32, + "speed_state": 0, + "speed_available": [0, 2, 3, 4, 5, 6], + "slats_vertical_1": 0, + "machineready": True, + "isConnected": True, + "tsensor_error": False, + "stat_rssi": -65, + "stat_ssid": "MyWiFi", + "version": "1.0.0", + "error_value": 0, + "error_ascii1": "", + "error_ascii2": "", + } + ], + } + ] + + def __repr__(self) -> str: + first = self._username[0] if self._username else "?" + token_state = "set" if self.token else "none" + return f"DknCloudNaClient(u={first}***, token={token_state})" +``` + +**Step 2: Commit** + +```bash +git add custom_components/dkncloudna/api.py +git commit -m "feat: add DknCloudNaClient stub" +``` + +--- + +## Task 4: Coordinator (`coordinator.py`) + +**Files:** +- Create: `custom_components/dkncloudna/coordinator.py` + +**Step 1: Create `coordinator.py`** + +```python +"""DataUpdateCoordinator for DKN Cloud NA.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import DknAuthError, DknCloudNaClient, DknConnectionError +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER + + +class DknCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Coordinator that polls all installations and exposes a flat device map. + + ``data`` is ``{mac_address: device_dict}`` where device_dict matches the + shape returned by DknCloudNaClient.fetch_installations() device entries. + + The coordinator owns the client instance used across all platforms. + Per-device asyncio.Lock objects for write serialization are stored in + ``hass.data[DOMAIN][entry_id]["device_locks"]``. + """ + + client: DknCloudNaClient + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + client: DknCloudNaClient, + ) -> None: + scan_interval = int(entry.options.get("scan_interval", DEFAULT_SCAN_INTERVAL)) + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=scan_interval), + ) + self.client = client + self._entry = entry + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + """Fetch all installations and flatten into {mac: device_dict}.""" + try: + installations = await self.client.fetch_installations() + except DknAuthError as err: + # 401 — trigger the reauth UI and mark entities unavailable. + raise ConfigEntryAuthFailed("Token invalid or expired") from err + except DknConnectionError as err: + raise UpdateFailed(f"Cannot reach DKN Cloud NA: {err}") from err + except asyncio.CancelledError: + raise + except Exception as err: # noqa: BLE001 + raise UpdateFailed(f"Unexpected error: {type(err).__name__}") from err + + devices: dict[str, dict[str, Any]] = {} + for installation in installations or []: + inst_id = installation.get("_id", "") + for device in installation.get("devices", []): + mac = str(device.get("mac") or "").strip().lower() + if not mac: + continue + device["_installation_id"] = inst_id + devices[mac] = device + + return devices +``` + +**Step 2: Commit** + +```bash +git add custom_components/dkncloudna/coordinator.py +git commit -m "feat: add DknCoordinator" +``` + +--- + +## Task 5: Base entity (`entity.py`) + +**Files:** +- Create: `custom_components/dkncloudna/entity.py` + +**Step 1: Create `entity.py`** + +```python +"""Shared base entity for DKN Cloud NA.""" + +from __future__ import annotations + +import asyncio +import time +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 .coordinator import DknCoordinator + + +class DknEntity(CoordinatorEntity[DknCoordinator]): + """Base class for all DKN Cloud NA entities. + + Provides: + - device_info populated from the device's MAC and name. + - Per-device asyncio.Lock for serializing concurrent writes. + - Optimistic overlay: set_optimistic() / get_optimistic() / clear_optimistic() + so entities can show a locally-set value until the coordinator refreshes. + - schedule_refresh(): coalesced post-write coordinator refresh. + """ + + _attr_has_entity_name = True + + def __init__(self, coordinator: DknCoordinator, mac: str) -> None: + super().__init__(coordinator) + self._mac = mac + + @property + def _device_data(self) -> dict[str, Any]: + """Return raw device dict from coordinator, or empty dict if unavailable.""" + return (self.coordinator.data or {}).get(self._mac, {}) + + @property + def device_info(self) -> DeviceInfo: + data = self._device_data + return DeviceInfo( + identifiers={(DOMAIN, self._mac)}, + connections={(CONNECTION_NETWORK_MAC, self._mac)}, + name=data.get("name") or self._mac, + manufacturer=MANUFACTURER, + sw_version=data.get("version"), + ) + + # ------------------------------------------------------------------ + # Per-device write lock + # ------------------------------------------------------------------ + + def _get_device_lock(self) -> asyncio.Lock: + """Return (creating if needed) the asyncio.Lock for this device.""" + bucket = self.hass.data.setdefault(DOMAIN, {}).setdefault( + self.coordinator._entry.entry_id, {} + ) + locks: dict[str, asyncio.Lock] = bucket.setdefault("device_locks", {}) + if self._mac not in locks: + locks[self._mac] = asyncio.Lock() + return locks[self._mac] + + # ------------------------------------------------------------------ + # Optimistic overlays + # ------------------------------------------------------------------ + + def _optimistic_set(self, key: str, value: Any) -> None: + """Store a locally-set value with a TTL timestamp.""" + bucket = self.hass.data.setdefault(DOMAIN, {}).setdefault( + self.coordinator._entry.entry_id, {} + ) + 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} + + 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.entry_id, {} + ) + overlays = bucket.get("optimistic", {}).get(self._mac, {}) + entry = overlays.get(key) + if entry and time.monotonic() < entry["expires"]: + return entry["value"] + return fallback + + def _optimistic_clear(self, key: str) -> None: + """Expire an optimistic overlay immediately.""" + bucket = self.hass.data.get(DOMAIN, {}).get( + self.coordinator._entry.entry_id, {} + ) + overlays = bucket.get("optimistic", {}).get(self._mac, {}) + overlays.pop(key, None) + + # ------------------------------------------------------------------ + # Post-write coordinator refresh (coalesced) + # ------------------------------------------------------------------ + + def _schedule_refresh(self) -> None: + """Schedule a coordinator refresh after POST_WRITE_REFRESH_DELAY_SEC. + + Multiple calls within the window collapse into a single refresh. + """ + bucket = self.hass.data.setdefault(DOMAIN, {}).setdefault( + self.coordinator._entry.entry_id, {} + ) + existing: asyncio.Task | None = bucket.get("pending_refresh") + if existing and not existing.done(): + return + + async def _do_refresh() -> None: + import asyncio as _asyncio + await _asyncio.sleep(POST_WRITE_REFRESH_DELAY_SEC) + await self.coordinator.async_request_refresh() + + bucket["pending_refresh"] = self.hass.async_create_task(_do_refresh()) +``` + +**Step 2: Commit** + +```bash +git add custom_components/dkncloudna/entity.py +git commit -m "feat: add DknEntity base with locks, overlays, and refresh" +``` + +--- + +## Task 6: Climate entity stub (`climate.py`) + +**Files:** +- Create: `custom_components/dkncloudna/climate.py` + +**Step 1: Create `climate.py`** + +```python +"""Climate entity for DKN Cloud NA.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + DEVICE_MODE_AUTO, + DEVICE_MODE_COOL, + DEVICE_MODE_DRY, + DEVICE_MODE_FAN, + DEVICE_MODE_HEAT, + DOMAIN, + LOGGER, + SPEED_20, + SPEED_40, + SPEED_60, + SPEED_80, + SPEED_100, + SPEED_AUTO, +) +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, + DEVICE_MODE_HEAT: HVACMode.HEAT, + DEVICE_MODE_FAN: HVACMode.FAN_ONLY, + DEVICE_MODE_DRY: HVACMode.DRY, +} +_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", + SPEED_20: "20%", + SPEED_40: "40%", + SPEED_60: "60%", + SPEED_80: "80%", + SPEED_100: "100%", +} +_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} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up climate entities from a config entry.""" + coordinator: DknCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] + async_add_entities( + DknClimateEntity(coordinator, mac) + for mac in (coordinator.data or {}) + ) + + +class DknClimateEntity(DknEntity, ClimateEntity): + """Climate entity representing one DKN Cloud NA AC unit.""" + + _attr_name = None # entity name = device name + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_precision = PRECISION_WHOLE + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.DRY, + HVACMode.FAN_ONLY, + ] + _attr_fan_modes = _FAN_MODES + _attr_swing_modes = ["off", "swing"] + _attr_min_temp = 16 + _attr_max_temp = 32 + _attr_target_temperature_step = 1 + + def __init__(self, coordinator: DknCoordinator, mac: str) -> None: + super().__init__(coordinator, mac) + self._attr_unique_id = f"{DOMAIN}_{mac}" + + @property + 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: + features |= ClimateEntityFeature.TARGET_TEMPERATURE + features |= ClimateEntityFeature.FAN_MODE + features |= ClimateEntityFeature.SWING_MODE + return features + + @property + def hvac_mode(self) -> HVACMode: + data = self._device_data + if not data.get("power", False): + return HVACMode.OFF + 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") + + @property + def target_temperature(self) -> float | None: + if self.hvac_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")) + + @property + def target_temperature_high(self) -> float | None: + return None # no range mode + + @property + def target_temperature_low(self) -> float | None: + return None + + @property + def fan_mode(self) -> str | None: + speed = self._device_data.get("speed_state", SPEED_AUTO) + return self._optimistic_get("fan_mode", _SPEED_TO_FAN.get(int(speed), "auto")) + + @property + def swing_mode(self) -> str | None: + 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") + + 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") + + 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") + + 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") + + 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) +``` + +**Step 2: Commit** + +```bash +git add custom_components/dkncloudna/climate.py +git commit -m "feat: add DknClimateEntity stub" +``` + +--- + +## Task 7: Sensor + binary sensor stubs + +**Files:** +- Create: `custom_components/dkncloudna/sensor.py` +- Create: `custom_components/dkncloudna/binary_sensor.py` + +**Step 1: Create `sensor.py`** + +```python +"""Sensor entities for DKN Cloud NA.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, LOGGER +from .coordinator import DknCoordinator +from .entity import DknEntity + + +@dataclass(frozen=True, kw_only=True) +class DknSensorEntityDescription(SensorEntityDescription): + """Extend SensorEntityDescription with a device_data key.""" + data_key: str = "" + + +SENSOR_DESCRIPTIONS: tuple[DknSensorEntityDescription, ...] = ( + DknSensorEntityDescription( + key="room_temperature", + translation_key="room_temperature", + data_key="work_temp", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + DknSensorEntityDescription( + key="exterior_temperature", + translation_key="exterior_temperature", + data_key="ext_temp", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_registry_enabled_default=False, + ), + DknSensorEntityDescription( + key="wifi_signal", + translation_key="wifi_signal", + data_key="stat_rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DknSensorEntityDescription( + key="error_code", + translation_key="error_code", + data_key="error_ascii1", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensor entities.""" + coordinator: DknCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] + async_add_entities( + DknSensorEntity(coordinator, mac, desc) + for mac in (coordinator.data or {}) + for desc in SENSOR_DESCRIPTIONS + ) + + +class DknSensorEntity(DknEntity, SensorEntity): + """A single sensor for one property of a DKN device.""" + + entity_description: DknSensorEntityDescription + + def __init__( + self, + coordinator: DknCoordinator, + mac: str, + description: DknSensorEntityDescription, + ) -> None: + super().__init__(coordinator, mac) + self.entity_description = description + self._attr_unique_id = f"{DOMAIN}_{mac}_{description.key}" + + @property + def native_value(self) -> Any: + return self._device_data.get(self.entity_description.data_key) +``` + +**Step 2: Create `binary_sensor.py`** + +```python +"""Binary sensor entities for DKN Cloud NA.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import DknCoordinator +from .entity import DknEntity + + +@dataclass(frozen=True, kw_only=True) +class DknBinarySensorEntityDescription(BinarySensorEntityDescription): + """Extend BinarySensorEntityDescription with a device_data key.""" + data_key: str = "" + + +BINARY_SENSOR_DESCRIPTIONS: tuple[DknBinarySensorEntityDescription, ...] = ( + DknBinarySensorEntityDescription( + key="connected", + translation_key="connected", + data_key="isConnected", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DknBinarySensorEntityDescription( + key="machine_ready", + translation_key="machine_ready", + data_key="machineready", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensor entities.""" + coordinator: DknCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] + async_add_entities( + DknBinarySensorEntity(coordinator, mac, desc) + for mac in (coordinator.data or {}) + for desc in BINARY_SENSOR_DESCRIPTIONS + ) + + +class DknBinarySensorEntity(DknEntity, BinarySensorEntity): + """A binary sensor for one boolean property of a DKN device.""" + + entity_description: DknBinarySensorEntityDescription + + def __init__( + self, + coordinator: DknCoordinator, + mac: str, + description: DknBinarySensorEntityDescription, + ) -> None: + super().__init__(coordinator, mac) + self.entity_description = description + self._attr_unique_id = f"{DOMAIN}_{mac}_{description.key}" + + @property + def is_on(self) -> bool | None: + value = self._device_data.get(self.entity_description.data_key) + if value is None: + return None + return bool(value) +``` + +**Step 3: Commit** + +```bash +git add custom_components/dkncloudna/sensor.py custom_components/dkncloudna/binary_sensor.py +git commit -m "feat: add sensor and binary_sensor entity stubs" +``` + +--- + +## Task 8: Config flow (`config_flow.py`) + +**Files:** +- Create: `custom_components/dkncloudna/config_flow.py` + +**Step 1: Create `config_flow.py`** + +```python +"""Config flow for DKN Cloud NA.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import DknAuthError, DknCloudNaClient, DknConnectionError +from .const import ( + CONF_EXPOSE_PII, + CONF_REFRESH_TOKEN, + CONF_SCAN_INTERVAL, + CONF_USER_TOKEN, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + MAX_SCAN_INTERVAL, + MIN_SCAN_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + +_STEP_USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): vol.All( + vol.Coerce(int), vol.Range(min=MIN_SCAN_INTERVAL, max=MAX_SCAN_INTERVAL) + ), + vol.Optional(CONF_EXPOSE_PII, default=False): cv.boolean, + } +) + + +class DknConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for DKN Cloud NA. + + Steps: + user → (optionally) token_display → entry created + Reauth: reauth_confirm → updates token in options + """ + + VERSION = 1 + + def __init__(self) -> None: + self._email: str = "" + self._scan_interval: int = DEFAULT_SCAN_INTERVAL + self._expose_pii: bool = False + self._token: str = "" + self._refresh_token: str = "" + + @staticmethod + def async_get_options_flow( + entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + return DknOptionsFlow(entry) + + # ------------------------------------------------------------------ + # Step 1: credentials + # ------------------------------------------------------------------ + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.FlowResult: + errors: dict[str, str] = {} + + if user_input is not None: + email = str(user_input.get(CONF_EMAIL, "")).strip() + password = str(user_input.get(CONF_PASSWORD, "")) + + if not email or not password: + errors["base"] = "invalid_auth" + else: + normalized = email.casefold() + await self.async_set_unique_id(normalized) + self._abort_if_unique_id_configured() + + session = async_get_clientsession(self.hass) + client = DknCloudNaClient( + email, session, password=password, token=None + ) + try: + await asyncio.wait_for(client.login(), timeout=60.0) + except TimeoutError: + errors["base"] = "timeout" + except DknAuthError: + errors["base"] = "invalid_auth" + except (DknConnectionError, NotImplementedError): + # NotImplementedError: stub not yet implemented → treat as cannot_connect + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + _LOGGER.exception("Unexpected error during login") + errors["base"] = "unknown" + finally: + client.clear_password() + + if not errors: + self._email = email + self._scan_interval = int( + user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ) + self._expose_pii = bool(user_input.get(CONF_EXPOSE_PII, False)) + self._token = client.token or "" + self._refresh_token = client.refresh_token or "" + + if self._expose_pii: + return await self.async_step_token_display() + return self._create_entry() + + return self.async_show_form( + step_id="user", + data_schema=_STEP_USER_SCHEMA, + errors=errors, + ) + + # ------------------------------------------------------------------ + # Step 2 (optional): show tokens to user + # ------------------------------------------------------------------ + + async def async_step_token_display( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.FlowResult: + """Show retrieved tokens read-only, then create the entry.""" + if user_input is not None: + return self._create_entry() + + schema = vol.Schema( + { + vol.Optional( + "access_token_display", default=self._token + ): cv.string, + vol.Optional( + "refresh_token_display", default=self._refresh_token + ): cv.string, + } + ) + return self.async_show_form( + step_id="token_display", + data_schema=schema, + errors={}, + ) + + def _create_entry(self) -> config_entries.FlowResult: + return self.async_create_entry( + title=self._email, + data={"username": self._email}, + options={ + CONF_USER_TOKEN: self._token, + CONF_REFRESH_TOKEN: self._refresh_token, + CONF_SCAN_INTERVAL: self._scan_interval, + CONF_EXPOSE_PII: self._expose_pii, + }, + ) + + # ------------------------------------------------------------------ + # Reauth + # ------------------------------------------------------------------ + + async def async_step_reauth( + self, entry_data: dict[str, Any] + ) -> config_entries.FlowResult: + self._reauth_entry_id = (self.context or {}).get("entry_id") + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.FlowResult: + entry = None + if getattr(self, "_reauth_entry_id", None): + entry = self.hass.config_entries.async_get_entry(self._reauth_entry_id) + if entry is None: + entries = self.hass.config_entries.async_entries(DOMAIN) + entry = entries[0] if len(entries) == 1 else None + if entry is None: + return self.async_abort(reason="reauth_failed") + + username = entry.data.get("username", "") + schema = vol.Schema({vol.Required(CONF_PASSWORD): cv.string}) + errors: dict[str, str] = {} + + if user_input is not None: + session = async_get_clientsession(self.hass) + client = DknCloudNaClient( + username, session, password=str(user_input[CONF_PASSWORD]) + ) + try: + await asyncio.wait_for(client.login(), timeout=60.0) + except TimeoutError: + errors["base"] = "timeout" + except DknAuthError: + errors["base"] = "invalid_auth" + except (DknConnectionError, NotImplementedError): + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + errors["base"] = "unknown" + finally: + client.clear_password() + + if not errors: + new_opts = dict(entry.options) + new_opts[CONF_USER_TOKEN] = client.token or "" + new_opts[CONF_REFRESH_TOKEN] = client.refresh_token or "" + self.hass.config_entries.async_update_entry(entry, options=new_opts) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=schema, + errors=errors, + description_placeholders={"username": username}, + ) + + +class DknOptionsFlow(config_entries.OptionsFlow): + """Options flow: scan interval + PII toggle.""" + + def __init__(self, entry: config_entries.ConfigEntry) -> None: + self._entry = entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.FlowResult: + opts = self._entry.options + defaults = { + CONF_SCAN_INTERVAL: int(opts.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)), + CONF_EXPOSE_PII: bool(opts.get(CONF_EXPOSE_PII, False)), + } + schema = vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, default=defaults[CONF_SCAN_INTERVAL] + ): vol.All( + vol.Coerce(int), + vol.Range(min=MIN_SCAN_INTERVAL, max=MAX_SCAN_INTERVAL), + ), + vol.Optional( + CONF_EXPOSE_PII, default=defaults[CONF_EXPOSE_PII] + ): cv.boolean, + } + ) + + if user_input is not None: + # Preserve hidden keys (tokens) when updating options + next_opts = dict(self._entry.options) + next_opts[CONF_SCAN_INTERVAL] = int( + user_input.get(CONF_SCAN_INTERVAL, defaults[CONF_SCAN_INTERVAL]) + ) + next_opts[CONF_EXPOSE_PII] = bool( + user_input.get(CONF_EXPOSE_PII, defaults[CONF_EXPOSE_PII]) + ) + return self.async_create_entry(title="", data=next_opts) + + return self.async_show_form(step_id="init", data_schema=schema, errors={}) +``` + +**Step 2: Commit** + +```bash +git add custom_components/dkncloudna/config_flow.py +git commit -m "feat: add config flow with user, token_display, options, and reauth steps" +``` + +--- + +## Task 9: Integration entry point (`__init__.py`) + +**Files:** +- Create: `custom_components/dkncloudna/__init__.py` + +**Step 1: Create `__init__.py`** + +```python +"""DKN Cloud NA integration for Home Assistant.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import DknCloudNaClient +from .const import CONF_REFRESH_TOKEN, CONF_USER_TOKEN, DOMAIN, LOGGER +from .coordinator import DknCoordinator + +PLATFORMS: list[Platform] = [ + Platform.CLIMATE, + Platform.SENSOR, + Platform.BINARY_SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up DKN Cloud NA from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + token = entry.options.get(CONF_USER_TOKEN) + if not token: + raise ConfigEntryAuthFailed("No token in options; reauthentication required") + + username = entry.data.get("username", "") + refresh_token = entry.options.get(CONF_REFRESH_TOKEN) + session = async_get_clientsession(hass) + + client = DknCloudNaClient( + username, + session, + token=token, + refresh_token=refresh_token, + ) + + coordinator = DknCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = { + "coordinator": coordinator, + "client": client, + } + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_reload_entry)) + + LOGGER.info( + "DKN Cloud NA set up (entry=%s, scan_interval=%ss)", + entry.entry_id, + coordinator.update_interval.total_seconds() if coordinator.update_interval else "?", + ) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id, None) + return unload_ok + + +async def _async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload entry when options change.""" + await hass.config_entries.async_reload(entry.entry_id) +``` + +**Step 2: Commit** + +```bash +git add custom_components/dkncloudna/__init__.py +git commit -m "feat: add integration entry point" +``` + +--- + +## Task 10: Translations (`strings.json` + `translations/en.json`) + +**Files:** +- Create: `custom_components/dkncloudna/strings.json` +- Create: `custom_components/dkncloudna/translations/en.json` + +Both files are identical — `strings.json` is used by `hassfest` validation, `translations/en.json` is used at runtime. + +**Step 1: Create both files with this content** + +```json +{ + "title": "DKN Cloud NA", + "config": { + "step": { + "user": { + "title": "Sign in", + "description": "Enter your DKN Cloud NA account credentials.", + "data": { + "email": "Email", + "password": "Password", + "scan_interval": "Scan interval (seconds)", + "expose_pii": "Show tokens after login" + }, + "data_description": { + "scan_interval": "How often to poll the DKN Cloud NA API (30–300 seconds).", + "expose_pii": "Display access and refresh tokens after login for advanced use." + } + }, + "token_display": { + "title": "Tokens", + "description": "Your tokens have been saved. You can copy them for reference.", + "data": { + "access_token_display": "Access token", + "refresh_token_display": "Refresh token" + } + }, + "reauth_confirm": { + "title": "Reauthenticate", + "description": "Your session for {username} has expired. Enter your password to get a new token.", + "data": { + "password": "Password" + } + } + }, + "error": { + "invalid_auth": "Invalid email or password.", + "cannot_connect": "Cannot connect to DKN Cloud NA.", + "timeout": "The request timed out. Please try again.", + "unknown": "Unexpected error. Check the logs for details." + }, + "abort": { + "already_configured": "This account is already configured.", + "reauth_successful": "Reauthentication successful.", + "reauth_failed": "Reauthentication failed." + } + }, + "options": { + "step": { + "init": { + "title": "Options", + "data": { + "scan_interval": "Scan interval (seconds)", + "expose_pii": "Show tokens after login" + }, + "data_description": { + "scan_interval": "How often to poll the DKN Cloud NA API (30–300 seconds)." + } + } + } + }, + "entity": { + "sensor": { + "room_temperature": {"name": "Room temperature"}, + "exterior_temperature": {"name": "Exterior temperature"}, + "wifi_signal": {"name": "Wi-Fi signal"}, + "error_code": {"name": "Error code"} + }, + "binary_sensor": { + "connected": {"name": "Connected"}, + "machine_ready": {"name": "Machine ready"} + } + } +} +``` + +**Step 2: Commit** + +```bash +git add custom_components/dkncloudna/strings.json custom_components/dkncloudna/translations/en.json +git commit -m "feat: add translations and entity strings" +``` + +--- + +## Task 11: README + +**Files:** +- Create: `README.md` + +**Step 1: Create `README.md`** + +```markdown +# DKN Cloud NA — Home Assistant Integration + +[![HACS][hacs-badge]][hacs-url] +[![Validate][validate-badge]][validate-url] + +Control your Daikin mini-split air conditioners through Home Assistant using the DKN Cloud NA cloud service. + +This integration is a port of the [homebridge-dkncloudna](https://github.com/plecong/homebridge-dkncloudna) plugin by [@plecong](https://github.com/plecong), adapted for Home Assistant and distributed via HACS. + +--- + +## Supported Hardware + +Any Daikin mini-split system connected to the **DKN Cloud NA** WiFi adapter (North America). The adapter must be set up and working in the official DKN Cloud NA mobile app before using this integration. + +--- + +## Prerequisites + +- A working [DKN Cloud NA](https://dkncloudna.com) account +- Your Daikin unit(s) already set up and visible in the DKN Cloud NA app +- Home Assistant 2024.1 or later +- HACS 2.0 or later + +--- + +## Installation + +### Via HACS (recommended) + +1. Open HACS in your Home Assistant instance +2. Go to **Integrations** +3. Click the **⋮** menu → **Custom repositories** +4. Add `https://github.com/lavoiesl/homeassistant-dkncloudna` as an **Integration** +5. Search for **DKN Cloud NA** and install it +6. Restart Home Assistant + +### Manual + +Copy the `custom_components/dkncloudna/` directory into your Home Assistant `config/custom_components/` directory and restart. + +--- + +## Configuration + +1. Go to **Settings → Devices & Services → Add Integration** +2. Search for **DKN Cloud NA** +3. Enter your DKN Cloud NA email and password +4. Optionally adjust the scan interval (default: 60 seconds) +5. Optionally enable **Show tokens after login** to view your access and refresh tokens + +All discovered devices are added automatically. + +--- + +## Entities + +Each device exposes the following entities: + +| Entity | Type | Description | +|---|---|---| +| AC unit | `climate` | On/off, mode (auto/cool/heat/dry/fan), target temperature, fan speed, swing | +| Room temperature | `sensor` | Current room temperature (°C) | +| Exterior temperature | `sensor` | Outdoor temperature (°C) — disabled by default | +| Wi-Fi signal | `sensor` | RSSI in dBm — diagnostic | +| Error code | `sensor` | Active error code — diagnostic | +| Connected | `binary_sensor` | Whether the device is online — diagnostic | +| Machine ready | `binary_sensor` | Whether the device is ready to receive commands — diagnostic | + +--- + +## Known Limitations & Roadmap + +- **Device control is not yet implemented.** This release establishes the integration scaffolding and entity structure. Setting temperature, mode, fan speed, and swing will be implemented in a future release. +- Real-time updates via Socket.IO are not used; the integration polls the cloud API on a configurable interval (default 60s). +- Temperature units follow what the device reports; Fahrenheit devices are converted to Celsius for Home Assistant. + +--- + +## Credits + +- Original Homebridge plugin: [homebridge-dkncloudna](https://github.com/plecong/homebridge-dkncloudna) by [@plecong](https://github.com/plecong) +- EU counterpart inspiration: [DKNCloud-HASS](https://github.com/eXPerience83/DKNCloud-HASS) by [@eXPerience83](https://github.com/eXPerience83) + +[hacs-badge]: https://img.shields.io/badge/HACS-Custom-orange.svg +[hacs-url]: https://github.com/hacs/integration +[validate-badge]: https://github.com/lavoiesl/homeassistant-dkncloudna/actions/workflows/validate.yml/badge.svg +[validate-url]: https://github.com/lavoiesl/homeassistant-dkncloudna/actions/workflows/validate.yml +``` + +**Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: add README with installation and entity reference" +``` + +--- + +## Task 12: Final check — HACS validation requirements + +Verify these are all satisfied before pushing: + +| Requirement | File/Action | +|---|---| +| `hacs.json` with `name` | ✅ Task 1 | +| `README.md` exists | ✅ Task 11 | +| `manifest.json` has domain, name, codeowners, documentation, issue_tracker, version | ✅ Task 2 | +| GitHub Issues enabled on repo | ✅ (enable in repo settings) | +| GitHub repo has a description | ✅ (add in repo settings) | +| GitHub repo has at least one topic | ✅ (add `home-assistant`, `hacs`, `daikin` in repo settings) | +| `translations/en.json` present (config_flow=true requires it) | ✅ Task 10 | + +**Step 1: Push to GitHub** + +```bash +git push -u origin main +``` + +**Step 2: On GitHub, set:** +- Repository description: "Home Assistant HACS integration for Daikin DKN Cloud NA (North America)" +- Topics: `home-assistant`, `hacs`, `daikin`, `dkn-cloud`, `climate` + +**Step 3: Verify CI passes** + +Check that both `hassfest` and `hacs` workflow jobs pass on the Actions tab.