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
+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