feat: update-Entitaet fuer die Firmware und das Testgeruest

Aufgebaut wie ha-ninlight: die Abbilder liegen unter ninstrip_firmware im
Konfigurationsverzeichnis, Home Assistant liefert sie selbst unter einer
einmaligen Adresse aus, und zwar genau die Bytes, die vorher geprueft
wurden. Dazu firmware.py (Anhaenger, Versionsreihenfolge, WLAN-Pruefung,
Katalog), http.py, urls.py und shared.py.

Drei Unterschiede zu ninlight, alle aus der Firmware heraus:

* Verglichen wird device_type - die Platine -, nicht product. Letzteres ist
  bei allen Platinen 'ninstrip'; die Abbilder unterscheiden sich aber hart,
  die K1Y invertiert das Datensignal und die KL-RGBC3 nicht. Firmware ohne
  dieses Feld bekommt gar kein Angebot.
* Die WLAN-Pruefung sucht die SSID unverpackt: ein ESP32-Abbild ist nicht
  gzip-verpackt wie das des W600. An einem mit sdkconfig.local gebauten
  Abbild gegengeprueft - die SSID steht genau einmal NUL-gerahmt drin.
* Der Fehlerschluessel heisst device_url_too_long statt lamp_url_too_long.

Dazu das Testgeruest, das es hier bisher gar nicht gab: 204 Tests in
tests_unit/ (ohne HA-Instanz) und tests/ (mit), Abdeckung 97 %, dazu
scripts/check.sh als Ersatz fuer die fehlende CI. Die Nutzdaten in
tests_unit/samples.py sind an der Stehlampe abgefragt.

