Files
ha-ninlight/custom_components/ninlight/number.py
T
Michael Sauer ac60428fdb feat: sACN-Einstellungen und -Status als Entitaeten
Die Firmware kann seit dieser Woche sACN/E1.31. Einstellen liess sich das nur
per REST - jetzt steht es in Home Assistant: Schalter fuer den Empfang, Zahlen
fuer Universe und Startslot, Auswahlen fuer das Verhalten nach dem Timeout und
die Slotbelegung, dazu zwei Diagnose-Binaersensoren.

Die sACN-Einstellungen liegen hinter system/settings, nicht in device/state -
der Koordinator liest sie deshalb jetzt mit, eine dritte Anfrage je Zyklus. Der
Schreibweg liest die Einstellungen danach frisch zurueck statt den gesetzten
Wert anzunehmen: die Lampe validiert selbst (die Startslot-Grenze haengt an der
Slotbelegung), und eine Entitaet, die das Gewuenschte statt des Gespeicherten
zeigt, luegt in genau dem Moment, in dem es darauf ankommt.

Aeltere Firmware antwortet ohne den sacn-Block. Die Entitaeten entstehen
trotzdem und melden sich als nicht verfuegbar - so werden sie nach einem Update
der Lampe beim naechsten Poll von selbst lebendig, ohne Neuladen.

Die Obergrenze des Startslots kommt aus dem "slots"-Feld der Lampe, nicht aus
einer festen 512: sie haengt an Board UND Slotbelegung, und eine feste Grenze
wuerde Werte anbieten, die die Lampe dann ablehnt.

Der Multicast-Sensor ist ab Werk deaktiviert wie die uebrigen Diagnosewerte. Er
meldet im Normalbetrieb schlicht "beigetreten" - aber eine falsche
Gruppenadresse hat zwei Abende gekostet, und genau dort waere sie aufgefallen.

test_update.py misst seine Zusage jetzt um den Rescan herum statt nach dem
Install: dort pollt der Koordinator ohnehin wieder und liest seitdem auch die
Einstellungen mit.
2026-09-21 23:35:54 +02:00

130 lines
4.5 KiB
Python

"""Number settings of a ninlight lamp: fades, transition and sACN addressing."""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import NinlightConfigEntry, NinlightCoordinator
from .entity import NinlightEntity, NinlightSacnEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class NinlightNumberDescription(NumberEntityDescription):
"""A millisecond setting stored under `key` in the lamp state."""
entity_category: EntityCategory = EntityCategory.CONFIG
device_class: NumberDeviceClass = NumberDeviceClass.DURATION
native_unit_of_measurement: str = UnitOfTime.MILLISECONDS
native_min_value: float = 0
native_step: float = 50
mode: NumberMode = NumberMode.BOX
NUMBERS = (
NinlightNumberDescription(key="fade_in", translation_key="fade_in", native_max_value=10_000),
NinlightNumberDescription(key="fade_out", translation_key="fade_out", native_max_value=10_000),
NinlightNumberDescription(
key="transition_default", translation_key="transition_default", native_max_value=60_000
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: NinlightConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
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):
"""One millisecond setting."""
entity_description: NinlightNumberDescription
def __init__(
self, coordinator: NinlightCoordinator, description: NinlightNumberDescription
) -> None:
super().__init__(coordinator, description.key)
self.entity_description = description
@property
def native_value(self) -> int | None:
value = self.coordinator.data.state.get(self.entity_description.key)
return int(value) if value is not None else None
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))