feat: Coordinator, Geraet und Licht-Entitaet

This commit is contained in:
Michael Sauer
2026-09-16 15:32:07 +02:00
parent 75dc2b7dd6
commit 40186d95d1
13 changed files with 1137 additions and 0 deletions
+64
View File
@@ -1 +1,65 @@
"""The ninlight integration.""" """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)
+13
View File
@@ -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
+103
View File
@@ -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))
+43
View File
@@ -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 '<product> <last four MAC digits>'."""
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"],
)
+126
View File
@@ -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)
+106
View File
@@ -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."
}
}
}
@@ -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."
}
}
}
@@ -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."
}
}
}
+80
View File
@@ -3,11 +3,91 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Generator 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 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) @pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations: None) -> Generator[None]: def auto_enable_custom_integrations(enable_custom_integrations: None) -> Generator[None]:
"""Load custom_components/ in every Home Assistant test.""" """Load custom_components/ in every Home Assistant test."""
yield 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
+16
View File
@@ -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
+61
View File
@@ -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
+101
View File
@@ -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"
+212
View File
@@ -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