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:
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"},
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user