Files
Michael Sauer 2c77b2d325 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.
2026-09-22 23:10:07 +02:00

253 lines
8.9 KiB
Python

"""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")