feat: sACN-Einstellungen und -Status als Entitaeten

Die Firmware kann sACN/E1.31 - jetzt auch in Home Assistant, aufgebaut wie in
ha-ninlight: Schalter fuer den Empfang, Zahlen fuer Universe und Startslot,
Auswahl fuer das Verhalten nach Stromende, zwei Diagnose-Binaersensoren.

Die Einstellungen liegen hinter system/settings, der Koordinator liest sie
deshalb mit - eine vierte Anfrage je Zyklus. Nach dem Schreiben wird
zurueckgelesen statt angenommen, wie in ha-ninlight.

Zwei Unterschiede zu ninlight:
- Kein Layout-Feld: ninstrip nutzt fest drei Kanaele je Pixel.
- Der Startslot gilt in JEDEM belegten Universe. Die Obergrenze ist deshalb
  513 minus Kanaele je Pixel, also 510 - ein festes 512 boete Werte an, bei
  denen kein einziges Pixel mehr ins Universe passt.

Aeltere Firmware ohne sacn-Block: die Entitaeten entstehen trotzdem und
melden sich nicht verfuegbar, bis ein Update kommt.

In ha-dev an der Stehlampe geprueft: Universe, Startslot, on_timeout und der
Schalter kommen jeweils auf dem Geraet an; der Aktiv-Sensor steht waehrend
eines Stroms auf an, danach auf aus. 511 als Startslot weist Home Assistant
selbst ab ("outside valid range 1 - 510"). Einstellungen der Lampe danach
zurueckgestellt.
This commit is contained in:
ninharp
2026-09-22 18:09:40 +02:00
parent 8c9a9210d9
commit d0a9487805
12 changed files with 418 additions and 5 deletions
+88
View File
@@ -0,0 +1,88 @@
"""sACN-Zahlenfelder eines ninstrip-Geraets: Universe und Startslot."""
from __future__ import annotations
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import NinstripConfigEntry, NinstripCoordinator
from .entity import NinstripSacnEntity
PARALLEL_UPDATES = 0
# Kanaele je Pixel, falls das Geraet sie nicht meldet. Die Firmware stellt fest
# drei ein (R, G, B) - so senden LedFx, xLights und Resolume.
_KANAELE_VORGABE = 3
async def async_setup_entry(
hass: HomeAssistant,
entry: NinstripConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
coordinator = entry.runtime_data
async_add_entities(
[NinstripSacnUniverse(coordinator), NinstripSacnStartSlot(coordinator)]
)
class NinstripSacnUniverse(NinstripSacnEntity, NumberEntity):
"""Das erste Universe. Braucht der Strip mehr, folgen die naechsten."""
_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: NinstripCoordinator) -> 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 NinstripSacnStartSlot(NinstripSacnEntity, NumberEntity):
"""Der erste Slot - er gilt in JEDEM belegten Universe, nicht nur im ersten.
Anders als bei ninlight, wo eine Lampe ein einzelner Slotsatz ist: ein
Strip verteilt sich ueber mehrere Universes, und in jedem beginnt er an
derselben Stelle.
"""
_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: NinstripCoordinator) -> None:
super().__init__(coordinator, "sacn_start_slot")
@property
def native_max_value(self) -> float:
"""Der hoechste Startslot, hinter dem noch ein ganzes Pixel Platz hat.
Bei drei Kanaelen sind das 510. Ein festes 512 wuerde Werte anbieten,
bei denen kein einziges Pixel mehr ins Universe passt.
"""
kanaele = self.sacn.get("channels_per_pixel")
if not isinstance(kanaele, int) or not 1 <= kanaele <= 512:
kanaele = _KANAELE_VORGABE
return 513 - kanaele
@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))