diff --git a/README.md b/README.md index 34dab76..0143c3e 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,24 @@ Verschieden ist, was die Geräte verschieden macht: - **Leistungsbegrenzung als Sensor** — bei einer Lampe gibt es nichts zu 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 - 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 diff --git a/custom_components/ninstrip/coordinator.py b/custom_components/ninstrip/coordinator.py index d2f6417..0ec5de4 100644 --- a/custom_components/ninstrip/coordinator.py +++ b/custom_components/ninstrip/coordinator.py @@ -79,6 +79,11 @@ class NinstripCoordinator(DataUpdateCoordinator[NinstripData]): # teilen. Mehrere gleichzeitige Anfragen bringen nichts ausser Last. self.lock = asyncio.Lock() 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 async def _async_update_data(self) -> NinstripData: @@ -93,6 +98,14 @@ class NinstripCoordinator(DataUpdateCoordinator[NinstripData]): # 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() + 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: raise ConfigEntryAuthFailed(f"{self.client.host}: Token abgelehnt") from err except NinstripError as err: diff --git a/custom_components/ninstrip/light.py b/custom_components/ninstrip/light.py index 93aa4bc..cdaf505 100644 --- a/custom_components/ninstrip/light.py +++ b/custom_components/ninstrip/light.py @@ -14,6 +14,7 @@ from typing import Any from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, ATTR_RGB_COLOR, ATTR_TRANSITION, ColorMode, @@ -21,9 +22,10 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError 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 .entity import NinstripEntity @@ -56,7 +58,7 @@ class NinstripLight(NinstripEntity, LightEntity): _attr_name = None _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_max_color_temp_kelvin = MAX_KELVIN @@ -117,6 +119,35 @@ class NinstripLight(NinstripEntity, LightEntity): wert = self._einig("color_temp") 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 def extra_state_attributes(self) -> dict[str, Any]: state = self.coordinator.data.state @@ -132,7 +163,16 @@ class NinstripLight(NinstripEntity, LightEntity): 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: + 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] await self.coordinator.async_set_all_segments( mode="solid", color={"r": int(r), "g": int(g), "b": int(b)} diff --git a/custom_components/ninstrip/strings.json b/custom_components/ninstrip/strings.json index a95ef19..29e3bb3 100644 --- a/custom_components/ninstrip/strings.json +++ b/custom_components/ninstrip/strings.json @@ -187,6 +187,9 @@ }, "update_timeout": { "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": { diff --git a/custom_components/ninstrip/translations/de.json b/custom_components/ninstrip/translations/de.json index a95ef19..29e3bb3 100644 --- a/custom_components/ninstrip/translations/de.json +++ b/custom_components/ninstrip/translations/de.json @@ -187,6 +187,9 @@ }, "update_timeout": { "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": { diff --git a/custom_components/ninstrip/translations/en.json b/custom_components/ninstrip/translations/en.json index a9616e5..38ffc50 100644 --- a/custom_components/ninstrip/translations/en.json +++ b/custom_components/ninstrip/translations/en.json @@ -187,6 +187,9 @@ }, "update_timeout": { "message": "The device did not report the new firmware within 12 minutes." + }, + "unknown_effect": { + "message": "The device does not know this effect: {effect}" } }, "issues": { diff --git a/tests/conftest.py b/tests/conftest.py index a73f12c..5c24663 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.ninstrip.api import NinstripClient from custom_components.ninstrip.const import DOMAIN from tests_unit.samples import ( + EFFECTS_K1Y, HOST_K1Y, INFO_K1Y, MAC_K1Y, @@ -79,6 +80,7 @@ def mock_client( 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)) + client.get_effects = AsyncMock(side_effect=lambda: deepcopy(EFFECTS_K1Y)) async def put_segments(segments: list[dict[str, Any]]) -> None: # Wie das Geraet: es nimmt die Liste als Ganzes an und antwortet nur diff --git a/tests/test_effekte.py b/tests/test_effekte.py new file mode 100644 index 0000000..56e731c --- /dev/null +++ b/tests/test_effekte.py @@ -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"] == [] diff --git a/tests/test_zustandsfelder.py b/tests/test_zustandsfelder.py index 3fc4db0..781afa6 100644 --- a/tests/test_zustandsfelder.py +++ b/tests/test_zustandsfelder.py @@ -138,8 +138,12 @@ async def test_ohne_uebergang_wird_kein_feld_geschickt( 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 ) -> None: - state = hass.states.get(entity_id_for(hass, "light", "light")) - assert state.attributes["supported_features"] == 32 # LightEntityFeature.TRANSITION + from homeassistant.components.light import LightEntityFeature + + merkmale = hass.states.get(entity_id_for(hass, "light", "light")).attributes[ + "supported_features" + ] + assert merkmale == LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT diff --git a/tests_unit/samples.py b/tests_unit/samples.py index 74142a0..e41b911 100644 --- a/tests_unit/samples.py +++ b/tests_unit/samples.py @@ -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"], +}