From 9eb7adcdd7009d215b7fa1e06cc8455b987aa480 Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Wed, 23 Sep 2026 00:35:12 +0200 Subject: [PATCH] feat: nachgezogen, was ha-ninlight laengst hat Michaels Befund: die Integration ist nicht dasselbe wie die von ninlight. Verglichen und die Luecken geschlossen - alle Felder gibt es in der Firmware schon, sie waren nur nicht bedienbar: * number: fade_in, fade_out, transition_default in Millisekunden, mit den Grenzen der Firmware (10 s, 10 s, 60 s). * select: Einschaltverhalten (last, on, off). * switch: bei Aenderung einschalten. * light: Uebergangszeit beim Ein- und Ausschalten. HA rechnet in Sekunden, das Geraet in Millisekunden; laengeres als 60 s wird gekappt, sonst wiese das Geraet die ganze Anfrage mit 400 ab. Ohne Angabe wird kein Feld geschickt, sonst ueberschriebe jeder Schaltbefehl die Vorgabe. * sensor: letzter Update-Fehler und Startzeit (Zeitstempel statt einer Laufzeit, die jede Abfrage weiterlaeuft), beide ab Werk aus. * diagnostics.py: Diagnosedaten zum Herunterladen, ohne Token, Adressen, MAC und SSID. * config_flow: Neu-Konfigurieren von Adresse und Token, mit Gegenprobe der MAC - ein Tippfehler in der Adresse soll nicht still den falschen Eintrag umbiegen. * Hinweis in den Reparaturen, wenn ein Geraet noch auf dem oeffentlichen Werks-Token steht. Nicht uebernommen: die Auswahl sacn_layout. ninstrip ist immer rgb, ein Strip hat keinen Masterkanal - eine Auswahl mit einer Option waere nur Zierde. 241 Tests, 97 %. Die Kappung der Uebergangszeit per Mutation gegengeprueft. --- README.md | 26 +++- custom_components/ninstrip/__init__.py | 29 +++- custom_components/ninstrip/config_flow.py | 31 ++++ custom_components/ninstrip/const.py | 6 + custom_components/ninstrip/diagnostics.py | 46 ++++++ custom_components/ninstrip/light.py | 20 ++- custom_components/ninstrip/number.py | 67 +++++++- custom_components/ninstrip/select.py | 28 +++- custom_components/ninstrip/sensor.py | 56 ++++++- custom_components/ninstrip/strings.json | 42 ++++- custom_components/ninstrip/switch.py | 29 +++- .../ninstrip/translations/de.json | 42 ++++- .../ninstrip/translations/en.json | 42 ++++- tests/test_config_flow.py | 29 ++++ tests/test_diagnostics.py | 44 ++++++ tests/test_init.py | 36 ++++- tests/test_sensor.py | 39 +++++ tests/test_zustandsfelder.py | 145 ++++++++++++++++++ 18 files changed, 737 insertions(+), 20 deletions(-) create mode 100644 custom_components/ninstrip/diagnostics.py create mode 100644 tests/test_diagnostics.py create mode 100644 tests/test_zustandsfelder.py diff --git a/README.md b/README.md index 84b3bdb..34dab76 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,13 @@ Ohne mDNS geht es auch von Hand über Adresse und Token. | Entität | | |---|---| -| `light` | Hauptschalter, Helligkeit, Farbe und Farbtemperatur des ganzen Strips | +| `light` | Hauptschalter, Helligkeit, Farbe, Farbtemperatur, Übergangszeit | | `select` | aufgeschaltete Szene (acht Plätze in der Firmware) | +| `select` | Einschaltverhalten (`last` / `on` / `off`) | +| `number` | Einblend-, Ausblend- und Übergangszeit in ms | +| `switch` | bei Änderung einschalten | | `button` | Neustart | -| `sensor` | IP-Adresse, WLAN-Signal, Laufzeit, freier Speicher, Leistungsbegrenzung | +| `sensor` | IP-Adresse, WLAN-Signal, Laufzeit, freier Speicher, Leistungsbegrenzung, letzter Update-Fehler, Startzeit | | `switch` | sACN-Empfang an/aus | | `number` | sACN-Universe und -Startslot | | `select` | Verhalten nach Stromende (`release` / `hold`) | @@ -77,9 +80,28 @@ Weißkanäle bekommen dieselbe Spanne als RGB-Näherung. Für einzelne Segmente gibt es noch keine Entitäten. +## Unterschiede zu ha-ninlight + +Gleich sind: Einrichtung samt Neu-Konfigurieren und erneuter Anmeldung, +Diagnosedaten, Firmware-Aktualisierung, sACN, die Zeiten, das +Einschaltverhalten, „bei Änderung einschalten" und der Hinweis, wenn ein Gerät +noch auf dem Werks-Token steht. + +Verschieden ist, was die Geräte verschieden macht: + +- **Kein `sacn_layout`.** ninlight kann `dimmer` (Masterkanal plus Farben) und + `rgb`; ein Strip hat keinen Masterkanal, ninstrip ist immer `rgb`. Die + Firmware meldet das Feld trotzdem, damit dieselben Werkzeuge beide bedienen. +- **Farbe kommt aus den Segmenten**, bei ninlight aus `device/state`. +- **Szenen statt Farbmodi je Lampe**: acht Plätze in der Firmware, als + Auswahl. +- **Leistungsbegrenzung als Sensor** — bei einer Lampe gibt es nichts zu + begrenzen. + ## Noch nicht drin - Einzelne Segmente als eigene Entitäten +- Effekte (die Firmware kann sie je Segment, `device/effects` listet sie) ## Firmware-Aktualisierung diff --git a/custom_components/ninstrip/__init__.py b/custom_components/ninstrip/__init__.py index 7ff2c0d..2516017 100644 --- a/custom_components/ninstrip/__init__.py +++ b/custom_components/ninstrip/__init__.py @@ -3,14 +3,14 @@ from __future__ import annotations from homeassistant.const import CONF_HOST, CONF_TOKEN, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .api import NinstripClient -from .const import DEFAULT_PORT, DOMAIN +from .const import DEFAULT_PORT, DEFAULT_TOKEN, DOMAIN from .coordinator import NinstripConfigEntry, NinstripCoordinator from .shared import async_get_shared, async_start_catalog, async_stop_catalog @@ -46,6 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinstripConfigEntry) -> await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator + _async_werks_token_hinweis(hass, entry) await async_start_catalog(hass, async_get_shared(hass)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -66,6 +67,30 @@ async def async_unload_entry(hass: HomeAssistant, entry: NinstripConfigEntry) -> async def async_remove_entry(hass: HomeAssistant, entry: NinstripConfigEntry) -> None: ir.async_delete_issue(hass, DOMAIN, f"wifi_guard_{entry.entry_id}") + ir.async_delete_issue(hass, DOMAIN, f"default_token_{entry.entry_id}") + + +@callback +def _async_werks_token_hinweis(hass: HomeAssistant, entry: NinstripConfigEntry) -> None: + """Weist darauf hin, dass ein Geraet noch auf dem oeffentlichen Token steht. + + Das Werks-Token steht im Repo. Wer es kennt und im selben Netz ist, kann + das Geraet umkonfigurieren, neu starten, flashen und zuruecksetzen. Wie in + ha-ninlight. + """ + issue_id = f"default_token_{entry.entry_id}" + if entry.data[CONF_TOKEN] == DEFAULT_TOKEN: + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="default_token", + translation_placeholders={"name": entry.title}, + ) + else: + ir.async_delete_issue(hass, DOMAIN, issue_id) # Kein add_update_listener zum Neuladen: der Einstellungsdialog ist ein diff --git a/custom_components/ninstrip/config_flow.py b/custom_components/ninstrip/config_flow.py index 378ebb0..a6da672 100644 --- a/custom_components/ninstrip/config_flow.py +++ b/custom_components/ninstrip/config_flow.py @@ -125,6 +125,37 @@ class NinstripConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Adresse und Token eines bestehenden Geraets aendern. + + Gegengeprueft wird die MAC: wer aus Versehen die Adresse eines anderen + Geraets eintraegt, soll nicht stillschweigend den falschen Eintrag + umbiegen. + """ + eintrag = self._get_reconfigure_entry() + errors: dict[str, str] = {} + if user_input is not None: + host = user_input[CONF_HOST].strip() + info, errors = await self._async_probe(host, user_input[CONF_TOKEN]) + if info is not None: + await self.async_set_unique_id(format_mac(info["mac"])) + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + eintrag, data_updates={CONF_HOST: host, CONF_TOKEN: user_input[CONF_TOKEN]} + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=eintrag.data[CONF_HOST]): str, + vol.Required(CONF_TOKEN, default=eintrag.data[CONF_TOKEN]): str, + } + ), + errors=errors, + ) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> ConfigFlowResult: self._host = entry_data[CONF_HOST] return await self.async_step_reauth_confirm() diff --git a/custom_components/ninstrip/const.py b/custom_components/ninstrip/const.py index cd47fef..4508d09 100644 --- a/custom_components/ninstrip/const.py +++ b/custom_components/ninstrip/const.py @@ -27,6 +27,12 @@ MAX_SCAN_INTERVAL: Final = 300 # sofort alle Entitaeten ausgrauen. MAX_POLL_FAILURES: Final = 3 +# Laengster Uebergang, den die Firmware annimmt (nl_api_device.c: +# "transition ausserhalb 0..60000 ms"). +MAX_TRANSITION_MS: Final = 60_000 +# Die drei Millisekunden-Felder in device/state, mit den Grenzen der Firmware. +MAX_FADE_MS: Final = 10_000 + # Die Firmware erlaubt hoechstens 16 Segmente (nl_segments.h) und 512 Pixel. MAX_SEGMENTS: Final = 16 MAX_PIXELS: Final = 512 diff --git a/custom_components/ninstrip/diagnostics.py b/custom_components/ninstrip/diagnostics.py new file mode 100644 index 0000000..c08d55b --- /dev/null +++ b/custom_components/ninstrip/diagnostics.py @@ -0,0 +1,46 @@ +"""Diagnosedaten zum Herunterladen, fuer Fehlermeldungen.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from .coordinator import NinstripConfigEntry +from .shared import DATA_SHARED + +# Token, Adressen und Kennungen fliegen raus: so eine Datei landet schnell in +# einem Fehlerbericht. +TO_REDACT = {CONF_TOKEN, CONF_HOST, "ssid", "mac", "ip"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: NinstripConfigEntry +) -> dict[str, Any]: + coordinator = entry.runtime_data + device_type = coordinator.data.info.get("device_type") + shared = hass.data.get(DATA_SHARED) + abbilder = ( + [] + if shared is None + else [ + {"file": image.path.name, "device_type": image.device_type, "version": image.version} + for image in shared.catalog.images + if image.device_type == device_type + ] + ) + return { + **async_redact_data( + { + "entry": {"data": dict(entry.data), "options": dict(entry.options)}, + "state": coordinator.data.state, + "info": coordinator.data.info, + "segments": coordinator.data.segments, + "settings": coordinator.data.settings, + }, + TO_REDACT, + ), + "firmware_images": abbilder, + } diff --git a/custom_components/ninstrip/light.py b/custom_components/ninstrip/light.py index 6010ab0..93aa4bc 100644 --- a/custom_components/ninstrip/light.py +++ b/custom_components/ninstrip/light.py @@ -15,12 +15,15 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, + ATTR_TRANSITION, ColorMode, LightEntity, + LightEntityFeature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import MAX_TRANSITION_MS from .coordinator import NinstripConfigEntry, NinstripCoordinator from .entity import NinstripEntity @@ -31,6 +34,15 @@ MIN_KELVIN = 2700 MAX_KELVIN = 6500 +def uebergang_ms(sekunden: float) -> int: + """Home Assistant rechnet in Sekunden, das Geraet in Millisekunden. + + Gekappt auf das, was die Firmware annimmt - ein laengerer Wert wuerde + sonst die ganze Anfrage mit 400 abweisen, statt nur langsamer zu blenden. + """ + return max(0, min(MAX_TRANSITION_MS, round(sekunden * 1000))) + + async def async_setup_entry( hass: HomeAssistant, entry: NinstripConfigEntry, @@ -44,6 +56,7 @@ class NinstripLight(NinstripEntity, LightEntity): _attr_name = None _attr_supported_color_modes = {ColorMode.RGB, ColorMode.COLOR_TEMP} + _attr_supported_features = LightEntityFeature.TRANSITION _attr_min_color_temp_kelvin = MIN_KELVIN _attr_max_color_temp_kelvin = MAX_KELVIN @@ -131,7 +144,12 @@ class NinstripLight(NinstripEntity, LightEntity): fields: dict[str, Any] = {"on": True} if ATTR_BRIGHTNESS in kwargs: fields["brightness"] = round(int(kwargs[ATTR_BRIGHTNESS]) * 100 / 255) + if ATTR_TRANSITION in kwargs: + fields["transition"] = uebergang_ms(kwargs[ATTR_TRANSITION]) await self.coordinator.async_set_state(**fields) async def async_turn_off(self, **kwargs: Any) -> None: - await self.coordinator.async_set_state(on=False) + fields: dict[str, Any] = {"on": False} + if ATTR_TRANSITION in kwargs: + fields["transition"] = uebergang_ms(kwargs[ATTR_TRANSITION]) + await self.coordinator.async_set_state(**fields) diff --git a/custom_components/ninstrip/number.py b/custom_components/ninstrip/number.py index d91447d..904cba1 100644 --- a/custom_components/ninstrip/number.py +++ b/custom_components/ninstrip/number.py @@ -1,14 +1,22 @@ -"""sACN-Zahlenfelder eines ninstrip-Geraets: Universe und Startslot.""" +"""Zahlenfelder eines ninstrip-Geraets: Uebergangszeiten und sACN.""" from __future__ import annotations -from homeassistant.components.number import NumberEntity, NumberMode -from homeassistant.const import EntityCategory +from dataclasses import dataclass + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import MAX_FADE_MS, MAX_TRANSITION_MS from .coordinator import NinstripConfigEntry, NinstripCoordinator -from .entity import NinstripSacnEntity +from .entity import NinstripEntity, NinstripSacnEntity PARALLEL_UPDATES = 0 @@ -17,13 +25,62 @@ PARALLEL_UPDATES = 0 _KANAELE_VORGABE = 3 +@dataclass(frozen=True, kw_only=True) +class NinstripZeitDescription(NumberEntityDescription): + """Ein Millisekundenfeld aus device/state.""" + + entity_category: EntityCategory = EntityCategory.CONFIG + device_class: NumberDeviceClass = NumberDeviceClass.DURATION + native_unit_of_measurement: str = UnitOfTime.MILLISECONDS + native_min_value: float = 0 + native_step: float = 50 + mode: NumberMode = NumberMode.BOX + + +# Grenzen wie in der Firmware (nl_api_device.c). Feldnamen wie in ninlight. +ZEITEN = ( + NinstripZeitDescription(key="fade_in", translation_key="fade_in", native_max_value=MAX_FADE_MS), + NinstripZeitDescription( + key="fade_out", translation_key="fade_out", native_max_value=MAX_FADE_MS + ), + NinstripZeitDescription( + key="transition_default", + translation_key="transition_default", + native_max_value=MAX_TRANSITION_MS, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: NinstripConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: coordinator = entry.runtime_data - async_add_entities([NinstripSacnUniverse(coordinator), NinstripSacnStartSlot(coordinator)]) + entitaeten: list[NumberEntity] = [NinstripZeit(coordinator, d) for d in ZEITEN] + entitaeten.append(NinstripSacnUniverse(coordinator)) + entitaeten.append(NinstripSacnStartSlot(coordinator)) + async_add_entities(entitaeten) + + +class NinstripZeit(NinstripEntity, NumberEntity): + """Ein Millisekundenfeld: Einblenden, Ausblenden, Vorgabe fuer Uebergaenge.""" + + entity_description: NinstripZeitDescription + + def __init__( + self, coordinator: NinstripCoordinator, description: NinstripZeitDescription + ) -> None: + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def native_value(self) -> float | None: + wert = self.coordinator.data.state.get(self.entity_description.key) + return float(wert) if isinstance(wert, (int, float)) else None + + async def async_set_native_value(self, value: float) -> None: + await self.coordinator.async_set_state(**{self.entity_description.key: int(value)}) class NinstripSacnUniverse(NinstripSacnEntity, NumberEntity): diff --git a/custom_components/ninstrip/select.py b/custom_components/ninstrip/select.py index f072b9c..2b0a28e 100644 --- a/custom_components/ninstrip/select.py +++ b/custom_components/ninstrip/select.py @@ -17,6 +17,9 @@ NONE_OPTION = "—" # Was das Geraet 2,5 s nach dem letzten sACN-Paket tut. Werte wie ha-ninlight. ON_TIMEOUT_OPTIONS = ["release", "hold"] +# Was nach einer Stromunterbrechung passiert (nl_settings.c: last, on, off). +POWER_ON_OPTIONS = ["last", "on", "off"] + async def async_setup_entry( hass: HomeAssistant, @@ -25,10 +28,33 @@ async def async_setup_entry( ) -> None: coordinator = entry.runtime_data async_add_entities( - [NinstripPresetSelect(coordinator), NinstripSacnOnTimeoutSelect(coordinator)] + [ + NinstripPresetSelect(coordinator), + NinstripPowerOnSelect(coordinator), + NinstripSacnOnTimeoutSelect(coordinator), + ] ) +class NinstripPowerOnSelect(NinstripEntity, SelectEntity): + """Was der Strip tut, wenn der Strom wiederkommt.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "power_on_behaviour" + _attr_options = POWER_ON_OPTIONS + + def __init__(self, coordinator: NinstripCoordinator) -> None: + super().__init__(coordinator, "power_on_behaviour") + + @property + def current_option(self) -> str | None: + wert = self.coordinator.data.state.get("power_on_behaviour") + return wert if wert in POWER_ON_OPTIONS else None + + async def async_select_option(self, option: str) -> None: + await self.coordinator.async_set_state(power_on_behaviour=option) + + class NinstripPresetSelect(NinstripEntity, SelectEntity): """Waehlt eine der belegten Szenen aus.""" diff --git a/custom_components/ninstrip/sensor.py b/custom_components/ninstrip/sensor.py index 09c0753..5fdfd85 100644 --- a/custom_components/ninstrip/sensor.py +++ b/custom_components/ninstrip/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime, timedelta from typing import Any from homeassistant.components.sensor import ( @@ -18,12 +19,18 @@ from homeassistant.const import ( UnitOfInformation, UnitOfTime, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .coordinator import NinstripConfigEntry, NinstripCoordinator, NinstripData from .entity import NinstripEntity +# Die Laufzeit springt bei jeder Abfrage um ein paar Sekunden; daraus eine +# Startzeit zu rechnen ergaebe sonst dauernd einen neuen Zeitstempel. Wie +# ha-ninlight. +BOOT_TIME_TOLERANCE = timedelta(seconds=30) + @dataclass(frozen=True, kw_only=True) class NinstripSensorDescription(SensorEntityDescription): @@ -91,6 +98,15 @@ SENSORS: tuple[NinstripSensorDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, value=lambda d: _strip(d, "power_scale"), ), + # Was bei der letzten Aktualisierung schiefging - leer, solange nichts + # schiefging. Aus der Ferne der einzige Hinweis darauf. + NinstripSensorDescription( + key="last_ota_error", + translation_key="last_ota_error", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value=lambda d: d.info.get("last_ota_error"), + ), ) @@ -100,7 +116,9 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: coordinator = entry.runtime_data - async_add_entities(NinstripSensor(coordinator, d) for d in SENSORS) + entitaeten: list[SensorEntity] = [NinstripSensor(coordinator, d) for d in SENSORS] + entitaeten.append(NinstripBootTimeSensor(coordinator)) + async_add_entities(entitaeten) class NinstripSensor(NinstripEntity, SensorEntity): @@ -115,3 +133,37 @@ class NinstripSensor(NinstripEntity, SensorEntity): @property def native_value(self) -> Any: return self.entity_description.value(self.coordinator.data) + + +class NinstripBootTimeSensor(NinstripEntity, SensorEntity): + """Wann der Strip zuletzt gestartet ist - ein Zeitstempel statt einer + Laufzeit, die jede Abfrage weiterlaeuft.""" + + _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + _attr_translation_key = "boot_time" + + def __init__(self, coordinator: NinstripCoordinator) -> None: + super().__init__(coordinator, "boot_time") + self._boot: datetime | None = None + self._boot_aktualisieren() + + def _boot_aktualisieren(self) -> None: + uptime = self.coordinator.data.info.get("uptime") + if not isinstance(uptime, (int, float)): + return + boot = dt_util.utcnow() - timedelta(seconds=uptime) + # Nur uebernehmen, wenn er wirklich abweicht - sonst wandert der + # Zeitstempel bei jeder Abfrage um die Messungenauigkeit. + if self._boot is None or abs(boot - self._boot) > BOOT_TIME_TOLERANCE: + self._boot = boot + + @callback + def _handle_coordinator_update(self) -> None: + self._boot_aktualisieren() + super()._handle_coordinator_update() + + @property + def native_value(self) -> datetime | None: + return self._boot diff --git a/custom_components/ninstrip/strings.json b/custom_components/ninstrip/strings.json index 59af84b..a95ef19 100644 --- a/custom_components/ninstrip/strings.json +++ b/custom_components/ninstrip/strings.json @@ -22,6 +22,14 @@ "data": { "token": "API-Token" } + }, + "reconfigure": { + "title": "Gerät ändern", + "description": "Adresse und API-Token des Geräts {host}. Die MAC wird gegengeprüft — ein anderes Gerät wird abgelehnt.", + "data": { + "host": "Adresse", + "token": "API-Token" + } } }, "error": { @@ -32,7 +40,9 @@ }, "abort": { "already_configured": "Dieses Gerät ist bereits eingerichtet", - "reauth_successful": "Token erneuert" + "reauth_successful": "Token erneuert", + "wrong_device": "Dort antwortet ein anderes Gerät", + "reconfigure_successful": "Gerät geändert" } }, "options": { @@ -64,6 +74,14 @@ "release": "Zurück zum vorherigen Bild", "hold": "Letztes Bild halten" } + }, + "power_on_behaviour": { + "name": "Einschaltverhalten", + "state": { + "last": "Wie zuvor", + "on": "An", + "off": "Aus" + } } }, "button": { @@ -86,11 +104,20 @@ }, "power_scale": { "name": "Leistungsbegrenzung" + }, + "last_ota_error": { + "name": "Letzter Update-Fehler" + }, + "boot_time": { + "name": "Gestartet" } }, "switch": { "sacn_enabled": { "name": "sACN-Empfang" + }, + "wake_on_change": { + "name": "Bei Änderung einschalten" } }, "number": { @@ -99,6 +126,15 @@ }, "sacn_start_slot": { "name": "sACN-Startslot" + }, + "fade_in": { + "name": "Einblendzeit" + }, + "fade_out": { + "name": "Ausblendzeit" + }, + "transition_default": { + "name": "Übergangszeit (Vorgabe)" } }, "binary_sensor": { @@ -157,6 +193,10 @@ "wifi_guard": { "title": "Firmware für {name} wird nicht angeboten", "description": "Im Firmware-Ordner liegt {version} für {device_type}, aber ohne WLAN-Zugangsdaten für {ssid}. Das Gerät kennt sein WLAN nur aus der Firmware und verlöre nach dieser Aktualisierung seine Netzverbindung. Deshalb wird sie nicht angeboten. Wer das Gerät stattdessen über das Onboarding ins WLAN gebracht hat, bekommt das Update ohne diesen Vorbehalt." + }, + "default_token": { + "title": "{name} nutzt das öffentliche Werks-Token", + "description": "Mit dem öffentlichen Werks-Token kann jeder im selben Netz dieses Gerät umkonfigurieren, neu starten, neu flashen oder auf Werk zurücksetzen — dabei verliert es seine WLAN-Daten und fällt in den Onboarding-AP zurück. Schalten und Dimmen steht ohnehin jedem im Netz offen, solange `auth_all` aus ist. Setze am Gerät ein eigenes Token und trage es über „Neu konfigurieren\" ein." } } } diff --git a/custom_components/ninstrip/switch.py b/custom_components/ninstrip/switch.py index 08cec66..06d8942 100644 --- a/custom_components/ninstrip/switch.py +++ b/custom_components/ninstrip/switch.py @@ -1,4 +1,4 @@ -"""sACN-Schalter eines ninstrip-Geraets.""" +"""Schalter eines ninstrip-Geraets: Aufwecken bei Aenderung und sACN.""" from __future__ import annotations @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import NinstripConfigEntry, NinstripCoordinator -from .entity import NinstripSacnEntity +from .entity import NinstripEntity, NinstripSacnEntity PARALLEL_UPDATES = 0 @@ -20,7 +20,30 @@ async def async_setup_entry( entry: NinstripConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - async_add_entities([NinstripSacnSwitch(entry.runtime_data)]) + async_add_entities( + [NinstripWakeSwitch(entry.runtime_data), NinstripSacnSwitch(entry.runtime_data)] + ) + + +class NinstripWakeSwitch(NinstripEntity, SwitchEntity): + """Schaltet den Strip ein, sobald Farbe, Farbtemperatur oder Helligkeit + geaendert werden - statt die Aenderung nur zu merken.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "wake_on_change" + + def __init__(self, coordinator: NinstripCoordinator) -> None: + super().__init__(coordinator, "wake_on_change") + + @property + def is_on(self) -> bool: + return bool(self.coordinator.data.state.get("wake_on_change")) + + async def async_turn_on(self, **kwargs: Any) -> None: + await self.coordinator.async_set_state(wake_on_change=True) + + async def async_turn_off(self, **kwargs: Any) -> None: + await self.coordinator.async_set_state(wake_on_change=False) class NinstripSacnSwitch(NinstripSacnEntity, SwitchEntity): diff --git a/custom_components/ninstrip/translations/de.json b/custom_components/ninstrip/translations/de.json index 59af84b..a95ef19 100644 --- a/custom_components/ninstrip/translations/de.json +++ b/custom_components/ninstrip/translations/de.json @@ -22,6 +22,14 @@ "data": { "token": "API-Token" } + }, + "reconfigure": { + "title": "Gerät ändern", + "description": "Adresse und API-Token des Geräts {host}. Die MAC wird gegengeprüft — ein anderes Gerät wird abgelehnt.", + "data": { + "host": "Adresse", + "token": "API-Token" + } } }, "error": { @@ -32,7 +40,9 @@ }, "abort": { "already_configured": "Dieses Gerät ist bereits eingerichtet", - "reauth_successful": "Token erneuert" + "reauth_successful": "Token erneuert", + "wrong_device": "Dort antwortet ein anderes Gerät", + "reconfigure_successful": "Gerät geändert" } }, "options": { @@ -64,6 +74,14 @@ "release": "Zurück zum vorherigen Bild", "hold": "Letztes Bild halten" } + }, + "power_on_behaviour": { + "name": "Einschaltverhalten", + "state": { + "last": "Wie zuvor", + "on": "An", + "off": "Aus" + } } }, "button": { @@ -86,11 +104,20 @@ }, "power_scale": { "name": "Leistungsbegrenzung" + }, + "last_ota_error": { + "name": "Letzter Update-Fehler" + }, + "boot_time": { + "name": "Gestartet" } }, "switch": { "sacn_enabled": { "name": "sACN-Empfang" + }, + "wake_on_change": { + "name": "Bei Änderung einschalten" } }, "number": { @@ -99,6 +126,15 @@ }, "sacn_start_slot": { "name": "sACN-Startslot" + }, + "fade_in": { + "name": "Einblendzeit" + }, + "fade_out": { + "name": "Ausblendzeit" + }, + "transition_default": { + "name": "Übergangszeit (Vorgabe)" } }, "binary_sensor": { @@ -157,6 +193,10 @@ "wifi_guard": { "title": "Firmware für {name} wird nicht angeboten", "description": "Im Firmware-Ordner liegt {version} für {device_type}, aber ohne WLAN-Zugangsdaten für {ssid}. Das Gerät kennt sein WLAN nur aus der Firmware und verlöre nach dieser Aktualisierung seine Netzverbindung. Deshalb wird sie nicht angeboten. Wer das Gerät stattdessen über das Onboarding ins WLAN gebracht hat, bekommt das Update ohne diesen Vorbehalt." + }, + "default_token": { + "title": "{name} nutzt das öffentliche Werks-Token", + "description": "Mit dem öffentlichen Werks-Token kann jeder im selben Netz dieses Gerät umkonfigurieren, neu starten, neu flashen oder auf Werk zurücksetzen — dabei verliert es seine WLAN-Daten und fällt in den Onboarding-AP zurück. Schalten und Dimmen steht ohnehin jedem im Netz offen, solange `auth_all` aus ist. Setze am Gerät ein eigenes Token und trage es über „Neu konfigurieren\" ein." } } } diff --git a/custom_components/ninstrip/translations/en.json b/custom_components/ninstrip/translations/en.json index c7d445d..a9616e5 100644 --- a/custom_components/ninstrip/translations/en.json +++ b/custom_components/ninstrip/translations/en.json @@ -22,6 +22,14 @@ "data": { "token": "API token" } + }, + "reconfigure": { + "title": "Change device", + "description": "Address and API token of device {host}. The MAC is checked — a different device is rejected.", + "data": { + "host": "Address", + "token": "API token" + } } }, "error": { @@ -32,7 +40,9 @@ }, "abort": { "already_configured": "This device is already configured", - "reauth_successful": "Token renewed" + "reauth_successful": "Token renewed", + "wrong_device": "A different device answers there", + "reconfigure_successful": "Device changed" } }, "options": { @@ -64,6 +74,14 @@ "release": "Return to previous image", "hold": "Hold last image" } + }, + "power_on_behaviour": { + "name": "Power-on behaviour", + "state": { + "last": "As before", + "on": "On", + "off": "Off" + } } }, "button": { @@ -86,11 +104,20 @@ }, "power_scale": { "name": "Power limiting" + }, + "last_ota_error": { + "name": "Last update error" + }, + "boot_time": { + "name": "Started" } }, "switch": { "sacn_enabled": { "name": "sACN receiver" + }, + "wake_on_change": { + "name": "Turn on when changed" } }, "number": { @@ -99,6 +126,15 @@ }, "sacn_start_slot": { "name": "sACN start slot" + }, + "fade_in": { + "name": "Fade-in time" + }, + "fade_out": { + "name": "Fade-out time" + }, + "transition_default": { + "name": "Default transition time" } }, "binary_sensor": { @@ -157,6 +193,10 @@ "wifi_guard": { "title": "Firmware for {name} not offered", "description": "The firmware folder contains {version} for {device_type}, but without Wi-Fi credentials for {ssid}. The device only knows its Wi-Fi from its firmware and would lose its network connection after this update, so the update is not offered. A device that was put on the network through onboarding instead gets the update without this reservation." + }, + "default_token": { + "title": "{name} uses the public default token", + "description": "With the public default token, anyone on your network can reconfigure, restart, reflash or factory-reset this device, losing its Wi-Fi settings and falling back to its onboarding access point. Switching and dimming are open to anyone on the network anyway while `auth_all` is off. Set your own token on the device and enter it via \"Reconfigure\"." } } } diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 29d46c7..9928674 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -267,3 +267,32 @@ async def test_einstellungen_weisen_unbrauchbare_adressen_ab( ) assert ergebnis["type"] is FlowResultType.FORM assert ergebnis["errors"] == {CONF_DEVICE_BASE_URL: fehler} + + +async def test_neu_konfigurieren_aendert_adresse_und_token( + hass: HomeAssistant, mock_client: MagicMock +) -> None: + eintrag = bestehender_eintrag(hass) + ergebnis = await eintrag.start_reconfigure_flow(hass) + assert ergebnis["step_id"] == "reconfigure" + ergebnis = await hass.config_entries.flow.async_configure( + ergebnis["flow_id"], {CONF_HOST: " 192.168.2.99 ", CONF_TOKEN: NEUES_TOKEN} + ) + assert ergebnis["type"] is FlowResultType.ABORT + assert ergebnis["reason"] == "reconfigure_successful" + assert eintrag.data == {CONF_HOST: "192.168.2.99", CONF_TOKEN: NEUES_TOKEN} + + +async def test_neu_konfigurieren_lehnt_ein_anderes_geraet_ab( + hass: HomeAssistant, mock_client: MagicMock, strip_info: dict[str, Any] +) -> None: + """Sonst biegt ein Tippfehler in der Adresse still den falschen Eintrag um.""" + eintrag = bestehender_eintrag(hass) + strip_info["mac"] = "b0:81:84:49:ff:ff" + ergebnis = await eintrag.start_reconfigure_flow(hass) + ergebnis = await hass.config_entries.flow.async_configure( + ergebnis["flow_id"], {CONF_HOST: "192.168.2.99", CONF_TOKEN: TOKEN} + ) + assert ergebnis["type"] is FlowResultType.ABORT + assert ergebnis["reason"] == "wrong_device" + assert eintrag.data[CONF_HOST] == HOST_K1Y diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..807d2fe --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,44 @@ +"""Diagnosedaten - und was darin nicht stehen darf.""" + +from __future__ import annotations + +from pathlib import Path + +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.ninstrip.diagnostics import async_get_config_entry_diagnostics +from custom_components.ninstrip.shared import DATA_SHARED, async_scan_catalog +from tests_unit.images import build_image +from tests_unit.samples import HOST_K1Y, MAC_K1Y, TOKEN + + +def ablegen(verzeichnis: Path, name: str, device_type: str, version: str) -> None: + """Blockierendes Schreiben ausserhalb der Ereignisschleife - sonst meldet + ruff ASYNC240.""" + verzeichnis.mkdir(parents=True, exist_ok=True) + (verzeichnis / name).write_bytes(build_image(device_type, version)) + + +async def test_geheimnisse_stehen_nicht_drin( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + daten = await async_get_config_entry_diagnostics(hass, init_integration) + text = repr(daten) + for geheim in (TOKEN, HOST_K1Y, MAC_K1Y, "ninIOT"): + assert geheim not in text, geheim + assert daten["state"]["brightness"] == 100 + assert daten["info"]["fw_version"] == "0.1.0-12-g71a57" + assert daten["segments"]["segments"][0]["mode"] == "white" + + +async def test_nur_abbilder_der_eigenen_platine( + hass: HomeAssistant, init_integration: MockConfigEntry, firmware_dir: Path +) -> None: + await hass.async_add_executor_job( + ablegen, firmware_dir, "meins.img", "ninstrip-k1y", "0.1.1-rc1" + ) + await hass.async_add_executor_job(ablegen, firmware_dir, "fremd.img", "ninstrip-rgbc3", "0.2.0") + await async_scan_catalog(hass, hass.data[DATA_SHARED]) + daten = await async_get_config_entry_diagnostics(hass, init_integration) + assert [i["version"] for i in daten["firmware_images"]] == ["0.1.1-rc1"] diff --git a/tests/test_init.py b/tests/test_init.py index 728796b..71bbf62 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -5,13 +5,20 @@ from __future__ import annotations from unittest.mock import MagicMock from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import issue_registry as ir from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.ninstrip.api import NinstripAuthError, NinstripConnectionError -from custom_components.ninstrip.const import CONF_DEVICE_BASE_URL, CONF_SCAN_INTERVAL, DOMAIN +from custom_components.ninstrip.const import ( + CONF_DEVICE_BASE_URL, + CONF_SCAN_INTERVAL, + DEFAULT_TOKEN, + DOMAIN, +) from tests_unit.samples import MAC_K1Y @@ -84,3 +91,30 @@ async def test_einstellungen_lassen_sich_am_laufenden_eintrag_sichern( CONF_SCAN_INTERVAL: 45, CONF_DEVICE_BASE_URL: "http://192.168.1.131:8124", } + + +async def test_werks_token_gibt_einen_hinweis( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: MagicMock +) -> None: + """Wer im selben Netz ist, kann ein Geraet mit Werks-Token vollstaendig + uebernehmen - das soll man sehen, nicht raten muessen.""" + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, data={**mock_config_entry.data, CONF_TOKEN: DEFAULT_TOKEN} + ) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + hinweis = ir.async_get(hass).async_get_issue( + DOMAIN, f"default_token_{mock_config_entry.entry_id}" + ) + assert hinweis is not None + assert hinweis.severity is ir.IssueSeverity.WARNING + + +async def test_eigenes_token_gibt_keinen_hinweis( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + assert ( + ir.async_get(hass).async_get_issue(DOMAIN, f"default_token_{init_integration.entry_id}") + is None + ) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 728688e..d861c81 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import timedelta from typing import Any from freezegun.api import FrozenDateTimeFactory @@ -72,3 +73,41 @@ async def test_ohne_wifi_block_bleibt_die_adresse_leer( strip_info.pop("wifi") await abfragen(hass, freezer) assert hass.states.get(entity_id_for(hass, "sensor", "ip_address")).state == "unknown" + + +async def test_startzeit_und_ota_fehler_sind_diagnose_und_aus( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + registry = er.async_get(hass) + for key in ("boot_time", "last_ota_error"): + eintrag = registry.async_get(entity_id_for(hass, "sensor", key)) + assert eintrag.disabled_by is er.RegistryEntryDisabler.INTEGRATION, key + + +async def test_startzeit_wandert_nicht_bei_jeder_abfrage( + hass: HomeAssistant, + init_integration: MockConfigEntry, + strip_info: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """Die Laufzeit misst das Geraet grob; ohne Toleranz ergaebe jede Abfrage + einen neuen Zeitstempel und HA schriebe dauernd Verlauf.""" + from custom_components.ninstrip.sensor import NinstripBootTimeSensor + + sensor = NinstripBootTimeSensor(init_integration.runtime_data) + erst = sensor.native_value + assert erst is not None + + # Zwei Sekunden spaeter meldet das Geraet zwei Sekunden mehr Laufzeit - + # derselbe Start, also derselbe Zeitstempel. + freezer.tick(timedelta(seconds=2)) + strip_info["uptime"] += 2 + await init_integration.runtime_data.async_refresh() + sensor._boot_aktualisieren() + assert sensor.native_value == erst + + # Ein Neustart dagegen muss durchschlagen. + strip_info["uptime"] = 5 + await init_integration.runtime_data.async_refresh() + sensor._boot_aktualisieren() + assert sensor.native_value != erst diff --git a/tests/test_zustandsfelder.py b/tests/test_zustandsfelder.py new file mode 100644 index 0000000..3fc4db0 --- /dev/null +++ b/tests/test_zustandsfelder.py @@ -0,0 +1,145 @@ +"""Die Felder aus device/state, die ninlight schon lange hat: Zeiten, +Einschaltverhalten, Aufwecken - und die Uebergangszeit beim Schalten.""" + +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, STATE_OFF, STATE_ON +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 + + +@pytest.mark.parametrize( + ("key", "wert", "grenze"), + [("fade_in", 500, 10_000), ("fade_out", 500, 10_000), ("transition_default", 300, 60_000)], +) +async def test_zeiten_zeigen_und_setzen( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, + key: str, + wert: int, + grenze: int, +) -> None: + entity_id = entity_id_for(hass, "number", key) + state = hass.states.get(entity_id) + assert state.state == f"{float(wert)}" + # Die Obergrenzen sind die der Firmware (nl_api_device.c); alles darueber + # wiese sie mit 400 ab. + assert state.attributes["max"] == grenze + assert state.attributes["unit_of_measurement"] == "ms" + await hass.services.async_call( + "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 1200}, blocking=True + ) + mock_client.set_state.assert_awaited_once_with(**{key: 1200}) + + +@pytest.mark.parametrize("key", ["fade_in", "fade_out", "transition_default"]) +async def test_zeiten_ueber_der_grenze_werden_abgelehnt( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_client: MagicMock, key: str +) -> None: + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + "number", + "set_value", + {ATTR_ENTITY_ID: entity_id_for(hass, "number", key), "value": 60_001}, + blocking=True, + ) + mock_client.set_state.assert_not_awaited() + + +async def test_einschaltverhalten( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_client: MagicMock +) -> None: + entity_id = entity_id_for(hass, "select", "power_on_behaviour") + state = hass.states.get(entity_id) + assert state.state == "last" + assert state.attributes["options"] == ["last", "on", "off"] + await hass.services.async_call( + "select", "select_option", {ATTR_ENTITY_ID: entity_id, "option": "off"}, blocking=True + ) + mock_client.set_state.assert_awaited_once_with(power_on_behaviour="off") + assert hass.states.get(entity_id).state == "off" + + +async def test_unbekanntes_einschaltverhalten_zeigt_nichts( + hass: HomeAssistant, + init_integration: MockConfigEntry, + strip_state: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + # Gemeint ist das Verhalten, nicht die Zeile Code: Home Assistant selbst + # zeigt "unknown", sobald der Wert nicht in den Optionen steht. Die Pruefung + # in der Entitaet ist derselbe Riemen doppelt - wie in ha-ninlight. + strip_state["power_on_behaviour"] = "irgendwas" + await abfragen(hass, freezer) + assert hass.states.get(entity_id_for(hass, "select", "power_on_behaviour")).state == "unknown" + + +async def test_aufwecken_bei_aenderung( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_client: MagicMock +) -> None: + entity_id = entity_id_for(hass, "switch", "wake_on_change") + assert hass.states.get(entity_id).state == STATE_OFF + await hass.services.async_call("switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True) + mock_client.set_state.assert_awaited_once_with(wake_on_change=True) + assert hass.states.get(entity_id).state == STATE_ON + await hass.services.async_call("switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True) + mock_client.set_state.assert_awaited_with(wake_on_change=False) + + +@pytest.mark.parametrize( + ("sekunden", "millisekunden"), [(0, 0), (2.5, 2500), (0.04, 40), (120, 60_000)] +) +async def test_uebergangszeit_beim_einschalten( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, + sekunden: float, + millisekunden: int, +) -> None: + """HA rechnet in Sekunden, das Geraet in Millisekunden - und laengeres als + 60 s weist es mit 400 ab, deshalb wird gekappt statt durchgereicht.""" + await hass.services.async_call( + "light", + "turn_on", + {ATTR_ENTITY_ID: entity_id_for(hass, "light", "light"), "transition": sekunden}, + blocking=True, + ) + mock_client.set_state.assert_awaited_once_with(on=True, transition=millisekunden) + + +async def test_uebergangszeit_beim_ausschalten( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_client: MagicMock +) -> None: + await hass.services.async_call( + "light", + "turn_off", + {ATTR_ENTITY_ID: entity_id_for(hass, "light", "light"), "transition": 1.5}, + blocking=True, + ) + mock_client.set_state.assert_awaited_once_with(on=False, transition=1500) + + +async def test_ohne_uebergang_wird_kein_feld_geschickt( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_client: MagicMock +) -> None: + """Sonst ueberschriebe jeder Schaltbefehl die Vorgabe des Geraets.""" + await hass.services.async_call( + "light", "turn_off", {ATTR_ENTITY_ID: entity_id_for(hass, "light", "light")}, blocking=True + ) + mock_client.set_state.assert_awaited_once_with(on=False) + + +async def test_die_lichtentitaet_meldet_uebergaenge( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + state = hass.states.get(entity_id_for(hass, "light", "light")) + assert state.attributes["supported_features"] == 32 # LightEntityFeature.TRANSITION