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)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
|
||||
@@ -81,6 +81,10 @@ class NinlightClient:
|
||||
"""The one read that always requires a valid token (see spec §2)."""
|
||||
return _require(await self._request("GET", "system/settings"), (), "settings")
|
||||
|
||||
async def set_sacn(self, **fields: Any) -> None:
|
||||
"""Change sACN settings. Fields left out keep their value on the lamp."""
|
||||
await self._request("PUT", "system/settings", {"type": "sacn", **fields})
|
||||
|
||||
async def reboot(self) -> None:
|
||||
await self._request("PUT", "system/command", {"command": {"type": "reboot"}})
|
||||
|
||||
|
||||
@@ -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)
|
||||
class NinlightData:
|
||||
"""Last known lamp state and system info."""
|
||||
"""Last known lamp state, system info and settings."""
|
||||
|
||||
state: dict[str, Any]
|
||||
info: dict[str, Any]
|
||||
# Only the settings the lamp exposes; a lamp on firmware without sACN carries no
|
||||
# "sacn" block here, and the entities that need it report themselves unavailable.
|
||||
settings: dict[str, Any]
|
||||
|
||||
@property
|
||||
def sacn(self) -> dict[str, Any] | None:
|
||||
"""The lamp's sACN settings, or None on firmware that predates the feature."""
|
||||
block = self.settings.get("sacn")
|
||||
return block if isinstance(block, dict) else None
|
||||
|
||||
@property
|
||||
def sacn_info(self) -> dict[str, Any] | None:
|
||||
"""The sACN status from system/info, or None on firmware without it."""
|
||||
block = self.info.get("sacn")
|
||||
return block if isinstance(block, dict) else None
|
||||
|
||||
|
||||
class NinlightCoordinator(DataUpdateCoordinator[NinlightData]):
|
||||
@@ -68,6 +83,9 @@ class NinlightCoordinator(DataUpdateCoordinator[NinlightData]):
|
||||
async with self.lock:
|
||||
state = await self.client.get_state()
|
||||
info = await self.client.get_info()
|
||||
# The sACN settings live behind system/settings, not device/state - so the
|
||||
# config entities for them need this third request per cycle.
|
||||
settings = await self.client.get_settings()
|
||||
except NinlightAuthError as err:
|
||||
raise ConfigEntryAuthFailed(f"{self.client.host}: token rejected") from err
|
||||
except NinlightError as err:
|
||||
@@ -83,7 +101,7 @@ class NinlightCoordinator(DataUpdateCoordinator[NinlightData]):
|
||||
return self.data
|
||||
raise UpdateFailed(f"{self.client.host}: {err}") from err
|
||||
self._consecutive_poll_failures = 0
|
||||
return NinlightData(state, info)
|
||||
return NinlightData(state, info, settings)
|
||||
|
||||
async def async_call[T](self, action: Callable[[NinlightClient], Awaitable[T]]) -> T:
|
||||
"""Run one request under the lock and translate lamp errors for the UI."""
|
||||
@@ -110,8 +128,25 @@ class NinlightCoordinator(DataUpdateCoordinator[NinlightData]):
|
||||
translation_domain=DOMAIN, translation_key="cannot_connect"
|
||||
) from err
|
||||
|
||||
async def async_set_sacn(self, **fields: Any) -> None:
|
||||
"""Write sACN settings and adopt the lamp's own answer.
|
||||
|
||||
The lamp returns only {"ok": true}, so the settings are read back instead of
|
||||
guessed: it clamps and validates on its own (the start_slot limit depends on the
|
||||
slot layout, for one), and an entity that shows what was asked for rather than what
|
||||
was stored would lie the moment the two differ.
|
||||
"""
|
||||
|
||||
async def write_and_read(client: NinlightClient) -> dict[str, Any]:
|
||||
await client.set_sacn(**fields)
|
||||
return await client.get_settings()
|
||||
|
||||
settings = await self.async_call(write_and_read)
|
||||
self._consecutive_poll_failures = 0
|
||||
self.async_set_updated_data(NinlightData(self.data.state, self.data.info, settings))
|
||||
|
||||
async def async_set_state(self, **fields: Any) -> None:
|
||||
"""Write and adopt the lamp's complete answer directly - no debounced refresh."""
|
||||
state = await self.async_call(lambda client: client.set_state(**fields))
|
||||
self._consecutive_poll_failures = 0
|
||||
self.async_set_updated_data(NinlightData(state, self.data.info))
|
||||
self.async_set_updated_data(NinlightData(state, self.data.info, self.data.settings))
|
||||
|
||||
@@ -41,3 +41,22 @@ class NinlightEntity(CoordinatorEntity[NinlightCoordinator]):
|
||||
name=device_name(info),
|
||||
sw_version=info["fw_version"],
|
||||
)
|
||||
|
||||
|
||||
class NinlightSacnEntity(NinlightEntity):
|
||||
"""Base for entities backed by the lamp's sACN settings.
|
||||
|
||||
Firmware older than the sACN feature answers system/settings without a "sacn" block.
|
||||
Those entities are still created - so they come to life on the next poll after such a
|
||||
lamp is updated, without reloading the integration - but they report themselves
|
||||
unavailable until the block shows up.
|
||||
"""
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return super().available and self.coordinator.data.sacn is not None
|
||||
|
||||
@property
|
||||
def sacn(self) -> dict[str, Any]:
|
||||
"""The lamp's sACN settings; empty while the lamp does not report them."""
|
||||
return self.coordinator.data.sacn or {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Fade and transition settings of a ninlight lamp."""
|
||||
"""Number settings of a ninlight lamp: fades, transition and sACN addressing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import NinlightConfigEntry, NinlightCoordinator
|
||||
from .entity import NinlightEntity
|
||||
from .entity import NinlightEntity, NinlightSacnEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -46,7 +46,12 @@ async def async_setup_entry(
|
||||
entry: NinlightConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
async_add_entities(NinlightNumber(entry.runtime_data, description) for description in NUMBERS)
|
||||
entities: list[NumberEntity] = [
|
||||
NinlightNumber(entry.runtime_data, description) for description in NUMBERS
|
||||
]
|
||||
entities.append(NinlightSacnUniverse(entry.runtime_data))
|
||||
entities.append(NinlightSacnStartSlot(entry.runtime_data))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class NinlightNumber(NinlightEntity, NumberEntity):
|
||||
@@ -67,3 +72,58 @@ class NinlightNumber(NinlightEntity, NumberEntity):
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
await self.coordinator.async_set_state(**{self.entity_description.key: int(value)})
|
||||
|
||||
|
||||
class NinlightSacnUniverse(NinlightSacnEntity, NumberEntity):
|
||||
"""The DMX universe the lamp listens on."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_translation_key = "sacn_universe"
|
||||
_attr_native_min_value = 1
|
||||
_attr_native_max_value = 63_999
|
||||
_attr_native_step = 1
|
||||
_attr_mode = NumberMode.BOX
|
||||
|
||||
def __init__(self, coordinator: NinlightCoordinator) -> None:
|
||||
super().__init__(coordinator, "sacn_universe")
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
value = self.sacn.get("universe")
|
||||
return float(value) if isinstance(value, (int, float)) else None
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
await self.coordinator.async_set_sacn(universe=int(value))
|
||||
|
||||
|
||||
class NinlightSacnStartSlot(NinlightSacnEntity, NumberEntity):
|
||||
"""The first slot of the lamp inside its universe."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_translation_key = "sacn_start_slot"
|
||||
_attr_native_min_value = 1
|
||||
_attr_native_step = 1
|
||||
_attr_mode = NumberMode.BOX
|
||||
|
||||
def __init__(self, coordinator: NinlightCoordinator) -> None:
|
||||
super().__init__(coordinator, "sacn_start_slot")
|
||||
|
||||
@property
|
||||
def native_max_value(self) -> float:
|
||||
"""The highest start slot that still leaves room for all of the lamp's slots.
|
||||
|
||||
The lamp reports how many it needs, and that number depends on both the board and
|
||||
the slot layout - a fixed 512 here would offer settings the lamp then rejects.
|
||||
"""
|
||||
slots = self.sacn.get("slots")
|
||||
if not isinstance(slots, int) or not 1 <= slots <= 512:
|
||||
return 512
|
||||
return 512 - slots + 1
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
value = self.sacn.get("start_slot")
|
||||
return float(value) if isinstance(value, (int, float)) else None
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
await self.coordinator.async_set_sacn(start_slot=int(value))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Power-on behaviour of a ninlight lamp."""
|
||||
"""Select settings of a ninlight lamp: power-on behaviour and sACN."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,12 +8,15 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import NinlightConfigEntry, NinlightCoordinator
|
||||
from .entity import NinlightEntity
|
||||
from .entity import NinlightEntity, NinlightSacnEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
# POB_NAMES in the firmware's nl_api.c
|
||||
POWER_ON_OPTIONS = ["last", "on", "off"]
|
||||
# The two sACN settings the firmware takes as strings, same spelling as its API.
|
||||
ON_TIMEOUT_OPTIONS = ["release", "hold"]
|
||||
LAYOUT_OPTIONS = ["dimmer", "rgb"]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -21,7 +24,13 @@ async def async_setup_entry(
|
||||
entry: NinlightConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
async_add_entities([NinlightPowerOnSelect(entry.runtime_data)])
|
||||
async_add_entities(
|
||||
[
|
||||
NinlightPowerOnSelect(entry.runtime_data),
|
||||
NinlightSacnOnTimeoutSelect(entry.runtime_data),
|
||||
NinlightSacnLayoutSelect(entry.runtime_data),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class NinlightPowerOnSelect(NinlightEntity, SelectEntity):
|
||||
@@ -41,3 +50,41 @@ class NinlightPowerOnSelect(NinlightEntity, SelectEntity):
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
await self.coordinator.async_set_state(power_on_behaviour=option)
|
||||
|
||||
|
||||
class NinlightSacnOnTimeoutSelect(NinlightSacnEntity, SelectEntity):
|
||||
"""What the lamp does 2.5 s after the last sACN packet."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_translation_key = "sacn_on_timeout"
|
||||
_attr_options = ON_TIMEOUT_OPTIONS
|
||||
|
||||
def __init__(self, coordinator: NinlightCoordinator) -> None:
|
||||
super().__init__(coordinator, "sacn_on_timeout")
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
value = self.sacn.get("on_timeout")
|
||||
return value if value in ON_TIMEOUT_OPTIONS else None
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
await self.coordinator.async_set_sacn(on_timeout=option)
|
||||
|
||||
|
||||
class NinlightSacnLayoutSelect(NinlightSacnEntity, SelectEntity):
|
||||
"""Which slots the lamp reads: with a master dimmer, or plain R,G,B."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_translation_key = "sacn_layout"
|
||||
_attr_options = LAYOUT_OPTIONS
|
||||
|
||||
def __init__(self, coordinator: NinlightCoordinator) -> None:
|
||||
super().__init__(coordinator, "sacn_layout")
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
value = self.sacn.get("layout")
|
||||
return value if value in LAYOUT_OPTIONS else None
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
await self.coordinator.async_set_sacn(layout=option)
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
"user": {
|
||||
"title": "Set up ninlight lamp",
|
||||
"description": "Enter the address of the lamp. The token is prefilled with the public default token – anyone on your network who knows it can control and reflash the lamp.",
|
||||
"data": { "host": "Host", "token": "Token" },
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"token": "Token"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "IP address or ninlight-xxxx.local name of the lamp.",
|
||||
"token": "Authorization token of the lamp."
|
||||
@@ -14,16 +17,23 @@
|
||||
"zeroconf_confirm": {
|
||||
"title": "Set up {name}",
|
||||
"description": "Found the ninlight lamp {name} at {host}. The token is prefilled with the public default token – anyone on your network who knows it can control and reflash the lamp.",
|
||||
"data": { "token": "Token" }
|
||||
"data": {
|
||||
"token": "Token"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Re-authenticate {name}",
|
||||
"description": "The lamp rejected the stored token. Enter its current token.",
|
||||
"data": { "token": "Token" }
|
||||
"data": {
|
||||
"token": "Token"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure lamp",
|
||||
"data": { "host": "Host", "token": "Token" }
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"token": "Token"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -60,38 +70,111 @@
|
||||
},
|
||||
"entity": {
|
||||
"number": {
|
||||
"fade_in": { "name": "Fade-in" },
|
||||
"fade_out": { "name": "Fade-out" },
|
||||
"transition_default": { "name": "Default transition" }
|
||||
"fade_in": {
|
||||
"name": "Fade-in"
|
||||
},
|
||||
"fade_out": {
|
||||
"name": "Fade-out"
|
||||
},
|
||||
"transition_default": {
|
||||
"name": "Default transition"
|
||||
},
|
||||
"sacn_universe": {
|
||||
"name": "sACN universe"
|
||||
},
|
||||
"sacn_start_slot": {
|
||||
"name": "sACN start slot"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"power_on_behaviour": {
|
||||
"name": "Power-on behaviour",
|
||||
"state": { "last": "Last state", "on": "On", "off": "Off" }
|
||||
"state": {
|
||||
"last": "Last state",
|
||||
"on": "On",
|
||||
"off": "Off"
|
||||
}
|
||||
},
|
||||
"sacn_on_timeout": {
|
||||
"name": "sACN after timeout",
|
||||
"state": {
|
||||
"release": "Back to Home Assistant",
|
||||
"hold": "Hold last values"
|
||||
}
|
||||
},
|
||||
"sacn_layout": {
|
||||
"name": "sACN slot layout",
|
||||
"state": {
|
||||
"dimmer": "Dimmer first",
|
||||
"rgb": "RGB only"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"wake_on_change": { "name": "Turn on when changed" }
|
||||
"wake_on_change": {
|
||||
"name": "Turn on when changed"
|
||||
},
|
||||
"sacn_enabled": {
|
||||
"name": "sACN reception"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"ip_address": { "name": "IP address" },
|
||||
"free_heap": { "name": "Free memory" },
|
||||
"last_ota_error": { "name": "Last update error" },
|
||||
"boot_time": { "name": "Last start" }
|
||||
"ip_address": {
|
||||
"name": "IP address"
|
||||
},
|
||||
"free_heap": {
|
||||
"name": "Free memory"
|
||||
},
|
||||
"last_ota_error": {
|
||||
"name": "Last update error"
|
||||
},
|
||||
"boot_time": {
|
||||
"name": "Last start"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
"sacn_active": {
|
||||
"name": "Remote controlled"
|
||||
},
|
||||
"sacn_multicast": {
|
||||
"name": "Multicast group"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": { "message": "Cannot reach the lamp." },
|
||||
"auth_failed": { "message": "The lamp rejected the token." },
|
||||
"request_failed": { "message": "The lamp rejected the request: {message}" },
|
||||
"update_busy": { "message": "An update is already running on the lamp." },
|
||||
"no_update": { "message": "No firmware update is available." },
|
||||
"no_http_url": { "message": "Lamps can only reach Home Assistant over plain http. Set the address in the integration options." },
|
||||
"lamp_url_too_long": { "message": "The firmware URL is longer than the 255 characters the lamp accepts." },
|
||||
"image_invalid": { "message": "The firmware file changed or is no longer valid." },
|
||||
"wifi_guard": { "message": "This firmware does not contain the Wi-Fi credentials for {ssid}; the lamp would lose its network connection." },
|
||||
"update_failed": { "message": "The update failed: {reason}" },
|
||||
"update_timeout": { "message": "The lamp did not report the new firmware within 12 minutes." }
|
||||
"cannot_connect": {
|
||||
"message": "Cannot reach the lamp."
|
||||
},
|
||||
"auth_failed": {
|
||||
"message": "The lamp rejected the token."
|
||||
},
|
||||
"request_failed": {
|
||||
"message": "The lamp rejected the request: {message}"
|
||||
},
|
||||
"update_busy": {
|
||||
"message": "An update is already running on the lamp."
|
||||
},
|
||||
"no_update": {
|
||||
"message": "No firmware update is available."
|
||||
},
|
||||
"no_http_url": {
|
||||
"message": "Lamps can only reach Home Assistant over plain http. Set the address in the integration options."
|
||||
},
|
||||
"lamp_url_too_long": {
|
||||
"message": "The firmware URL is longer than the 255 characters the lamp accepts."
|
||||
},
|
||||
"image_invalid": {
|
||||
"message": "The firmware file changed or is no longer valid."
|
||||
},
|
||||
"wifi_guard": {
|
||||
"message": "This firmware does not contain the Wi-Fi credentials for {ssid}; the lamp would lose its network connection."
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "The update failed: {reason}"
|
||||
},
|
||||
"update_timeout": {
|
||||
"message": "The lamp did not report the new firmware within 12 minutes."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"default_token": {
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import NinlightConfigEntry, NinlightCoordinator
|
||||
from .entity import NinlightEntity
|
||||
from .entity import NinlightEntity, NinlightSacnEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -20,7 +20,9 @@ async def async_setup_entry(
|
||||
entry: NinlightConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
async_add_entities([NinlightWakeSwitch(entry.runtime_data)])
|
||||
async_add_entities(
|
||||
[NinlightWakeSwitch(entry.runtime_data), NinlightSacnSwitch(entry.runtime_data)]
|
||||
)
|
||||
|
||||
|
||||
class NinlightWakeSwitch(NinlightEntity, SwitchEntity):
|
||||
@@ -41,3 +43,23 @@ class NinlightWakeSwitch(NinlightEntity, SwitchEntity):
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
await self.coordinator.async_set_state(wake_on_change=False)
|
||||
|
||||
|
||||
class NinlightSacnSwitch(NinlightSacnEntity, SwitchEntity):
|
||||
"""Whether the lamp listens for sACN/E1.31 at all."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_translation_key = "sacn_enabled"
|
||||
|
||||
def __init__(self, coordinator: NinlightCoordinator) -> None:
|
||||
super().__init__(coordinator, "sacn_enabled")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
return bool(self.sacn.get("enabled"))
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
await self.coordinator.async_set_sacn(enabled=True)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
await self.coordinator.async_set_sacn(enabled=False)
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
"user": {
|
||||
"title": "ninlight-Lampe einrichten",
|
||||
"description": "Adresse der Lampe eingeben. Das Token ist mit dem öffentlichen Vorgabe-Token vorbelegt – wer es kennt, kann die Lampe in deinem Netz steuern und neu flashen.",
|
||||
"data": { "host": "Host", "token": "Token" },
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"token": "Token"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "IP-Adresse oder ninlight-xxxx.local-Name der Lampe.",
|
||||
"token": "Autorisierungs-Token der Lampe."
|
||||
@@ -14,16 +17,23 @@
|
||||
"zeroconf_confirm": {
|
||||
"title": "{name} einrichten",
|
||||
"description": "ninlight-Lampe {name} unter {host} gefunden. Das Token ist mit dem öffentlichen Vorgabe-Token vorbelegt – wer es kennt, kann die Lampe in deinem Netz steuern und neu flashen.",
|
||||
"data": { "token": "Token" }
|
||||
"data": {
|
||||
"token": "Token"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "{name} neu anmelden",
|
||||
"description": "Die Lampe hat das gespeicherte Token abgelehnt. Gib ihr aktuelles Token ein.",
|
||||
"data": { "token": "Token" }
|
||||
"data": {
|
||||
"token": "Token"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Lampe neu konfigurieren",
|
||||
"data": { "host": "Host", "token": "Token" }
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"token": "Token"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -60,38 +70,111 @@
|
||||
},
|
||||
"entity": {
|
||||
"number": {
|
||||
"fade_in": { "name": "Einblenden" },
|
||||
"fade_out": { "name": "Ausblenden" },
|
||||
"transition_default": { "name": "Standardübergang" }
|
||||
"fade_in": {
|
||||
"name": "Einblenden"
|
||||
},
|
||||
"fade_out": {
|
||||
"name": "Ausblenden"
|
||||
},
|
||||
"transition_default": {
|
||||
"name": "Standardübergang"
|
||||
},
|
||||
"sacn_universe": {
|
||||
"name": "sACN-Universe"
|
||||
},
|
||||
"sacn_start_slot": {
|
||||
"name": "sACN-Startslot"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"power_on_behaviour": {
|
||||
"name": "Einschaltverhalten",
|
||||
"state": { "last": "Letzter Zustand", "on": "Ein", "off": "Aus" }
|
||||
"state": {
|
||||
"last": "Letzter Zustand",
|
||||
"on": "Ein",
|
||||
"off": "Aus"
|
||||
}
|
||||
},
|
||||
"sacn_on_timeout": {
|
||||
"name": "sACN nach Timeout",
|
||||
"state": {
|
||||
"release": "Zurück zu Home Assistant",
|
||||
"hold": "Letzte Werte halten"
|
||||
}
|
||||
},
|
||||
"sacn_layout": {
|
||||
"name": "sACN-Slotbelegung",
|
||||
"state": {
|
||||
"dimmer": "Dimmer voran",
|
||||
"rgb": "Nur RGB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"wake_on_change": { "name": "Bei Änderung einschalten" }
|
||||
"wake_on_change": {
|
||||
"name": "Bei Änderung einschalten"
|
||||
},
|
||||
"sacn_enabled": {
|
||||
"name": "sACN-Empfang"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"ip_address": { "name": "IP-Adresse" },
|
||||
"free_heap": { "name": "Freier Speicher" },
|
||||
"last_ota_error": { "name": "Letzter Update-Fehler" },
|
||||
"boot_time": { "name": "Letzter Start" }
|
||||
"ip_address": {
|
||||
"name": "IP-Adresse"
|
||||
},
|
||||
"free_heap": {
|
||||
"name": "Freier Speicher"
|
||||
},
|
||||
"last_ota_error": {
|
||||
"name": "Letzter Update-Fehler"
|
||||
},
|
||||
"boot_time": {
|
||||
"name": "Letzter Start"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
"sacn_active": {
|
||||
"name": "Ferngesteuert"
|
||||
},
|
||||
"sacn_multicast": {
|
||||
"name": "Multicast-Gruppe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": { "message": "Die Lampe ist nicht erreichbar." },
|
||||
"auth_failed": { "message": "Die Lampe hat das Token abgelehnt." },
|
||||
"request_failed": { "message": "Die Lampe hat die Anfrage abgelehnt: {message}" },
|
||||
"update_busy": { "message": "Auf der Lampe läuft bereits ein Update." },
|
||||
"no_update": { "message": "Kein Firmware-Update verfügbar." },
|
||||
"no_http_url": { "message": "Lampen erreichen Home Assistant nur über reines http. Setze die Adresse in den Optionen der Integration." },
|
||||
"lamp_url_too_long": { "message": "Die Firmware-URL ist länger als die 255 Zeichen, die die Lampe akzeptiert." },
|
||||
"image_invalid": { "message": "Die Firmware-Datei hat sich geändert oder ist nicht mehr gültig." },
|
||||
"wifi_guard": { "message": "Diese Firmware enthält keine WLAN-Zugangsdaten für {ssid}; die Lampe wäre danach nicht mehr im Netz." },
|
||||
"update_failed": { "message": "Das Update ist fehlgeschlagen: {reason}" },
|
||||
"update_timeout": { "message": "Die Lampe hat die neue Firmware nicht innerhalb von 12 Minuten gemeldet." }
|
||||
"cannot_connect": {
|
||||
"message": "Die Lampe ist nicht erreichbar."
|
||||
},
|
||||
"auth_failed": {
|
||||
"message": "Die Lampe hat das Token abgelehnt."
|
||||
},
|
||||
"request_failed": {
|
||||
"message": "Die Lampe hat die Anfrage abgelehnt: {message}"
|
||||
},
|
||||
"update_busy": {
|
||||
"message": "Auf der Lampe läuft bereits ein Update."
|
||||
},
|
||||
"no_update": {
|
||||
"message": "Kein Firmware-Update verfügbar."
|
||||
},
|
||||
"no_http_url": {
|
||||
"message": "Lampen erreichen Home Assistant nur über reines http. Setze die Adresse in den Optionen der Integration."
|
||||
},
|
||||
"lamp_url_too_long": {
|
||||
"message": "Die Firmware-URL ist länger als die 255 Zeichen, die die Lampe akzeptiert."
|
||||
},
|
||||
"image_invalid": {
|
||||
"message": "Die Firmware-Datei hat sich geändert oder ist nicht mehr gültig."
|
||||
},
|
||||
"wifi_guard": {
|
||||
"message": "Diese Firmware enthält keine WLAN-Zugangsdaten für {ssid}; die Lampe wäre danach nicht mehr im Netz."
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "Das Update ist fehlgeschlagen: {reason}"
|
||||
},
|
||||
"update_timeout": {
|
||||
"message": "Die Lampe hat die neue Firmware nicht innerhalb von 12 Minuten gemeldet."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"default_token": {
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
"user": {
|
||||
"title": "Set up ninlight lamp",
|
||||
"description": "Enter the address of the lamp. The token is prefilled with the public default token – anyone on your network who knows it can control and reflash the lamp.",
|
||||
"data": { "host": "Host", "token": "Token" },
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"token": "Token"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "IP address or ninlight-xxxx.local name of the lamp.",
|
||||
"token": "Authorization token of the lamp."
|
||||
@@ -14,16 +17,23 @@
|
||||
"zeroconf_confirm": {
|
||||
"title": "Set up {name}",
|
||||
"description": "Found the ninlight lamp {name} at {host}. The token is prefilled with the public default token – anyone on your network who knows it can control and reflash the lamp.",
|
||||
"data": { "token": "Token" }
|
||||
"data": {
|
||||
"token": "Token"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Re-authenticate {name}",
|
||||
"description": "The lamp rejected the stored token. Enter its current token.",
|
||||
"data": { "token": "Token" }
|
||||
"data": {
|
||||
"token": "Token"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure lamp",
|
||||
"data": { "host": "Host", "token": "Token" }
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"token": "Token"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -60,38 +70,111 @@
|
||||
},
|
||||
"entity": {
|
||||
"number": {
|
||||
"fade_in": { "name": "Fade-in" },
|
||||
"fade_out": { "name": "Fade-out" },
|
||||
"transition_default": { "name": "Default transition" }
|
||||
"fade_in": {
|
||||
"name": "Fade-in"
|
||||
},
|
||||
"fade_out": {
|
||||
"name": "Fade-out"
|
||||
},
|
||||
"transition_default": {
|
||||
"name": "Default transition"
|
||||
},
|
||||
"sacn_universe": {
|
||||
"name": "sACN universe"
|
||||
},
|
||||
"sacn_start_slot": {
|
||||
"name": "sACN start slot"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"power_on_behaviour": {
|
||||
"name": "Power-on behaviour",
|
||||
"state": { "last": "Last state", "on": "On", "off": "Off" }
|
||||
"state": {
|
||||
"last": "Last state",
|
||||
"on": "On",
|
||||
"off": "Off"
|
||||
}
|
||||
},
|
||||
"sacn_on_timeout": {
|
||||
"name": "sACN after timeout",
|
||||
"state": {
|
||||
"release": "Back to Home Assistant",
|
||||
"hold": "Hold last values"
|
||||
}
|
||||
},
|
||||
"sacn_layout": {
|
||||
"name": "sACN slot layout",
|
||||
"state": {
|
||||
"dimmer": "Dimmer first",
|
||||
"rgb": "RGB only"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"wake_on_change": { "name": "Turn on when changed" }
|
||||
"wake_on_change": {
|
||||
"name": "Turn on when changed"
|
||||
},
|
||||
"sacn_enabled": {
|
||||
"name": "sACN reception"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"ip_address": { "name": "IP address" },
|
||||
"free_heap": { "name": "Free memory" },
|
||||
"last_ota_error": { "name": "Last update error" },
|
||||
"boot_time": { "name": "Last start" }
|
||||
"ip_address": {
|
||||
"name": "IP address"
|
||||
},
|
||||
"free_heap": {
|
||||
"name": "Free memory"
|
||||
},
|
||||
"last_ota_error": {
|
||||
"name": "Last update error"
|
||||
},
|
||||
"boot_time": {
|
||||
"name": "Last start"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
"sacn_active": {
|
||||
"name": "Remote controlled"
|
||||
},
|
||||
"sacn_multicast": {
|
||||
"name": "Multicast group"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": { "message": "Cannot reach the lamp." },
|
||||
"auth_failed": { "message": "The lamp rejected the token." },
|
||||
"request_failed": { "message": "The lamp rejected the request: {message}" },
|
||||
"update_busy": { "message": "An update is already running on the lamp." },
|
||||
"no_update": { "message": "No firmware update is available." },
|
||||
"no_http_url": { "message": "Lamps can only reach Home Assistant over plain http. Set the address in the integration options." },
|
||||
"lamp_url_too_long": { "message": "The firmware URL is longer than the 255 characters the lamp accepts." },
|
||||
"image_invalid": { "message": "The firmware file changed or is no longer valid." },
|
||||
"wifi_guard": { "message": "This firmware does not contain the Wi-Fi credentials for {ssid}; the lamp would lose its network connection." },
|
||||
"update_failed": { "message": "The update failed: {reason}" },
|
||||
"update_timeout": { "message": "The lamp did not report the new firmware within 12 minutes." }
|
||||
"cannot_connect": {
|
||||
"message": "Cannot reach the lamp."
|
||||
},
|
||||
"auth_failed": {
|
||||
"message": "The lamp rejected the token."
|
||||
},
|
||||
"request_failed": {
|
||||
"message": "The lamp rejected the request: {message}"
|
||||
},
|
||||
"update_busy": {
|
||||
"message": "An update is already running on the lamp."
|
||||
},
|
||||
"no_update": {
|
||||
"message": "No firmware update is available."
|
||||
},
|
||||
"no_http_url": {
|
||||
"message": "Lamps can only reach Home Assistant over plain http. Set the address in the integration options."
|
||||
},
|
||||
"lamp_url_too_long": {
|
||||
"message": "The firmware URL is longer than the 255 characters the lamp accepts."
|
||||
},
|
||||
"image_invalid": {
|
||||
"message": "The firmware file changed or is no longer valid."
|
||||
},
|
||||
"wifi_guard": {
|
||||
"message": "This firmware does not contain the Wi-Fi credentials for {ssid}; the lamp would lose its network connection."
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "The update failed: {reason}"
|
||||
},
|
||||
"update_timeout": {
|
||||
"message": "The lamp did not report the new firmware within 12 minutes."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"default_token": {
|
||||
|
||||
@@ -67,6 +67,12 @@ def mock_client(
|
||||
return deepcopy(lamp_state)
|
||||
|
||||
client.set_state = AsyncMock(side_effect=set_state)
|
||||
|
||||
async def set_sacn(**fields: Any) -> None:
|
||||
# Like the lamp: it stores what it accepts, and the caller reads the block back.
|
||||
lamp_settings.setdefault("sacn", {}).update(fields)
|
||||
|
||||
client.set_sacn = AsyncMock(side_effect=set_sacn)
|
||||
client.reboot = AsyncMock(return_value=None)
|
||||
client.fw_update = AsyncMock(return_value=None)
|
||||
patches = [patch(target, return_value=client) for target in CLIENT_PATCH_TARGETS]
|
||||
|
||||
@@ -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:
|
||||
# a periodic catalog scan happens to land while this install is in progress
|
||||
calls_before_rescan["count"] = mock_client.get_settings.await_count
|
||||
before = mock_client.get_settings.await_count
|
||||
await rescan(hass)
|
||||
# Measured around the rescan itself, not after the install: once the install is
|
||||
# done the coordinator polls again anyway, and since the sACN settings became
|
||||
# entities that poll reads system/settings too. Comparing after the fact would
|
||||
# count that poll and fail for a reason this test does not care about.
|
||||
calls_before_rescan["delta"] = mock_client.get_settings.await_count - before
|
||||
lamp_info["fw_version"] = NEWER
|
||||
lamp_info["uptime"] = 4
|
||||
|
||||
@@ -291,7 +296,7 @@ async def test_rescan_during_install_does_not_trigger_evaluation(
|
||||
await install(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_client.get_settings.await_count == calls_before_rescan["count"]
|
||||
assert calls_before_rescan["delta"] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -57,6 +57,7 @@ INFO_RGB: dict[str, Any] = {
|
||||
"uptime": 129832,
|
||||
"free_heap": 119872,
|
||||
"last_ota_error": None,
|
||||
"sacn": {"active": False, "universe": 1, "multicast": True},
|
||||
}
|
||||
|
||||
INFO_CW: dict[str, Any] = {
|
||||
@@ -79,14 +80,34 @@ INFO_CW: dict[str, Any] = {
|
||||
"last_ota_error": None,
|
||||
}
|
||||
|
||||
# Wie eine Lampe mit sACN antwortet. Aeltere Firmware laesst den Block weg - dafuer gibt es
|
||||
# SETTINGS_WITHOUT_SACN weiter unten.
|
||||
SACN_SETTINGS: dict[str, Any] = {
|
||||
"enabled": True,
|
||||
"universe": 1,
|
||||
"start_slot": 1,
|
||||
"on_timeout": "release",
|
||||
"layout": "dimmer",
|
||||
"slots": 6,
|
||||
}
|
||||
|
||||
SETTINGS_COMPILED: dict[str, Any] = {
|
||||
"name": "",
|
||||
"wifi": {"ssid": "", "source": "compiled"},
|
||||
"auth_all": False,
|
||||
"sacn": dict(SACN_SETTINGS),
|
||||
}
|
||||
|
||||
# Firmware vor sACN: derselbe Datensatz ohne den Block.
|
||||
SETTINGS_WITHOUT_SACN: dict[str, Any] = {
|
||||
"name": "",
|
||||
"wifi": {"ssid": "", "source": "compiled"},
|
||||
"auth_all": False,
|
||||
}
|
||||
|
||||
SETTINGS_STORED: dict[str, Any] = {
|
||||
"name": "",
|
||||
"wifi": {"ssid": "ninIOT", "source": "stored"},
|
||||
"auth_all": False,
|
||||
"sacn": dict(SACN_SETTINGS),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user