From d0a9487805d694e1b67267a9cd8bc2ad9e758857 Mon Sep 17 00:00:00 2001 From: ninharp Date: Tue, 22 Sep 2026 18:09:40 +0200 Subject: [PATCH] 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. --- README.md | 29 +++++- custom_components/ninstrip/__init__.py | 3 + custom_components/ninstrip/api.py | 4 + custom_components/ninstrip/binary_sensor.py | 70 +++++++++++++++ custom_components/ninstrip/coordinator.py | 44 +++++++++- custom_components/ninstrip/entity.py | 19 ++++ custom_components/ninstrip/number.py | 88 +++++++++++++++++++ custom_components/ninstrip/select.py | 35 +++++++- custom_components/ninstrip/strings.json | 28 ++++++ custom_components/ninstrip/switch.py | 47 ++++++++++ .../ninstrip/translations/de.json | 28 ++++++ .../ninstrip/translations/en.json | 28 ++++++ 12 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 custom_components/ninstrip/binary_sensor.py create mode 100644 custom_components/ninstrip/number.py create mode 100644 custom_components/ninstrip/switch.py diff --git a/README.md b/README.md index f104343..b933d54 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/custom_components/ninstrip/__init__.py b/custom_components/ninstrip/__init__.py index bb30cd3..391b9de 100644 --- a/custom_components/ninstrip/__init__.py +++ b/custom_components/ninstrip/__init__.py @@ -15,6 +15,9 @@ PLATFORMS: list[Platform] = [ Platform.SELECT, Platform.SENSOR, Platform.BUTTON, + Platform.SWITCH, + Platform.NUMBER, + Platform.BINARY_SENSOR, ] diff --git a/custom_components/ninstrip/api.py b/custom_components/ninstrip/api.py index 7209f4b..b18609d 100644 --- a/custom_components/ninstrip/api.py +++ b/custom_components/ninstrip/api.py @@ -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"}}) diff --git a/custom_components/ninstrip/binary_sensor.py b/custom_components/ninstrip/binary_sensor.py new file mode 100644 index 0000000..a6998f2 --- /dev/null +++ b/custom_components/ninstrip/binary_sensor.py @@ -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") diff --git a/custom_components/ninstrip/coordinator.py b/custom_components/ninstrip/coordinator.py index 10a4bbb..11e3358 100644 --- a/custom_components/ninstrip/coordinator.py +++ b/custom_components/ninstrip/coordinator.py @@ -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).""" diff --git a/custom_components/ninstrip/entity.py b/custom_components/ninstrip/entity.py index 0f06a1f..c76eaa4 100644 --- a/custom_components/ninstrip/entity.py +++ b/custom_components/ninstrip/entity.py @@ -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 {} diff --git a/custom_components/ninstrip/number.py b/custom_components/ninstrip/number.py new file mode 100644 index 0000000..0ff0ad1 --- /dev/null +++ b/custom_components/ninstrip/number.py @@ -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)) diff --git a/custom_components/ninstrip/select.py b/custom_components/ninstrip/select.py index fc02571..3ae412c 100644 --- a/custom_components/ninstrip/select.py +++ b/custom_components/ninstrip/select.py @@ -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) diff --git a/custom_components/ninstrip/strings.json b/custom_components/ninstrip/strings.json index e7c5103..3083527 100644 --- a/custom_components/ninstrip/strings.json +++ b/custom_components/ninstrip/strings.json @@ -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": { diff --git a/custom_components/ninstrip/switch.py b/custom_components/ninstrip/switch.py new file mode 100644 index 0000000..08cec66 --- /dev/null +++ b/custom_components/ninstrip/switch.py @@ -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) diff --git a/custom_components/ninstrip/translations/de.json b/custom_components/ninstrip/translations/de.json index e7c5103..3083527 100644 --- a/custom_components/ninstrip/translations/de.json +++ b/custom_components/ninstrip/translations/de.json @@ -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": { diff --git a/custom_components/ninstrip/translations/en.json b/custom_components/ninstrip/translations/en.json index b330a84..4a2e975 100644 --- a/custom_components/ninstrip/translations/en.json +++ b/custom_components/ninstrip/translations/en.json @@ -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": {