From e8971649600f5a4851a69058dce7ec637f0a789d Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Sun, 13 Sep 2026 17:48:56 +0200 Subject: [PATCH] feat(station): Produkt-ID-Tabelle der alten Klyqa-Firmware --- tools/station/products.py | 65 ++++++++++++++++++++++++++ tools/station/tests/test_products.py | 70 ++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 tools/station/products.py create mode 100644 tools/station/tests/test_products.py diff --git a/tools/station/products.py b/tools/station/products.py new file mode 100644 index 0000000..1d0d8c3 --- /dev/null +++ b/tools/station/products.py @@ -0,0 +1,65 @@ +"""Produkt-IDs der alten Klyqa-Firmware und ihre Zuordnung zu ninlight-Zielen. + +Vollstaendig uebernommen aus qcl_product_info.c der alten Firmware +(~/workspace/qcx-w600-firmware). Der "-dev"-Anhang entscheidet zugleich ueber den +Signierschluessel: Lampen mit Dev-Produkt-ID akzeptieren nur den Entwicklungs-, +Produktionslampen nur den Produktionsschluessel. +""" +import re +from collections import namedtuple + +Product = namedtuple("Product", "product_id target variant name") +ParsedRequest = namedtuple("ParsedRequest", "product_id lamp_version product") + +# (Produkt-ID ohne "@", Ziel, Anzeigename) - die Dev-Variante wird darunter erzeugt. +_BASE = [ + ("klyqa.lighting.rgb-cw-ww.e27", "e27_rgb", "Klyqa E27 RGB CW/WW"), + ("klyqa.lighting.rgb-cw-ww.e14", "e14_rgb", "Klyqa E14 RGB CW/WW"), + ("klyqa.lighting.rgb-cw-ww.gu10", "gu10_rgb", "Klyqa GU10 RGB CW/WW"), + ("klyqa.lighting.cw-ww.e27", "e27_cw", "Klyqa E27 CW/WW"), + ("klyqa.lighting.cw-ww.e14", "e14_cw", "Klyqa E14 CW/WW"), + ("klyqa.lighting.cw-ww.gu10", "gu10_cw", "Klyqa GU10 CW/WW"), + ("klyqa.lighting.cw-ww.g95", "g95_cw", "Klyqa G95 Vintage CW/WW"), +] + +_UNSAFE = re.compile(r"[^0-9A-Za-z+\-_.]") +# Die Produkt-ID darf kein "_" enthalten - daran trennen wir Version und ID. +_PATH = re.compile(r"^/qcx_firmware_([^/_]+)_([^/_]+)_ota_light\.img$") + + +def websafe(product_id): + """Ersetzt jedes Zeichen ausserhalb [0-9A-Za-z+-_.] durch '-', wie BuildOTAURL() + in qc_ota.c der alten Firmware. Praktisch betrifft das nur das fuehrende '@'.""" + return _UNSAFE.sub("-", product_id) + + +def _build(): + t = {} + for pid, target, name in _BASE: + for suffix, variant in (("", "prod"), ("-dev", "dev")): + key = websafe("@" + pid + suffix) + t[key] = Product(key, target, variant, name) + # Das EVK hat nur eine Produktions-ID. Im Dev-Build meldet es sich laut + # qcl_product_info.c als "@klyqa.lighting.rgb-cw-ww.e27-dev" - die EVK-Zeile ist dort + # auskommentiert und durch die E27 ersetzt. Ein dev-signiertes EVK ist damit von einer + # dev-signierten E27 RGB NICHT unterscheidbar und bekommt deren Image. Das EVK wird + # ohnehin seriell geflasht; die Zeile steht nur der Vollstaendigkeit halber hier. + key = websafe("@klyqa.lighting.w600.evk1.2") + t[key] = Product(key, "evk", "prod", "W600 EVK v1.2") + return t + + +PRODUCTS = _build() + + +def parse_request(path): + """Zerlegt einen angefragten Pfad. Gibt None zurueck, wenn er nicht dem Muster der + alten Firmware entspricht, sonst ParsedRequest - dessen .product ist None, wenn die + Produkt-ID unbekannt ist. Unbekannt heisst 404, nicht raten.""" + m = _PATH.match(path) + if not m: + return None + pid, version = m.group(1), m.group(2) + if pid != websafe(pid) or "/" in pid or ".." in pid.split("."): + return None + return ParsedRequest(pid, version, PRODUCTS.get(pid)) diff --git a/tools/station/tests/test_products.py b/tools/station/tests/test_products.py new file mode 100644 index 0000000..6a41ea3 --- /dev/null +++ b/tools/station/tests/test_products.py @@ -0,0 +1,70 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import products # noqa: E402 + + +def test_table_is_complete(): + """Acht Geraete mal zwei Varianten, minus das EVK, das keine eigene Dev-ID hat.""" + assert len(products.PRODUCTS) == 15 + targets = {p.target for p in products.PRODUCTS.values()} + assert targets == {"evk", "e27_rgb", "e14_rgb", "gu10_rgb", + "e27_cw", "e14_cw", "gu10_cw", "g95_cw"} + + +def test_production_id_maps_to_prod_variant(): + p = products.PRODUCTS["-klyqa.lighting.rgb-cw-ww.gu10"] + assert (p.target, p.variant) == ("gu10_rgb", "prod") + assert p.name == "Klyqa GU10 RGB CW/WW" + + +def test_dev_suffix_selects_dev_variant(): + p = products.PRODUCTS["-klyqa.lighting.cw-ww.g95-dev"] + assert (p.target, p.variant) == ("g95_cw", "dev") + + +def test_parses_real_request_from_g95(): + """Am 2026-09-13 an einer echten G95 protokolliert.""" + r = products.parse_request( + "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1.1.1.1800-dev_ota_light.img") + assert r is not None + assert r.product_id == "-klyqa.lighting.cw-ww.g95-dev" + assert r.lamp_version == "V1.1.1.1800-dev" + assert r.product.target == "g95_cw" + assert r.product.variant == "dev" + + +def test_parses_real_request_from_gu10(): + r = products.parse_request( + "/qcx_firmware_-klyqa.lighting.rgb-cw-ww.gu10_V1.1.0.1138_ota_light.img") + assert r.product.target == "gu10_rgb" + assert r.product.variant == "prod" + assert r.lamp_version == "V1.1.0.1138" + + +def test_unknown_product_id_parses_but_has_no_product(): + r = products.parse_request( + "/qcx_firmware_-acme.lightbulb.xxl_V9.9_ota_light.img") + assert r is not None + assert r.product is None + assert r.product_id == "-acme.lightbulb.xxl" + + +def test_non_matching_paths(): + for p in ("/", "/ninlight_evk_ota.img", "/qcx_firmware_.img", + "/qcx_firmware_-klyqa.lighting.cw-ww.g95_ota_light.img", + "/x/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1_ota_light.img"): + assert products.parse_request(p) is None, p + + +def test_path_traversal_is_not_parseable(): + assert products.parse_request( + "/qcx_firmware_../../etc/passwd_V1_ota_light.img") is None + + +def test_websafe_transform(): + assert products.websafe("@klyqa.lighting.cw-ww.g95") == "-klyqa.lighting.cw-ww.g95" + assert products.websafe("a b/c") == "a-b-c" + assert products.websafe("keep+all-_.09Az") == "keep+all-_.09Az"