feat(light): Farbe und Farbtemperatur ueber alle Segmente

In Home Assistant liessen sich nur Szenen und Helligkeit stellen, keine
Farben - device/state traegt naemlich keine. Die steht je Segment, und die
Segmente hat der Koordinator bisher gar nicht gelesen.

Jetzt holt er device/segments mit (fuenfte Anfrage je Zyklus), und die
Lichtentitaet bietet rgb_color und color_temp_kelvin an:

* Setzen schreibt in ALLE Segmente, aber nur die Farbfelder - Grenzen,
  Namen und Helligkeit bleiben stehen. Die Einteilung eines Strips ist von
  Hand gemacht, die reisst eine Farbwahl nicht ein. Geschrieben wird die
  ganze Liste auf einmal, danach zurueckgelesen statt angenommen.
* Angezeigt wird nur, worauf sich alle Segmente einigen. Zwei Farben und
  eine Entitaet - da waere jede Angabe gelogen, also gibt es keine.
* Farbtemperatur nur, wenn alle auf "white" stehen; sonst RGB, sonst
  blendete HA den Farbkreis aus, sobald ein Segment einen Effekt faehrt.
* Die Farbe geht VOR dem Einschalten raus, sonst leuchtet der Strip kurz
  in der alten auf.

Die Spanne 2700-6500 K ist die, die die Firmware auf die Weisskanaele
umrechnet (nl_ct.h); ohne Weisskanaele naehert sie sie in RGB an.

Dazu: das Markenbild ist jetzt dasselbe wie bei ninlight - Michaels
Wunsch, die beiden Integrationen gehoeren zusammen. Das Skript fuer ein
eigenes Motiv bleibt als Ausgangspunkt liegen.
This commit is contained in:
Michael Sauer
2026-09-23 00:20:23 +02:00
parent 2c340ea11f
commit c40c68b537
11 changed files with 361 additions and 22 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

