merge: sACN-Einstellungen in der Integration

* sacn-settings:
  feat: sACN-Einstellungen und -Status als Entitaeten
This commit is contained in:
Michael Sauer
2026-09-21 23:36:02 +02:00
15 changed files with 791 additions and 85 deletions
+1
View File
@@ -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,
+4
View File
@@ -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")
+38 -3
View File
@@ -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))
+19
View File
@@ -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 {}
+63 -3
View File
@@ -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))
+50 -3
View File
@@ -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)
+107 -24
View File
@@ -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": {
+24 -2
View File
@@ -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)
+107 -24
View File
@@ -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": {
+107 -24
View File
@@ -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": {
+6
View File
@@ -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]
+173
View File
@@ -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
+7 -2
View File
@@ -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(
+21
View File
@@ -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),
} }