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.
This commit is contained in:
Michael Sauer
2026-09-23 00:35:12 +02:00
parent c40c68b537
commit 9eb7adcdd7
18 changed files with 737 additions and 20 deletions
+24 -2
View File
@@ -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
+27 -2
View File
@@ -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
+31
View File
@@ -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()
+6
View File
@@ -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
+46
View File
@@ -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,
}
+19 -1
View File
@@ -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)
+62 -5
View File
@@ -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):
+27 -1
View File
@@ -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."""
+54 -2
View File
@@ -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
+41 -1
View File
@@ -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."
}
}
}
+26 -3
View File
@@ -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):
@@ -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."
}
}
}
@@ -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\"."
}
}
}
+29
View File
@@ -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
+44
View File
@@ -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"]
+35 -1
View File
@@ -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
)
+39
View File
@@ -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
+145
View File
@@ -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