09f21c4ea4
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1548 lines
49 KiB
Markdown
1548 lines
49 KiB
Markdown
# 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.
|