Beim Gegenpruefen per Mutation fiel auf, dass die Platinenpruefung
unmittelbar vor dem Senden ungeprueft war - der Test dazu ist jetzt da und
faellt, wenn man die Pruefung entfernt.
This commit is contained in:
Michael Sauer
2026-09-22 23:10:07 +02:00
parent d0a9487805
commit 2c77b2d325
44 changed files with 3600 additions and 43 deletions
+179
View File
@@ -0,0 +1,179 @@
"""HTTP-Client gegen einen echten lokalen Server."""
from __future__ import annotations
import aiohttp
import pytest
from custom_components.ninstrip.api import (
NinstripAuthError,
NinstripBusyError,
NinstripClient,
NinstripConnectionError,
NinstripRequestError,
NinstripResponseError,
)
from tests_unit.conftest import FakeStrip
from tests_unit.samples import INFO_K1Y, PRESETS_K1Y, SETTINGS_COMPILED, STATE_K1Y, TOKEN
def client_for(strip: FakeStrip, session: aiohttp.ClientSession, **kwargs: float) -> NinstripClient:
return NinstripClient(session, strip.host, TOKEN, port=strip.port, **kwargs)
async def test_zustand_schickt_das_token_roh(strip: FakeStrip, session) -> None:
strip.reply("GET", "/api/v1/device/state", payload=STATE_K1Y)
zustand = await client_for(strip, session).get_state()
assert zustand == STATE_K1Y
assert strip.calls[-1].headers["Authorization"] == TOKEN # kein "Bearer"
async def test_set_state_schickt_felder_und_gibt_die_antwort_zurueck(
strip: FakeStrip, session
) -> None:
antwort = {**STATE_K1Y, "brightness": 55} # die Antwort weicht von der Anfrage ab
strip.reply("POST", "/api/v1/device/state", payload=antwort)
ergebnis = await client_for(strip, session).set_state(brightness=78, transition=2500)
assert ergebnis == antwort
assert strip.calls[-1].body == {"brightness": 78, "transition": 2500}
async def test_unbekannte_felder_bleiben_erhalten(strip: FakeStrip, session) -> None:
strip.reply("GET", "/api/v1/device/state", payload={**STATE_K1Y, "kuenftig": 7})
assert (await client_for(strip, session).get_state())["kuenftig"] == 7
async def test_info_settings_und_presets(strip: FakeStrip, session) -> None:
strip.reply("GET", "/api/v1/system/info", payload=INFO_K1Y)
strip.reply("GET", "/api/v1/system/settings", payload=SETTINGS_COMPILED)
strip.reply("GET", "/api/v1/device/presets", payload=PRESETS_K1Y)
client = client_for(strip, session)
assert await client.get_info() == INFO_K1Y
assert await client.get_settings() == SETTINGS_COMPILED
assert await client.get_presets() == PRESETS_K1Y
async def test_sacn_wird_als_settings_typ_geschrieben(strip: FakeStrip, session) -> None:
strip.reply("PUT", "/api/v1/system/settings", payload={"status": "ok"})
await client_for(strip, session).set_sacn(universe=7, start_slot=4)
assert strip.calls[-1].body == {"type": "sacn", "universe": 7, "start_slot": 4}
async def test_szenen_anwenden_und_sichern(strip: FakeStrip, session) -> None:
strip.reply("PUT", "/api/v1/device/presets/3/apply", payload={"status": "ok"})
strip.reply("PUT", "/api/v1/device/presets/3", payload={"status": "ok"})
client = client_for(strip, session)
await client.apply_preset(3)
assert strip.calls[-1].path == "/api/v1/device/presets/3/apply"
await client.save_preset(3, "Abend")
assert strip.calls[-1].body == {"name": "Abend"}
await client.save_preset(3)
assert strip.calls[-1].body == {}
async def test_befehle_liegen_verschachtelt(strip: FakeStrip, session) -> None:
strip.reply("PUT", "/api/v1/system/command", payload={"ok": True})
client = client_for(strip, session)
await client.reboot()
assert strip.calls[-1].body == {"command": {"type": "reboot"}}
strip.reply("PUT", "/api/v1/system/command", status=202, payload={"status": "updating"})
await client.fw_update("http://192.168.1.50:8123/api/ninstrip/firmware/k3y")
assert strip.calls[-1].body == {
"command": {
"type": "fw_update",
"url": "http://192.168.1.50:8123/api/ninstrip/firmware/k3y",
}
}
async def test_401_ist_ein_auth_fehler(strip: FakeStrip, session) -> None:
strip.reply("GET", "/api/v1/system/settings", status=401, payload={"error": "Token fehlt"})
with pytest.raises(NinstripAuthError):
await client_for(strip, session).get_settings()
async def test_409_ist_beschaeftigt_mit_meldung(strip: FakeStrip, session) -> None:
strip.reply(
"PUT", "/api/v1/system/command", status=409, payload={"error": "Update laeuft schon"}
)
with pytest.raises(NinstripBusyError) as err:
await client_for(strip, session).fw_update("http://192.168.1.50:8123/x")
assert err.value.status == 409
assert err.value.message == "Update laeuft schon"
async def test_400_ist_ein_anfragefehler_kein_beschaeftigt(strip: FakeStrip, session) -> None:
strip.reply("POST", "/api/v1/device/state", status=400, payload={"error": "brightness"})
with pytest.raises(NinstripRequestError) as err:
await client_for(strip, session).set_state(brightness=0)
assert not isinstance(err.value, NinstripBusyError)
assert (err.value.status, err.value.message) == (400, "brightness")
async def test_fehlertext_ohne_json_kommt_roh(strip: FakeStrip, session) -> None:
strip.reply("POST", "/api/v1/device/state", status=500, raw="peng")
with pytest.raises(NinstripRequestError) as err:
await client_for(strip, session).set_state(on=True)
assert err.value.message == "peng"
async def test_json_fehlertext_ohne_error_feld_kommt_roh(strip: FakeStrip, session) -> None:
strip.reply("POST", "/api/v1/device/state", status=500, payload={"detail": "zu hell"})
with pytest.raises(NinstripRequestError) as err:
await client_for(strip, session).set_state(on=True)
assert "detail" in err.value.message
async def test_erfolg_ohne_json_ist_ein_antwortfehler(strip: FakeStrip, session) -> None:
strip.reply("GET", "/api/v1/device/state", raw="<html>")
with pytest.raises(NinstripResponseError):
await client_for(strip, session).get_state()
async def test_json_array_als_erfolg_ist_ein_antwortfehler(strip: FakeStrip, session) -> None:
strip.reply("GET", "/api/v1/device/state", raw="[1, 2, 3]")
with pytest.raises(NinstripResponseError):
await client_for(strip, session).get_state()
@pytest.mark.parametrize("fehlt", ["on", "brightness", "segment_count", "pixel_count"])
async def test_zustand_ohne_pflichtfeld_ist_ein_antwortfehler(
strip: FakeStrip, session, fehlt: str
) -> None:
strip.reply(
"GET", "/api/v1/device/state", payload={k: v for k, v in STATE_K1Y.items() if k != fehlt}
)
with pytest.raises(NinstripResponseError):
await client_for(strip, session).get_state()
@pytest.mark.parametrize("fehlt", ["mac", "product", "fw_version"])
async def test_info_ohne_pflichtfeld_ist_ein_antwortfehler(
strip: FakeStrip, session, fehlt: str
) -> None:
strip.reply(
"GET", "/api/v1/system/info", payload={k: v for k, v in INFO_K1Y.items() if k != fehlt}
)
with pytest.raises(NinstripResponseError):
await client_for(strip, session).get_info()
async def test_zeitueberschreitung_ist_ein_verbindungsfehler(strip: FakeStrip, session) -> None:
strip.reply("GET", "/api/v1/device/state", payload=STATE_K1Y, delay=0.5)
with pytest.raises(NinstripConnectionError):
await client_for(strip, session, timeout=0.05).get_state()
async def test_abgewiesene_verbindung_ist_ein_verbindungsfehler(session) -> None:
client = NinstripClient(session, "127.0.0.1", TOKEN, port=1)
with pytest.raises(NinstripConnectionError):
await client.get_state()
async def test_host_gibt_die_eingestellte_adresse(session) -> None:
assert NinstripClient(session, "192.168.2.238", TOKEN, port=3333).host == "192.168.2.238"
async def test_befehl_ohne_antwortkoerper_geht_durch(strip: FakeStrip, session) -> None:
strip.reply("PUT", "/api/v1/system/command", status=204, raw="")
await client_for(strip, session).reboot()