feat(light): Effekte zur Auswahl

Die neun Effekte der Firmware waren in Home Assistant nicht erreichbar -
das Auswahlfeld, das es schon gab, sind die Szenen. Jetzt bietet die
Lichtentitaet sie an:

* Gewaehlt wird ueber den NAMEN aus der Firmware ("Regenbogen"),
  geschickt wird die Kennung ("rainbow"). Eine Liste englischer
  Kennungen waere in einer deutschen Oberflaeche unnoetig sperrig.
* Ein Effekt laeuft wie eine Farbe in ALLEN Segmenten; angezeigt wird er
  nur, wenn alle denselben fahren.
* Eine Kennung, die nicht in der Liste steht (neuere Firmware, aeltere
  Liste), wird roh angezeigt statt verschluckt.
* Ein unbekannter Name wird abgelehnt, statt stillschweigend nichts zu
  tun.

Die Liste steht fest in der Firmware und wird deshalb einmal geholt,
nicht je Abfragezyklus - erneut erst, wenn sich die Version aendert. Eine
sechste Anfrage je Zyklus fuer etwas, das sich nie aendert, waere
Verschwendung.

Effekt-Parameter (Geschwindigkeit, Intensitaet, Palette) fehlen noch;
dafuer braucht es Entitaeten je Segment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michael Sauer
2026-09-23 11:15:36 +02:00
co-authored by Claude Opus 5
parent 9eb7adcdd7
commit ffcbbdfe4a
10 changed files with 235 additions and 7 deletions
+15 -1
View File
@@ -98,10 +98,24 @@ Verschieden ist, was die Geräte verschieden macht:
- **Leistungsbegrenzung als Sensor** — bei einer Lampe gibt es nichts zu - **Leistungsbegrenzung als Sensor** — bei einer Lampe gibt es nichts zu
begrenzen. begrenzen.
## Effekte
Die neun Effekte der Firmware (`device/effects`) stehen in der Lichtentität
zur Wahl — mit ihren **Namen**, nicht den Kennungen: angezeigt wird
„Regenbogen", geschickt wird `rainbow`. Ein gewählter Effekt läuft, wie eine
Farbe, in **allen** Segmenten; angezeigt wird er nur, wenn alle denselben
fahren.
Die Liste steht fest in der Firmware und wird deshalb **einmal** geholt, nicht
bei jeder Abfrage — erneut erst, wenn sich die Firmware-Version ändert.
Effekt-Parameter (Geschwindigkeit, Intensität, Palette) sind noch nicht
bedienbar; dafür braucht es Entitäten je Segment.
## Noch nicht drin ## Noch nicht drin
- Einzelne Segmente als eigene Entitäten - Einzelne Segmente als eigene Entitäten
- Effekte (die Firmware kann sie je Segment, `device/effects` listet sie) - Effekt-Parameter: Geschwindigkeit, Intensität, Palette
## Firmware-Aktualisierung ## Firmware-Aktualisierung
+13
View File
@@ -79,6 +79,11 @@ class NinstripCoordinator(DataUpdateCoordinator[NinstripData]):
# teilen. Mehrere gleichzeitige Anfragen bringen nichts ausser Last. # teilen. Mehrere gleichzeitige Anfragen bringen nichts ausser Last.
self.lock = asyncio.Lock() self.lock = asyncio.Lock()
self.ota_in_progress = False self.ota_in_progress = False
# Die Effektliste steht fest in der Firmware; sie wird einmal geholt
# und nach einem Update erneut. Jede Abfrage damit zu belasten waere
# eine sechste Anfrage je Zyklus fuer etwas, das sich nie aendert.
self.effects: list[dict[str, Any]] = []
self._effects_fuer: str | None = None
self._consecutive_poll_failures = 0 self._consecutive_poll_failures = 0
async def _async_update_data(self) -> NinstripData: async def _async_update_data(self) -> NinstripData:
@@ -93,6 +98,14 @@ class NinstripCoordinator(DataUpdateCoordinator[NinstripData]):
# 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()
fw = str(info.get("fw_version"))
if self._effects_fuer != fw:
antwort = await self.client.get_effects()
roh = antwort.get("effects")
self.effects = (
[e for e in roh if isinstance(e, dict)] if isinstance(roh, list) else []
)
self._effects_fuer = fw
except NinstripAuthError as err: except NinstripAuthError as err:
raise ConfigEntryAuthFailed(f"{self.client.host}: Token abgelehnt") from err raise ConfigEntryAuthFailed(f"{self.client.host}: Token abgelehnt") from err
except NinstripError as err: except NinstripError as err:
+43 -3
View File
@@ -14,6 +14,7 @@ from typing import Any
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN, ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_RGB_COLOR, ATTR_RGB_COLOR,
ATTR_TRANSITION, ATTR_TRANSITION,
ColorMode, ColorMode,
@@ -21,9 +22,10 @@ from homeassistant.components.light import (
LightEntityFeature, LightEntityFeature,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import MAX_TRANSITION_MS from .const import DOMAIN, MAX_TRANSITION_MS
from .coordinator import NinstripConfigEntry, NinstripCoordinator from .coordinator import NinstripConfigEntry, NinstripCoordinator
from .entity import NinstripEntity from .entity import NinstripEntity
@@ -56,7 +58,7 @@ class NinstripLight(NinstripEntity, LightEntity):
_attr_name = None _attr_name = None
_attr_supported_color_modes = {ColorMode.RGB, ColorMode.COLOR_TEMP} _attr_supported_color_modes = {ColorMode.RGB, ColorMode.COLOR_TEMP}
_attr_supported_features = LightEntityFeature.TRANSITION _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT
_attr_min_color_temp_kelvin = MIN_KELVIN _attr_min_color_temp_kelvin = MIN_KELVIN
_attr_max_color_temp_kelvin = MAX_KELVIN _attr_max_color_temp_kelvin = MAX_KELVIN
@@ -117,6 +119,35 @@ class NinstripLight(NinstripEntity, LightEntity):
wert = self._einig("color_temp") wert = self._einig("color_temp")
return int(wert) if isinstance(wert, (int, float)) else None return int(wert) if isinstance(wert, (int, float)) else None
# --- Effekte -------------------------------------------------------
@property
def effect_list(self) -> list[str]:
"""Die Namen aus der Firmware, nicht die Kennungen.
Angezeigt wird "Regenbogen", geschickt wird "rainbow" - eine Liste
voller englischer Kennungen waere in einer deutschen Oberflaeche
unnoetig sperrig.
"""
return [str(e["name"]) for e in self.coordinator.effects if e.get("name") and e.get("id")]
def _effekt_id(self, name: str) -> str | None:
for e in self.coordinator.effects:
if e.get("name") == name:
return str(e["id"])
return None
@property
def effect(self) -> str | None:
"""Nur, wenn alle Segmente denselben Effekt fahren."""
if self._einig("mode") != "effect":
return None
kennung = self._einig("effect")
for e in self.coordinator.effects:
if e.get("id") == kennung:
return str(e.get("name") or kennung)
return str(kennung) if kennung 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
@@ -132,7 +163,16 @@ class NinstripLight(NinstripEntity, LightEntity):
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 # Erst die Farbe in die Segmente, dann der Hauptschalter: so leuchtet
# nichts kurz in der alten Farbe auf. # nichts kurz in der alten Farbe auf.
if ATTR_RGB_COLOR in kwargs: if ATTR_EFFECT in kwargs:
kennung = self._effekt_id(kwargs[ATTR_EFFECT])
if kennung is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unknown_effect",
translation_placeholders={"effect": str(kwargs[ATTR_EFFECT])},
)
await self.coordinator.async_set_all_segments(mode="effect", effect=kennung)
elif ATTR_RGB_COLOR in kwargs:
r, g, b = kwargs[ATTR_RGB_COLOR] r, g, b = kwargs[ATTR_RGB_COLOR]
await self.coordinator.async_set_all_segments( await self.coordinator.async_set_all_segments(
mode="solid", color={"r": int(r), "g": int(g), "b": int(b)} mode="solid", color={"r": int(r), "g": int(g), "b": int(b)}
+3
View File
@@ -187,6 +187,9 @@
}, },
"update_timeout": { "update_timeout": {
"message": "Das Gerät hat sich innerhalb von 12 Minuten nicht mit der neuen Firmware gemeldet." "message": "Das Gerät hat sich innerhalb von 12 Minuten nicht mit der neuen Firmware gemeldet."
},
"unknown_effect": {
"message": "Diesen Effekt kennt das Gerät nicht: {effect}"
} }
}, },
"issues": { "issues": {
@@ -187,6 +187,9 @@
}, },
"update_timeout": { "update_timeout": {
"message": "Das Gerät hat sich innerhalb von 12 Minuten nicht mit der neuen Firmware gemeldet." "message": "Das Gerät hat sich innerhalb von 12 Minuten nicht mit der neuen Firmware gemeldet."
},
"unknown_effect": {
"message": "Diesen Effekt kennt das Gerät nicht: {effect}"
} }
}, },
"issues": { "issues": {
@@ -187,6 +187,9 @@
}, },
"update_timeout": { "update_timeout": {
"message": "The device did not report the new firmware within 12 minutes." "message": "The device did not report the new firmware within 12 minutes."
},
"unknown_effect": {
"message": "The device does not know this effect: {effect}"
} }
}, },
"issues": { "issues": {
+2
View File
@@ -16,6 +16,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.ninstrip.api import NinstripClient from custom_components.ninstrip.api import NinstripClient
from custom_components.ninstrip.const import DOMAIN from custom_components.ninstrip.const import DOMAIN
from tests_unit.samples import ( from tests_unit.samples import (
EFFECTS_K1Y,
HOST_K1Y, HOST_K1Y,
INFO_K1Y, INFO_K1Y,
MAC_K1Y, MAC_K1Y,
@@ -79,6 +80,7 @@ def mock_client(
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)) client.get_segments = AsyncMock(side_effect=lambda: deepcopy(strip_segments))
client.get_effects = AsyncMock(side_effect=lambda: deepcopy(EFFECTS_K1Y))
async def put_segments(segments: list[dict[str, Any]]) -> None: async def put_segments(segments: list[dict[str, Any]]) -> None:
# Wie das Geraet: es nimmt die Liste als Ganzes an und antwortet nur # Wie das Geraet: es nimmt die Liste als Ganzes an und antwortet nur
+136
View File
@@ -0,0 +1,136 @@
"""Effekte: Liste, Anzeige, Setzen - und was passiert, wenn sie fehlen."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
from tests.helpers import abfragen, entity_id_for
def light_id(hass: HomeAssistant) -> str:
return entity_id_for(hass, "light", "light")
async def test_die_liste_zeigt_namen_nicht_kennungen(
hass: HomeAssistant, init_integration: MockConfigEntry
) -> None:
state = hass.states.get(light_id(hass))
assert state.attributes["effect_list"] == ["Regenbogen", "Feuer", "Lauflicht"]
async def test_die_liste_wird_nur_einmal_geholt(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Sie steht fest in der Firmware - eine sechste Anfrage je Zyklus waere
Verschwendung."""
vorher = mock_client.get_effects.await_count
await abfragen(hass, freezer)
await abfragen(hass, freezer)
assert mock_client.get_effects.await_count == vorher
async def test_nach_einem_firmware_update_wird_sie_neu_geholt(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
strip_info: dict[str, Any],
freezer: FrozenDateTimeFactory,
) -> None:
"""Eine neue Firmware kann neue Effekte mitbringen."""
vorher = mock_client.get_effects.await_count
strip_info["fw_version"] = "0.2.0"
await abfragen(hass, freezer)
assert mock_client.get_effects.await_count == vorher + 1
async def test_laufender_effekt_wird_mit_namen_angezeigt(
hass: HomeAssistant,
init_integration: MockConfigEntry,
strip_segments: dict[str, Any],
freezer: FrozenDateTimeFactory,
) -> None:
strip_segments["segments"][0].update({"mode": "effect", "effect": "fire"})
await abfragen(hass, freezer)
assert hass.states.get(light_id(hass)).attributes["effect"] == "Feuer"
async def test_unbekannte_kennung_wird_roh_gezeigt(
hass: HomeAssistant,
init_integration: MockConfigEntry,
strip_segments: dict[str, Any],
freezer: FrozenDateTimeFactory,
) -> None:
"""Neuere Firmware, aeltere Liste - dann lieber die Kennung als nichts."""
strip_segments["segments"][0].update({"mode": "effect", "effect": "meteor"})
await abfragen(hass, freezer)
assert hass.states.get(light_id(hass)).attributes["effect"] == "meteor"
async def test_ohne_effektmodus_kein_effekt(
hass: HomeAssistant, init_integration: MockConfigEntry
) -> None:
assert hass.states.get(light_id(hass)).attributes["effect"] is None
async def test_uneinige_segmente_zeigen_keinen_effekt(
hass: HomeAssistant,
init_integration: MockConfigEntry,
strip_segments: dict[str, Any],
freezer: FrozenDateTimeFactory,
) -> None:
strip_segments["segments"] = [
{"id": 0, "start": 0, "end": 29, "mode": "effect", "effect": "fire", "brightness": 80},
{"id": 1, "start": 30, "end": 59, "mode": "effect", "effect": "chase", "brightness": 80},
]
await abfragen(hass, freezer)
assert hass.states.get(light_id(hass)).attributes["effect"] is None
async def test_effekt_setzen_schreibt_die_kennung(
hass: HomeAssistant, init_integration: MockConfigEntry, mock_client: MagicMock
) -> None:
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: light_id(hass), "effect": "Lauflicht"},
blocking=True,
)
(geschrieben,) = mock_client.put_segments.await_args.args
assert [(s["mode"], s["effect"]) for s in geschrieben] == [("effect", "chase")]
assert hass.states.get(light_id(hass)).attributes["effect"] == "Lauflicht"
async def test_unbekannter_effekt_wird_abgelehnt(
hass: HomeAssistant, init_integration: MockConfigEntry, mock_client: MagicMock
) -> None:
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: light_id(hass), "effect": "Diskokugel"},
blocking=True,
)
mock_client.put_segments.assert_not_awaited()
async def test_firmware_ohne_effektliste_bietet_keine_an(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_client: MagicMock,
) -> None:
mock_client.get_effects.side_effect = lambda: {"palettes": []}
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get(light_id(hass)).attributes["effect_list"] == []
+7 -3
View File
@@ -138,8 +138,12 @@ async def test_ohne_uebergang_wird_kein_feld_geschickt(
mock_client.set_state.assert_awaited_once_with(on=False) mock_client.set_state.assert_awaited_once_with(on=False)
async def test_die_lichtentitaet_meldet_uebergaenge( async def test_die_lichtentitaet_meldet_uebergaenge_und_effekte(
hass: HomeAssistant, init_integration: MockConfigEntry hass: HomeAssistant, init_integration: MockConfigEntry
) -> None: ) -> None:
state = hass.states.get(entity_id_for(hass, "light", "light")) from homeassistant.components.light import LightEntityFeature
assert state.attributes["supported_features"] == 32 # LightEntityFeature.TRANSITION
merkmale = hass.states.get(entity_id_for(hass, "light", "light")).attributes[
"supported_features"
]
assert merkmale == LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT
+10
View File
@@ -111,3 +111,13 @@ SEGMENTS_K1Y: dict[str, Any] = {
} }
] ]
} }
# Die Effekttabelle der Firmware (nl_effects.c), gekuerzt auf drei Eintraege.
EFFECTS_K1Y: dict[str, Any] = {
"effects": [
{"id": "rainbow", "name": "Regenbogen", "params": ["speed", "palette"]},
{"id": "fire", "name": "Feuer", "params": ["speed"]},
{"id": "chase", "name": "Lauflicht", "params": ["speed", "intensity", "palette"]},
],
"palettes": ["rainbow", "warm", "cool"],
}