chore: drop HACS distribution and move CI to Gitea
CI / Validate integration (pull_request) Successful in 40s
CI / Lint (pull_request) Successful in 49s

The GitHub mirror is gone, so the HACS packaging and the GitHub-only CI
no longer have anything to run against.

Remove hacs.json and the .github/ workflow. Both of its jobs (hassfest
and hacs/action) were gated on `github.server_url == 'https://github.com'`
and are GitHub-hosted actions, so neither can run on the Gitea runner.

Replace them with .gitea/workflows/ci.yml:
  - lint: ruff check + ruff format --check, version pinned because ruff's
    default rule set and formatter output move between releases
  - validate: byte-compile, then scripts/validate_integration.py, which
    covers the part of hassfest that matters for a manually installed
    custom component (manifest keys, domain/directory agreement, and
    translations matching strings.json key for key)

README now documents manual installation only, and points at the local
CI commands. manifest.json documentation and issue_tracker point at the
Gitea repo; NOTICE keeps its upstream attribution.

Adopting ruff surfaced two real defects, fixed here:
  - climate.async_set_hvac_mode raised HomeAssistantError for an
    unsupported mode from inside a try that catches Exception, so the
    message was re-wrapped as "Failed to set HVAC mode: Unsupported HVAC
    mode: ...". The validation is now hoisted above the try.
  - config_flow.async_step_reauth_confirm swallowed unexpected exceptions
    into a bare "unknown" error with no log, unlike the user step. It now
    logs via _LOGGER.exception.

Remaining changes are mechanical: import ordering, docstrings on public
methods, ClassVar on mutable class attributes, contextlib.suppress, and
asyncio.TimeoutError -> TimeoutError (an alias since 3.11). The smoke
test now reads the new DknCloudNaClient.socket_connected property rather
than reaching into _socket.

Co-authored-by: anthropic/claude-opus-5
This commit is contained in:
2026-09-19 10:09:11 -03:00
co-authored by anthropic/claude-opus-5
parent d6e935e8d7
commit 706580fab2
17 changed files with 461 additions and 99 deletions
+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":