merge: sACN-Einstellungen in der Integration
* sacn-settings: feat: sACN-Einstellungen und -Status als Entitaeten
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