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
View File
+86
View File
@@ -0,0 +1,86 @@
"""Ein echter lokaler aiohttp-Server spielt das Geraet; kein Home Assistant im Spiel."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Awaitable, Callable
from dataclasses import dataclass, field
import json
from typing import Any
import aiohttp
from aiohttp import web
from aiohttp.test_utils import TestServer
import pytest
@dataclass
class Call:
"""Eine Anfrage, die das gespielte Geraet bekommen hat."""
method: str
path: str
headers: dict[str, str]
body: Any
@dataclass
class FakeStrip:
"""Vorgegebene Antworten je (METHODE, Pfad); merkt sich jeden Aufruf."""
host: str
port: int
routes: dict[tuple[str, str], tuple[int, str, float]] = field(default_factory=dict)
calls: list[Call] = field(default_factory=list)
def reply(
self,
method: str,
path: str,
status: int = 200,
payload: Any = None,
*,
raw: str | None = None,
delay: float = 0.0,
) -> None:
body = raw if raw is not None else json.dumps(payload)
self.routes[(method.upper(), path)] = (status, body, delay)
@pytest.fixture(autouse=True)
def _enable_sockets(socket_enabled: None) -> None:
"""pytest-homeassistant-custom-component sperrt Sockets; hier wird localhost gebraucht."""
@pytest.fixture
async def strip(aiohttp_server: Callable[..., Awaitable[TestServer]]) -> FakeStrip:
holder: list[FakeStrip] = []
async def handler(request: web.Request) -> web.Response:
fake = holder[0]
text = await request.text()
try:
body: Any = json.loads(text) if text else None
except ValueError:
body = text
fake.calls.append(Call(request.method, request.path, dict(request.headers), body))
route = fake.routes.get((request.method, request.path))
if route is None:
return web.Response(status=404, text="no route")
status, text_out, delay = route
if delay:
await asyncio.sleep(delay)
return web.Response(status=status, text=text_out)
app = web.Application()
app.router.add_route("*", "/{tail:.*}", handler)
server = await aiohttp_server(app)
fake = FakeStrip(server.host, server.port)
holder.append(fake)
return fake
@pytest.fixture
async def session() -> AsyncGenerator[aiohttp.ClientSession]:
async with aiohttp.ClientSession() as client_session:
yield client_session
+33
View File
@@ -0,0 +1,33 @@
"""Kuenstliche ninstrip-OTA-Abbilder: Nutzdaten + 512-Byte-NINL-Anhaenger.
Der Aufbau folgt ninstrip-firmware/tools/mkimage.py. Die Tests bauen sich ihre
Abbilder selbst, statt echte Firmware-Dateien mitzuschleppen.
Anders als bei ninlight liegen die Nutzdaten unverpackt vor: ein ESP32-Abbild
ist nicht gzip-verpackt, die einkompilierte SSID steht als Zeichenkette drin.
"""
from __future__ import annotations
import hashlib
CONTENT_WITH_SSID = b"\x00boot\x00ninIOT\x00ninstrip\x00"
CONTENT_WITHOUT_SSID = b"\x00boot\x00ninstrip\x00"
def build_image(
device_type: str = "ninstrip-k1y",
version: str = "0.1.0-47-g71a57",
*,
payload: bytes = CONTENT_WITH_SSID,
magic: bytes = b"NINL",
fmt: int = 1,
corrupt_hash: bool = False,
) -> bytes:
trailer = bytearray(b"\xff" * 512)
trailer[0:4] = magic
trailer[4] = fmt
trailer[5:21] = device_type.encode().ljust(16, b"\x00")
trailer[21:37] = version.encode().ljust(16, b"\x00")
trailer[37:69] = bytes(32) if corrupt_hash else hashlib.sha256(payload).digest()
return payload + bytes(trailer)
+97
View File
@@ -0,0 +1,97 @@
"""Echte Nutzdaten zweier Geraete. Bewusst keine Grenzwerte - die stehen in den Tests selbst."""
from __future__ import annotations
from typing import Any
TOKEN = "a201883d13a570dd3cf1f3b64b5e1df7"
HOST_K1Y = "192.168.2.238"
MAC_K1Y = "b0:81:84:49:ad:4c"
# Abgefragt an der Stehlampe (KL-LSC5C3WV2-K1Y, SK6812-RGBW, 60 Pixel).
STATE_K1Y: dict[str, Any] = {
"on": True,
"brightness": 100,
"power_on_behaviour": "last",
"fade_in": 500,
"fade_out": 500,
"transition_default": 300,
"wake_on_change": False,
"preset": 2,
"preset_modified": False,
"segment_count": 1,
"pixel_count": 60,
}
INFO_K1Y: dict[str, Any] = {
"product": "ninstrip",
"device_type": "ninstrip-k1y",
"fw_version": "0.1.0-12-g71a57",
"build_date": "2026-09-22",
"mac": MAC_K1Y,
"name": "",
"wifi": {"mode": "sta", "ssid": "ninIOT", "rssi": -68, "ip": HOST_K1Y, "error": None},
"sacn": {"enabled": True, "active": False, "universe": 1, "universes": 1, "multicast": True},
"uptime": 1459,
"free_heap": 156848,
"strip": {
"chipset": "sk6812_rgbw",
"color_order": "grb",
"data_pin": 19,
"pixel_count": 60,
"leds_per_pixel": 1,
"has_white": True,
"inverted": True,
"power_scale": 100,
"max_milliamps": 1800,
},
"render": {"fps": 50, "render_us": 900, "show_us": 1800, "dropped_frames": 0},
"last_ota_error": None,
}
# wifi.source "compiled": das Geraet kennt sein WLAN nur aus der Firmware.
SETTINGS_COMPILED: dict[str, Any] = {
"name": "",
"wifi": {"ssid": "ninIOT", "source": "compiled"},
"auth_all": False,
"sacn": {
"enabled": True,
"universe": 1,
"start_slot": 1,
"on_timeout": "release",
"channels_per_pixel": 3,
},
"strip": {
"chipset": "sk6812_rgbw",
"color_order": "grb",
"data_pin": 19,
"pixel_count": 60,
"leds_per_pixel": 1,
"max_milliamps": 1800,
"supply_volts": 12,
"inverted": True,
},
}
PRESETS_K1Y: dict[str, Any] = {
"presets": [
{"id": 0, "name": "Warmweiss", "used": True, "segment_count": 1},
{"id": 1, "name": "Kaltweiss", "used": True, "segment_count": 1},
{"id": 2, "name": "Regenbogen", "used": True, "segment_count": 1},
{"id": 3, "name": "Feuer", "used": True, "segment_count": 1},
{"id": 4, "name": "", "used": False, "segment_count": 0},
{"id": 5, "name": "", "used": False, "segment_count": 0},
{"id": 6, "name": "", "used": False, "segment_count": 0},
{"id": 7, "name": "", "used": False, "segment_count": 0},
],
"active": 2,
"modified": False,
}
# wifi.source "stored": ueber das Onboarding ins WLAN gebracht, also vom
# Abbild unabhaengig.
SETTINGS_STORED: dict[str, Any] = {
**SETTINGS_COMPILED,
"wifi": {"ssid": "ninIOT", "source": "stored"},
}
+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()
+252
View File
@@ -0,0 +1,252 @@
"""Anhaenger, Versionsreihenfolge, WLAN-Pruefung und der Abbild-Katalog."""
from __future__ import annotations
from pathlib import Path
import pytest
from custom_components.ninstrip import firmware
from custom_components.ninstrip.firmware import (
FirmwareCatalog,
FirmwareVersion,
InvalidImageError,
contains_ssid,
is_newer,
parse_trailer,
parse_version,
)
from tests_unit.images import CONTENT_WITH_SSID, CONTENT_WITHOUT_SSID, build_image
# Streng aufsteigend. Geprueft werden alle Nachbarpaare und alle Paare ueber
# die ganze Liste.
ORDER = [
"0.1.0",
"0.1.0-36-gca9bb",
"0.1.0-47-g71a57",
"0.1.1-rc1",
"0.1.1-rc1-4-gab12",
"0.1.1-rc2",
"0.1.1",
"0.2.0-rc1",
"0.10.0",
]
@pytest.mark.parametrize(("aelter", "neuer"), list(zip(ORDER, ORDER[1:], strict=False))) # noqa: RUF007
def test_nachbarversionen(aelter: str, neuer: str) -> None:
assert is_newer(neuer, aelter)
assert not is_newer(aelter, neuer)
def test_reihenfolge_gilt_ueber_die_ganze_liste() -> None:
for i, aelter in enumerate(ORDER):
for neuer in ORDER[i + 1 :]:
assert is_newer(neuer, aelter), (neuer, aelter)
@pytest.mark.parametrize(
("text", "erwartet"),
[
("0.1.0-36-gca9bb", FirmwareVersion((0, 1, 0), None, 36)),
("0.1.0-38-gd3cc9", FirmwareVersion((0, 1, 0), None, 38)), # genau 15 Zeichen, mit Hash
("0.1.0-36-g", FirmwareVersion((0, 1, 0), None, 36)), # Hash weggekuerzt
("0.1.0-36", FirmwareVersion((0, 1, 0), None, 36)),
("0.1.0-3-gab-dir", FirmwareVersion((0, 1, 0), None, 3)), # "-dirty" auf "-dir" gekuerzt
("0.1.0-dirty", FirmwareVersion((0, 1, 0), None, 0)),
("0.2.0-rc1-12-ga", FirmwareVersion((0, 2, 0), 1, 12)), # 15 Zeichen, Hash auf ein Zeichen
("0.1.1-rc2", FirmwareVersion((0, 1, 1), 2, 0)),
],
)
def test_version_lesen(text: str, erwartet: FirmwareVersion) -> None:
assert parse_version(text) == erwartet
@pytest.mark.parametrize(
"text",
[
"",
# Genau das meldete die Firmware vor dem Tag v0.1.0: git describe
# --always ohne Tags. Daran laesst sich keine Reihenfolge ablesen.
"652b2e2-dirty",
"v0.1.0", # das fuehrende "v" schneidet cmake/version.cmake ab
"0.1",
"0.1.0-",
"0.10.10-rc10-12", # 15 Zeichen, endet auf Ziffern ohne "-g": Zahl kann gekuerzt sein
"0.1.0-36-gXYZ",
"0.1.0 ",
],
)
def test_unlesbare_versionen(text: str) -> None:
assert parse_version(text) is None
def test_gleiche_commitzahl_mit_anderem_hash_ist_nicht_neuer() -> None:
assert not is_newer("0.1.0-36-gffff0", "0.1.0-36-gca9bb")
assert not is_newer("0.1.0-36-gca9bb", "0.1.0-36-gffff0")
def test_eine_unlesbare_seite_ist_nie_neuer() -> None:
assert not is_newer("652b2e2", "0.1.0")
assert not is_newer("0.1.1", "652b2e2")
def test_anhaenger_liest_nul_aufgefuellte_felder() -> None:
assert parse_trailer(build_image("ninstrip-rgbc3", "0.1.0-38-gd3cc9")) == (
"ninstrip-rgbc3",
"0.1.0-38-gd3cc9",
)
@pytest.mark.parametrize(
("data", "grund"),
[
(b"\xff" * 512, "kurz"),
(build_image(magic=b"NINX"), "NINL"),
(build_image(fmt=2), "format"),
(build_image(corrupt_hash=True), "sha256"),
],
)
def test_ungueltige_abbilder(data: bytes, grund: str) -> None:
with pytest.raises(InvalidImageError, match=grund):
parse_trailer(data)
def test_ssid_wird_nul_gerahmt_gefunden() -> None:
assert contains_ssid(build_image(payload=CONTENT_WITH_SSID), "ninIOT")
def test_ssid_fehlt_im_abbild_ohne_zugangsdaten() -> None:
assert not contains_ssid(build_image(payload=CONTENT_WITHOUT_SSID), "ninIOT")
@pytest.mark.parametrize("payload", [b"\x00xninIOT\x00", b"\x00ninIOTx\x00", b"ninIOT"])
def test_ssid_muss_nul_gerahmt_sein(payload: bytes) -> None:
assert not contains_ssid(build_image(payload=payload), "ninIOT")
def test_eine_andere_ssid_passt_nicht() -> None:
assert not contains_ssid(build_image(payload=CONTENT_WITH_SSID), "ninIOT-gast")
def test_leere_ssid_passt_nie() -> None:
assert not contains_ssid(build_image(payload=b"\x00\x00\x00"), "")
def test_die_ssid_im_anhaenger_zaehlt_nicht() -> None:
"""Sonst koennte der Anhaenger selbst die WLAN-Pruefung bestehen lassen."""
abbild = build_image(payload=CONTENT_WITHOUT_SSID)
manipuliert = bytearray(abbild)
manipuliert[-200:-190] = b"\x00ninIOT\x00\x00\x00"
assert not contains_ssid(bytes(manipuliert), "ninIOT")
def _schreiben(verzeichnis: Path, name: str, data: bytes) -> Path:
pfad = verzeichnis / name
pfad.write_bytes(data)
return pfad
def test_durchlauf_legt_das_verzeichnis_an_und_nimmt_nur_gueltiges(tmp_path: Path) -> None:
verzeichnis = tmp_path / "ninstrip_firmware"
katalog = FirmwareCatalog(verzeichnis)
assert katalog.scan() == ()
assert verzeichnis.is_dir()
_schreiben(verzeichnis, "a.img", build_image("ninstrip-k1y", "0.1.0-47-g71a57"))
_schreiben(verzeichnis, "kaputt.img", build_image(corrupt_hash=True))
_schreiben(verzeichnis, "notizen.txt", build_image())
abbilder = katalog.scan()
assert [(i.path.name, i.device_type, i.version) for i in abbilder] == [
("a.img", "ninstrip-k1y", "0.1.0-47-g71a57")
]
assert katalog.images == abbilder
def test_ungueltige_datei_wird_einmal_gemeldet(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
katalog = FirmwareCatalog(tmp_path)
_schreiben(tmp_path, "kaputt.img", build_image(corrupt_hash=True))
katalog.scan()
katalog.scan()
assert sum("kaputt.img" in r.getMessage() for r in caplog.records) == 1
def test_erneuter_durchlauf_sieht_neue_und_geloeschte_dateien(tmp_path: Path) -> None:
katalog = FirmwareCatalog(tmp_path)
alt = _schreiben(tmp_path, "a.img", build_image(version="0.1.0-47-g71a57"))
katalog.scan()
_schreiben(tmp_path, "b.img", build_image(version="0.1.1-rc1"))
alt.unlink()
assert [i.version for i in katalog.scan()] == ["0.1.1-rc1"]
def test_neuere_abbilder_nur_exakt_dieser_platine_neuestes_zuerst(tmp_path: Path) -> None:
katalog = FirmwareCatalog(tmp_path)
_schreiben(tmp_path, "1.img", build_image("ninstrip-k1y", "0.1.0-47-g71a57"))
_schreiben(tmp_path, "2.img", build_image("ninstrip-k1y", "0.1.1-rc1"))
# Neuer, aber fuer die andere Platine: die KL-RGBC3 invertiert das
# Datensignal nicht, die K1Y schon.
_schreiben(tmp_path, "3.img", build_image("ninstrip-rgbc3", "0.2.0"))
_schreiben(tmp_path, "4.img", build_image("ninstrip", "0.5.0")) # Praefix des Typs
_schreiben(tmp_path, "5.img", build_image("ninstrip-k1y", "0.1.0-36-gffff0")) # gleich alt
_schreiben(tmp_path, "6.img", build_image("ninstrip-k1y", "0.1.0")) # aelter
katalog.scan()
neuer = katalog.newer_images("ninstrip-k1y", "0.1.0-36-gca9bb")
assert [(i.device_type, i.version) for i in neuer] == [
("ninstrip-k1y", "0.1.1-rc1"),
("ninstrip-k1y", "0.1.0-47-g71a57"),
]
def test_verify_and_read_liefert_genau_die_geprueften_bytes(tmp_path: Path) -> None:
katalog = FirmwareCatalog(tmp_path)
daten = build_image(version="0.1.0-47-g71a57")
pfad = _schreiben(tmp_path, "a.img", daten)
(abbild,) = katalog.scan()
assert katalog.verify_and_read(abbild) == daten
pfad.write_bytes(build_image(version="0.1.1-rc1"))
assert katalog.verify_and_read(abbild) is None
pfad.unlink()
assert katalog.verify_and_read(abbild) is None
def test_verify_and_read_weist_gleich_grosse_aber_andere_datei_ab(tmp_path: Path) -> None:
"""Gleiche Groesse und gleiche mtime, anderer Inhalt: der Anhaenger entscheidet."""
katalog = FirmwareCatalog(tmp_path)
pfad = _schreiben(tmp_path, "a.img", build_image("ninstrip-k1y", "0.1.0-47-g71a57"))
(abbild,) = katalog.scan()
stat = pfad.stat()
pfad.write_bytes(build_image("ninstrip-k1y", "0.1.0-47-g71a58"))
import os
os.utime(pfad, ns=(stat.st_atime_ns, stat.st_mtime_ns))
assert katalog.verify_and_read(abbild) is None
def test_ssid_pruefung_wird_je_abbild_und_ssid_gemerkt(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
katalog = FirmwareCatalog(tmp_path)
_schreiben(tmp_path, "a.img", build_image(payload=CONTENT_WITH_SSID))
(abbild,) = katalog.scan()
aufrufe: list[str] = []
echt = firmware.contains_ssid
def spion(data: bytes, ssid: str) -> bool:
aufrufe.append(ssid)
return echt(data, ssid)
monkeypatch.setattr(firmware, "contains_ssid", spion)
assert katalog.contains_ssid(abbild, "ninIOT")
assert katalog.contains_ssid(abbild, "ninIOT")
assert not katalog.contains_ssid(abbild, "andere")
assert aufrufe == ["ninIOT", "andere"]
def test_ssid_pruefung_einer_geloeschten_datei_ist_falsch(tmp_path: Path) -> None:
katalog = FirmwareCatalog(tmp_path)
pfad = _schreiben(tmp_path, "a.img", build_image(payload=CONTENT_WITH_SSID))
(abbild,) = katalog.scan()
pfad.unlink()
assert not katalog.contains_ssid(abbild, "ninIOT")
+26
View File
@@ -0,0 +1,26 @@
"""The manifest must agree with the constants the code relies on."""
from __future__ import annotations
import json
from pathlib import Path
from custom_components.ninstrip.const import DOMAIN
MANIFEST = Path(__file__).parents[1] / "custom_components" / "ninstrip" / "manifest.json"
def test_manifest_matches_constants() -> None:
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
assert manifest["domain"] == DOMAIN
assert manifest["zeroconf"] == [{"type": "_ninstrip._tcp.local."}]
assert manifest["iot_class"] == "local_polling"
assert manifest["config_flow"] is True
assert manifest["requirements"] == []
assert manifest["dependencies"] == ["http"]
def test_manifest_points_to_gitea() -> None:
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
assert manifest["documentation"] == "https://gitea.zukunpht.de/ninharp/ha-ninstrip"
assert manifest["issue_tracker"] == "https://gitea.zukunpht.de/ninharp/ha-ninstrip/issues"
+46
View File
@@ -0,0 +1,46 @@
"""Pruefung der Adresse, unter der Geraete Home Assistant erreichen."""
from __future__ import annotations
import pytest
from custom_components.ninstrip.urls import firmware_url, validate_device_base_url
@pytest.mark.parametrize(
"url", ["http://192.168.1.50:8123", "http://ha.local:8123/", "http://192.168.1.50"]
)
def test_gueltige_adressen(url: str) -> None:
assert validate_device_base_url(url) is None
@pytest.mark.parametrize(
"url",
[
"https://192.168.1.50:8123",
"http://",
"ftp://ha.local",
"http://user:pass@ha.local:8123",
"http://ha.local:8123/ha",
"http://ha.local:8123?x=1",
"http://ha.local:8123#frag",
"http://ha.local:99999",
"192.168.1.50:8123",
],
)
def test_ungueltige_adressen(url: str) -> None:
assert validate_device_base_url(url) == "invalid_base_url"
def test_laengengrenze_sind_die_255_zeichen_des_geraets() -> None:
laengste = "http://" + "a" * 182
assert len(firmware_url(laengste, "k" * 43)) == 255
assert validate_device_base_url(laengste) is None
assert validate_device_base_url(laengste + "a") == "base_url_too_long"
def test_adresse_wird_ohne_doppelten_schraegstrich_zusammengesetzt() -> None:
assert (
firmware_url("http://ha.local:8123/", "abc")
== "http://ha.local:8123/api/ninstrip/firmware/abc"
)