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
+28 -1
View File
@@ -50,6 +50,10 @@ Ohne mDNS geht es auch von Hand über Adresse und Token.
| `select` | aufgeschaltete Szene (acht Plätze in der Firmware) |
| `button` | Neustart |
| `sensor` | WLAN-Signal, Laufzeit, freier Speicher, Leistungsbegrenzung |
| `switch` | sACN-Empfang an/aus |
| `number` | sACN-Universe und -Startslot |
| `select` | Verhalten nach Stromende (`release` / `hold`) |
| `binary_sensor` | sACN aktiv, Multicast beigetreten (letzterer standardmäßig aus) |
Die Diagnose-Sensoren sind standardmäßig abgeschaltet; die
Leistungsbegrenzung ist an, weil sie verrät, ob die Strombegrenzung gerade
@@ -64,7 +68,30 @@ eine Farbe, die stimmen würde. Die Segmente stellt man über Szenen.
- Einzelne Segmente als eigene Entitäten
- Firmware-Aktualisierung über eine `update`-Entität (die Firmware kann OTA
mit Rückfallebene, die Integration nutzt es noch nicht)
- sACN/E1.31 — in der Firmware geplant, siehe ninstrip-Repo
## sACN / E1.31
Aufbau wie ha-ninlight: die Einstellungen kommen aus `system/settings`, der
Zustand aus `system/info`, und nach jedem Schreiben wird **zurückgelesen**
statt angenommen — das Gerät prüft selbst, und eine Entität, die das
Gewünschte statt des Gespeicherten zeigt, lügt genau dann, wenn beides
auseinanderläuft.
Zwei Unterschiede zu ninlight:
- **Kein Layout-Feld.** ninstrip nutzt fest drei Kanäle je Pixel (R, G, B), so
wie LedFx, xLights und Resolume senden.
- **Der Startslot gilt in jedem Universe**, nicht nur im ersten — ein Strip
verteilt sich über mehrere. Die Obergrenze ist deshalb `513 − 3 = 510`:
dahinter passt kein ganzes Pixel mehr hinein.
Jede Änderung über Home Assistant holt einem laufenden oder gehaltenen Strom
die Kontrolle ab — das erledigt die Firmware.
**Multicast über VLAN-Grenzen:** „beigetreten" heißt nur, dass das Gerät der
Gruppe beigetreten ist. Ob das Netz sie weiterreicht, steht auf einem anderen
Blatt; ohne Multicast-Router kommt sie über eine VLAN-Grenze nie an. Unicast
geht dann trotzdem.
## Entwicklung
+3
View File
@@ -15,6 +15,9 @@ PLATFORMS: list[Platform] = [
Platform.SELECT,
Platform.SENSOR,
Platform.BUTTON,
Platform.SWITCH,
Platform.NUMBER,
Platform.BINARY_SENSOR,
]
+4
View File
@@ -113,6 +113,10 @@ class NinstripClient:
async def apply_preset(self, index: int) -> Any:
return await self._request("PUT", f"device/presets/{index}/apply")
async def set_sacn(self, **fields: Any) -> None:
"""sACN-Einstellungen aendern. Weggelassene Felder behalten ihren Wert."""
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,70 @@
"""Diagnose-Binaersensoren eines ninstrip-Geraets: Zustand des sACN-Empfangs."""
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 NinstripConfigEntry, NinstripCoordinator
from .entity import NinstripEntity
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: NinstripConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
coordinator = entry.runtime_data
async_add_entities(
[NinstripSacnActive(coordinator), NinstripSacnMulticast(coordinator)]
)
class _NinstripSacnInfo(NinstripEntity, BinarySensorEntity):
"""Basis fuer die beiden Merker unter 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 NinstripSacnActive(_NinstripSacnInfo):
"""An, solange ein Sender das Bild bestimmt - auch in der Haltephase."""
_attr_translation_key = "sacn_active"
_field = "active"
def __init__(self, coordinator: NinstripCoordinator) -> None:
super().__init__(coordinator, "sacn_active")
class NinstripSacnMulticast(_NinstripSacnInfo):
"""An, wenn der Beitritt zu allen Multicast-Gruppen gelang.
Standardmaessig aus, wie bei ha-ninlight: im Normalbetrieb steht er auf
"an" und verdient seinen Platz erst, wenn Multicast nicht ankommt. Bei
ninlight hat eine falsche Gruppenadresse zwei Abende Suche gekostet - hier
zeigt es sich.
Achtung: "an" heisst nur, dass das GERAET beigetreten ist. Ob das Netz
die Gruppe weiterreicht, steht auf einem anderen Blatt - ueber eine
VLAN-Grenze kommt Multicast ohne Multicast-Router nie an.
"""
_attr_translation_key = "sacn_multicast"
_attr_entity_registry_enabled_default = False
_field = "multicast"
def __init__(self, coordinator: NinstripCoordinator) -> None:
super().__init__(coordinator, "sacn_multicast")
+42 -2
View File
@@ -35,6 +35,22 @@ class NinstripData:
state: dict[str, Any]
info: dict[str, Any]
presets: dict[str, Any]
# Nur, was das Geraet unter system/settings zeigt. Aeltere Firmware traegt
# dort keinen sacn-Block - die Entitaeten melden sich dann nicht
# verfuegbar, statt zu verschwinden.
settings: dict[str, Any]
@property
def sacn(self) -> dict[str, Any] | None:
"""sACN-Einstellungen, oder None bei Firmware ohne sACN."""
block = self.settings.get("sacn")
return block if isinstance(block, dict) else None
@property
def sacn_info(self) -> dict[str, Any] | None:
"""sACN-Zustand aus system/info, oder None bei Firmware ohne sACN."""
block = self.info.get("sacn")
return block if isinstance(block, dict) else None
class NinstripCoordinator(DataUpdateCoordinator[NinstripData]):
@@ -69,6 +85,9 @@ class NinstripCoordinator(DataUpdateCoordinator[NinstripData]):
state = await self.client.get_state()
info = await self.client.get_info()
presets = await self.client.get_presets()
# Die sACN-Einstellungen liegen hinter system/settings, nicht in
# device/state - eine vierte Anfrage je Zyklus. Wie ha-ninlight.
settings = await self.client.get_settings()
except NinstripAuthError as err:
raise ConfigEntryAuthFailed(f"{self.client.host}: Token abgelehnt") from err
except NinstripError as err:
@@ -84,7 +103,7 @@ class NinstripCoordinator(DataUpdateCoordinator[NinstripData]):
return self.data
raise UpdateFailed(f"{self.client.host}: {err}") from err
self._consecutive_poll_failures = 0
return NinstripData(state, info, presets)
return NinstripData(state, info, presets, settings)
async def async_call[T](self, action: Callable[[NinstripClient], Awaitable[T]]) -> T:
"""Fuehrt eine Anfrage unter der Sperre aus und uebersetzt Fehler fuer die Oberflaeche."""
@@ -115,7 +134,28 @@ class NinstripCoordinator(DataUpdateCoordinator[NinstripData]):
"""Schreibt und uebernimmt die vollstaendige Antwort des Geraets direkt."""
state = await self.async_call(lambda client: client.set_state(**fields))
self._consecutive_poll_failures = 0
self.async_set_updated_data(NinstripData(state, self.data.info, self.data.presets))
self.async_set_updated_data(
NinstripData(state, self.data.info, self.data.presets, self.data.settings)
)
async def async_set_sacn(self, **fields: Any) -> None:
"""sACN-Einstellungen schreiben und die Antwort des Geraets uebernehmen.
Das Geraet antwortet nur mit {"status": "ok"}, also wird zurueckgelesen
statt angenommen: es prueft selbst, und eine Entitaet, die das
Gewuenschte statt des Gespeicherten zeigt, luegt genau dann, wenn
beides auseinanderlaeuft. Wie ha-ninlight.
"""
async def schreiben_und_lesen(client: NinstripClient) -> dict[str, Any]:
await client.set_sacn(**fields)
return await client.get_settings()
settings = await self.async_call(schreiben_und_lesen)
self._consecutive_poll_failures = 0
self.async_set_updated_data(
NinstripData(self.data.state, self.data.info, self.data.presets, settings)
)
async def async_after_change(self) -> None:
"""Nach einem Eingriff, der den ganzen Stand aendern kann (Szene, Segmente)."""
+19
View File
@@ -52,3 +52,22 @@ class NinstripEntity(CoordinatorEntity[NinstripCoordinator]):
name=device_name(info),
sw_version=info["fw_version"],
)
class NinstripSacnEntity(NinstripEntity):
"""Basis fuer Entitaeten, die an den sACN-Einstellungen haengen.
Firmware von vor sACN antwortet ohne sacn-Block. Die Entitaeten entstehen
trotzdem - so werden sie nach einem Update beim naechsten Abfragen
lebendig, ohne die Integration neu zu laden -, melden sich aber bis dahin
nicht verfuegbar. Wie ha-ninlight.
"""
@property
def available(self) -> bool:
return super().available and self.coordinator.data.sacn is not None
@property
def sacn(self) -> dict[str, Any]:
"""Die sACN-Einstellungen; leer, solange das Geraet keine meldet."""
return self.coordinator.data.sacn or {}
+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))
+33 -2
View File
@@ -3,23 +3,30 @@
from __future__ import annotations
from homeassistant.components.select import SelectEntity
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 NinstripEntity
from .entity import NinstripEntity, NinstripSacnEntity
# Wird angezeigt, wenn keine Szene aufgeschaltet ist oder jemand die Segmente
# seit dem Aufschalten veraendert hat.
NONE_OPTION = "—"
# Was das Geraet 2,5 s nach dem letzten sACN-Paket tut. Werte wie ha-ninlight.
ON_TIMEOUT_OPTIONS = ["release", "hold"]
async def async_setup_entry(
hass: HomeAssistant,
entry: NinstripConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
async_add_entities([NinstripPresetSelect(entry.runtime_data)])
coordinator = entry.runtime_data
async_add_entities(
[NinstripPresetSelect(coordinator), NinstripSacnOnTimeoutSelect(coordinator)]
)
class NinstripPresetSelect(NinstripEntity, SelectEntity):
@@ -65,3 +72,27 @@ class NinstripPresetSelect(NinstripEntity, SelectEntity):
await self.coordinator.async_call(lambda c: c.apply_preset(index))
await self.coordinator.async_after_change()
return
class NinstripSacnOnTimeoutSelect(NinstripSacnEntity, SelectEntity):
"""Was nach dem Ende eines sACN-Stroms passiert.
release: zurueck zum vorherigen Bild. hold: das letzte Bild bleibt stehen,
bis ein Bedienbefehl kommt - jede Aenderung ueber Home Assistant holt die
Kontrolle zurueck.
"""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "sacn_on_timeout"
_attr_options = ON_TIMEOUT_OPTIONS
def __init__(self, coordinator: NinstripCoordinator) -> 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)
+28
View File
@@ -49,6 +49,13 @@
"select": {
"preset": {
"name": "Szene"
},
"sacn_on_timeout": {
"name": "sACN nach Stromende",
"state": {
"release": "Zurück zum vorherigen Bild",
"hold": "Letztes Bild halten"
}
}
},
"button": {
@@ -69,6 +76,27 @@
"power_scale": {
"name": "Leistungsbegrenzung"
}
},
"switch": {
"sacn_enabled": {
"name": "sACN-Empfang"
}
},
"number": {
"sacn_universe": {
"name": "sACN-Universe"
},
"sacn_start_slot": {
"name": "sACN-Startslot"
}
},
"binary_sensor": {
"sacn_active": {
"name": "sACN aktiv"
},
"sacn_multicast": {
"name": "sACN-Multicast beigetreten"
}
}
},
"exceptions": {
+47
View File
@@ -0,0 +1,47 @@
"""sACN-Schalter eines ninstrip-Geraets."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
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
async def async_setup_entry(
hass: HomeAssistant,
entry: NinstripConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
async_add_entities([NinstripSacnSwitch(entry.runtime_data)])
class NinstripSacnSwitch(NinstripSacnEntity, SwitchEntity):
"""Ob das Geraet ueberhaupt auf sACN/E1.31 hoert.
Abschalten holt dem Strom auch die Kontrolle ab - bei on_timeout "hold"
bliebe sonst das letzte Bild stehen. Die Firmware erledigt das selbst.
"""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "sacn_enabled"
def __init__(self, coordinator: NinstripCoordinator) -> 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)
@@ -49,6 +49,13 @@
"select": {
"preset": {
"name": "Szene"
},
"sacn_on_timeout": {
"name": "sACN nach Stromende",
"state": {
"release": "Zurück zum vorherigen Bild",
"hold": "Letztes Bild halten"
}
}
},
"button": {
@@ -69,6 +76,27 @@
"power_scale": {
"name": "Leistungsbegrenzung"
}
},
"switch": {
"sacn_enabled": {
"name": "sACN-Empfang"
}
},
"number": {
"sacn_universe": {
"name": "sACN-Universe"
},
"sacn_start_slot": {
"name": "sACN-Startslot"
}
},
"binary_sensor": {
"sacn_active": {
"name": "sACN aktiv"
},
"sacn_multicast": {
"name": "sACN-Multicast beigetreten"
}
}
},
"exceptions": {
@@ -49,6 +49,13 @@
"select": {
"preset": {
"name": "Scene"
},
"sacn_on_timeout": {
"name": "sACN on stream end",
"state": {
"release": "Return to previous image",
"hold": "Hold last image"
}
}
},
"button": {
@@ -69,6 +76,27 @@
"power_scale": {
"name": "Power limiting"
}
},
"switch": {
"sacn_enabled": {
"name": "sACN receiver"
}
},
"number": {
"sacn_universe": {
"name": "sACN universe"
},
"sacn_start_slot": {
"name": "sACN start slot"
}
},
"binary_sensor": {
"sacn_active": {
"name": "sACN active"
},
"sacn_multicast": {
"name": "sACN multicast joined"
}
}
},
"exceptions": {