diff --git a/custom_components/ninlight/__init__.py b/custom_components/ninlight/__init__.py index a15d9de..931f124 100644 --- a/custom_components/ninlight/__init__.py +++ b/custom_components/ninlight/__init__.py @@ -25,6 +25,7 @@ from .shared import async_get_shared, async_start_catalog, async_stop_catalog CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT, Platform.NUMBER, diff --git a/custom_components/ninlight/api.py b/custom_components/ninlight/api.py index 90b8268..094106e 100644 --- a/custom_components/ninlight/api.py +++ b/custom_components/ninlight/api.py @@ -81,6 +81,10 @@ class NinlightClient: """The one read that always requires a valid token (see spec §2).""" return _require(await self._request("GET", "system/settings"), (), "settings") + async def set_sacn(self, **fields: Any) -> None: + """Change sACN settings. Fields left out keep their value on the lamp.""" + await self._request("PUT", "system/settings", {"type": "sacn", **fields}) + async def reboot(self) -> None: await self._request("PUT", "system/command", {"command": {"type": "reboot"}}) diff --git a/custom_components/ninlight/binary_sensor.py b/custom_components/ninlight/binary_sensor.py new file mode 100644 index 0000000..15b6151 --- /dev/null +++ b/custom_components/ninlight/binary_sensor.py @@ -0,0 +1,64 @@ +"""Diagnostic binary sensors of a ninlight lamp: the sACN receiver's state.""" + +from __future__ import annotations + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import NinlightConfigEntry, NinlightCoordinator +from .entity import NinlightEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NinlightConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + async_add_entities( + [NinlightSacnActive(entry.runtime_data), NinlightSacnMulticast(entry.runtime_data)] + ) + + +class _NinlightSacnInfo(NinlightEntity, BinarySensorEntity): + """Base for the two flags the lamp reports under system/info -> sacn.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _field: str + + @property + def available(self) -> bool: + return super().available and self.coordinator.data.sacn_info is not None + + @property + def is_on(self) -> bool: + return bool((self.coordinator.data.sacn_info or {}).get(self._field)) + + +class NinlightSacnActive(_NinlightSacnInfo): + """True while a controller is driving the lamp.""" + + _attr_translation_key = "sacn_active" + _field = "active" + + def __init__(self, coordinator: NinlightCoordinator) -> None: + super().__init__(coordinator, "sacn_active") + + +class NinlightSacnMulticast(_NinlightSacnInfo): + """True when the lamp joined the multicast group of its universe. + + Off by default like the other diagnostics: it reads "true" in normal operation and + only earns its place when multicast does not arrive. It is worth having - a wrong + group address once cost two evenings of searching, and this flag is where that shows. + """ + + _attr_translation_key = "sacn_multicast" + _attr_entity_registry_enabled_default = False + _field = "multicast" + + def __init__(self, coordinator: NinlightCoordinator) -> None: + super().__init__(coordinator, "sacn_multicast") diff --git a/custom_components/ninlight/coordinator.py b/custom_components/ninlight/coordinator.py index 978bae3..991a439 100644 --- a/custom_components/ninlight/coordinator.py +++ b/custom_components/ninlight/coordinator.py @@ -30,10 +30,25 @@ type NinlightConfigEntry = ConfigEntry[NinlightCoordinator] @dataclass(frozen=True, slots=True) class NinlightData: - """Last known lamp state and system info.""" + """Last known lamp state, system info and settings.""" state: dict[str, Any] info: dict[str, Any] + # Only the settings the lamp exposes; a lamp on firmware without sACN carries no + # "sacn" block here, and the entities that need it report themselves unavailable. + settings: dict[str, Any] + + @property + def sacn(self) -> dict[str, Any] | None: + """The lamp's sACN settings, or None on firmware that predates the feature.""" + block = self.settings.get("sacn") + return block if isinstance(block, dict) else None + + @property + def sacn_info(self) -> dict[str, Any] | None: + """The sACN status from system/info, or None on firmware without it.""" + block = self.info.get("sacn") + return block if isinstance(block, dict) else None class NinlightCoordinator(DataUpdateCoordinator[NinlightData]): @@ -68,6 +83,9 @@ class NinlightCoordinator(DataUpdateCoordinator[NinlightData]): async with self.lock: state = await self.client.get_state() info = await self.client.get_info() + # The sACN settings live behind system/settings, not device/state - so the + # config entities for them need this third request per cycle. + settings = await self.client.get_settings() except NinlightAuthError as err: raise ConfigEntryAuthFailed(f"{self.client.host}: token rejected") from err except NinlightError as err: @@ -83,7 +101,7 @@ class NinlightCoordinator(DataUpdateCoordinator[NinlightData]): return self.data raise UpdateFailed(f"{self.client.host}: {err}") from err self._consecutive_poll_failures = 0 - return NinlightData(state, info) + return NinlightData(state, info, settings) 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.""" @@ -110,8 +128,25 @@ class NinlightCoordinator(DataUpdateCoordinator[NinlightData]): translation_domain=DOMAIN, translation_key="cannot_connect" ) from err + async def async_set_sacn(self, **fields: Any) -> None: + """Write sACN settings and adopt the lamp's own answer. + + The lamp returns only {"ok": true}, so the settings are read back instead of + guessed: it clamps and validates on its own (the start_slot limit depends on the + slot layout, for one), and an entity that shows what was asked for rather than what + was stored would lie the moment the two differ. + """ + + async def write_and_read(client: NinlightClient) -> dict[str, Any]: + await client.set_sacn(**fields) + return await client.get_settings() + + settings = await self.async_call(write_and_read) + self._consecutive_poll_failures = 0 + self.async_set_updated_data(NinlightData(self.data.state, self.data.info, settings)) + 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._consecutive_poll_failures = 0 - self.async_set_updated_data(NinlightData(state, self.data.info)) + self.async_set_updated_data(NinlightData(state, self.data.info, self.data.settings)) diff --git a/custom_components/ninlight/entity.py b/custom_components/ninlight/entity.py index 9d73af7..29d7f60 100644 --- a/custom_components/ninlight/entity.py +++ b/custom_components/ninlight/entity.py @@ -41,3 +41,22 @@ class NinlightEntity(CoordinatorEntity[NinlightCoordinator]): name=device_name(info), sw_version=info["fw_version"], ) + + +class NinlightSacnEntity(NinlightEntity): + """Base for entities backed by the lamp's sACN settings. + + Firmware older than the sACN feature answers system/settings without a "sacn" block. + Those entities are still created - so they come to life on the next poll after such a + lamp is updated, without reloading the integration - but they report themselves + unavailable until the block shows up. + """ + + @property + def available(self) -> bool: + return super().available and self.coordinator.data.sacn is not None + + @property + def sacn(self) -> dict[str, Any]: + """The lamp's sACN settings; empty while the lamp does not report them.""" + return self.coordinator.data.sacn or {} diff --git a/custom_components/ninlight/number.py b/custom_components/ninlight/number.py index 4297aaf..1485fd3 100644 --- a/custom_components/ninlight/number.py +++ b/custom_components/ninlight/number.py @@ -1,4 +1,4 @@ -"""Fade and transition settings of a ninlight lamp.""" +"""Number settings of a ninlight lamp: fades, transition and sACN addressing.""" from __future__ import annotations @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import NinlightConfigEntry, NinlightCoordinator -from .entity import NinlightEntity +from .entity import NinlightEntity, NinlightSacnEntity PARALLEL_UPDATES = 0 @@ -46,7 +46,12 @@ async def async_setup_entry( entry: NinlightConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - async_add_entities(NinlightNumber(entry.runtime_data, description) for description in NUMBERS) + entities: list[NumberEntity] = [ + NinlightNumber(entry.runtime_data, description) for description in NUMBERS + ] + entities.append(NinlightSacnUniverse(entry.runtime_data)) + entities.append(NinlightSacnStartSlot(entry.runtime_data)) + async_add_entities(entities) class NinlightNumber(NinlightEntity, NumberEntity): @@ -67,3 +72,58 @@ class NinlightNumber(NinlightEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: await self.coordinator.async_set_state(**{self.entity_description.key: int(value)}) + + +class NinlightSacnUniverse(NinlightSacnEntity, NumberEntity): + """The DMX universe the lamp listens on.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "sacn_universe" + _attr_native_min_value = 1 + _attr_native_max_value = 63_999 + _attr_native_step = 1 + _attr_mode = NumberMode.BOX + + def __init__(self, coordinator: NinlightCoordinator) -> None: + super().__init__(coordinator, "sacn_universe") + + @property + def native_value(self) -> float | None: + value = self.sacn.get("universe") + return float(value) if isinstance(value, (int, float)) else None + + async def async_set_native_value(self, value: float) -> None: + await self.coordinator.async_set_sacn(universe=int(value)) + + +class NinlightSacnStartSlot(NinlightSacnEntity, NumberEntity): + """The first slot of the lamp inside its universe.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "sacn_start_slot" + _attr_native_min_value = 1 + _attr_native_step = 1 + _attr_mode = NumberMode.BOX + + def __init__(self, coordinator: NinlightCoordinator) -> None: + super().__init__(coordinator, "sacn_start_slot") + + @property + def native_max_value(self) -> float: + """The highest start slot that still leaves room for all of the lamp's slots. + + The lamp reports how many it needs, and that number depends on both the board and + the slot layout - a fixed 512 here would offer settings the lamp then rejects. + """ + slots = self.sacn.get("slots") + if not isinstance(slots, int) or not 1 <= slots <= 512: + return 512 + return 512 - slots + 1 + + @property + def native_value(self) -> float | None: + value = self.sacn.get("start_slot") + return float(value) if isinstance(value, (int, float)) else None + + async def async_set_native_value(self, value: float) -> None: + await self.coordinator.async_set_sacn(start_slot=int(value)) diff --git a/custom_components/ninlight/select.py b/custom_components/ninlight/select.py index 27a2be4..e414c10 100644 --- a/custom_components/ninlight/select.py +++ b/custom_components/ninlight/select.py @@ -1,4 +1,4 @@ -"""Power-on behaviour of a ninlight lamp.""" +"""Select settings of a ninlight lamp: power-on behaviour and sACN.""" from __future__ import annotations @@ -8,12 +8,15 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import NinlightConfigEntry, NinlightCoordinator -from .entity import NinlightEntity +from .entity import NinlightEntity, NinlightSacnEntity PARALLEL_UPDATES = 0 # POB_NAMES in the firmware's nl_api.c POWER_ON_OPTIONS = ["last", "on", "off"] +# The two sACN settings the firmware takes as strings, same spelling as its API. +ON_TIMEOUT_OPTIONS = ["release", "hold"] +LAYOUT_OPTIONS = ["dimmer", "rgb"] async def async_setup_entry( @@ -21,7 +24,13 @@ async def async_setup_entry( entry: NinlightConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - async_add_entities([NinlightPowerOnSelect(entry.runtime_data)]) + async_add_entities( + [ + NinlightPowerOnSelect(entry.runtime_data), + NinlightSacnOnTimeoutSelect(entry.runtime_data), + NinlightSacnLayoutSelect(entry.runtime_data), + ] + ) class NinlightPowerOnSelect(NinlightEntity, SelectEntity): @@ -41,3 +50,41 @@ class NinlightPowerOnSelect(NinlightEntity, SelectEntity): async def async_select_option(self, option: str) -> None: await self.coordinator.async_set_state(power_on_behaviour=option) + + +class NinlightSacnOnTimeoutSelect(NinlightSacnEntity, SelectEntity): + """What the lamp does 2.5 s after the last sACN packet.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "sacn_on_timeout" + _attr_options = ON_TIMEOUT_OPTIONS + + def __init__(self, coordinator: NinlightCoordinator) -> None: + super().__init__(coordinator, "sacn_on_timeout") + + @property + def current_option(self) -> str | None: + value = self.sacn.get("on_timeout") + return value if value in ON_TIMEOUT_OPTIONS else None + + async def async_select_option(self, option: str) -> None: + await self.coordinator.async_set_sacn(on_timeout=option) + + +class NinlightSacnLayoutSelect(NinlightSacnEntity, SelectEntity): + """Which slots the lamp reads: with a master dimmer, or plain R,G,B.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "sacn_layout" + _attr_options = LAYOUT_OPTIONS + + def __init__(self, coordinator: NinlightCoordinator) -> None: + super().__init__(coordinator, "sacn_layout") + + @property + def current_option(self) -> str | None: + value = self.sacn.get("layout") + return value if value in LAYOUT_OPTIONS else None + + async def async_select_option(self, option: str) -> None: + await self.coordinator.async_set_sacn(layout=option) diff --git a/custom_components/ninlight/strings.json b/custom_components/ninlight/strings.json index 47d8419..1be73ed 100644 --- a/custom_components/ninlight/strings.json +++ b/custom_components/ninlight/strings.json @@ -5,7 +5,10 @@ "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": { + "host": "Host", + "token": "Token" + }, "data_description": { "host": "IP address or ninlight-xxxx.local name of the lamp.", "token": "Authorization token of the lamp." @@ -14,16 +17,23 @@ "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" } + "data": { + "token": "Token" + } }, "reauth_confirm": { "title": "Re-authenticate {name}", "description": "The lamp rejected the stored token. Enter its current token.", - "data": { "token": "Token" } + "data": { + "token": "Token" + } }, "reconfigure": { "title": "Reconfigure lamp", - "data": { "host": "Host", "token": "Token" } + "data": { + "host": "Host", + "token": "Token" + } } }, "error": { @@ -60,38 +70,111 @@ }, "entity": { "number": { - "fade_in": { "name": "Fade-in" }, - "fade_out": { "name": "Fade-out" }, - "transition_default": { "name": "Default transition" } + "fade_in": { + "name": "Fade-in" + }, + "fade_out": { + "name": "Fade-out" + }, + "transition_default": { + "name": "Default transition" + }, + "sacn_universe": { + "name": "sACN universe" + }, + "sacn_start_slot": { + "name": "sACN start slot" + } }, "select": { "power_on_behaviour": { "name": "Power-on behaviour", - "state": { "last": "Last state", "on": "On", "off": "Off" } + "state": { + "last": "Last state", + "on": "On", + "off": "Off" + } + }, + "sacn_on_timeout": { + "name": "sACN after timeout", + "state": { + "release": "Back to Home Assistant", + "hold": "Hold last values" + } + }, + "sacn_layout": { + "name": "sACN slot layout", + "state": { + "dimmer": "Dimmer first", + "rgb": "RGB only" + } } }, "switch": { - "wake_on_change": { "name": "Turn on when changed" } + "wake_on_change": { + "name": "Turn on when changed" + }, + "sacn_enabled": { + "name": "sACN reception" + } }, "sensor": { - "ip_address": { "name": "IP address" }, - "free_heap": { "name": "Free memory" }, - "last_ota_error": { "name": "Last update error" }, - "boot_time": { "name": "Last start" } + "ip_address": { + "name": "IP address" + }, + "free_heap": { + "name": "Free memory" + }, + "last_ota_error": { + "name": "Last update error" + }, + "boot_time": { + "name": "Last start" + } + }, + "binary_sensor": { + "sacn_active": { + "name": "Remote controlled" + }, + "sacn_multicast": { + "name": "Multicast group" + } } }, "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." } + "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": { diff --git a/custom_components/ninlight/switch.py b/custom_components/ninlight/switch.py index a1ddedf..6999ef9 100644 --- a/custom_components/ninlight/switch.py +++ b/custom_components/ninlight/switch.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import NinlightConfigEntry, NinlightCoordinator -from .entity import NinlightEntity +from .entity import NinlightEntity, NinlightSacnEntity PARALLEL_UPDATES = 0 @@ -20,7 +20,9 @@ async def async_setup_entry( entry: NinlightConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - async_add_entities([NinlightWakeSwitch(entry.runtime_data)]) + async_add_entities( + [NinlightWakeSwitch(entry.runtime_data), NinlightSacnSwitch(entry.runtime_data)] + ) class NinlightWakeSwitch(NinlightEntity, SwitchEntity): @@ -41,3 +43,23 @@ class NinlightWakeSwitch(NinlightEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: await self.coordinator.async_set_state(wake_on_change=False) + + +class NinlightSacnSwitch(NinlightSacnEntity, SwitchEntity): + """Whether the lamp listens for sACN/E1.31 at all.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "sacn_enabled" + + def __init__(self, coordinator: NinlightCoordinator) -> None: + super().__init__(coordinator, "sacn_enabled") + + @property + def is_on(self) -> bool: + return bool(self.sacn.get("enabled")) + + async def async_turn_on(self, **kwargs: Any) -> None: + await self.coordinator.async_set_sacn(enabled=True) + + async def async_turn_off(self, **kwargs: Any) -> None: + await self.coordinator.async_set_sacn(enabled=False) diff --git a/custom_components/ninlight/translations/de.json b/custom_components/ninlight/translations/de.json index b9cb01b..8b5adaf 100644 --- a/custom_components/ninlight/translations/de.json +++ b/custom_components/ninlight/translations/de.json @@ -5,7 +5,10 @@ "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": { + "host": "Host", + "token": "Token" + }, "data_description": { "host": "IP-Adresse oder ninlight-xxxx.local-Name der Lampe.", "token": "Autorisierungs-Token der Lampe." @@ -14,16 +17,23 @@ "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" } + "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" } + "data": { + "token": "Token" + } }, "reconfigure": { "title": "Lampe neu konfigurieren", - "data": { "host": "Host", "token": "Token" } + "data": { + "host": "Host", + "token": "Token" + } } }, "error": { @@ -60,38 +70,111 @@ }, "entity": { "number": { - "fade_in": { "name": "Einblenden" }, - "fade_out": { "name": "Ausblenden" }, - "transition_default": { "name": "Standardübergang" } + "fade_in": { + "name": "Einblenden" + }, + "fade_out": { + "name": "Ausblenden" + }, + "transition_default": { + "name": "Standardübergang" + }, + "sacn_universe": { + "name": "sACN-Universe" + }, + "sacn_start_slot": { + "name": "sACN-Startslot" + } }, "select": { "power_on_behaviour": { "name": "Einschaltverhalten", - "state": { "last": "Letzter Zustand", "on": "Ein", "off": "Aus" } + "state": { + "last": "Letzter Zustand", + "on": "Ein", + "off": "Aus" + } + }, + "sacn_on_timeout": { + "name": "sACN nach Timeout", + "state": { + "release": "Zurück zu Home Assistant", + "hold": "Letzte Werte halten" + } + }, + "sacn_layout": { + "name": "sACN-Slotbelegung", + "state": { + "dimmer": "Dimmer voran", + "rgb": "Nur RGB" + } } }, "switch": { - "wake_on_change": { "name": "Bei Änderung einschalten" } + "wake_on_change": { + "name": "Bei Änderung einschalten" + }, + "sacn_enabled": { + "name": "sACN-Empfang" + } }, "sensor": { - "ip_address": { "name": "IP-Adresse" }, - "free_heap": { "name": "Freier Speicher" }, - "last_ota_error": { "name": "Letzter Update-Fehler" }, - "boot_time": { "name": "Letzter Start" } + "ip_address": { + "name": "IP-Adresse" + }, + "free_heap": { + "name": "Freier Speicher" + }, + "last_ota_error": { + "name": "Letzter Update-Fehler" + }, + "boot_time": { + "name": "Letzter Start" + } + }, + "binary_sensor": { + "sacn_active": { + "name": "Ferngesteuert" + }, + "sacn_multicast": { + "name": "Multicast-Gruppe" + } } }, "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." } + "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": { diff --git a/custom_components/ninlight/translations/en.json b/custom_components/ninlight/translations/en.json index 47d8419..1be73ed 100644 --- a/custom_components/ninlight/translations/en.json +++ b/custom_components/ninlight/translations/en.json @@ -5,7 +5,10 @@ "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": { + "host": "Host", + "token": "Token" + }, "data_description": { "host": "IP address or ninlight-xxxx.local name of the lamp.", "token": "Authorization token of the lamp." @@ -14,16 +17,23 @@ "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" } + "data": { + "token": "Token" + } }, "reauth_confirm": { "title": "Re-authenticate {name}", "description": "The lamp rejected the stored token. Enter its current token.", - "data": { "token": "Token" } + "data": { + "token": "Token" + } }, "reconfigure": { "title": "Reconfigure lamp", - "data": { "host": "Host", "token": "Token" } + "data": { + "host": "Host", + "token": "Token" + } } }, "error": { @@ -60,38 +70,111 @@ }, "entity": { "number": { - "fade_in": { "name": "Fade-in" }, - "fade_out": { "name": "Fade-out" }, - "transition_default": { "name": "Default transition" } + "fade_in": { + "name": "Fade-in" + }, + "fade_out": { + "name": "Fade-out" + }, + "transition_default": { + "name": "Default transition" + }, + "sacn_universe": { + "name": "sACN universe" + }, + "sacn_start_slot": { + "name": "sACN start slot" + } }, "select": { "power_on_behaviour": { "name": "Power-on behaviour", - "state": { "last": "Last state", "on": "On", "off": "Off" } + "state": { + "last": "Last state", + "on": "On", + "off": "Off" + } + }, + "sacn_on_timeout": { + "name": "sACN after timeout", + "state": { + "release": "Back to Home Assistant", + "hold": "Hold last values" + } + }, + "sacn_layout": { + "name": "sACN slot layout", + "state": { + "dimmer": "Dimmer first", + "rgb": "RGB only" + } } }, "switch": { - "wake_on_change": { "name": "Turn on when changed" } + "wake_on_change": { + "name": "Turn on when changed" + }, + "sacn_enabled": { + "name": "sACN reception" + } }, "sensor": { - "ip_address": { "name": "IP address" }, - "free_heap": { "name": "Free memory" }, - "last_ota_error": { "name": "Last update error" }, - "boot_time": { "name": "Last start" } + "ip_address": { + "name": "IP address" + }, + "free_heap": { + "name": "Free memory" + }, + "last_ota_error": { + "name": "Last update error" + }, + "boot_time": { + "name": "Last start" + } + }, + "binary_sensor": { + "sacn_active": { + "name": "Remote controlled" + }, + "sacn_multicast": { + "name": "Multicast group" + } } }, "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." } + "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": { diff --git a/tests/conftest.py b/tests/conftest.py index 50cae5a..b6f8422 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -67,6 +67,12 @@ def mock_client( return deepcopy(lamp_state) client.set_state = AsyncMock(side_effect=set_state) + + async def set_sacn(**fields: Any) -> None: + # Like the lamp: it stores what it accepts, and the caller reads the block back. + lamp_settings.setdefault("sacn", {}).update(fields) + + client.set_sacn = AsyncMock(side_effect=set_sacn) 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] diff --git a/tests/test_sacn.py b/tests/test_sacn.py new file mode 100644 index 0000000..b31bd5e --- /dev/null +++ b/tests/test_sacn.py @@ -0,0 +1,173 @@ +"""The sACN/E1.31 settings and status entities.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from homeassistant.components.number import ( + ATTR_MAX, + ATTR_VALUE, + SERVICE_SET_VALUE, +) +from homeassistant.components.number import ( + DOMAIN as NUMBER_DOMAIN, +) +from homeassistant.components.select import ( + ATTR_OPTION, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, +) +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, +) +from homeassistant.components.switch import ( + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +import pytest +from pytest_homeassistant_custom_component.common import async_fire_time_changed + +from tests.helpers import entity_id_for + + +async def poll(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: + """Let one poll cycle pass so the entities see the lamp's changed answers.""" + freezer.tick(timedelta(seconds=31)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + +async def test_switch_reads_and_writes( + hass: HomeAssistant, init_integration, mock_client: MagicMock +) -> None: + entity_id = entity_id_for(hass, "switch", "sacn_enabled") + assert hass.states.get(entity_id).state == STATE_ON + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_client.set_sacn.assert_awaited_once_with(enabled=False) + assert hass.states.get(entity_id).state == STATE_OFF + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_ON + + +@pytest.mark.parametrize( + ("key", "field", "value"), + [("sacn_universe", "universe", 12), ("sacn_start_slot", "start_slot", 7)], +) +async def test_numbers_read_and_write( + hass: HomeAssistant, + init_integration, + mock_client: MagicMock, + key: str, + field: str, + value: int, +) -> None: + entity_id = entity_id_for(hass, "number", key) + assert hass.states.get(entity_id).state == "1.0" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + mock_client.set_sacn.assert_awaited_once_with(**{field: value}) + assert hass.states.get(entity_id).state == f"{float(value)}" + + +async def test_start_slot_limit_follows_the_lamp( + hass: HomeAssistant, + init_integration, + lamp_settings: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """The highest usable start slot depends on how many slots the lamp occupies, and that + number changes with the slot layout - so it is read from the lamp, not hard-coded.""" + entity_id = entity_id_for(hass, "number", "sacn_start_slot") + assert hass.states.get(entity_id).attributes[ATTR_MAX] == 507 # 6 slots + + lamp_settings["sacn"]["slots"] = 3 + lamp_settings["sacn"]["layout"] = "rgb" + await poll(hass, freezer) + assert hass.states.get(entity_id).attributes[ATTR_MAX] == 510 + + +@pytest.mark.parametrize( + ("key", "field", "option"), + [("sacn_on_timeout", "on_timeout", "hold"), ("sacn_layout", "layout", "rgb")], +) +async def test_selects_read_and_write( + hass: HomeAssistant, + init_integration, + mock_client: MagicMock, + key: str, + field: str, + option: str, +) -> None: + entity_id = entity_id_for(hass, "select", key) + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: option}, + blocking=True, + ) + mock_client.set_sacn.assert_awaited_once_with(**{field: option}) + assert hass.states.get(entity_id).state == option + + +async def test_status_flags_come_from_system_info( + hass: HomeAssistant, + init_integration, + lamp_info: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + entity_id = entity_id_for(hass, "binary_sensor", "sacn_active") + assert hass.states.get(entity_id).state == STATE_OFF + + lamp_info["sacn"]["active"] = True + await poll(hass, freezer) + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_multicast_flag_is_off_by_default(hass: HomeAssistant, init_integration) -> None: + """It reads "joined" in normal operation and only earns attention when multicast does + not arrive - like the other diagnostics, it is there but not in the way.""" + entry = er.async_get(hass).async_get(entity_id_for(hass, "binary_sensor", "sacn_multicast")) + assert entry is not None and entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.parametrize( + ("platform", "key"), + [ + ("switch", "sacn_enabled"), + ("number", "sacn_universe"), + ("number", "sacn_start_slot"), + ("select", "sacn_on_timeout"), + ("select", "sacn_layout"), + ], +) +async def test_unavailable_on_firmware_without_sacn( + hass: HomeAssistant, + init_integration, + lamp_settings: dict[str, Any], + freezer: FrozenDateTimeFactory, + platform: str, + key: str, +) -> None: + """Older firmware answers system/settings without a "sacn" block. The entities exist - + so they come to life on the next poll after such a lamp is updated - but they say so + instead of showing a made-up value.""" + entity_id = entity_id_for(hass, platform, key) + lamp_settings.pop("sacn") + await poll(hass, freezer) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE diff --git a/tests/test_update.py b/tests/test_update.py index bc9115a..6f83302 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -282,8 +282,13 @@ async def test_rescan_during_install_does_not_trigger_evaluation( async def lamp_flashes(url: str) -> None: # a periodic catalog scan happens to land while this install is in progress - calls_before_rescan["count"] = mock_client.get_settings.await_count + before = mock_client.get_settings.await_count await rescan(hass) + # Measured around the rescan itself, not after the install: once the install is + # done the coordinator polls again anyway, and since the sACN settings became + # entities that poll reads system/settings too. Comparing after the fact would + # count that poll and fail for a reason this test does not care about. + calls_before_rescan["delta"] = mock_client.get_settings.await_count - before lamp_info["fw_version"] = NEWER lamp_info["uptime"] = 4 @@ -291,7 +296,7 @@ async def test_rescan_during_install_does_not_trigger_evaluation( await install(hass) await hass.async_block_till_done() - assert mock_client.get_settings.await_count == calls_before_rescan["count"] + assert calls_before_rescan["delta"] == 0 @pytest.mark.parametrize( diff --git a/tests_unit/samples.py b/tests_unit/samples.py index 3e6ab42..5c29aaf 100644 --- a/tests_unit/samples.py +++ b/tests_unit/samples.py @@ -57,6 +57,7 @@ INFO_RGB: dict[str, Any] = { "uptime": 129832, "free_heap": 119872, "last_ota_error": None, + "sacn": {"active": False, "universe": 1, "multicast": True}, } INFO_CW: dict[str, Any] = { @@ -79,14 +80,34 @@ INFO_CW: dict[str, Any] = { "last_ota_error": None, } +# Wie eine Lampe mit sACN antwortet. Aeltere Firmware laesst den Block weg - dafuer gibt es +# SETTINGS_WITHOUT_SACN weiter unten. +SACN_SETTINGS: dict[str, Any] = { + "enabled": True, + "universe": 1, + "start_slot": 1, + "on_timeout": "release", + "layout": "dimmer", + "slots": 6, +} + SETTINGS_COMPILED: dict[str, Any] = { "name": "", "wifi": {"ssid": "", "source": "compiled"}, "auth_all": False, + "sacn": dict(SACN_SETTINGS), +} + +# Firmware vor sACN: derselbe Datensatz ohne den Block. +SETTINGS_WITHOUT_SACN: dict[str, Any] = { + "name": "", + "wifi": {"ssid": "", "source": "compiled"}, + "auth_all": False, } SETTINGS_STORED: dict[str, Any] = { "name": "", "wifi": {"ssid": "ninIOT", "source": "stored"}, "auth_all": False, + "sacn": dict(SACN_SETTINGS), }