diff --git a/README.md b/README.md index f102fc4..84b3bdb 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Ohne mDNS geht es auch von Hand über Adresse und Token. | 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) | | `button` | Neustart | | `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. **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 -eine Farbe, die stimmen würde. Die Segmente stellt man über Szenen. +Hauptschalter und trägt keine Farbe — die steht je **Segment**. Die +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 @@ -138,11 +149,15 @@ eingebunden, der Quellcode bleibt in diesem Repo. ### Markenbilder -`scripts/make_brand.py` zeichnet die vier PNGs unter -`custom_components/ninstrip/brand/`. Ab Home Assistant 2026.3 liefert HA sie -für eine eigene Integration von dort aus; ohne sie bleibt in der -Geräteübersicht ein Platzhalter. Das Motiv ist ein Strip mit fünf leuchtenden -Pixeln — ninlight hat an derselben Stelle eine Glühbirne. +Die vier PNGs unter `custom_components/ninstrip/brand/` sind **dieselben wie +in ha-ninlight**: die beiden Integrationen gehören zusammen, und zwei +verschiedene Motive in derselben Geräteübersicht stiften nur Verwirrung. Ab +Home Assistant 2026.3 liefert HA sie für eine eigene Integration von dort +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 diff --git a/custom_components/ninstrip/brand/icon.png b/custom_components/ninstrip/brand/icon.png index e594dd7..334ba15 100644 Binary files a/custom_components/ninstrip/brand/icon.png and b/custom_components/ninstrip/brand/icon.png differ diff --git a/custom_components/ninstrip/brand/icon@2x.png b/custom_components/ninstrip/brand/icon@2x.png index f9ff975..4ab0f82 100644 Binary files a/custom_components/ninstrip/brand/icon@2x.png and b/custom_components/ninstrip/brand/icon@2x.png differ diff --git a/custom_components/ninstrip/brand/logo.png b/custom_components/ninstrip/brand/logo.png index e594dd7..334ba15 100644 Binary files a/custom_components/ninstrip/brand/logo.png and b/custom_components/ninstrip/brand/logo.png differ diff --git a/custom_components/ninstrip/brand/logo@2x.png b/custom_components/ninstrip/brand/logo@2x.png index f9ff975..4ab0f82 100644 Binary files a/custom_components/ninstrip/brand/logo@2x.png and b/custom_components/ninstrip/brand/logo@2x.png differ diff --git a/custom_components/ninstrip/coordinator.py b/custom_components/ninstrip/coordinator.py index 11e3358..d2f6417 100644 --- a/custom_components/ninstrip/coordinator.py +++ b/custom_components/ninstrip/coordinator.py @@ -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: diff --git a/custom_components/ninstrip/light.py b/custom_components/ninstrip/light.py index 6d5aef9..6010ab0 100644 --- a/custom_components/ninstrip/light.py +++ b/custom_components/ninstrip/light.py @@ -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) diff --git a/scripts/make_brand.py b/scripts/make_brand.py index b1bfd78..73725cd 100644 --- a/scripts/make_brand.py +++ b/scripts/make_brand.py @@ -2,9 +2,13 @@ Ab Home Assistant 2026.3 nimmt HA fuer eine eigene Integration die Bilder aus diesem Ordner - ohne sie bleibt in der Geraeteuebersicht ein Platzhalter. -ha-ninlight macht es genauso (dort eine Gluehbirne), hier ist es ein Strip: -ein dunkles Band mit fuenf leuchtenden Pixeln in Regenbogenfarben, weil genau -das den Unterschied zu ninlight ausmacht - viele Pixel statt einer Lampe. +**Nicht mehr in Gebrauch.** Michael wollte am 23.09.2026 dasselbe Bild wie +bei ninlight - die beiden Integrationen gehoeren zusammen, und zwei +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 diff --git a/tests/conftest.py b/tests/conftest.py index c6610e2..a73f12c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,7 @@ from tests_unit.samples import ( INFO_K1Y, MAC_K1Y, PRESETS_K1Y, + SEGMENTS_K1Y, SETTINGS_COMPILED, STATE_K1Y, TOKEN, @@ -57,12 +58,18 @@ def strip_presets() -> dict[str, Any]: return deepcopy(PRESETS_K1Y) +@pytest.fixture +def strip_segments() -> dict[str, Any]: + return deepcopy(SEGMENTS_K1Y) + + @pytest.fixture def mock_client( strip_state: dict[str, Any], strip_info: dict[str, Any], strip_settings: dict[str, Any], strip_presets: dict[str, Any], + strip_segments: dict[str, Any], ) -> Generator[MagicMock]: """Ein Geraet, dessen Antworten den veraenderbaren strip_*-Fixtures folgen.""" client = MagicMock(spec=NinstripClient) @@ -71,6 +78,14 @@ def mock_client( client.get_info = AsyncMock(side_effect=lambda: deepcopy(strip_info)) client.get_settings = AsyncMock(side_effect=lambda: deepcopy(strip_settings)) 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]: strip_state.update(fields) diff --git a/tests/test_light.py b/tests/test_light.py index e0d3efd..62f2327 100644 --- a/tests/test_light.py +++ b/tests/test_light.py @@ -35,7 +35,9 @@ async def test_zustand_und_helligkeit_aus_prozent( assert state.state == STATE_ON # Das Geraet rechnet in Prozent: 100 % sind 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["segment_count"] == 1 assert state.attributes["preset"] == 2 @@ -123,3 +125,178 @@ async def test_die_antwort_des_geraets_zaehlt_nicht_die_anfrage( blocking=True, ) 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() diff --git a/tests_unit/samples.py b/tests_unit/samples.py index f17181a..74142a0 100644 --- a/tests_unit/samples.py +++ b/tests_unit/samples.py @@ -95,3 +95,19 @@ SETTINGS_STORED: dict[str, Any] = { **SETTINGS_COMPILED, "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, + } + ] +}