chore: drop HACS distribution and move CI to Gitea #6

Merged
thatguygriff merged 1 commits from chore/drop-hacs-gitea-ci into main 2026-09-19 13:18:23 +00:00
17 changed files with 461 additions and 99 deletions
+49
View File
@@ -0,0 +1,49 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
# This repo used to run Home Assistant's hassfest and the HACS validation
# action. Both are GitHub-hosted actions that only work against a github.com
# repository, and the integration is no longer distributed through HACS, so the
# checks here are self-contained: ruff for the Python, and a manifest/strings
# consistency check that covers the parts of hassfest that actually matter for
# a manually installed custom component.
env:
# Pinned: ruff's default rule set and formatter output change between
# releases, so an unpinned version turns an unrelated push red.
RUFF_VERSION: "0.16.8"
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install ruff
run: |
python3 -m venv .venv
.venv/bin/pip install --quiet "ruff==${RUFF_VERSION}"
- name: Ruff check
run: .venv/bin/ruff check --output-format=github .
- name: Ruff format
run: .venv/bin/ruff format --check --diff .
validate:
name: Validate integration
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Byte-compile
run: python3 -m compileall -q custom_components smoke-test
- name: Validate manifest and translations
run: python3 scripts/validate_integration.py
-33
View File
@@ -1,33 +0,0 @@
name: Validate
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * *"
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
hassfest:
name: Hassfest validation
runs-on: ubuntu-latest
if: github.server_url == 'https://github.com'
steps:
- uses: actions/checkout@v4
- uses: home-assistant/actions/hassfest@master
hacs:
name: HACS validation
runs-on: ubuntu-latest
if: github.server_url == 'https://github.com'
steps:
- uses: actions/checkout@v4
- uses: hacs/action@main
with:
category: integration
ignore: brands
+4
View File
@@ -1 +1,5 @@
/.env
__pycache__/
*.py[cod]
/.venv/
/smoke-test/.venv/
+28 -20
View File
@@ -1,11 +1,8 @@
# 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.
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.
---
@@ -20,24 +17,18 @@ Any Daikin mini-split system connected to the **DKN Cloud NA** WiFi adapter (Nor
- 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)
This integration is installed manually — it is not published to HACS.
1. Open HACS in your Home Assistant instance
2. Go to **Integrations**
3. Click the **⋮** menu → **Custom repositories**
4. Add `https://github.com/thatguygriff/homeassistant-dkncloudna` as an **Integration**
5. Search for **DKN Cloud NA** and install it
6. Restart Home Assistant
1. Download or clone this repository
2. Copy the `custom_components/dkncloudna/` directory into your Home Assistant `config/custom_components/` directory, so that you end up with `config/custom_components/dkncloudna/manifest.json`
3. Restart Home Assistant
### Manual
Copy the `custom_components/dkncloudna/` directory into your Home Assistant `config/custom_components/` directory and restart.
To upgrade, replace the `dkncloudna` directory with the newer copy and restart again.
---
@@ -84,12 +75,29 @@ Each device exposes the following entities:
---
## Development
CI runs on Gitea Actions (`.gitea/workflows/ci.yml`). To reproduce it locally:
```sh
pip install ruff==0.16.8 # version is pinned in CI
ruff check .
ruff format --check .
python3 scripts/validate_integration.py
```
`scripts/validate_integration.py` checks that every JSON file parses, that
`manifest.json` has the keys Home Assistant requires of a custom integration,
and that each file in `translations/` has the same key structure as
`strings.json`.
`smoke-test/run.sh` exercises the client against a real DKN Cloud NA account.
It needs a `.env` with `DKN_CLOUD_NA_EMAIL` and `DKN_CLOUD_NA_PASSWORD` (see
`.env.example`) and is run by hand, not in CI.
---
## 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
+27 -5
View File
@@ -49,6 +49,11 @@ class DknCloudNaClient:
token: str | None = None,
refresh_token: str | None = None,
) -> None:
"""Initialise the client.
Either ``password`` (for a fresh login) or ``token`` /
``refresh_token`` (to resume an existing session) should be supplied.
"""
self._username = username
self._session = session
self._password = password
@@ -190,7 +195,11 @@ class DknCloudNaClient:
socketio_path=API_SOCKET_PATH.strip("/"),
namespaces=namespaces,
)
except Exception as err: # noqa: BLE001
except Exception as err: # noqa: BLE001 - socket is best-effort
# The Socket.IO connection is an optimisation on top of polling.
# python-socketio surfaces transport failures as a grab-bag of
# exception types, and none of them should take the integration
# down: returning False just means we fall back to the poll cycle.
LOGGER.debug("DKN socket connect failed: %s", err)
await sio.disconnect()
return False
@@ -220,6 +229,15 @@ class DknCloudNaClient:
async with self._socket_lock:
await self._disconnect_socket_locked()
@property
def socket_connected(self) -> bool:
"""Return whether the Socket.IO connection is currently up.
Live updates arrive over this connection; when it is down the
integration still works, but only refreshes on the poll interval.
"""
return self._socket is not None and self._socket.connected
async def async_send_machine_event(
self,
installation_id: str,
@@ -251,7 +269,7 @@ class DknCloudNaClient:
"ack": ack,
}
LOGGER.debug("DKN socket ack %s %s", namespace, ack)
except Exception as err: # noqa: BLE001
except Exception as err:
raise DknConnectionError(str(err) or type(err).__name__) from err
def pop_last_command_debug(self) -> dict[str, Any] | None:
@@ -275,8 +293,11 @@ class DknCloudNaClient:
if socket is not None:
try:
await socket.disconnect()
except Exception: # noqa: BLE001
pass
except Exception as err: # noqa: BLE001 - teardown must never raise
# The socket reference is already dropped, so a failed
# disconnect leaves nothing to act on. Log it rather than
# swallowing it silently, so a recurring failure is visible.
LOGGER.debug("DKN socket disconnect failed: %s", err)
def _store_tokens(self, data: Any) -> None:
"""Persist access and refresh tokens from an API response."""
@@ -397,7 +418,7 @@ class DknCloudNaClient:
timeout=REQUEST_TIMEOUT,
) as response:
data = await self._read_response(response)
except asyncio.TimeoutError as err:
except TimeoutError as err:
raise DknConnectionError("Request timed out") from err
except ClientError as err:
raise DknConnectionError(str(err) or type(err).__name__) from err
@@ -451,6 +472,7 @@ class DknCloudNaClient:
return f"HTTP {response.status}: {response.reason}"
def __repr__(self) -> str:
"""Return a debug representation with the username and token redacted."""
first = self._username[0] if self._username else "?"
token_state = "set" if self.token else "none"
return f"DknCloudNaClient(u={first}***, token={token_state})"
@@ -22,6 +22,7 @@ from .entity import DknEntity
@dataclass(frozen=True, kw_only=True)
class DknBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Extend BinarySensorEntityDescription with a device_data key."""
data_key: str = ""
@@ -67,12 +68,14 @@ class DknBinarySensorEntity(DknEntity, BinarySensorEntity):
mac: str,
description: DknBinarySensorEntityDescription,
) -> None:
"""Initialise the binary sensor from its description."""
super().__init__(coordinator, mac)
self.entity_description = description
self._attr_unique_id = f"{DOMAIN}_{mac}_{description.key}"
@property
def is_on(self) -> bool | None:
"""Return the described device flag, or None if absent."""
value = self._device_data.get(self.entity_description.data_key)
if value is None:
return None
+41 -14
View File
@@ -2,12 +2,12 @@
from __future__ import annotations
from typing import Any
from typing import Any, ClassVar
from homeassistant.components.climate import (
HVACAction,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
@@ -30,6 +30,8 @@ from .const import (
SPEED_100,
SPEED_AUTO,
)
from .coordinator import DknCoordinator
from .entity import DknEntity
from .model import (
available_fan_speeds,
current_temperature as model_current_temperature,
@@ -39,11 +41,9 @@ from .model import (
supports_swing,
target_temperature as model_target_temperature,
target_temperature_key,
writable_target_temperature_key,
to_device_temperature,
writable_target_temperature_key,
)
from .coordinator import DknCoordinator
from .entity import DknEntity
_MODE_TO_HVAC: dict[int, HVACMode] = {
DEVICE_MODE_AUTO: HVACMode.AUTO,
@@ -85,7 +85,7 @@ class DknClimateEntity(DknEntity, ClimateEntity):
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_precision = PRECISION_WHOLE
_attr_hvac_modes = [
_attr_hvac_modes: ClassVar[list[HVACMode]] = [
HVACMode.OFF,
HVACMode.AUTO,
HVACMode.COOL,
@@ -93,12 +93,13 @@ class DknClimateEntity(DknEntity, ClimateEntity):
HVACMode.DRY,
HVACMode.FAN_ONLY,
]
_attr_swing_modes = ["off", "swing"]
_attr_swing_modes: ClassVar[list[str]] = ["off", "swing"]
_attr_min_temp = 16
_attr_max_temp = 32
_attr_target_temperature_step = 1
def __init__(self, coordinator: DknCoordinator, mac: str) -> None:
"""Initialise the climate entity for a single indoor unit."""
super().__init__(coordinator, mac)
self._attr_unique_id = f"{DOMAIN}_{mac}"
@@ -116,11 +117,13 @@ class DknClimateEntity(DknEntity, ClimateEntity):
@property
def fan_modes(self) -> list[str] | None:
"""Return the fan speeds this unit reports as available."""
labels = fan_mode_labels(self._device_data)
return labels or None
@property
def hvac_mode(self) -> HVACMode:
"""Return the current mode, preferring an unexpired optimistic write."""
data = self._device_data
power = self._optimistic_get("power", data.get("power", False))
if not power:
@@ -133,6 +136,11 @@ class DknClimateEntity(DknEntity, ClimateEntity):
@property
def hvac_action(self) -> HVACAction | None:
"""Return what the unit is inferred to be doing right now.
The cloud API reports no explicit action, so this is derived from the
mode and the gap between room and target temperature.
"""
mode = self.hvac_mode
if mode == HVACMode.OFF:
return HVACAction.OFF
@@ -157,10 +165,12 @@ class DknClimateEntity(DknEntity, ClimateEntity):
@property
def current_temperature(self) -> float | None:
"""Return the room temperature in Celsius."""
return model_current_temperature(self._device_data)
@property
def target_temperature(self) -> float | None:
"""Return the setpoint for the effective mode, in Celsius."""
mode = self.hvac_mode
if mode in _NO_TARGET_TEMP_MODES:
return None
@@ -176,23 +186,38 @@ class DknClimateEntity(DknEntity, ClimateEntity):
@property
def target_temperature_high(self) -> float | None:
"""Return None: the unit has a single setpoint, not a range."""
return None
@property
def target_temperature_low(self) -> float | None:
"""Return None: the unit has a single setpoint, not a range."""
return None
@property
def fan_mode(self) -> str | None:
"""Return the current fan speed label."""
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:
"""Return whether the vertical slats are swinging."""
slat = self._device_data.get("slats_vertical_1", 0)
return self._optimistic_get("swing_mode", "swing" if int(slat) == 9 else "off")
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Turn the unit off, or switch it on into the requested mode."""
# Resolved before the try block below. An unsupported mode is a caller
# error, not a cloud failure, and raising it inside the try would let
# the blanket handler re-wrap it as "Failed to set HVAC mode:
# Unsupported HVAC mode: ...".
mode: int | None = None
if hvac_mode != HVACMode.OFF:
mode = _HVAC_TO_MODE.get(hvac_mode)
if mode is None:
raise HomeAssistantError(f"Unsupported HVAC mode: {hvac_mode}")
installation_id = self._installation_id
async with self._get_device_lock():
try:
@@ -210,9 +235,6 @@ class DknClimateEntity(DknEntity, ClimateEntity):
device_value=False,
)
else:
mode = _HVAC_TO_MODE.get(hvac_mode)
if mode is None:
raise HomeAssistantError(f"Unsupported HVAC mode: {hvac_mode}")
await self.coordinator.client.async_send_machine_event(
installation_id, self._command_mac, "power", True
)
@@ -228,13 +250,14 @@ class DknClimateEntity(DknEntity, ClimateEntity):
device_key="mode",
device_value=mode,
)
except Exception as err: # noqa: BLE001
except Exception as err:
raise HomeAssistantError(f"Failed to set HVAC mode: {err}") from err
self._schedule_refresh()
self.async_write_ha_state()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the target temperature, optionally changing mode first."""
hvac_mode = kwargs.get("hvac_mode")
if hvac_mode is not None:
await self.async_set_hvac_mode(hvac_mode)
@@ -269,7 +292,7 @@ class DknClimateEntity(DknEntity, ClimateEntity):
await self.coordinator.client.async_send_machine_event(
installation_id, self._command_mac, property_name, device_temp
)
except Exception as err: # noqa: BLE001
except Exception as err:
raise HomeAssistantError(f"Failed to set temperature: {err}") from err
self._optimistic_set(
@@ -282,6 +305,7 @@ class DknClimateEntity(DknEntity, ClimateEntity):
self.async_write_ha_state()
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set the fan speed."""
speed = _FAN_TO_SPEED.get(fan_mode)
if speed is None:
raise HomeAssistantError(f"Unsupported fan mode: {fan_mode}")
@@ -294,7 +318,7 @@ class DknClimateEntity(DknEntity, ClimateEntity):
await self.coordinator.client.async_send_machine_event(
installation_id, self._command_mac, "speed_state", speed
)
except Exception as err: # noqa: BLE001
except Exception as err:
raise HomeAssistantError(f"Failed to set fan mode: {err}") from err
self._optimistic_set(
@@ -304,6 +328,7 @@ class DknClimateEntity(DknEntity, ClimateEntity):
self.async_write_ha_state()
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Start or stop the vertical slat swing."""
if swing_mode not in {"off", "swing"}:
raise HomeAssistantError(f"Unsupported swing mode: {swing_mode}")
if not supports_swing(self._device_data):
@@ -316,7 +341,7 @@ class DknClimateEntity(DknEntity, ClimateEntity):
await self.coordinator.client.async_send_machine_event(
installation_id, self._command_mac, "slats_vertical_1", slat
)
except Exception as err: # noqa: BLE001
except Exception as err:
raise HomeAssistantError(f"Failed to set swing mode: {err}") from err
self._optimistic_set(
@@ -326,9 +351,11 @@ class DknClimateEntity(DknEntity, ClimateEntity):
self.async_write_ha_state()
async def async_turn_on(self) -> None:
"""Turn the unit on into auto mode."""
await self.async_set_hvac_mode(HVACMode.AUTO)
async def async_turn_off(self) -> None:
"""Turn the unit off."""
await self.async_set_hvac_mode(HVACMode.OFF)
@property
+13 -4
View File
@@ -6,11 +6,11 @@ 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
import voluptuous as vol
from .api import DknAuthError, DknCloudNaClient, DknConnectionError
from .const import (
@@ -49,6 +49,7 @@ class DknConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
def __init__(self) -> None:
"""Initialise the flow's accumulated state."""
self._email: str = ""
self._scan_interval: int = DEFAULT_SCAN_INTERVAL
self._expose_pii: bool = False
@@ -59,6 +60,7 @@ class DknConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
def async_get_options_flow(
entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Return the options flow for an existing entry."""
return DknOptionsFlow(entry)
# ------------------------------------------------------------------
@@ -68,6 +70,7 @@ class DknConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> config_entries.FlowResult:
"""Collect credentials and verify them against the cloud."""
errors: dict[str, str] = {}
if user_input is not None:
@@ -91,7 +94,7 @@ class DknConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_auth"
except DknConnectionError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
except Exception:
_LOGGER.exception("Unexpected error during login")
errors["base"] = "unknown"
finally:
@@ -158,14 +161,17 @@ class DknConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# ------------------------------------------------------------------
async def async_step_reauth(
self, entry_data: dict[str, Any]
self,
entry_data: dict[str, Any], # noqa: ARG002 - required by the HA flow API
) -> config_entries.FlowResult:
"""Handle re-authentication after the refresh token stops working."""
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:
"""Prompt for the password again and store a fresh token."""
entry = None
if getattr(self, "_reauth_entry_id", None):
entry = self.hass.config_entries.async_get_entry(self._reauth_entry_id)
@@ -192,7 +198,8 @@ class DknConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_auth"
except DknConnectionError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
except Exception:
_LOGGER.exception("Unexpected error during reauthentication")
errors["base"] = "unknown"
finally:
client.clear_password()
@@ -216,11 +223,13 @@ class DknOptionsFlow(config_entries.OptionsFlow):
"""Options flow: scan interval + PII toggle."""
def __init__(self, entry: config_entries.ConfigEntry) -> None:
"""Initialise the options flow for an existing entry."""
self._entry = entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> config_entries.FlowResult:
"""Show and persist the scan interval and PII toggle."""
opts = self._entry.options
defaults = {
CONF_SCAN_INTERVAL: int(
+3 -2
View File
@@ -31,8 +31,9 @@ SOCKET_RECONNECT_ATTEMPTS = 5
# Config/options keys
CONF_SCAN_INTERVAL = "scan_interval"
CONF_EXPOSE_PII = "expose_pii"
CONF_USER_TOKEN = "user_token"
CONF_REFRESH_TOKEN = "refresh_token"
# These two are config-entry key names, not credentials.
CONF_USER_TOKEN = "user_token" # noqa: S105
CONF_REFRESH_TOKEN = "refresh_token" # noqa: S105
# Defaults
DEFAULT_SCAN_INTERVAL = 60 # seconds
+9 -2
View File
@@ -12,7 +12,13 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import DknAuthError, DknCloudNaClient, DknConnectionError
from .const import CONF_REFRESH_TOKEN, CONF_USER_TOKEN, DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER
from .const import (
CONF_REFRESH_TOKEN,
CONF_USER_TOKEN,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
LOGGER,
)
class DknCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
@@ -34,6 +40,7 @@ class DknCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
entry: ConfigEntry,
client: DknCloudNaClient,
) -> None:
"""Initialise the coordinator with the entry's scan interval."""
scan_interval = int(entry.options.get("scan_interval", DEFAULT_SCAN_INTERVAL))
super().__init__(
hass,
@@ -66,7 +73,7 @@ class DknCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
raise UpdateFailed(f"Cannot reach DKN Cloud NA: {err}") from err
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
except Exception as err:
raise UpdateFailed(f"Unexpected error: {type(err).__name__}") from err
self._persist_tokens_if_changed()
+2
View File
@@ -34,6 +34,7 @@ class DknEntity(CoordinatorEntity[DknCoordinator]):
_attr_has_entity_name = True
def __init__(self, coordinator: DknCoordinator, mac: str) -> None:
"""Initialise the entity for the device with this MAC."""
super().__init__(coordinator)
self._mac = mac
@@ -52,6 +53,7 @@ class DknEntity(CoordinatorEntity[DknCoordinator]):
@property
def device_info(self) -> DeviceInfo:
"""Return the device registry entry for this indoor unit."""
data = self._device_data
return DeviceInfo(
identifiers={(DOMAIN, self._mac)},
+3 -3
View File
@@ -1,12 +1,12 @@
{
"domain": "dkncloudna",
"name": "DKN Cloud NA",
"codeowners": ["@lavoiesl"],
"codeowners": ["@thatguygriff"],
"config_flow": true,
"documentation": "https://github.com/lavoiesl/homeassistant-dkncloudna",
"documentation": "https://git.unsupervised.ca/GitHub/homeassistant-dkncloudna",
"integration_type": "hub",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/lavoiesl/homeassistant-dkncloudna/issues",
"issue_tracker": "https://git.unsupervised.ca/GitHub/homeassistant-dkncloudna/issues",
"requirements": ["python-socketio>=4.6.1,<5"],
"version": "0.2.1"
}
+2
View File
@@ -91,12 +91,14 @@ class DknSensorEntity(DknEntity, SensorEntity):
mac: str,
description: DknSensorEntityDescription,
) -> None:
"""Initialise the sensor from its description."""
super().__init__(coordinator, mac)
self.entity_description = description
self._attr_unique_id = f"{DOMAIN}_{mac}_{description.key}"
@property
def native_value(self) -> Any:
"""Return the described device value, converted where needed."""
if self.entity_description.data_key == "work_temp":
return current_temperature(self._device_data)
if self.entity_description.data_key == "ext_temp":
-5
View File
@@ -1,5 +0,0 @@
{
"name": "DKN Cloud NA",
"homeassistant": "2024.1.0",
"hacs": "2.0.0"
}
+81
View File
@@ -0,0 +1,81 @@
# Lint config for CI.
#
# The rule set is broad on purpose and tracks Home Assistant core's conventions
# reasonably closely, so that this integration would not need a rewrite to be
# read by anyone used to HA code.
#
# CI pins the ruff version (see .gitea/workflows/ci.yml) because ruff's default
# rule set and formatter output change between releases.
target-version = "py312"
line-length = 88
# Dated design records. They contain illustrative, intentionally incomplete
# code blocks and are not maintained source.
exclude = ["docs"]
[lint]
select = [
"A", # flake8-builtins
"ARG", # flake8-unused-arguments
"ASYNC", # flake8-async
"B", # flake8-bugbear
"BLE", # flake8-blind-except
"C4", # flake8-comprehensions
"D", # pydocstyle
"E", # pycodestyle errors
"F", # pyflakes
"FLY", # flynt
"G", # flake8-logging-format
"I", # isort
"ICN", # flake8-import-conventions
"INP", # flake8-no-pep420
"ISC", # flake8-implicit-str-concat
"LOG", # flake8-logging
"N", # pep8-naming
"PERF", # perflint
"PIE", # flake8-pie
"PT", # flake8-pytest-style
"PTH", # flake8-use-pathlib
"Q", # flake8-quotes
"RET", # flake8-return
"RSE", # flake8-raise
"RUF", # ruff-specific
"S", # flake8-bandit
"SIM", # flake8-simplify
"SLF", # flake8-self
"TID", # flake8-tidy-imports
"TRY", # tryceratops
"UP", # pyupgrade
"W", # pycodestyle warnings
]
ignore = [
# Wants every raise to use a purpose-built exception class whose message is
# baked in, rather than `raise DknConnectionError("Request timed out")`.
# That trades readable, situation-specific messages for a pile of
# single-use classes. Home Assistant core disables this rule too.
"TRY003",
# Wants `async with asyncio.timeout(...)` at the call site instead of a
# `timeout` parameter. The wait helpers here use the timeout value for
# scheduling decisions (e.g. refreshing at the halfway point), not just for
# cancellation, so the value has to be passed in.
"ASYNC109",
]
[lint.per-file-ignores]
# Standalone scripts, deliberately not packages, and printing to stdout is
# their entire job.
"scripts/*" = ["INP001", "T201"]
"smoke-test/*" = ["INP001", "T201"]
[lint.isort]
# Home Assistant core style.
force-sort-within-sections = true
combine-as-imports = true
[lint.flake8-tidy-imports]
ban-relative-imports = "parents"
[lint.pydocstyle]
convention = "pep257"
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Validate the integration's static metadata.
This replaces the parts of Home Assistant's `hassfest` action that matter for a
manually installed custom component. hassfest itself is a GitHub-hosted action
and cannot run on the Gitea runner, and it also enforces rules that only apply
to integrations vendored into HA core.
Checks performed:
* every JSON file in the component parses
* manifest.json has the keys HA requires of a custom integration, with sane
values (domain matches the directory, version is present, etc.)
* every translation file has exactly the same key structure as strings.json,
so a missing translation shows up here rather than as a blank label in the
UI
Exits non-zero with one line per problem.
"""
from __future__ import annotations
import json
from pathlib import Path
import sys
REPO_ROOT = Path(__file__).resolve().parent.parent
COMPONENTS_DIR = REPO_ROOT / "custom_components"
# Keys HA reads from a custom integration's manifest. `version` is required for
# custom components specifically (core integrations must omit it).
REQUIRED_MANIFEST_KEYS = (
"domain",
"name",
"codeowners",
"documentation",
"iot_class",
"version",
)
VALID_IOT_CLASSES = {
"assumed_state",
"cloud_polling",
"cloud_push",
"local_polling",
"local_push",
"calculated",
}
VALID_INTEGRATION_TYPES = {
"device",
"entity",
"hardware",
"helper",
"hub",
"service",
"system",
"virtual",
}
def key_structure(value: object, prefix: str = "") -> set[str]:
"""Flatten a nested dict into dotted key paths, ignoring leaf values."""
if not isinstance(value, dict):
return {prefix}
paths: set[str] = set()
for key, child in value.items():
paths |= key_structure(child, f"{prefix}.{key}" if prefix else key)
return paths
def check_json_parses(errors: list[str]) -> dict[Path, object]:
"""Parse every JSON file under custom_components/, recording failures."""
parsed: dict[Path, object] = {}
for path in sorted(COMPONENTS_DIR.rglob("*.json")):
try:
parsed[path] = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as err:
errors.append(f"{path.relative_to(REPO_ROOT)}: invalid JSON: {err}")
return parsed
def check_manifest(component: Path, manifest: object, errors: list[str]) -> None:
"""Validate a single manifest.json payload."""
rel = (component / "manifest.json").relative_to(REPO_ROOT)
if not isinstance(manifest, dict):
errors.append(f"{rel}: expected a JSON object")
return
errors.extend(
f"{rel}: missing required key '{key}'"
for key in REQUIRED_MANIFEST_KEYS
if key not in manifest
)
if manifest.get("domain") != component.name:
errors.append(
f"{rel}: domain '{manifest.get('domain')}' does not match "
f"directory name '{component.name}'"
)
iot_class = manifest.get("iot_class")
if iot_class is not None and iot_class not in VALID_IOT_CLASSES:
errors.append(f"{rel}: unknown iot_class '{iot_class}'")
integration_type = manifest.get("integration_type")
if integration_type is not None and integration_type not in VALID_INTEGRATION_TYPES:
errors.append(f"{rel}: unknown integration_type '{integration_type}'")
codeowners = manifest.get("codeowners")
if not isinstance(codeowners, list):
errors.append(f"{rel}: codeowners must be a list")
else:
errors.extend(
f"{rel}: codeowner '{owner}' must start with '@'"
for owner in codeowners
if not isinstance(owner, str) or not owner.startswith("@")
)
# config_flow: true requires strings.json to describe the flow, otherwise
# the user sees untranslated keys.
if manifest.get("config_flow") and not (component / "strings.json").is_file():
errors.append(f"{rel}: config_flow is true but strings.json is missing")
def check_translations(
component: Path, parsed: dict[Path, object], errors: list[str]
) -> None:
"""Ensure each translation file mirrors strings.json exactly."""
strings_path = component / "strings.json"
if strings_path not in parsed:
return
expected = key_structure(parsed[strings_path])
translations_dir = component / "translations"
if not translations_dir.is_dir():
errors.append(
f"{component.relative_to(REPO_ROOT)}: strings.json exists but "
"translations/ is missing"
)
return
for path in sorted(translations_dir.glob("*.json")):
if path not in parsed:
continue
rel = path.relative_to(REPO_ROOT)
actual = key_structure(parsed[path])
errors.extend(
f"{rel}: missing key '{key}'" for key in sorted(expected - actual)
)
errors.extend(
f"{rel}: key '{key}' not present in strings.json"
for key in sorted(actual - expected)
)
def main() -> int:
"""Run every check and report the problems found."""
errors: list[str] = []
parsed = check_json_parses(errors)
components = sorted(p for p in COMPONENTS_DIR.iterdir() if p.is_dir())
if not components:
errors.append("custom_components/ contains no integration directory")
for component in components:
manifest_path = component / "manifest.json"
if manifest_path not in parsed:
if not manifest_path.is_file():
errors.append(
f"{component.relative_to(REPO_ROOT)}: manifest.json is missing"
)
continue
check_manifest(component, parsed[manifest_path], errors)
check_translations(component, parsed, errors)
if errors:
for error in errors:
print(f"error: {error}", file=sys.stderr)
print(f"\n{len(errors)} problem(s) found.", file=sys.stderr)
return 1
print(
f"OK: validated {len(components)} integration(s), {len(parsed)} JSON file(s)."
)
return 0
if __name__ == "__main__":
sys.exit(main())
+6 -11
View File
@@ -3,12 +3,12 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
import importlib.util
import json
import os
import sys
from contextlib import suppress
from pathlib import Path
import sys
from typing import Any
from aiohttp import ClientSession
@@ -311,8 +311,7 @@ async def _main() -> None:
current_device["_installation_id"] = installation_id
await client.ensure_socket_connection(installations, on_data, on_refresh)
socket = client._socket # noqa: SLF001
results["socket_connect"] = bool(socket and socket.connected)
results["socket_connect"] = client.socket_connected
if not results["socket_connect"]:
raise RuntimeError("Socket.IO connection did not come up")
@@ -323,10 +322,8 @@ async def _main() -> None:
if _has_live_state(current_device):
return True
remaining = max(0.1, deadline - loop.time())
try:
with suppress(TimeoutError):
await asyncio.wait_for(queue.get(), timeout=min(2.0, remaining))
except asyncio.TimeoutError:
pass
if _has_live_state(current_device):
return True
await fetch_current_device()
@@ -349,10 +346,8 @@ async def _main() -> None:
deadline = loop.time() + timeout
while loop.time() < deadline:
remaining = max(0.1, deadline - loop.time())
try:
with suppress(TimeoutError):
await asyncio.wait_for(queue.get(), timeout=min(2.0, remaining))
except asyncio.TimeoutError:
pass
if current_device.get(property_name) == expected:
return True
await fetch_current_device()
@@ -370,7 +365,7 @@ async def _main() -> None:
payload = await asyncio.wait_for(
queue.get(), timeout=min(0.5, remaining)
)
except asyncio.TimeoutError:
except TimeoutError:
continue
deltas.append(payload)
return deltas