feat(firmware): Trailer, Versionsordnung, WLAN-Pruefung und Firmware-Katalog
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
"""Synthetic ninlight OTA images: 56-byte W600 header + gzip stream + 512-byte NINL trailer.
|
||||
|
||||
The layout mirrors ninlight-firmware/tools/mkimage/nl_mkimage.py and was checked against a real
|
||||
image (spec §2). Tests build their own images instead of copying firmware binaries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
|
||||
CONTENT_WITH_SSID = b"\x00boot\x00ninIOT\x00ninlight-%s\x00"
|
||||
CONTENT_WITHOUT_SSID = b"\x00boot\x00ninlight-%s\x00"
|
||||
|
||||
|
||||
def build_payload(content: bytes) -> bytes:
|
||||
return b"\xab" * 56 + gzip.compress(content, mtime=0)
|
||||
|
||||
|
||||
def build_image(
|
||||
device_type: str = "gu10_rgb",
|
||||
version: str = "0.3.8-47-g71a57",
|
||||
*,
|
||||
content: bytes = CONTENT_WITH_SSID,
|
||||
payload: bytes | None = None,
|
||||
magic: bytes = b"NINL",
|
||||
fmt: int = 1,
|
||||
corrupt_hash: bool = False,
|
||||
) -> bytes:
|
||||
body = payload if payload is not None else build_payload(content)
|
||||
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(body).digest()
|
||||
return body + bytes(trailer)
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Trailer, version order, Wi-Fi credential check and the firmware catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.ninlight import firmware
|
||||
from custom_components.ninlight.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
|
||||
|
||||
# Strictly ascending. Every neighbour pair and every pair across the list is checked.
|
||||
ORDER = [
|
||||
"0.3.8",
|
||||
"0.3.8-36-gca9bb",
|
||||
"0.3.8-47-g71a57",
|
||||
"0.3.9-rc1",
|
||||
"0.3.9-rc1-4-gab12",
|
||||
"0.3.9-rc2",
|
||||
"0.3.9",
|
||||
"0.4.0-rc1",
|
||||
"0.10.0",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("older", "newer"), list(zip(ORDER, ORDER[1:], strict=False))) # noqa: RUF007
|
||||
def test_version_neighbours(older: str, newer: str) -> None:
|
||||
assert is_newer(newer, older)
|
||||
assert not is_newer(older, newer)
|
||||
|
||||
|
||||
def test_version_order_holds_across_the_whole_list() -> None:
|
||||
for i, older in enumerate(ORDER):
|
||||
for newer in ORDER[i + 1 :]:
|
||||
assert is_newer(newer, older), (newer, older)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "expected"),
|
||||
[
|
||||
("0.3.8-36-gca9bb", FirmwareVersion((0, 3, 8), None, 36)),
|
||||
("0.3.8-38-gd3cc9", FirmwareVersion((0, 3, 8), None, 38)), # exactly 15 chars, hash present
|
||||
("0.3.8-36-g", FirmwareVersion((0, 3, 8), None, 36)), # hash cut to nothing
|
||||
("0.3.8-36", FirmwareVersion((0, 3, 8), None, 36)),
|
||||
("0.3.8-3-gab-dir", FirmwareVersion((0, 3, 8), None, 3)), # "-dirty" cut to "-dir"
|
||||
("0.3.8-dirty", FirmwareVersion((0, 3, 8), None, 0)),
|
||||
("0.4.0-rc1-12-ga", FirmwareVersion((0, 4, 0), 1, 12)), # 15 chars, hash cut to one char
|
||||
("0.3.9-rc2", FirmwareVersion((0, 3, 9), 2, 0)),
|
||||
],
|
||||
)
|
||||
def test_parse_version(text: str, expected: FirmwareVersion) -> None:
|
||||
assert parse_version(text) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"",
|
||||
"gca9bb", # git describe --always without a tag
|
||||
"v0.3.8", # the firmware strips the "v"
|
||||
"0.3",
|
||||
"0.3.8-",
|
||||
"0.10.10-rc10-12", # 15 chars ending in digits without "-g": commit count may be cut
|
||||
"0.3.8-36-gXYZ",
|
||||
"0.3.8 ",
|
||||
],
|
||||
)
|
||||
def test_unparseable_versions(text: str) -> None:
|
||||
assert parse_version(text) is None
|
||||
|
||||
|
||||
def test_same_commit_count_with_other_hash_is_not_newer() -> None:
|
||||
assert not is_newer("0.3.8-36-gffff0", "0.3.8-36-gca9bb")
|
||||
assert not is_newer("0.3.8-36-gca9bb", "0.3.8-36-gffff0")
|
||||
|
||||
|
||||
def test_unparseable_side_is_never_newer() -> None:
|
||||
assert not is_newer("gca9bb", "0.3.8")
|
||||
assert not is_newer("0.3.9", "gca9bb")
|
||||
|
||||
|
||||
def test_parse_trailer_reads_nul_padded_fields() -> None:
|
||||
assert parse_trailer(build_image("g95_cw", "0.3.8-38-gd3cc9")) == ("g95_cw", "0.3.8-38-gd3cc9")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("data", "reason"),
|
||||
[
|
||||
(b"\xff" * 512, "short"),
|
||||
(build_image(magic=b"NINX"), "NINL"),
|
||||
(build_image(fmt=2), "format"),
|
||||
(build_image(corrupt_hash=True), "sha256"),
|
||||
],
|
||||
)
|
||||
def test_invalid_images(data: bytes, reason: str) -> None:
|
||||
with pytest.raises(InvalidImageError, match=reason):
|
||||
parse_trailer(data)
|
||||
|
||||
|
||||
def test_ssid_found_when_nul_framed() -> None:
|
||||
assert contains_ssid(build_image(content=CONTENT_WITH_SSID), "ninIOT")
|
||||
|
||||
|
||||
def test_ssid_missing_in_image_without_credentials() -> None:
|
||||
assert not contains_ssid(build_image(content=CONTENT_WITHOUT_SSID), "ninIOT")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", [b"\x00xninIOT\x00", b"\x00ninIOTx\x00", b"ninIOT"])
|
||||
def test_ssid_must_be_nul_framed(content: bytes) -> None:
|
||||
assert not contains_ssid(build_image(content=content), "ninIOT")
|
||||
|
||||
|
||||
def test_other_ssid_does_not_match() -> None:
|
||||
assert not contains_ssid(build_image(content=CONTENT_WITH_SSID), "ninIOT-gast")
|
||||
|
||||
|
||||
def test_empty_ssid_never_matches() -> None:
|
||||
assert not contains_ssid(build_image(content=b"\x00\x00\x00"), "")
|
||||
|
||||
|
||||
def test_broken_gzip_is_not_a_match() -> None:
|
||||
payload = b"\xab" * 56 + b"\x1f\x8b\x08" + bytes(7) + b"\xff" * 64
|
||||
assert not contains_ssid(build_image(payload=payload), "ninIOT")
|
||||
|
||||
|
||||
def test_gzip_stream_inside_the_header_is_ignored() -> None:
|
||||
payload = gzip.compress(CONTENT_WITH_SSID, mtime=0).ljust(120, b"\x00")
|
||||
assert not contains_ssid(build_image(payload=payload), "ninIOT")
|
||||
|
||||
|
||||
def _write(directory: Path, name: str, data: bytes) -> Path:
|
||||
path = directory / name
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
|
||||
def test_scan_creates_directory_and_accepts_valid_images_only(tmp_path: Path) -> None:
|
||||
directory = tmp_path / "ninlight_firmware"
|
||||
catalog = FirmwareCatalog(directory)
|
||||
assert catalog.scan() == ()
|
||||
assert directory.is_dir()
|
||||
_write(directory, "a.img", build_image("gu10_rgb", "0.3.8-47-g71a57"))
|
||||
_write(directory, "broken.img", build_image(corrupt_hash=True))
|
||||
_write(directory, "notes.txt", build_image())
|
||||
images = catalog.scan()
|
||||
assert [(i.path.name, i.device_type, i.version) for i in images] == [
|
||||
("a.img", "gu10_rgb", "0.3.8-47-g71a57")
|
||||
]
|
||||
assert catalog.images == images
|
||||
|
||||
|
||||
def test_invalid_file_is_logged_once(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
catalog = FirmwareCatalog(tmp_path)
|
||||
_write(tmp_path, "broken.img", build_image(corrupt_hash=True))
|
||||
catalog.scan()
|
||||
catalog.scan()
|
||||
assert sum("broken.img" in r.getMessage() for r in caplog.records) == 1
|
||||
|
||||
|
||||
def test_rescan_sees_new_and_removed_files(tmp_path: Path) -> None:
|
||||
catalog = FirmwareCatalog(tmp_path)
|
||||
old = _write(tmp_path, "a.img", build_image(version="0.3.8-47-g71a57"))
|
||||
catalog.scan()
|
||||
_write(tmp_path, "b.img", build_image(version="0.3.9-rc1"))
|
||||
old.unlink()
|
||||
assert [i.version for i in catalog.scan()] == ["0.3.9-rc1"]
|
||||
|
||||
|
||||
def test_newer_images_filters_exact_device_type_and_sorts_descending(tmp_path: Path) -> None:
|
||||
catalog = FirmwareCatalog(tmp_path)
|
||||
_write(tmp_path, "1.img", build_image("gu10_rgb", "0.3.8-47-g71a57"))
|
||||
_write(tmp_path, "2.img", build_image("gu10_rgb", "0.3.9-rc1"))
|
||||
_write(tmp_path, "3.img", build_image("gu10_cw", "0.4.0")) # newest, but other type
|
||||
_write(tmp_path, "4.img", build_image("gu10", "0.5.0")) # prefix of the type
|
||||
_write(tmp_path, "5.img", build_image("gu10_rgb", "0.3.8-36-gffff0")) # equal to installed
|
||||
_write(tmp_path, "6.img", build_image("gu10_rgb", "0.3.8")) # older
|
||||
catalog.scan()
|
||||
newer = catalog.newer_images("gu10_rgb", "0.3.8-36-gca9bb")
|
||||
assert [(i.device_type, i.version) for i in newer] == [
|
||||
("gu10_rgb", "0.3.9-rc1"),
|
||||
("gu10_rgb", "0.3.8-47-g71a57"),
|
||||
]
|
||||
|
||||
|
||||
def test_verify_detects_changed_and_removed_files(tmp_path: Path) -> None:
|
||||
catalog = FirmwareCatalog(tmp_path)
|
||||
path = _write(tmp_path, "a.img", build_image(version="0.3.8-47-g71a57"))
|
||||
(image,) = catalog.scan()
|
||||
assert catalog.verify(image)
|
||||
path.write_bytes(build_image(version="0.3.9-rc1"))
|
||||
assert not catalog.verify(image)
|
||||
path.unlink()
|
||||
assert not catalog.verify(image)
|
||||
|
||||
|
||||
def test_catalog_ssid_check_is_cached_per_image_and_ssid(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
catalog = FirmwareCatalog(tmp_path)
|
||||
_write(tmp_path, "a.img", build_image(content=CONTENT_WITH_SSID))
|
||||
(image,) = catalog.scan()
|
||||
calls: list[str] = []
|
||||
real = firmware.contains_ssid
|
||||
|
||||
def spy(data: bytes, ssid: str) -> bool:
|
||||
calls.append(ssid)
|
||||
return real(data, ssid)
|
||||
|
||||
monkeypatch.setattr(firmware, "contains_ssid", spy)
|
||||
assert catalog.contains_ssid(image, "ninIOT")
|
||||
assert catalog.contains_ssid(image, "ninIOT")
|
||||
assert not catalog.contains_ssid(image, "andere")
|
||||
assert calls == ["ninIOT", "andere"]
|
||||
|
||||
|
||||
def test_catalog_ssid_check_on_removed_file_is_false(tmp_path: Path) -> None:
|
||||
catalog = FirmwareCatalog(tmp_path)
|
||||
path = _write(tmp_path, "a.img", build_image(content=CONTENT_WITH_SSID))
|
||||
(image,) = catalog.scan()
|
||||
path.unlink()
|
||||
assert not catalog.contains_ssid(image, "ninIOT")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Validation of the address lamps use to reach Home Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.ninlight.urls import firmware_url, validate_lamp_base_url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url", ["http://192.168.1.50:8123", "http://ha.local:8123/", "http://192.168.1.50"]
|
||||
)
|
||||
def test_valid_base_urls(url: str) -> None:
|
||||
assert validate_lamp_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_invalid_base_urls(url: str) -> None:
|
||||
assert validate_lamp_base_url(url) == "invalid_base_url"
|
||||
|
||||
|
||||
def test_length_boundary_is_the_lamps_255_characters() -> None:
|
||||
longest_ok = "http://" + "a" * 182
|
||||
assert len(firmware_url(longest_ok, "k" * 43)) == 255
|
||||
assert validate_lamp_base_url(longest_ok) is None
|
||||
assert validate_lamp_base_url(longest_ok + "a") == "base_url_too_long"
|
||||
|
||||
|
||||
def test_firmware_url_joins_without_double_slash() -> None:
|
||||
assert (
|
||||
firmware_url("http://ha.local:8123/", "abc")
|
||||
== "http://ha.local:8123/api/ninlight/firmware/abc"
|
||||
)
|
||||
Reference in New Issue
Block a user