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:
@@ -15,6 +15,9 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.SWITCH,
|
||||
Platform.NUMBER,
|
||||
Platform.BINARY_SENSOR,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user