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:
co-authored by
Claude Opus 5
parent
9eb7adcdd7
commit
ffcbbdfe4a
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] == []
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user