From 706580fab2453f400de98d0cc3fad16248d38765 Mon Sep 17 00:00:00 2001 From: Kydoimos Date: Sat, 19 Sep 2026 10:09:11 -0300 Subject: [PATCH] chore: drop HACS distribution and move CI to Gitea 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 --- .gitea/workflows/ci.yml | 49 +++++ .github/workflows/validate.yml | 33 --- .gitignore | 4 + README.md | 48 +++-- custom_components/dkncloudna/api.py | 32 ++- custom_components/dkncloudna/binary_sensor.py | 3 + custom_components/dkncloudna/climate.py | 55 +++-- custom_components/dkncloudna/config_flow.py | 17 +- custom_components/dkncloudna/const.py | 5 +- custom_components/dkncloudna/coordinator.py | 11 +- custom_components/dkncloudna/entity.py | 2 + custom_components/dkncloudna/manifest.json | 6 +- custom_components/dkncloudna/sensor.py | 2 + hacs.json | 5 - ruff.toml | 81 ++++++++ scripts/validate_integration.py | 190 ++++++++++++++++++ smoke-test/smoke_test.py | 17 +- 17 files changed, 461 insertions(+), 99 deletions(-) create mode 100644 .gitea/workflows/ci.yml delete mode 100644 .github/workflows/validate.yml delete mode 100644 hacs.json create mode 100644 ruff.toml create mode 100755 scripts/validate_integration.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..0f4c4b2 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml deleted file mode 100644 index 362601e..0000000 --- a/.github/workflows/validate.yml +++ /dev/null @@ -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 diff --git a/.gitignore b/.gitignore index f10862a..b135b49 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ /.env +__pycache__/ +*.py[cod] +/.venv/ +/smoke-test/.venv/ diff --git a/README.md b/README.md index 3b0fd16..2d8fa6a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/custom_components/dkncloudna/api.py b/custom_components/dkncloudna/api.py index ab56c8e..d837d3a 100644 --- a/custom_components/dkncloudna/api.py +++ b/custom_components/dkncloudna/api.py @@ -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})" diff --git a/custom_components/dkncloudna/binary_sensor.py b/custom_components/dkncloudna/binary_sensor.py index 5380316..f7c9029 100644 --- a/custom_components/dkncloudna/binary_sensor.py +++ b/custom_components/dkncloudna/binary_sensor.py @@ -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 diff --git a/custom_components/dkncloudna/climate.py b/custom_components/dkncloudna/climate.py index fd3a705..c6fa6d9 100644 --- a/custom_components/dkncloudna/climate.py +++ b/custom_components/dkncloudna/climate.py @@ -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 diff --git a/custom_components/dkncloudna/config_flow.py b/custom_components/dkncloudna/config_flow.py index 9c5e7be..f1507b5 100644 --- a/custom_components/dkncloudna/config_flow.py +++ b/custom_components/dkncloudna/config_flow.py @@ -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( diff --git a/custom_components/dkncloudna/const.py b/custom_components/dkncloudna/const.py index 9971075..118f3a0 100644 --- a/custom_components/dkncloudna/const.py +++ b/custom_components/dkncloudna/const.py @@ -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 diff --git a/custom_components/dkncloudna/coordinator.py b/custom_components/dkncloudna/coordinator.py index 3944745..4689450 100644 --- a/custom_components/dkncloudna/coordinator.py +++ b/custom_components/dkncloudna/coordinator.py @@ -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() diff --git a/custom_components/dkncloudna/entity.py b/custom_components/dkncloudna/entity.py index 0388b26..c996dd9 100644 --- a/custom_components/dkncloudna/entity.py +++ b/custom_components/dkncloudna/entity.py @@ -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)}, diff --git a/custom_components/dkncloudna/manifest.json b/custom_components/dkncloudna/manifest.json index 2784fb7..71cbee9 100644 --- a/custom_components/dkncloudna/manifest.json +++ b/custom_components/dkncloudna/manifest.json @@ -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" } diff --git a/custom_components/dkncloudna/sensor.py b/custom_components/dkncloudna/sensor.py index 7656394..1c78a56 100644 --- a/custom_components/dkncloudna/sensor.py +++ b/custom_components/dkncloudna/sensor.py @@ -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": diff --git a/hacs.json b/hacs.json deleted file mode 100644 index 6931c20..0000000 --- a/hacs.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "DKN Cloud NA", - "homeassistant": "2024.1.0", - "hacs": "2.0.0" -} diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..4b8bcc3 --- /dev/null +++ b/ruff.toml @@ -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" diff --git a/scripts/validate_integration.py b/scripts/validate_integration.py new file mode 100755 index 0000000..3d5a7ce --- /dev/null +++ b/scripts/validate_integration.py @@ -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()) diff --git a/smoke-test/smoke_test.py b/smoke-test/smoke_test.py index b81148f..497619c 100644 --- a/smoke-test/smoke_test.py +++ b/smoke-test/smoke_test.py @@ -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