feat: Coordinator, Geraet und Licht-Entitaet
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user