+46 -3
View File
@@ -35,6 +35,10 @@ class NinstripData:
state: dict[str, Any]
info: dict[str, Any]
presets: dict[str, Any]
# Farbe und Farbtemperatur stehen NICHT in device/state, sondern je
# Segment. Ohne diese Liste koennte die Lichtentitaet keine Farbe zeigen
# und keine setzen.
segments: 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.
@@ -85,6 +89,7 @@ class NinstripCoordinator(DataUpdateCoordinator[NinstripData]):
state = await self.client.get_state()
info = await self.client.get_info()
presets = await self.client.get_presets()
segments = await self.client.get_segments()
# 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()
@@ -103,7 +108,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, settings)
return NinstripData(state, info, presets, segments, 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."""
@@ -135,7 +140,9 @@ class NinstripCoordinator(DataUpdateCoordinator[NinstripData]):
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.data.settings)
NinstripData(
state, self.data.info, self.data.presets, self.data.segments, self.data.settings
)
)
async def async_set_sacn(self, **fields: Any) -> None:
@@ -154,7 +161,43 @@ class NinstripCoordinator(DataUpdateCoordinator[NinstripData]):
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)
NinstripData(
self.data.state,
self.data.info,
self.data.presets,
self.data.segments,
settings,
)
)
async def async_set_all_segments(self, **fields: Any) -> None:
"""Schreibt dieselben Felder in JEDES Segment und liest zurueck.
Die Grenzen und Namen der Segmente bleiben dabei stehen - gesetzt wird
nur, was uebergeben wird. Eine Farbe ueber die Lichtentitaet soll die
muehsam von Hand gemachte Einteilung des Strips nicht einreissen.
Geschrieben wird die ganze Liste auf einmal: das Geraet nimmt sie als
Ganzes an oder gar nicht, und es ist eine Anfrage statt einer je
Segment. Danach wird zurueckgelesen statt angenommen, wie bei sACN.
"""
async def schreiben_und_lesen(client: NinstripClient) -> dict[str, Any]:
alt = self.data.segments.get("segments") or []
neu = [{**seg, **fields} for seg in alt]
await client.put_segments(neu)
return await client.get_segments()
segments = 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,
segments,
self.data.settings,
)
)
async def async_after_change(self) -> None:
+76 -7
View File
@@ -1,22 +1,35 @@
"""Hauptschalter und Helligkeit eines ninstrip.
"""Hauptschalter, Helligkeit und Farbe eines ninstrip.
Bewusst eine Entitaet je Geraet, nicht je Segment: device/state ist der
Hauptschalter und traegt keine Farbe - bei mehreren Segmenten gaebe es keine
eine Farbe, die stimmen wuerde. Die Segmente selbst werden ueber Szenen
gestellt; einzelne Segmententitaeten koennen spaeter dazukommen.
Hauptschalter. Farbe und Farbtemperatur stehen dagegen NICHT dort, sondern je
Segment - die Entitaet schreibt sie deshalb in alle Segmente zugleich und
laesst deren Grenzen und Namen stehen. Einzelne Segmententitaeten koennen
spaeter dazukommen.
"""
from __future__ import annotations
from typing import Any
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
ColorMode,
LightEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import NinstripConfigEntry, NinstripCoordinator
from .entity import NinstripEntity
# Die Firmware rechnet Farbtemperaturen ausserhalb dieser Spanne nicht auf die
# Weisskanaele um (nl_ct.h). Strips ohne Weisskanaele bekommen dieselbe Spanne
# als RGB-Naeherung, damit "warmweiss" auf jedem Chip leuchtet.
MIN_KELVIN = 2700
MAX_KELVIN = 6500
async def async_setup_entry(
hass: HomeAssistant,
@@ -30,12 +43,31 @@ class NinstripLight(NinstripEntity, LightEntity):
"""Der Strip als Ganzes."""
_attr_name = None
_attr_color_mode = ColorMode.BRIGHTNESS
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
_attr_supported_color_modes = {ColorMode.RGB, ColorMode.COLOR_TEMP}
_attr_min_color_temp_kelvin = MIN_KELVIN
_attr_max_color_temp_kelvin = MAX_KELVIN
def __init__(self, coordinator: NinstripCoordinator) -> None:
super().__init__(coordinator, "light")
# --- Zustand -------------------------------------------------------
def _segments(self) -> list[dict[str, Any]]:
return self.coordinator.data.segments.get("segments") or []
def _einig(self, feld: str) -> Any:
"""Der Wert, wenn alle Segmente sich einig sind - sonst None.
Ein Strip kann segmentweise verschiedene Farben zeigen. Home Assistant
kennt nur eine je Entitaet; in dem Fall waere jede Angabe gelogen, also
gibt es keine.
"""
segmente = self._segments()
if not segmente:
return None
werte = [seg.get(feld) for seg in segmente]
return werte[0] if all(w == werte[0] and w is not None for w in werte) else None
@property
def is_on(self) -> bool:
return bool(self.coordinator.data.state.get("on"))
@@ -48,6 +80,30 @@ class NinstripLight(NinstripEntity, LightEntity):
return None
return round(max(0, min(100, int(value))) * 255 / 100)
@property
def color_mode(self) -> ColorMode:
"""Weiss nur, wenn sich alle Segmente auf 'white' einigen.
Sonst RGB: ein Strip mit Effekten oder gemischten Segmenten hat keine
eine Farbtemperatur, und HA wuerde den Farbkreis sonst ausblenden.
"""
return ColorMode.COLOR_TEMP if self._einig("mode") == "white" else ColorMode.RGB
@property
def rgb_color(self) -> tuple[int, int, int] | None:
farbe = self._einig("color")
if not isinstance(farbe, dict):
return None
try:
return (int(farbe["r"]), int(farbe["g"]), int(farbe["b"]))
except KeyError, TypeError, ValueError:
return None
@property
def color_temp_kelvin(self) -> int | None:
wert = self._einig("color_temp")
return int(wert) if isinstance(wert, (int, float)) else None
@property
def extra_state_attributes(self) -> dict[str, Any]:
state = self.coordinator.data.state
@@ -58,7 +114,20 @@ class NinstripLight(NinstripEntity, LightEntity):
"preset_modified": state.get("preset_modified"),
}
# --- Schalten ------------------------------------------------------
async def async_turn_on(self, **kwargs: Any) -> None:
# Erst die Farbe in die Segmente, dann der Hauptschalter: so leuchtet
# nichts kurz in der alten Farbe auf.
if ATTR_RGB_COLOR in kwargs:
r, g, b = kwargs[ATTR_RGB_COLOR]
await self.coordinator.async_set_all_segments(
mode="solid", color={"r": int(r), "g": int(g), "b": int(b)}
)
elif ATTR_COLOR_TEMP_KELVIN in kwargs:
kelvin = max(MIN_KELVIN, min(MAX_KELVIN, int(kwargs[ATTR_COLOR_TEMP_KELVIN])))
await self.coordinator.async_set_all_segments(mode="white", color_temp=kelvin)
fields: dict[str, Any] = {"on": True}
if ATTR_BRIGHTNESS in kwargs:
fields["brightness"] = round(int(kwargs[ATTR_BRIGHTNESS]) * 100 / 255)