diff --git a/custom_components/ninlight/__init__.py b/custom_components/ninlight/__init__.py index 96636a5..4f03cad 100644 --- a/custom_components/ninlight/__init__.py +++ b/custom_components/ninlight/__init__.py @@ -1 +1,65 @@ """The ninlight integration.""" + +from __future__ import annotations + +from homeassistant.const import CONF_HOST, CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac + +from .api import NinlightClient +from .const import DEFAULT_TOKEN, DOMAIN +from .coordinator import NinlightConfigEntry, NinlightCoordinator + +PLATFORMS: list[Platform] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> bool: + client = NinlightClient( + async_get_clientsession(hass), entry.data[CONF_HOST], entry.data[CONF_TOKEN] + ) + coordinator = NinlightCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + _async_update_default_token_issue(hass, entry) + + @callback + def _sync_firmware_version() -> None: + info = coordinator.data.info + registry = dr.async_get(hass) + device = registry.async_get_device_by_identifier( + (DOMAIN, format_mac(info["mac"])), entry.entry_id + ) + if device is not None and device.sw_version != info["fw_version"]: + registry.async_update_device(device.id, sw_version=info["fw_version"]) + + entry.async_on_unload(coordinator.async_add_listener(_sync_firmware_version)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> bool: + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> None: + ir.async_delete_issue(hass, DOMAIN, f"default_token_{entry.entry_id}") + + +@callback +def _async_update_default_token_issue(hass: HomeAssistant, entry: NinlightConfigEntry) -> None: + issue_id = f"default_token_{entry.entry_id}" + if entry.data[CONF_TOKEN] == DEFAULT_TOKEN: + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="default_token", + translation_placeholders={"name": entry.title}, + ) + else: + ir.async_delete_issue(hass, DOMAIN, issue_id) diff --git a/custom_components/ninlight/config_flow.py b/custom_components/ninlight/config_flow.py new file mode 100644 index 0000000..54a514a --- /dev/null +++ b/custom_components/ninlight/config_flow.py @@ -0,0 +1,13 @@ +"""Minimal config flow stub; Task 5 replaces this with the real flow.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigFlow + +from .const import DOMAIN + + +class NinlightConfigFlow(ConfigFlow, domain=DOMAIN): + """Placeholder so manifest.json's config_flow: true can be imported.""" + + VERSION = 1 diff --git a/custom_components/ninlight/coordinator.py b/custom_components/ninlight/coordinator.py new file mode 100644 index 0000000..f46ad1d --- /dev/null +++ b/custom_components/ninlight/coordinator.py @@ -0,0 +1,103 @@ +"""Data coordinator for one ninlight lamp.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import ( + NinlightAuthError, + NinlightBusyError, + NinlightClient, + NinlightError, + NinlightRequestError, +) +from .const import CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type NinlightConfigEntry = ConfigEntry[NinlightCoordinator] + + +@dataclass(frozen=True, slots=True) +class NinlightData: + """Last known lamp state and system info.""" + + state: dict[str, Any] + info: dict[str, Any] + + +class NinlightCoordinator(DataUpdateCoordinator[NinlightData]): + """Polls one lamp; every request to the lamp goes through `lock`.""" + + config_entry: NinlightConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: NinlightConfigEntry, client: NinlightClient + ) -> None: + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"{DOMAIN} {client.host}", + update_interval=timedelta( + seconds=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ), + ) + self.client = client + # The W600 web server has a listen backlog of 4: never talk to one lamp concurrently. + self.lock = asyncio.Lock() + # While a firmware update runs, the update entity watches the lamp itself. + self.ota_in_progress = False + + async def _async_update_data(self) -> NinlightData: + if self.ota_in_progress and self.data is not None: + return self.data + try: + async with self.lock: + state = await self.client.get_state() + info = await self.client.get_info() + except NinlightAuthError as err: + raise ConfigEntryAuthFailed(f"{self.client.host}: token rejected") from err + except NinlightError as err: + raise UpdateFailed(f"{self.client.host}: {err}") from err + return NinlightData(state, info) + + async def async_call[T](self, action: Callable[[NinlightClient], Awaitable[T]]) -> T: + """Run one request under the lock and translate lamp errors for the UI.""" + try: + async with self.lock: + return await action(self.client) + except NinlightAuthError as err: + self.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="auth_failed" + ) from err + except NinlightBusyError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="update_busy" + ) from err + except NinlightRequestError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="request_failed", + translation_placeholders={"message": err.message}, + ) from err + except NinlightError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from err + + async def async_set_state(self, **fields: Any) -> None: + """Write and adopt the lamp's complete answer directly - no debounced refresh.""" + state = await self.async_call(lambda client: client.set_state(**fields)) + self.async_set_updated_data(NinlightData(state, self.data.info)) diff --git a/custom_components/ninlight/entity.py b/custom_components/ninlight/entity.py new file mode 100644 index 0000000..9d73af7 --- /dev/null +++ b/custom_components/ninlight/entity.py @@ -0,0 +1,43 @@ +"""Base entity and device info for ninlight lamps.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import NinlightCoordinator + + +def device_name(info: dict[str, Any]) -> str: + """The lamp's own name, else ' '.""" + name = str(info.get("name") or "").strip() + if name: + return name + mac4 = format_mac(info["mac"]).replace(":", "")[-4:] + product = info.get("product_name") or info["device_type"] + return f"{product} {mac4}" + + +class NinlightEntity(CoordinatorEntity[NinlightCoordinator]): + """Common base: unique id from the MAC, one device per lamp.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: NinlightCoordinator, key: str) -> None: + super().__init__(coordinator) + info = coordinator.data.info + mac = format_mac(info["mac"]) + self._attr_unique_id = f"{mac}_{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, mac)}, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + manufacturer="ninlight", + model=info.get("product_name") or None, + model_id=info["device_type"], + name=device_name(info), + sw_version=info["fw_version"], + ) diff --git a/custom_components/ninlight/light.py b/custom_components/ninlight/light.py new file mode 100644 index 0000000..d63110a --- /dev/null +++ b/custom_components/ninlight/light.py @@ -0,0 +1,126 @@ +"""Light platform for ninlight lamps.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ATTR_TRANSITION, + ColorMode, + LightEntity, + LightEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import MAX_TRANSITION_MS +from .coordinator import NinlightConfigEntry, NinlightCoordinator +from .entity import NinlightEntity + +PARALLEL_UPDATES = 0 # the coordinator lock serializes per lamp + +_FALLBACK_KELVIN = (2700, 6500) + + +def ha_to_lamp_brightness(value: int) -> int: + """HA 1-255 -> lamp 1-100. Never 0: 0 would switch the lamp off.""" + return max(1, min(100, round(value * 100 / 255))) + + +def lamp_to_ha_brightness(value: int) -> int: + """Lamp 1-100 -> HA 1-255; round-trips back to the same lamp value.""" + return max(1, min(255, round(value * 255 / 100))) + + +def transition_ms(seconds: float) -> int: + return max(0, min(MAX_TRANSITION_MS, round(seconds * 1000))) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NinlightConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + async_add_entities([NinlightLight(entry.runtime_data)]) + + +class NinlightLight(NinlightEntity, LightEntity): + """The lamp itself.""" + + _attr_name = None + _attr_supported_features = LightEntityFeature.TRANSITION + + def __init__(self, coordinator: NinlightCoordinator) -> None: + super().__init__(coordinator, "light") + + @property + def _state(self) -> dict[str, Any]: + return self.coordinator.data.state + + @property + def is_on(self) -> bool: + return bool(self._state["on"]) + + @property + def brightness(self) -> int: + return lamp_to_ha_brightness(int(self._state["brightness"])) + + @property + def supported_color_modes(self) -> set[ColorMode]: + if self._state["capabilities"].get("color"): + return {ColorMode.RGB, ColorMode.COLOR_TEMP} + return {ColorMode.COLOR_TEMP} + + @property + def color_mode(self) -> ColorMode: + if self._state.get("mode") == "color" and ColorMode.RGB in self.supported_color_modes: + return ColorMode.RGB + return ColorMode.COLOR_TEMP + + @property + def rgb_color(self) -> tuple[int, int, int] | None: + color = self._state.get("color") + if not isinstance(color, dict): + return None + return (int(color["r"]), int(color["g"]), int(color["b"])) + + @property + def color_temp_kelvin(self) -> int | None: + value = self._state.get("color_temp") + return int(value) if value is not None else None + + def _kelvin_range(self) -> tuple[int, int]: + value = self._state["capabilities"].get("color_temp") + if isinstance(value, list) and len(value) == 2: + return (int(value[0]), int(value[1])) + return _FALLBACK_KELVIN + + @property + def min_color_temp_kelvin(self) -> int: + return self._kelvin_range()[0] + + @property + def max_color_temp_kelvin(self) -> int: + return self._kelvin_range()[1] + + async def async_turn_on(self, **kwargs: Any) -> None: + fields: dict[str, Any] = {"on": True} + if ATTR_BRIGHTNESS in kwargs: + fields["brightness"] = ha_to_lamp_brightness(kwargs[ATTR_BRIGHTNESS]) + if ATTR_RGB_COLOR in kwargs: + red, green, blue = kwargs[ATTR_RGB_COLOR] + fields["color"] = {"r": red, "g": green, "b": blue} + if ATTR_COLOR_TEMP_KELVIN in kwargs: + fields["color_temp"] = int(kwargs[ATTR_COLOR_TEMP_KELVIN]) + if ATTR_TRANSITION in kwargs: + fields["transition"] = transition_ms(kwargs[ATTR_TRANSITION]) + await self.coordinator.async_set_state(**fields) + + async def async_turn_off(self, **kwargs: Any) -> None: + fields: dict[str, Any] = {"on": False} + if ATTR_TRANSITION in kwargs: + fields["transition"] = transition_ms(kwargs[ATTR_TRANSITION]) + await self.coordinator.async_set_state(**fields) diff --git a/custom_components/ninlight/strings.json b/custom_components/ninlight/strings.json new file mode 100644 index 0000000..2c804b2 --- /dev/null +++ b/custom_components/ninlight/strings.json @@ -0,0 +1,106 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "title": "Set up ninlight lamp", + "description": "Enter the address of the lamp. The token is prefilled with the public default token – anyone on your network who knows it can control and reflash the lamp.", + "data": { "host": "Host", "token": "Token" }, + "data_description": { + "host": "IP address or ninlight-xxxx.local name of the lamp.", + "token": "Authorization token of the lamp." + } + }, + "zeroconf_confirm": { + "title": "Set up {name}", + "description": "Found the ninlight lamp {name} at {host}. The token is prefilled with the public default token – anyone on your network who knows it can control and reflash the lamp.", + "data": { "token": "Token" } + }, + "reauth_confirm": { + "title": "Re-authenticate {name}", + "description": "The lamp rejected the stored token. Enter its current token.", + "data": { "token": "Token" } + }, + "reconfigure": { + "title": "Reconfigure lamp", + "data": { "host": "Host", "token": "Token" } + } + }, + "error": { + "cannot_connect": "Cannot reach the lamp.", + "invalid_auth": "The lamp rejected the token.", + "not_ninlight": "The device did not answer like a ninlight lamp.", + "unknown": "Unexpected error." + }, + "abort": { + "already_configured": "This lamp is already set up.", + "not_ninlight": "The device did not announce itself like a ninlight lamp.", + "wrong_device": "This address belongs to a different lamp.", + "reauth_successful": "Re-authentication was successful.", + "reconfigure_successful": "Reconfiguration was successful." + } + }, + "options": { + "step": { + "init": { + "title": "ninlight options", + "data": { + "scan_interval": "Polling interval (seconds)", + "lamp_base_url": "Address lamps use to reach Home Assistant" + }, + "data_description": { + "lamp_base_url": "Only needed for firmware updates if lamps cannot reach Home Assistant's internal URL over plain http, e.g. http://192.168.1.50:8123" + } + } + }, + "error": { + "invalid_base_url": "Must be a plain http:// address with host and optional port, without path.", + "base_url_too_long": "Address too long – the lamp accepts at most 255 characters for the whole firmware URL." + } + }, + "entity": { + "number": { + "fade_in": { "name": "Fade-in" }, + "fade_out": { "name": "Fade-out" }, + "transition_default": { "name": "Default transition" } + }, + "select": { + "power_on_behaviour": { + "name": "Power-on behaviour", + "state": { "last": "Last state", "on": "On", "off": "Off" } + } + }, + "switch": { + "wake_on_change": { "name": "Turn on when changed" } + }, + "sensor": { + "ip_address": { "name": "IP address" }, + "free_heap": { "name": "Free memory" }, + "last_ota_error": { "name": "Last update error" }, + "boot_time": { "name": "Last start" } + } + }, + "exceptions": { + "cannot_connect": { "message": "Cannot reach the lamp." }, + "auth_failed": { "message": "The lamp rejected the token." }, + "request_failed": { "message": "The lamp rejected the request: {message}" }, + "update_busy": { "message": "An update is already running on the lamp." }, + "no_update": { "message": "No firmware update is available." }, + "no_http_url": { "message": "Lamps can only reach Home Assistant over plain http. Set the address in the integration options." }, + "lamp_url_too_long": { "message": "The firmware URL is longer than the 255 characters the lamp accepts." }, + "image_invalid": { "message": "The firmware file changed or is no longer valid." }, + "wifi_guard": { "message": "This firmware does not contain the Wi-Fi credentials for {ssid}; the lamp would lose its network connection." }, + "update_failed": { "message": "The update failed: {reason}" }, + "update_timeout": { "message": "The lamp did not report the new firmware within 12 minutes." } + }, + "issues": { + "default_token": { + "title": "{name} uses the public default token", + "description": "Anyone on your network can control and reflash this lamp with the default token. Set your own token on the lamp and enter it via reconfigure." + }, + "wifi_guard": { + "title": "Firmware for {name} not offered", + "description": "The firmware folder contains {version} for {device_type}, but without Wi-Fi credentials for {ssid}. The lamp only knows its Wi-Fi from its firmware and would lose its network connection after this update, so the update is not offered." + } + } +} diff --git a/custom_components/ninlight/translations/de.json b/custom_components/ninlight/translations/de.json new file mode 100644 index 0000000..b2a29c3 --- /dev/null +++ b/custom_components/ninlight/translations/de.json @@ -0,0 +1,106 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "title": "ninlight-Lampe einrichten", + "description": "Adresse der Lampe eingeben. Das Token ist mit dem öffentlichen Vorgabe-Token vorbelegt – wer es kennt, kann die Lampe in deinem Netz steuern und neu flashen.", + "data": { "host": "Host", "token": "Token" }, + "data_description": { + "host": "IP-Adresse oder ninlight-xxxx.local-Name der Lampe.", + "token": "Autorisierungs-Token der Lampe." + } + }, + "zeroconf_confirm": { + "title": "{name} einrichten", + "description": "ninlight-Lampe {name} unter {host} gefunden. Das Token ist mit dem öffentlichen Vorgabe-Token vorbelegt – wer es kennt, kann die Lampe in deinem Netz steuern und neu flashen.", + "data": { "token": "Token" } + }, + "reauth_confirm": { + "title": "{name} neu anmelden", + "description": "Die Lampe hat das gespeicherte Token abgelehnt. Gib ihr aktuelles Token ein.", + "data": { "token": "Token" } + }, + "reconfigure": { + "title": "Lampe neu konfigurieren", + "data": { "host": "Host", "token": "Token" } + } + }, + "error": { + "cannot_connect": "Die Lampe ist nicht erreichbar.", + "invalid_auth": "Die Lampe hat das Token abgelehnt.", + "not_ninlight": "Das Gerät antwortet nicht wie eine ninlight-Lampe.", + "unknown": "Unerwarteter Fehler." + }, + "abort": { + "already_configured": "Diese Lampe ist bereits eingerichtet.", + "not_ninlight": "Das Gerät meldet sich nicht wie eine ninlight-Lampe.", + "wrong_device": "Unter dieser Adresse antwortet eine andere Lampe.", + "reauth_successful": "Neuanmeldung erfolgreich.", + "reconfigure_successful": "Neukonfiguration erfolgreich." + } + }, + "options": { + "step": { + "init": { + "title": "ninlight-Optionen", + "data": { + "scan_interval": "Abfrageintervall (Sekunden)", + "lamp_base_url": "Adresse, unter der Lampen Home Assistant erreichen" + }, + "data_description": { + "lamp_base_url": "Nur für Firmware-Updates nötig, wenn die Lampen die interne URL von Home Assistant nicht über reines http erreichen, z. B. http://192.168.1.50:8123" + } + } + }, + "error": { + "invalid_base_url": "Muss eine reine http://-Adresse mit Host und optionalem Port sein, ohne Pfad.", + "base_url_too_long": "Adresse zu lang – die Lampe akzeptiert höchstens 255 Zeichen für die gesamte Firmware-URL." + } + }, + "entity": { + "number": { + "fade_in": { "name": "Einblenden" }, + "fade_out": { "name": "Ausblenden" }, + "transition_default": { "name": "Standardübergang" } + }, + "select": { + "power_on_behaviour": { + "name": "Einschaltverhalten", + "state": { "last": "Letzter Zustand", "on": "Ein", "off": "Aus" } + } + }, + "switch": { + "wake_on_change": { "name": "Bei Änderung einschalten" } + }, + "sensor": { + "ip_address": { "name": "IP-Adresse" }, + "free_heap": { "name": "Freier Speicher" }, + "last_ota_error": { "name": "Letzter Update-Fehler" }, + "boot_time": { "name": "Letzter Start" } + } + }, + "exceptions": { + "cannot_connect": { "message": "Die Lampe ist nicht erreichbar." }, + "auth_failed": { "message": "Die Lampe hat das Token abgelehnt." }, + "request_failed": { "message": "Die Lampe hat die Anfrage abgelehnt: {message}" }, + "update_busy": { "message": "Auf der Lampe läuft bereits ein Update." }, + "no_update": { "message": "Kein Firmware-Update verfügbar." }, + "no_http_url": { "message": "Lampen erreichen Home Assistant nur über reines http. Setze die Adresse in den Optionen der Integration." }, + "lamp_url_too_long": { "message": "Die Firmware-URL ist länger als die 255 Zeichen, die die Lampe akzeptiert." }, + "image_invalid": { "message": "Die Firmware-Datei hat sich geändert oder ist nicht mehr gültig." }, + "wifi_guard": { "message": "Diese Firmware enthält keine WLAN-Zugangsdaten für {ssid}; die Lampe wäre danach nicht mehr im Netz." }, + "update_failed": { "message": "Das Update ist fehlgeschlagen: {reason}" }, + "update_timeout": { "message": "Die Lampe hat die neue Firmware nicht innerhalb von 12 Minuten gemeldet." } + }, + "issues": { + "default_token": { + "title": "{name} nutzt das öffentliche Vorgabe-Token", + "description": "Mit dem Vorgabe-Token kann jeder in deinem Netz diese Lampe steuern und neu flashen. Setze ein eigenes Token auf der Lampe und trage es über „Neu konfigurieren“ ein." + }, + "wifi_guard": { + "title": "Firmware für {name} nicht angeboten", + "description": "Im Firmware-Ordner liegt {version} für {device_type}, aber ohne WLAN-Zugangsdaten für {ssid}. Die Lampe kennt ihr WLAN nur aus ihrer Firmware und wäre nach diesem Update nicht mehr im Netz – das Update wird deshalb nicht angeboten." + } + } +} diff --git a/custom_components/ninlight/translations/en.json b/custom_components/ninlight/translations/en.json new file mode 100644 index 0000000..2c804b2 --- /dev/null +++ b/custom_components/ninlight/translations/en.json @@ -0,0 +1,106 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "title": "Set up ninlight lamp", + "description": "Enter the address of the lamp. The token is prefilled with the public default token – anyone on your network who knows it can control and reflash the lamp.", + "data": { "host": "Host", "token": "Token" }, + "data_description": { + "host": "IP address or ninlight-xxxx.local name of the lamp.", + "token": "Authorization token of the lamp." + } + }, + "zeroconf_confirm": { + "title": "Set up {name}", + "description": "Found the ninlight lamp {name} at {host}. The token is prefilled with the public default token – anyone on your network who knows it can control and reflash the lamp.", + "data": { "token": "Token" } + }, + "reauth_confirm": { + "title": "Re-authenticate {name}", + "description": "The lamp rejected the stored token. Enter its current token.", + "data": { "token": "Token" } + }, + "reconfigure": { + "title": "Reconfigure lamp", + "data": { "host": "Host", "token": "Token" } + } + }, + "error": { + "cannot_connect": "Cannot reach the lamp.", + "invalid_auth": "The lamp rejected the token.", + "not_ninlight": "The device did not answer like a ninlight lamp.", + "unknown": "Unexpected error." + }, + "abort": { + "already_configured": "This lamp is already set up.", + "not_ninlight": "The device did not announce itself like a ninlight lamp.", + "wrong_device": "This address belongs to a different lamp.", + "reauth_successful": "Re-authentication was successful.", + "reconfigure_successful": "Reconfiguration was successful." + } + }, + "options": { + "step": { + "init": { + "title": "ninlight options", + "data": { + "scan_interval": "Polling interval (seconds)", + "lamp_base_url": "Address lamps use to reach Home Assistant" + }, + "data_description": { + "lamp_base_url": "Only needed for firmware updates if lamps cannot reach Home Assistant's internal URL over plain http, e.g. http://192.168.1.50:8123" + } + } + }, + "error": { + "invalid_base_url": "Must be a plain http:// address with host and optional port, without path.", + "base_url_too_long": "Address too long – the lamp accepts at most 255 characters for the whole firmware URL." + } + }, + "entity": { + "number": { + "fade_in": { "name": "Fade-in" }, + "fade_out": { "name": "Fade-out" }, + "transition_default": { "name": "Default transition" } + }, + "select": { + "power_on_behaviour": { + "name": "Power-on behaviour", + "state": { "last": "Last state", "on": "On", "off": "Off" } + } + }, + "switch": { + "wake_on_change": { "name": "Turn on when changed" } + }, + "sensor": { + "ip_address": { "name": "IP address" }, + "free_heap": { "name": "Free memory" }, + "last_ota_error": { "name": "Last update error" }, + "boot_time": { "name": "Last start" } + } + }, + "exceptions": { + "cannot_connect": { "message": "Cannot reach the lamp." }, + "auth_failed": { "message": "The lamp rejected the token." }, + "request_failed": { "message": "The lamp rejected the request: {message}" }, + "update_busy": { "message": "An update is already running on the lamp." }, + "no_update": { "message": "No firmware update is available." }, + "no_http_url": { "message": "Lamps can only reach Home Assistant over plain http. Set the address in the integration options." }, + "lamp_url_too_long": { "message": "The firmware URL is longer than the 255 characters the lamp accepts." }, + "image_invalid": { "message": "The firmware file changed or is no longer valid." }, + "wifi_guard": { "message": "This firmware does not contain the Wi-Fi credentials for {ssid}; the lamp would lose its network connection." }, + "update_failed": { "message": "The update failed: {reason}" }, + "update_timeout": { "message": "The lamp did not report the new firmware within 12 minutes." } + }, + "issues": { + "default_token": { + "title": "{name} uses the public default token", + "description": "Anyone on your network can control and reflash this lamp with the default token. Set your own token on the lamp and enter it via reconfigure." + }, + "wifi_guard": { + "title": "Firmware for {name} not offered", + "description": "The firmware folder contains {version} for {device_type}, but without Wi-Fi credentials for {ssid}. The lamp only knows its Wi-Fi from its firmware and would lose its network connection after this update, so the update is not offered." + } + } +} diff --git a/tests/conftest.py b/tests/conftest.py index d6651e5..2637e10 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,11 +3,91 @@ from __future__ import annotations from collections.abc import Generator +from copy import deepcopy +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.ninlight.api import NinlightClient +from custom_components.ninlight.const import DOMAIN +from tests_unit.samples import ( + HOST_RGB, + INFO_RGB, + MAC_RGB, + SETTINGS_COMPILED, + STATE_RGB, + TOKEN, +) + +CLIENT_PATCH_TARGETS = ["custom_components.ninlight.NinlightClient"] @pytest.fixture(autouse=True) def auto_enable_custom_integrations(enable_custom_integrations: None) -> Generator[None]: """Load custom_components/ in every Home Assistant test.""" yield + + +@pytest.fixture +def lamp_state() -> dict[str, Any]: + return deepcopy(STATE_RGB) + + +@pytest.fixture +def lamp_info() -> dict[str, Any]: + return deepcopy(INFO_RGB) + + +@pytest.fixture +def lamp_settings() -> dict[str, Any]: + return deepcopy(SETTINGS_COMPILED) + + +@pytest.fixture +def mock_client( + lamp_state: dict[str, Any], lamp_info: dict[str, Any], lamp_settings: dict[str, Any] +) -> Generator[MagicMock]: + """A lamp whose answers follow the mutable lamp_* fixtures.""" + client = MagicMock(spec=NinlightClient) + client.host = HOST_RGB + client.get_state = AsyncMock(side_effect=lambda: deepcopy(lamp_state)) + client.get_info = AsyncMock(side_effect=lambda: deepcopy(lamp_info)) + client.get_settings = AsyncMock(side_effect=lambda: deepcopy(lamp_settings)) + + async def set_state(**fields: Any) -> dict[str, Any]: + lamp_state.update(fields) + return deepcopy(lamp_state) + + client.set_state = AsyncMock(side_effect=set_state) + client.reboot = AsyncMock(return_value=None) + client.fw_update = AsyncMock(return_value=None) + patches = [patch(target, return_value=client) for target in CLIENT_PATCH_TARGETS] + for p in patches: + p.start() + yield client + for p in patches: + p.stop() + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + return MockConfigEntry( + domain=DOMAIN, + title="Klyqa GU10 RGB CW/WW 06b8", + unique_id=MAC_RGB, + data={CONF_HOST: HOST_RGB, CONF_TOKEN: TOKEN}, + ) + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: MagicMock +) -> MockConfigEntry: + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..8ec47bc --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,16 @@ +"""Helpers for Home Assistant tests.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from custom_components.ninlight.const import DOMAIN +from tests_unit.samples import MAC_RGB + + +def entity_id_for(hass: HomeAssistant, platform: str, key: str, mac: str = MAC_RGB) -> str: + """Look entities up by unique_id instead of guessing slugs.""" + entity_id = er.async_get(hass).async_get_entity_id(platform, DOMAIN, f"{mac}_{key}") + assert entity_id is not None, f"no {platform} entity with key {key}" + return entity_id diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 0000000..8a149dc --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,61 @@ +"""Coordinator behaviour that is not specific to one platform.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +import pytest +from pytest_homeassistant_custom_component.common import async_fire_time_changed + +from custom_components.ninlight.api import ( + NinlightBusyError, + NinlightConnectionError, + NinlightRequestError, +) +from tests.helpers import entity_id_for + + +@pytest.mark.parametrize( + ("error", "key", "placeholders"), + [ + ( + NinlightRequestError(400, "invalid brightness"), + "request_failed", + {"message": "invalid brightness"}, + ), + (NinlightBusyError(409, "an update is already running"), "update_busy", None), + (NinlightConnectionError("reset"), "cannot_connect", None), + ], +) +async def test_write_errors_are_translated( + hass: HomeAssistant, init_integration, mock_client: MagicMock, error, key, placeholders +) -> None: + mock_client.set_state.side_effect = error + with pytest.raises(HomeAssistantError) as raised: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id_for(hass, "light", "light")}, + blocking=True, + ) + assert raised.value.translation_key == key + assert raised.value.translation_placeholders == placeholders + + +async def test_polling_pauses_while_an_update_runs( + hass: HomeAssistant, init_integration, mock_client: MagicMock, freezer: FrozenDateTimeFactory +) -> None: + init_integration.runtime_data.ota_in_progress = True + mock_client.get_state.side_effect = NinlightConnectionError("rebooting") + reads = mock_client.get_state.await_count + freezer.tick(timedelta(seconds=31)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_client.get_state.await_count == reads + assert hass.states.get(entity_id_for(hass, "light", "light")).state == STATE_ON diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..b25e6eb --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,101 @@ +"""Setup, unload, repair issues and the device registry.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import issue_registry as ir +from pytest_homeassistant_custom_component.common import MockConfigEntry, async_fire_time_changed + +from custom_components.ninlight.api import NinlightConnectionError +from custom_components.ninlight.const import DEFAULT_TOKEN, DOMAIN +from custom_components.ninlight.entity import device_name +from tests_unit.samples import HOST_RGB, INFO_CW, INFO_RGB, MAC_RGB + + +def test_device_name_prefers_lamp_name() -> None: + assert device_name(INFO_RGB) == "Klyqa GU10 RGB CW/WW 06b8" + assert device_name(INFO_CW) == "Wohnzimmer" + assert device_name({**INFO_RGB, "name": " ", "product_name": ""}) == "gu10_rgb 06b8" + + +async def test_setup_and_unload(hass: HomeAssistant, init_integration: MockConfigEntry) -> None: + assert init_integration.state is ConfigEntryState.LOADED + device = dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, MAC_RGB), init_integration.entry_id + ) + assert device is not None + assert (device.name, device.model, device.model_id, device.sw_version) == ( + "Klyqa GU10 RGB CW/WW 06b8", + "Klyqa GU10 RGB CW/WW", + "gu10_rgb", + "0.3.8-36-gca9bb", + ) + assert (dr.CONNECTION_NETWORK_MAC, MAC_RGB) in device.connections + assert await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + assert init_integration.state is ConfigEntryState.NOT_LOADED + + +async def test_unreachable_at_startup_retries( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: MagicMock +) -> None: + mock_client.get_state.side_effect = NinlightConnectionError("down") + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_default_token_raises_repair_issue( + hass: HomeAssistant, mock_client: MagicMock +) -> None: + entry = MockConfigEntry( + domain=DOMAIN, + title="Lampe", + unique_id=MAC_RGB, + data={CONF_HOST: HOST_RGB, CONF_TOKEN: DEFAULT_TOKEN}, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + issue = ir.async_get(hass).async_get_issue(DOMAIN, f"default_token_{entry.entry_id}") + assert issue is not None + assert issue.translation_key == "default_token" + assert issue.translation_placeholders == {"name": "Lampe"} + + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + assert ir.async_get(hass).async_get_issue(DOMAIN, f"default_token_{entry.entry_id}") is None + + +async def test_own_token_raises_no_issue( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + assert ( + ir.async_get(hass).async_get_issue(DOMAIN, f"default_token_{init_integration.entry_id}") + is None + ) + + +async def test_device_registry_follows_firmware_version( + hass: HomeAssistant, + init_integration: MockConfigEntry, + lamp_info: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + lamp_info["fw_version"] = "0.3.8-47-g71a57" + freezer.tick(timedelta(seconds=31)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + device = dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, MAC_RGB), init_integration.entry_id + ) + assert device.sw_version == "0.3.8-47-g71a57" diff --git a/tests/test_light.py b/tests/test_light.py new file mode 100644 index 0000000..65a0d10 --- /dev/null +++ b/tests/test_light.py @@ -0,0 +1,212 @@ +"""The light entity.""" + +from __future__ import annotations + +import asyncio +from copy import deepcopy +from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, + ATTR_COLOR_TEMP_KELVIN, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ATTR_SUPPORTED_COLOR_MODES, + ATTR_TRANSITION, + ColorMode, +) +from homeassistant.components.light import ( + DOMAIN as LIGHT_DOMAIN, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_TOKEN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry, async_fire_time_changed + +from custom_components.ninlight.api import NinlightConnectionError +from custom_components.ninlight.const import DOMAIN +from custom_components.ninlight.light import ( + ha_to_lamp_brightness, + lamp_to_ha_brightness, + transition_ms, +) +from tests.helpers import entity_id_for +from tests_unit.samples import HOST_CW, INFO_CW, MAC_CW, STATE_CW, STATE_RGB, TOKEN + + +def test_brightness_never_zero_and_round_trips() -> None: + for ha_value in range(1, 256): + assert 1 <= ha_to_lamp_brightness(ha_value) <= 100, ha_value + for lamp_value in range(1, 101): + assert 1 <= lamp_to_ha_brightness(lamp_value) <= 255, lamp_value + assert ha_to_lamp_brightness(lamp_to_ha_brightness(lamp_value)) == lamp_value, lamp_value + + +def test_transition_ms_rounds_and_clamps() -> None: + assert transition_ms(2.5) == 2500 + assert transition_ms(0.0014) == 1 + assert transition_ms(-1) == 0 + assert transition_ms(90) == 60000 + + +async def test_state_attributes_of_color_lamp(hass: HomeAssistant, init_integration) -> None: + state = hass.states.get(entity_id_for(hass, "light", "light")) + assert state.state == STATE_ON + assert state.attributes[ATTR_BRIGHTNESS] == 94 # lamp 37 % + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.RGB + assert state.attributes[ATTR_RGB_COLOR] == (12, 200, 90) + assert set(state.attributes[ATTR_SUPPORTED_COLOR_MODES]) == { + ColorMode.RGB, + ColorMode.COLOR_TEMP, + } + assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 + assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 + + +async def test_white_lamp_offers_only_color_temp( + hass: HomeAssistant, + mock_client: MagicMock, + lamp_state: dict[str, Any], + lamp_info: dict[str, Any], +) -> None: + lamp_state.clear() + lamp_state.update(deepcopy(STATE_CW)) + lamp_info.clear() + lamp_info.update(deepcopy(INFO_CW)) + entry = MockConfigEntry( + domain=DOMAIN, unique_id=MAC_CW, data={CONF_HOST: HOST_CW, CONF_TOKEN: TOKEN} + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + state = hass.states.get(entity_id_for(hass, "light", "light", mac=MAC_CW)) + assert state.state == STATE_OFF + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.COLOR_TEMP] + assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2000 + assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 5000 + + +async def test_turn_on_sends_everything_in_one_request( + hass: HomeAssistant, init_integration, mock_client: MagicMock +) -> None: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: entity_id_for(hass, "light", "light"), + ATTR_BRIGHTNESS: 200, + ATTR_COLOR_TEMP_KELVIN: 4100, + ATTR_TRANSITION: 2.5, + }, + blocking=True, + ) + mock_client.set_state.assert_awaited_once_with( + on=True, brightness=78, color_temp=4100, transition=2500 + ) + + +async def test_turn_on_with_rgb_and_without_transition( + hass: HomeAssistant, init_integration, mock_client: MagicMock +) -> None: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id_for(hass, "light", "light"), ATTR_RGB_COLOR: (40, 10, 220)}, + blocking=True, + ) + mock_client.set_state.assert_awaited_once_with(on=True, color={"r": 40, "g": 10, "b": 220}) + + +async def test_turn_off_clamps_transition( + hass: HomeAssistant, init_integration, mock_client: MagicMock +) -> None: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id_for(hass, "light", "light"), ATTR_TRANSITION: 90}, + blocking=True, + ) + mock_client.set_state.assert_awaited_once_with(on=False, transition=60000) + + +async def test_write_adopts_lamp_answer_without_reading_again( + hass: HomeAssistant, init_integration, mock_client: MagicMock +) -> None: + async def lamp_clamps(**fields: Any) -> dict[str, Any]: + return {**deepcopy(STATE_RGB), "mode": "white", "brightness": 55} + + mock_client.set_state.side_effect = lamp_clamps + reads_before = mock_client.get_state.await_count + entity_id = entity_id_for(hass, "light", "light") + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 200}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_BRIGHTNESS] == 140 # the lamp's 55 %, not the requested 78 % + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + assert mock_client.get_state.await_count == reads_before + + +async def test_requests_to_one_lamp_never_overlap( + hass: HomeAssistant, init_integration, mock_client: MagicMock, lamp_state: dict[str, Any] +) -> None: + active = 0 + peak = 0 + + async def slow(**fields: Any) -> dict[str, Any]: + nonlocal active, peak + active += 1 + peak = max(peak, active) + await asyncio.sleep(0.01) + active -= 1 + lamp_state.update(fields) + return deepcopy(lamp_state) + + mock_client.set_state.side_effect = slow + entity_id = entity_id_for(hass, "light", "light") + await asyncio.gather( + *( + hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: value}, + blocking=True, + ) + for value in (60, 120, 180) + ) + ) + assert mock_client.set_state.await_count == 3 + assert peak == 1 + + +async def test_unreachable_lamp_becomes_unavailable_and_recovers( + hass: HomeAssistant, init_integration, mock_client: MagicMock, freezer: FrozenDateTimeFactory +) -> None: + entity_id = entity_id_for(hass, "light", "light") + mock_client.get_state.side_effect = NinlightConnectionError("down") + freezer.tick(timedelta(seconds=31)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + mock_client.get_state.side_effect = lambda: deepcopy(STATE_RGB) + freezer.tick(timedelta(seconds=31)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_ON