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
+23 -8
View File
@@ -46,7 +46,7 @@ Ohne mDNS geht es auch von Hand über Adresse und Token.
| Entität | | | Entität | |
|---|---| |---|---|
| `light` | Hauptschalter und Helligkeit des ganzen Strips | | `light` | Hauptschalter, Helligkeit, Farbe und Farbtemperatur des ganzen Strips |
| `select` | aufgeschaltete Szene (acht Plätze in der Firmware) | | `select` | aufgeschaltete Szene (acht Plätze in der Firmware) |
| `button` | Neustart | | `button` | Neustart |
| `sensor` | IP-Adresse, WLAN-Signal, Laufzeit, freier Speicher, Leistungsbegrenzung | | `sensor` | IP-Adresse, WLAN-Signal, Laufzeit, freier Speicher, Leistungsbegrenzung |
@@ -63,8 +63,19 @@ Signal, Laufzeit und freier Speicher sind aus; die braucht man erst im
Fehlerfall. Fehlerfall.
**Eine Lichtentität je Gerät, nicht je Segment.** `device/state` ist der **Eine Lichtentität je Gerät, nicht je Segment.** `device/state` ist der
Hauptschalter und trägt keine Farbe — bei mehreren Segmenten gäbe es keine Hauptschalter und trägt keine Farbe — die steht je **Segment**. Die
eine Farbe, die stimmen würde. Die Segmente stellt man über Szenen. Lichtentität schreibt eine gewählte Farbe deshalb in **alle** Segmente und
lässt deren Grenzen, Namen und Helligkeit stehen; die mühsam von Hand gemachte
Einteilung bleibt also erhalten.
Zeigen kann sie nur **eine** Farbe. Sind sich die Segmente nicht einig, meldet
sie gar keine, statt sich eine auszusuchen — jede Angabe wäre dann gelogen.
Farbtemperatur meldet sie nur, wenn **alle** Segmente auf `white` stehen;
sobald eines einen Effekt fährt, ist es RGB. Die Spanne ist 2700–6500 K, das
rechnet die Firmware auf die Weißkanäle um (`nl_ct.h`); Strips ohne
Weißkanäle bekommen dieselbe Spanne als RGB-Näherung.
Für einzelne Segmente gibt es noch keine Entitäten.
## Noch nicht drin ## Noch nicht drin
@@ -138,11 +149,15 @@ eingebunden, der Quellcode bleibt in diesem Repo.
### Markenbilder ### Markenbilder
`scripts/make_brand.py` zeichnet die vier PNGs unter Die vier PNGs unter `custom_components/ninstrip/brand/` sind **dieselben wie
`custom_components/ninstrip/brand/`. Ab Home Assistant 2026.3 liefert HA sie in ha-ninlight**: die beiden Integrationen gehören zusammen, und zwei
für eine eigene Integration von dort aus; ohne sie bleibt in der verschiedene Motive in derselben Geräteübersicht stiften nur Verwirrung. Ab
Geräteübersicht ein Platzhalter. Das Motiv ist ein Strip mit fünf leuchtenden Home Assistant 2026.3 liefert HA sie für eine eigene Integration von dort
Pixeln — ninlight hat an derselben Stelle eine Glühbirne. aus; ohne sie bleibt ein Platzhalter.
`scripts/make_brand.py` zeichnet ein eigenes Motiv (ein Band mit fünf
leuchtenden Pixeln) und liegt als Ausgangspunkt bereit, falls doch einmal
eines gewünscht ist — in Gebrauch ist es nicht.
### Tests ### Tests
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] state: dict[str, Any]
info: dict[str, Any] info: dict[str, Any]
presets: 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 # Nur, was das Geraet unter system/settings zeigt. Aeltere Firmware traegt
# dort keinen sacn-Block - die Entitaeten melden sich dann nicht # dort keinen sacn-Block - die Entitaeten melden sich dann nicht
# verfuegbar, statt zu verschwinden. # verfuegbar, statt zu verschwinden.
@@ -85,6 +89,7 @@ class NinstripCoordinator(DataUpdateCoordinator[NinstripData]):
state = await self.client.get_state() state = await self.client.get_state()
info = await self.client.get_info() info = await self.client.get_info()
presets = await self.client.get_presets() presets = await self.client.get_presets()
segments = await self.client.get_segments()
# Die sACN-Einstellungen liegen hinter system/settings, nicht in # Die sACN-Einstellungen liegen hinter system/settings, nicht in
# device/state - eine vierte Anfrage je Zyklus. Wie ha-ninlight. # device/state - eine vierte Anfrage je Zyklus. Wie ha-ninlight.
settings = await self.client.get_settings() settings = await self.client.get_settings()
@@ -103,7 +108,7 @@ class NinstripCoordinator(DataUpdateCoordinator[NinstripData]):
return self.data return self.data
raise UpdateFailed(f"{self.client.host}: {err}") from err raise UpdateFailed(f"{self.client.host}: {err}") from err
self._consecutive_poll_failures = 0 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: 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.""" """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)) state = await self.async_call(lambda client: client.set_state(**fields))
self._consecutive_poll_failures = 0 self._consecutive_poll_failures = 0
self.async_set_updated_data( 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: 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) settings = await self.async_call(schreiben_und_lesen)
self._consecutive_poll_failures = 0 self._consecutive_poll_failures = 0
self.async_set_updated_data( 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: 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 Bewusst eine Entitaet je Geraet, nicht je Segment: device/state ist der
Hauptschalter und traegt keine Farbe - bei mehreren Segmenten gaebe es keine Hauptschalter. Farbe und Farbtemperatur stehen dagegen NICHT dort, sondern je
eine Farbe, die stimmen wuerde. Die Segmente selbst werden ueber Szenen Segment - die Entitaet schreibt sie deshalb in alle Segmente zugleich und
gestellt; einzelne Segmententitaeten koennen spaeter dazukommen. laesst deren Grenzen und Namen stehen. Einzelne Segmententitaeten koennen
spaeter dazukommen.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any 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.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import NinstripConfigEntry, NinstripCoordinator from .coordinator import NinstripConfigEntry, NinstripCoordinator
from .entity import NinstripEntity 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@@ -30,12 +43,31 @@ class NinstripLight(NinstripEntity, LightEntity):
"""Der Strip als Ganzes.""" """Der Strip als Ganzes."""
_attr_name = None _attr_name = None
_attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.RGB, ColorMode.COLOR_TEMP}
_attr_supported_color_modes = {ColorMode.BRIGHTNESS} _attr_min_color_temp_kelvin = MIN_KELVIN
_attr_max_color_temp_kelvin = MAX_KELVIN
def __init__(self, coordinator: NinstripCoordinator) -> None: def __init__(self, coordinator: NinstripCoordinator) -> None:
super().__init__(coordinator, "light") 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 @property
def is_on(self) -> bool: def is_on(self) -> bool:
return bool(self.coordinator.data.state.get("on")) return bool(self.coordinator.data.state.get("on"))
@@ -48,6 +80,30 @@ class NinstripLight(NinstripEntity, LightEntity):
return None return None
return round(max(0, min(100, int(value))) * 255 / 100) 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 @property
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> dict[str, Any]:
state = self.coordinator.data.state state = self.coordinator.data.state
@@ -58,7 +114,20 @@ class NinstripLight(NinstripEntity, LightEntity):
"preset_modified": state.get("preset_modified"), "preset_modified": state.get("preset_modified"),
} }
# --- Schalten ------------------------------------------------------
async def async_turn_on(self, **kwargs: Any) -> None: 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} fields: dict[str, Any] = {"on": True}
if ATTR_BRIGHTNESS in kwargs: if ATTR_BRIGHTNESS in kwargs:
fields["brightness"] = round(int(kwargs[ATTR_BRIGHTNESS]) * 100 / 255) fields["brightness"] = round(int(kwargs[ATTR_BRIGHTNESS]) * 100 / 255)
+7 -3
View File
@@ -2,9 +2,13 @@
Ab Home Assistant 2026.3 nimmt HA fuer eine eigene Integration die Bilder aus Ab Home Assistant 2026.3 nimmt HA fuer eine eigene Integration die Bilder aus
diesem Ordner - ohne sie bleibt in der Geraeteuebersicht ein Platzhalter. diesem Ordner - ohne sie bleibt in der Geraeteuebersicht ein Platzhalter.
ha-ninlight macht es genauso (dort eine Gluehbirne), hier ist es ein Strip: **Nicht mehr in Gebrauch.** Michael wollte am 23.09.2026 dasselbe Bild wie
ein dunkles Band mit fuenf leuchtenden Pixeln in Regenbogenfarben, weil genau bei ninlight - die beiden Integrationen gehoeren zusammen, und zwei
das den Unterschied zu ninlight ausmacht - viele Pixel statt einer Lampe. verschiedene Motive in derselben Geraeteuebersicht stiften nur Verwirrung.
Die Bilder in brand/ sind deshalb aus ha-ninlight uebernommen.
Das Skript bleibt als Ausgangspunkt liegen, falls doch einmal ein eigenes
Motiv gewuenscht ist: ein dunkles Band mit fuenf leuchtenden Pixeln.
""" """
from __future__ import annotations from __future__ import annotations
+15
View File
@@ -20,6 +20,7 @@ from tests_unit.samples import (
INFO_K1Y, INFO_K1Y,
MAC_K1Y, MAC_K1Y,
PRESETS_K1Y, PRESETS_K1Y,
SEGMENTS_K1Y,
SETTINGS_COMPILED, SETTINGS_COMPILED,
STATE_K1Y, STATE_K1Y,
TOKEN, TOKEN,
@@ -57,12 +58,18 @@ def strip_presets() -> dict[str, Any]:
return deepcopy(PRESETS_K1Y) return deepcopy(PRESETS_K1Y)
@pytest.fixture
def strip_segments() -> dict[str, Any]:
return deepcopy(SEGMENTS_K1Y)
@pytest.fixture @pytest.fixture
def mock_client( def mock_client(
strip_state: dict[str, Any], strip_state: dict[str, Any],
strip_info: dict[str, Any], strip_info: dict[str, Any],
strip_settings: dict[str, Any], strip_settings: dict[str, Any],
strip_presets: dict[str, Any], strip_presets: dict[str, Any],
strip_segments: dict[str, Any],
) -> Generator[MagicMock]: ) -> Generator[MagicMock]:
"""Ein Geraet, dessen Antworten den veraenderbaren strip_*-Fixtures folgen.""" """Ein Geraet, dessen Antworten den veraenderbaren strip_*-Fixtures folgen."""
client = MagicMock(spec=NinstripClient) client = MagicMock(spec=NinstripClient)
@@ -71,6 +78,14 @@ def mock_client(
client.get_info = AsyncMock(side_effect=lambda: deepcopy(strip_info)) client.get_info = AsyncMock(side_effect=lambda: deepcopy(strip_info))
client.get_settings = AsyncMock(side_effect=lambda: deepcopy(strip_settings)) client.get_settings = AsyncMock(side_effect=lambda: deepcopy(strip_settings))
client.get_presets = AsyncMock(side_effect=lambda: deepcopy(strip_presets)) client.get_presets = AsyncMock(side_effect=lambda: deepcopy(strip_presets))
client.get_segments = AsyncMock(side_effect=lambda: deepcopy(strip_segments))
async def put_segments(segments: list[dict[str, Any]]) -> None:
# Wie das Geraet: es nimmt die Liste als Ganzes an und antwortet nur
# mit "ok"; der Aufrufer liest danach zurueck.
strip_segments["segments"] = deepcopy(segments)
client.put_segments = AsyncMock(side_effect=put_segments)
async def set_state(**fields: Any) -> dict[str, Any]: async def set_state(**fields: Any) -> dict[str, Any]:
strip_state.update(fields) strip_state.update(fields)
+178 -1
View File
@@ -35,7 +35,9 @@ async def test_zustand_und_helligkeit_aus_prozent(
assert state.state == STATE_ON assert state.state == STATE_ON
# Das Geraet rechnet in Prozent: 100 % sind 255. # Das Geraet rechnet in Prozent: 100 % sind 255.
assert state.attributes[ATTR_BRIGHTNESS] == 255 assert state.attributes[ATTR_BRIGHTNESS] == 255
assert state.attributes["color_mode"] == ColorMode.BRIGHTNESS # Alle Segmente stehen auf weiss, also meldet die Entitaet Farbtemperatur.
assert state.attributes["color_mode"] == ColorMode.COLOR_TEMP
assert state.attributes["color_temp_kelvin"] == 2700
assert state.attributes["pixel_count"] == 60 assert state.attributes["pixel_count"] == 60
assert state.attributes["segment_count"] == 1 assert state.attributes["segment_count"] == 1
assert state.attributes["preset"] == 2 assert state.attributes["preset"] == 2
@@ -123,3 +125,178 @@ async def test_die_antwort_des_geraets_zaehlt_nicht_die_anfrage(
blocking=True, blocking=True,
) )
assert hass.states.get(light_id(hass)).attributes[ATTR_BRIGHTNESS] == 51 assert hass.states.get(light_id(hass)).attributes[ATTR_BRIGHTNESS] == 51
# --- Farbe und Farbtemperatur ------------------------------------------
async def test_farbe_aus_den_segmenten(
hass: HomeAssistant,
init_integration: MockConfigEntry,
strip_segments: dict[str, Any],
freezer: FrozenDateTimeFactory,
) -> None:
"""Die Farbe steht nicht in device/state, sondern je Segment."""
strip_segments["segments"][0].update({"mode": "solid", "color": {"r": 12, "g": 200, "b": 90}})
await abfragen(hass, freezer)
state = hass.states.get(light_id(hass))
assert state.attributes["color_mode"] == ColorMode.RGB
assert state.attributes["rgb_color"] == (12, 200, 90)
async def test_farbe_setzen_schreibt_in_alle_segmente(
hass: HomeAssistant, init_integration: MockConfigEntry, mock_client: MagicMock
) -> None:
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: light_id(hass), "rgb_color": (255, 40, 0)},
blocking=True,
)
(geschrieben,) = mock_client.put_segments.await_args.args
assert [(s["mode"], s["color"]) for s in geschrieben] == [
("solid", {"r": 255, "g": 40, "b": 0})
]
assert hass.states.get(light_id(hass)).attributes["rgb_color"] == (255, 40, 0)
async def test_farbe_setzen_laesst_die_einteilung_stehen(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
strip_segments: dict[str, Any],
freezer: FrozenDateTimeFactory,
) -> None:
"""Grenzen, Namen und Helligkeit der Segmente bleiben unangetastet.
Sonst risse eine Farbe ueber die Lichtentitaet die ganze Einteilung des
Strips ein - und die ist muehsam von Hand gemacht.
"""
strip_segments["segments"] = [
{
"id": 0,
"name": "Regal",
"start": 0,
"end": 19,
"mode": "white",
"color_temp": 2700,
"brightness": 80,
},
{
"id": 1,
"name": "Kante",
"start": 20,
"end": 59,
"mode": "white",
"color_temp": 4000,
"brightness": 55,
},
]
await abfragen(hass, freezer)
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: light_id(hass), "rgb_color": (0, 0, 255)},
blocking=True,
)
(geschrieben,) = mock_client.put_segments.await_args.args
assert [(s["name"], s["start"], s["end"], s["brightness"]) for s in geschrieben] == [
("Regal", 0, 19, 80),
("Kante", 20, 59, 55),
]
assert all(s["mode"] == "solid" for s in geschrieben)
async def test_uneinige_segmente_zeigen_keine_farbe(
hass: HomeAssistant,
init_integration: MockConfigEntry,
strip_segments: dict[str, Any],
freezer: FrozenDateTimeFactory,
) -> None:
"""Zwei Farben, eine Entitaet - dann waere jede Angabe gelogen."""
strip_segments["segments"] = [
{
"id": 0,
"start": 0,
"end": 29,
"mode": "solid",
"color": {"r": 255, "g": 0, "b": 0},
"brightness": 80,
},
{
"id": 1,
"start": 30,
"end": 59,
"mode": "solid",
"color": {"r": 0, "g": 0, "b": 255},
"brightness": 80,
},
]
await abfragen(hass, freezer)
state = hass.states.get(light_id(hass))
assert state.attributes["color_mode"] == ColorMode.RGB
assert state.attributes["rgb_color"] is None
async def test_effektsegmente_melden_keine_farbtemperatur(
hass: HomeAssistant,
init_integration: MockConfigEntry,
strip_segments: dict[str, Any],
freezer: FrozenDateTimeFactory,
) -> None:
strip_segments["segments"][0].update({"mode": "effect", "effect": "rainbow"})
strip_segments["segments"][0].pop("color_temp")
await abfragen(hass, freezer)
state = hass.states.get(light_id(hass))
assert state.attributes["color_mode"] == ColorMode.RGB
assert state.attributes["color_temp_kelvin"] is None
@pytest.mark.parametrize(("gewuenscht", "erwartet"), [(2700, 2700), (4000, 4000), (6500, 6500)])
async def test_farbtemperatur_setzen(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
gewuenscht: int,
erwartet: int,
) -> None:
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: light_id(hass), "color_temp_kelvin": gewuenscht},
blocking=True,
)
(geschrieben,) = mock_client.put_segments.await_args.args
assert [(s["mode"], s["color_temp"]) for s in geschrieben] == [("white", erwartet)]
async def test_farbe_wird_vor_dem_einschalten_gesetzt(
hass: HomeAssistant, init_integration: MockConfigEntry, mock_client: MagicMock
) -> None:
"""Sonst leuchtet der Strip kurz in der alten Farbe auf."""
reihenfolge: list[str] = []
mock_client.put_segments.side_effect = lambda segments: reihenfolge.append("segmente")
alt = mock_client.set_state.side_effect
async def merken(**fields: Any) -> dict[str, Any]:
reihenfolge.append("state")
return await alt(**fields)
mock_client.set_state.side_effect = merken
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: light_id(hass), "rgb_color": (10, 20, 30), ATTR_BRIGHTNESS: 128},
blocking=True,
)
assert reihenfolge == ["segmente", "state"]
mock_client.set_state.assert_awaited_once_with(on=True, brightness=50)
async def test_ohne_farbe_werden_die_segmente_nicht_angefasst(
hass: HomeAssistant, init_integration: MockConfigEntry, mock_client: MagicMock
) -> None:
await hass.services.async_call(
"light", "turn_on", {ATTR_ENTITY_ID: light_id(hass)}, blocking=True
)
mock_client.put_segments.assert_not_awaited()
+16
View File
@@ -95,3 +95,19 @@ SETTINGS_STORED: dict[str, Any] = {
**SETTINGS_COMPILED, **SETTINGS_COMPILED,
"wifi": {"ssid": "ninIOT", "source": "stored"}, "wifi": {"ssid": "ninIOT", "source": "stored"},
} }
# Ein Segment ueber den ganzen Strip, warmweiss - so kommt ein Geraet aus dem
# Werkszustand. Farbe und Farbtemperatur stehen nur hier, nicht in state.
SEGMENTS_K1Y: dict[str, Any] = {
"segments": [
{
"id": 0,
"name": None,
"start": 0,
"end": 59,
"mode": "white",
"color_temp": 2700,
"brightness": 80,
}
]
}