feat: sACN-Einstellungen und -Status als Entitaeten
Die Firmware kann seit dieser Woche sACN/E1.31. Einstellen liess sich das nur per REST - jetzt steht es in Home Assistant: Schalter fuer den Empfang, Zahlen fuer Universe und Startslot, Auswahlen fuer das Verhalten nach dem Timeout und die Slotbelegung, dazu zwei Diagnose-Binaersensoren. Die sACN-Einstellungen liegen hinter system/settings, nicht in device/state - der Koordinator liest sie deshalb jetzt mit, eine dritte Anfrage je Zyklus. Der Schreibweg liest die Einstellungen danach frisch zurueck statt den gesetzten Wert anzunehmen: die Lampe validiert selbst (die Startslot-Grenze haengt an der Slotbelegung), und eine Entitaet, die das Gewuenschte statt des Gespeicherten zeigt, luegt in genau dem Moment, in dem es darauf ankommt. Aeltere Firmware antwortet ohne den sacn-Block. Die Entitaeten entstehen trotzdem und melden sich als nicht verfuegbar - so werden sie nach einem Update der Lampe beim naechsten Poll von selbst lebendig, ohne Neuladen. Die Obergrenze des Startslots kommt aus dem "slots"-Feld der Lampe, nicht aus einer festen 512: sie haengt an Board UND Slotbelegung, und eine feste Grenze wuerde Werte anbieten, die die Lampe dann ablehnt. Der Multicast-Sensor ist ab Werk deaktiviert wie die uebrigen Diagnosewerte. Er meldet im Normalbetrieb schlicht "beigetreten" - aber eine falsche Gruppenadresse hat zwei Abende gekostet, und genau dort waere sie aufgefallen. test_update.py misst seine Zusage jetzt um den Rescan herum statt nach dem Install: dort pollt der Koordinator ohnehin wieder und liest seitdem auch die Einstellungen mit.
This commit is contained in:
@@ -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)
|
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [
|
PLATFORMS: list[Platform] = [
|
||||||
|
Platform.BINARY_SENSOR,
|
||||||
Platform.BUTTON,
|
Platform.BUTTON,
|
||||||
Platform.LIGHT,
|
Platform.LIGHT,
|
||||||
Platform.NUMBER,
|
Platform.NUMBER,
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ class NinlightClient:
|
|||||||
"""The one read that always requires a valid token (see spec §2)."""
|
"""The one read that always requires a valid token (see spec §2)."""
|
||||||
return _require(await self._request("GET", "system/settings"), (), "settings")
|
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:
|
async def reboot(self) -> None:
|
||||||
await self._request("PUT", "system/command", {"command": {"type": "reboot"}})
|
await self._request("PUT", "system/command", {"command": {"type": "reboot"}})
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -30,10 +30,25 @@ type NinlightConfigEntry = ConfigEntry[NinlightCoordinator]
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class NinlightData:
|
class NinlightData:
|
||||||
"""Last known lamp state and system info."""
|
"""Last known lamp state, system info and settings."""
|
||||||
|
|
||||||
state: dict[str, Any]
|
state: dict[str, Any]
|
||||||
info: 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]):
|
class NinlightCoordinator(DataUpdateCoordinator[NinlightData]):
|
||||||
@@ -68,6 +83,9 @@ class NinlightCoordinator(DataUpdateCoordinator[NinlightData]):
|
|||||||
async with self.lock:
|
async with self.lock:
|
||||||
state = await self.client.get_state()
|
state = await self.client.get_state()
|
||||||
info = await self.client.get_info()
|
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:
|
except NinlightAuthError as err:
|
||||||
raise ConfigEntryAuthFailed(f"{self.client.host}: token rejected") from err
|
raise ConfigEntryAuthFailed(f"{self.client.host}: token rejected") from err
|
||||||
except NinlightError as err:
|
except NinlightError as err:
|
||||||
@@ -83,7 +101,7 @@ class NinlightCoordinator(DataUpdateCoordinator[NinlightData]):
|
|||||||
return self.data
|
return self.data
|
||||||
raise UpdateFailed(f"{self.client.host}: {err}") from err
|
raise UpdateFailed(f"{self.client.host}: {err}") from err
|
||||||
self._consecutive_poll_failures = 0
|
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:
|
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."""
|
"""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"
|
translation_domain=DOMAIN, translation_key="cannot_connect"
|
||||||
) from err
|
) 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:
|
async def async_set_state(self, **fields: Any) -> None:
|
||||||
"""Write and adopt the lamp's complete answer directly - no debounced refresh."""
|
"""Write and adopt the lamp's complete answer directly - no debounced refresh."""
|
||||||
state = await self.async_call(lambda client: client.set_state(**fields))
|
state = await self.async_call(lambda client: client.set_state(**fields))
|
||||||
self._consecutive_poll_failures = 0
|
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))
|
||||||
|
|||||||
@@ -41,3 +41,22 @@ class NinlightEntity(CoordinatorEntity[NinlightCoordinator]):
|
|||||||
name=device_name(info),
|
name=device_name(info),
|
||||||
sw_version=info["fw_version"],
|
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 {}
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from .coordinator import NinlightConfigEntry, NinlightCoordinator
|
from .coordinator import NinlightConfigEntry, NinlightCoordinator
|
||||||
from .entity import NinlightEntity
|
from .entity import NinlightEntity, NinlightSacnEntity
|
||||||
|
|
||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
|
|
||||||
@@ -46,7 +46,12 @@ async def async_setup_entry(
|
|||||||
entry: NinlightConfigEntry,
|
entry: NinlightConfigEntry,
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> 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):
|
class NinlightNumber(NinlightEntity, NumberEntity):
|
||||||
@@ -67,3 +72,58 @@ class NinlightNumber(NinlightEntity, NumberEntity):
|
|||||||
|
|
||||||
async def async_set_native_value(self, value: float) -> None:
|
async def async_set_native_value(self, value: float) -> None:
|
||||||
await self.coordinator.async_set_state(**{self.entity_description.key: int(value)})
|
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))
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -8,12 +8,15 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from .coordinator import NinlightConfigEntry, NinlightCoordinator
|
from .coordinator import NinlightConfigEntry, NinlightCoordinator
|
||||||
from .entity import NinlightEntity
|
from .entity import NinlightEntity, NinlightSacnEntity
|
||||||
|
|
||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
|
|
||||||
# POB_NAMES in the firmware's nl_api.c
|
# POB_NAMES in the firmware's nl_api.c
|
||||||
POWER_ON_OPTIONS = ["last", "on", "off"]
|
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(
|
async def async_setup_entry(
|
||||||
@@ -21,7 +24,13 @@ async def async_setup_entry(
|
|||||||
entry: NinlightConfigEntry,
|
entry: NinlightConfigEntry,
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> 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):
|
class NinlightPowerOnSelect(NinlightEntity, SelectEntity):
|
||||||
@@ -41,3 +50,41 @@ class NinlightPowerOnSelect(NinlightEntity, SelectEntity):
|
|||||||
|
|
||||||
async def async_select_option(self, option: str) -> None:
|
async def async_select_option(self, option: str) -> None:
|
||||||
await self.coordinator.async_set_state(power_on_behaviour=option)
|
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)
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
"user": {
|
"user": {
|
||||||
"title": "Set up ninlight lamp",
|
"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.",
|
"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": {
|
"data_description": {
|
||||||
"host": "IP address or ninlight-xxxx.local name of the lamp.",
|
"host": "IP address or ninlight-xxxx.local name of the lamp.",
|
||||||
"token": "Authorization token of the lamp."
|
"token": "Authorization token of the lamp."
|
||||||
@@ -14,16 +17,23 @@
|
|||||||
"zeroconf_confirm": {
|
"zeroconf_confirm": {
|
||||||
"title": "Set up {name}",
|
"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.",
|
"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": {
|
"reauth_confirm": {
|
||||||
"title": "Re-authenticate {name}",
|
"title": "Re-authenticate {name}",
|
||||||
"description": "The lamp rejected the stored token. Enter its current token.",
|
"description": "The lamp rejected the stored token. Enter its current token.",
|
||||||
"data": { "token": "Token" }
|
"data": {
|
||||||
|
"token": "Token"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"reconfigure": {
|
"reconfigure": {
|
||||||
"title": "Reconfigure lamp",
|
"title": "Reconfigure lamp",
|
||||||
"data": { "host": "Host", "token": "Token" }
|
"data": {
|
||||||
|
"host": "Host",
|
||||||
|
"token": "Token"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -60,38 +70,111 @@
|
|||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"number": {
|
"number": {
|
||||||
"fade_in": { "name": "Fade-in" },
|
"fade_in": {
|
||||||
"fade_out": { "name": "Fade-out" },
|
"name": "Fade-in"
|
||||||
"transition_default": { "name": "Default transition" }
|
},
|
||||||
|
"fade_out": {
|
||||||
|
"name": "Fade-out"
|
||||||
|
},
|
||||||
|
"transition_default": {
|
||||||
|
"name": "Default transition"
|
||||||
|
},
|
||||||
|
"sacn_universe": {
|
||||||
|
"name": "sACN universe"
|
||||||
|
},
|
||||||
|
"sacn_start_slot": {
|
||||||
|
"name": "sACN start slot"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"select": {
|
"select": {
|
||||||
"power_on_behaviour": {
|
"power_on_behaviour": {
|
||||||
"name": "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": {
|
"switch": {
|
||||||
"wake_on_change": { "name": "Turn on when changed" }
|
"wake_on_change": {
|
||||||
|
"name": "Turn on when changed"
|
||||||
|
},
|
||||||
|
"sacn_enabled": {
|
||||||
|
"name": "sACN reception"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"sensor": {
|
"sensor": {
|
||||||
"ip_address": { "name": "IP address" },
|
"ip_address": {
|
||||||
"free_heap": { "name": "Free memory" },
|
"name": "IP address"
|
||||||
"last_ota_error": { "name": "Last update error" },
|
},
|
||||||
"boot_time": { "name": "Last start" }
|
"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": {
|
"exceptions": {
|
||||||
"cannot_connect": { "message": "Cannot reach the lamp." },
|
"cannot_connect": {
|
||||||
"auth_failed": { "message": "The lamp rejected the token." },
|
"message": "Cannot reach the lamp."
|
||||||
"request_failed": { "message": "The lamp rejected the request: {message}" },
|
},
|
||||||
"update_busy": { "message": "An update is already running on the lamp." },
|
"auth_failed": {
|
||||||
"no_update": { "message": "No firmware update is available." },
|
"message": "The lamp rejected the token."
|
||||||
"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." },
|
"request_failed": {
|
||||||
"image_invalid": { "message": "The firmware file changed or is no longer valid." },
|
"message": "The lamp rejected the request: {message}"
|
||||||
"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_busy": {
|
||||||
"update_timeout": { "message": "The lamp did not report the new firmware within 12 minutes." }
|
"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": {
|
"issues": {
|
||||||
"default_token": {
|
"default_token": {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from .coordinator import NinlightConfigEntry, NinlightCoordinator
|
from .coordinator import NinlightConfigEntry, NinlightCoordinator
|
||||||
from .entity import NinlightEntity
|
from .entity import NinlightEntity, NinlightSacnEntity
|
||||||
|
|
||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
|
|
||||||
@@ -20,7 +20,9 @@ async def async_setup_entry(
|
|||||||
entry: NinlightConfigEntry,
|
entry: NinlightConfigEntry,
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
async_add_entities([NinlightWakeSwitch(entry.runtime_data)])
|
async_add_entities(
|
||||||
|
[NinlightWakeSwitch(entry.runtime_data), NinlightSacnSwitch(entry.runtime_data)]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NinlightWakeSwitch(NinlightEntity, SwitchEntity):
|
class NinlightWakeSwitch(NinlightEntity, SwitchEntity):
|
||||||
@@ -41,3 +43,23 @@ class NinlightWakeSwitch(NinlightEntity, SwitchEntity):
|
|||||||
|
|
||||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
await self.coordinator.async_set_state(wake_on_change=False)
|
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)
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
"user": {
|
"user": {
|
||||||
"title": "ninlight-Lampe einrichten",
|
"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.",
|
"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": {
|
"data_description": {
|
||||||
"host": "IP-Adresse oder ninlight-xxxx.local-Name der Lampe.",
|
"host": "IP-Adresse oder ninlight-xxxx.local-Name der Lampe.",
|
||||||
"token": "Autorisierungs-Token der Lampe."
|
"token": "Autorisierungs-Token der Lampe."
|
||||||
@@ -14,16 +17,23 @@
|
|||||||
"zeroconf_confirm": {
|
"zeroconf_confirm": {
|
||||||
"title": "{name} einrichten",
|
"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.",
|
"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": {
|
"reauth_confirm": {
|
||||||
"title": "{name} neu anmelden",
|
"title": "{name} neu anmelden",
|
||||||
"description": "Die Lampe hat das gespeicherte Token abgelehnt. Gib ihr aktuelles Token ein.",
|
"description": "Die Lampe hat das gespeicherte Token abgelehnt. Gib ihr aktuelles Token ein.",
|
||||||
"data": { "token": "Token" }
|
"data": {
|
||||||
|
"token": "Token"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"reconfigure": {
|
"reconfigure": {
|
||||||
"title": "Lampe neu konfigurieren",
|
"title": "Lampe neu konfigurieren",
|
||||||
"data": { "host": "Host", "token": "Token" }
|
"data": {
|
||||||
|
"host": "Host",
|
||||||
|
"token": "Token"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -60,38 +70,111 @@
|
|||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"number": {
|
"number": {
|
||||||
"fade_in": { "name": "Einblenden" },
|
"fade_in": {
|
||||||
"fade_out": { "name": "Ausblenden" },
|
"name": "Einblenden"
|
||||||
"transition_default": { "name": "Standardübergang" }
|
},
|
||||||
|
"fade_out": {
|
||||||
|
"name": "Ausblenden"
|
||||||
|
},
|
||||||
|
"transition_default": {
|
||||||
|
"name": "Standardübergang"
|
||||||
|
},
|
||||||
|
"sacn_universe": {
|
||||||
|
"name": "sACN-Universe"
|
||||||
|
},
|
||||||
|
"sacn_start_slot": {
|
||||||
|
"name": "sACN-Startslot"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"select": {
|
"select": {
|
||||||
"power_on_behaviour": {
|
"power_on_behaviour": {
|
||||||
"name": "Einschaltverhalten",
|
"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": {
|
"switch": {
|
||||||
"wake_on_change": { "name": "Bei Änderung einschalten" }
|
"wake_on_change": {
|
||||||
|
"name": "Bei Änderung einschalten"
|
||||||
|
},
|
||||||
|
"sacn_enabled": {
|
||||||
|
"name": "sACN-Empfang"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"sensor": {
|
"sensor": {
|
||||||
"ip_address": { "name": "IP-Adresse" },
|
"ip_address": {
|
||||||
"free_heap": { "name": "Freier Speicher" },
|
"name": "IP-Adresse"
|
||||||
"last_ota_error": { "name": "Letzter Update-Fehler" },
|
},
|
||||||
"boot_time": { "name": "Letzter Start" }
|
"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": {
|
"exceptions": {
|
||||||
"cannot_connect": { "message": "Die Lampe ist nicht erreichbar." },
|
"cannot_connect": {
|
||||||
"auth_failed": { "message": "Die Lampe hat das Token abgelehnt." },
|
"message": "Die Lampe ist nicht erreichbar."
|
||||||
"request_failed": { "message": "Die Lampe hat die Anfrage abgelehnt: {message}" },
|
},
|
||||||
"update_busy": { "message": "Auf der Lampe läuft bereits ein Update." },
|
"auth_failed": {
|
||||||
"no_update": { "message": "Kein Firmware-Update verfügbar." },
|
"message": "Die Lampe hat das Token abgelehnt."
|
||||||
"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." },
|
"request_failed": {
|
||||||
"image_invalid": { "message": "Die Firmware-Datei hat sich geändert oder ist nicht mehr gültig." },
|
"message": "Die Lampe hat die Anfrage abgelehnt: {message}"
|
||||||
"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_busy": {
|
||||||
"update_timeout": { "message": "Die Lampe hat die neue Firmware nicht innerhalb von 12 Minuten gemeldet." }
|
"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": {
|
"issues": {
|
||||||
"default_token": {
|
"default_token": {
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
"user": {
|
"user": {
|
||||||
"title": "Set up ninlight lamp",
|
"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.",
|
"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": {
|
"data_description": {
|
||||||
"host": "IP address or ninlight-xxxx.local name of the lamp.",
|
"host": "IP address or ninlight-xxxx.local name of the lamp.",
|
||||||
"token": "Authorization token of the lamp."
|
"token": "Authorization token of the lamp."
|
||||||
@@ -14,16 +17,23 @@
|
|||||||
"zeroconf_confirm": {
|
"zeroconf_confirm": {
|
||||||
"title": "Set up {name}",
|
"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.",
|
"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": {
|
"reauth_confirm": {
|
||||||
"title": "Re-authenticate {name}",
|
"title": "Re-authenticate {name}",
|
||||||
"description": "The lamp rejected the stored token. Enter its current token.",
|
"description": "The lamp rejected the stored token. Enter its current token.",
|
||||||
"data": { "token": "Token" }
|
"data": {
|
||||||
|
"token": "Token"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"reconfigure": {
|
"reconfigure": {
|
||||||
"title": "Reconfigure lamp",
|
"title": "Reconfigure lamp",
|
||||||
"data": { "host": "Host", "token": "Token" }
|
"data": {
|
||||||
|
"host": "Host",
|
||||||
|
"token": "Token"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -60,38 +70,111 @@
|
|||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"number": {
|
"number": {
|
||||||
"fade_in": { "name": "Fade-in" },
|
"fade_in": {
|
||||||
"fade_out": { "name": "Fade-out" },
|
"name": "Fade-in"
|
||||||
"transition_default": { "name": "Default transition" }
|
},
|
||||||
|
"fade_out": {
|
||||||
|
"name": "Fade-out"
|
||||||
|
},
|
||||||
|
"transition_default": {
|
||||||
|
"name": "Default transition"
|
||||||
|
},
|
||||||
|
"sacn_universe": {
|
||||||
|
"name": "sACN universe"
|
||||||
|
},
|
||||||
|
"sacn_start_slot": {
|
||||||
|
"name": "sACN start slot"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"select": {
|
"select": {
|
||||||
"power_on_behaviour": {
|
"power_on_behaviour": {
|
||||||
"name": "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": {
|
"switch": {
|
||||||
"wake_on_change": { "name": "Turn on when changed" }
|
"wake_on_change": {
|
||||||
|
"name": "Turn on when changed"
|
||||||
|
},
|
||||||
|
"sacn_enabled": {
|
||||||
|
"name": "sACN reception"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"sensor": {
|
"sensor": {
|
||||||
"ip_address": { "name": "IP address" },
|
"ip_address": {
|
||||||
"free_heap": { "name": "Free memory" },
|
"name": "IP address"
|
||||||
"last_ota_error": { "name": "Last update error" },
|
},
|
||||||
"boot_time": { "name": "Last start" }
|
"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": {
|
"exceptions": {
|
||||||
"cannot_connect": { "message": "Cannot reach the lamp." },
|
"cannot_connect": {
|
||||||
"auth_failed": { "message": "The lamp rejected the token." },
|
"message": "Cannot reach the lamp."
|
||||||
"request_failed": { "message": "The lamp rejected the request: {message}" },
|
},
|
||||||
"update_busy": { "message": "An update is already running on the lamp." },
|
"auth_failed": {
|
||||||
"no_update": { "message": "No firmware update is available." },
|
"message": "The lamp rejected the token."
|
||||||
"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." },
|
"request_failed": {
|
||||||
"image_invalid": { "message": "The firmware file changed or is no longer valid." },
|
"message": "The lamp rejected the request: {message}"
|
||||||
"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_busy": {
|
||||||
"update_timeout": { "message": "The lamp did not report the new firmware within 12 minutes." }
|
"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": {
|
"issues": {
|
||||||
"default_token": {
|
"default_token": {
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ def mock_client(
|
|||||||
return deepcopy(lamp_state)
|
return deepcopy(lamp_state)
|
||||||
|
|
||||||
client.set_state = AsyncMock(side_effect=set_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.reboot = AsyncMock(return_value=None)
|
||||||
client.fw_update = AsyncMock(return_value=None)
|
client.fw_update = AsyncMock(return_value=None)
|
||||||
patches = [patch(target, return_value=client) for target in CLIENT_PATCH_TARGETS]
|
patches = [patch(target, return_value=client) for target in CLIENT_PATCH_TARGETS]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -282,8 +282,13 @@ async def test_rescan_during_install_does_not_trigger_evaluation(
|
|||||||
|
|
||||||
async def lamp_flashes(url: str) -> None:
|
async def lamp_flashes(url: str) -> None:
|
||||||
# a periodic catalog scan happens to land while this install is in progress
|
# 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)
|
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["fw_version"] = NEWER
|
||||||
lamp_info["uptime"] = 4
|
lamp_info["uptime"] = 4
|
||||||
|
|
||||||
@@ -291,7 +296,7 @@ async def test_rescan_during_install_does_not_trigger_evaluation(
|
|||||||
await install(hass)
|
await install(hass)
|
||||||
await hass.async_block_till_done()
|
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(
|
@pytest.mark.parametrize(
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ INFO_RGB: dict[str, Any] = {
|
|||||||
"uptime": 129832,
|
"uptime": 129832,
|
||||||
"free_heap": 119872,
|
"free_heap": 119872,
|
||||||
"last_ota_error": None,
|
"last_ota_error": None,
|
||||||
|
"sacn": {"active": False, "universe": 1, "multicast": True},
|
||||||
}
|
}
|
||||||
|
|
||||||
INFO_CW: dict[str, Any] = {
|
INFO_CW: dict[str, Any] = {
|
||||||
@@ -79,14 +80,34 @@ INFO_CW: dict[str, Any] = {
|
|||||||
"last_ota_error": None,
|
"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] = {
|
SETTINGS_COMPILED: dict[str, Any] = {
|
||||||
"name": "",
|
"name": "",
|
||||||
"wifi": {"ssid": "", "source": "compiled"},
|
"wifi": {"ssid": "", "source": "compiled"},
|
||||||
"auth_all": False,
|
"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] = {
|
SETTINGS_STORED: dict[str, Any] = {
|
||||||
"name": "",
|
"name": "",
|
||||||
"wifi": {"ssid": "ninIOT", "source": "stored"},
|
"wifi": {"ssid": "ninIOT", "source": "stored"},
|
||||||
"auth_all": False,
|
"auth_all": False,
|
||||||
|
"sacn": dict(SACN_SETTINGS),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user