fix(station): Signiervariante aus Produkt-ID und Version bestimmen

Eine Entwicklungs-G95 meldete sich am 2026-09-15 mit einer Produkt-ID OHNE
"-dev", aber einer Version MIT "-dev". products.py leitete die Variante
ausschliesslich aus der Produkt-ID ab, die Lampe galt als Produktionsgeraet,
bekam das produktionssigniertes Image und lehnte es ab - ihr Bootloader prueft
gegen den Entwicklungsschluessel.

Die Variante ist damit keine Eigenschaft der Produkt-ID, sondern der Anfrage.
PRODUCTS bildet nur noch auf Geraetetyp und Anzeigename ab; beide Schreibweisen
der Produkt-ID fuehren auf denselben Eintrag. classify_variant() in
parse_request() wertet Produkt-ID UND Version aus: "-dev" an einer der beiden
Stellen bedeutet Entwicklungsgeraet, in der Version nur als ganzes Wort
("-devel"/"-dev2" zaehlen nicht).

Damit eine Fehlklassifikation nicht wieder erst an der Lampe auffaellt, fuehren
status.json (last_seen und jeder recent-Eintrag) und das Panel die Quelle der
Einstufung mit: "g95_cw (dev/Ver)", "dev/ID", "dev/ID+Ver" oder "prod".

Tests: die drei am Geraet mitgeschnittenen Anfragen plus die vierte denkbare
Kombination, die Wortgrenze in der Version, zwei Ende-zu-Ende-Tests im Server
(dev-Image statt prod-Image) und die Panel-Beschriftung.
This commit is contained in:
Michael Sauer
2026-09-15 00:24:29 +02:00
parent 6f0137908c
commit 6325ce073f
6 changed files with 216 additions and 33 deletions
+68 -13
View File
@@ -7,43 +7,98 @@ import products # noqa: E402
def test_table_is_complete():
"""Acht Geraete mal zwei Varianten, minus das EVK, das keine eigene Dev-ID hat."""
"""Sieben Geraete in beiden Schreibweisen der Produkt-ID, plus das EVK, das nur eine
Produktions-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():
def test_table_carries_no_variant():
"""Die Variante ist eine Eigenschaft der Anfrage, nicht der Produkt-ID - sie darf gar
nicht mehr aus der Tabelle zu holen sein (Hardwarebefund 2026-09-15)."""
assert not hasattr(products.PRODUCTS["-klyqa.lighting.cw-ww.g95"], "variant")
assert "variant" not in products.Product._fields
def test_both_spellings_map_to_the_same_device():
with_dev = products.PRODUCTS["-klyqa.lighting.cw-ww.g95-dev"]
without = products.PRODUCTS["-klyqa.lighting.cw-ww.g95"]
assert with_dev.target == without.target == "g95_cw"
assert with_dev.name == without.name == "Klyqa G95 Vintage CW/WW"
def test_production_id_maps_to_device_and_name():
p = products.PRODUCTS["-klyqa.lighting.rgb-cw-ww.gu10"]
assert (p.target, p.variant) == ("gu10_rgb", "prod")
assert p.target == "gu10_rgb"
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."""
def test_parses_real_request_from_g95_dev_in_both_places():
"""Am 2026-09-13 an einer echten Entwicklungs-G95 protokolliert: "-dev" in Produkt-ID
UND Version."""
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"
assert (r.variant, r.variant_source) == ("dev", "both")
def test_parses_real_request_from_gu10():
def test_parses_real_request_from_g95_dev_only_in_version():
"""Am 2026-09-15 an einer echten Entwicklungs-G95 protokolliert - die Anfrage, an der
die Station bisher scheiterte: Produkt-ID OHNE "-dev", Version MIT. Sie muss trotzdem
als Entwicklungsgeraet gelten, sonst geht das produktionssignierte Image raus."""
r = products.parse_request(
"/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1.1.1.2357-dev_ota_light.img")
assert r is not None
assert r.product_id == "-klyqa.lighting.cw-ww.g95"
assert r.lamp_version == "V1.1.1.2357-dev"
assert r.product.target == "g95_cw"
assert (r.variant, r.variant_source) == ("dev", "version")
def test_parses_real_request_from_gu10_without_any_dev():
"""Die erfolgreich migrierte Produktionslampe: nirgends ein "-dev"."""
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.variant, r.variant_source) == ("prod", "none")
assert r.lamp_version == "V1.1.0.1138"
def test_dev_only_in_product_id_is_dev():
"""Die vierte denkbare Kombination - so noch nicht am Geraet gesehen, aber die
Machbarkeitsprobe zeigt, dass die Produkt-ID das "-dev" tragen kann, und nichts
garantiert, dass die Version es dann auch tut."""
r = products.parse_request(
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1.1.1.1800_ota_light.img")
assert r.product.target == "g95_cw"
assert (r.variant, r.variant_source) == ("dev", "product_id")
def test_dev_must_be_a_whole_word_in_the_version():
""""-devel"/"-dev2" sind kein Entwicklungskennzeichen - sonst wuerde eine
Produktionslampe mit so einer Version das dev-signierte Image bekommen."""
for version in ("V1.1.1.2357-devel", "V1.1.1.2357-dev2"):
r = products.parse_request(
"/qcx_firmware_-klyqa.lighting.cw-ww.g95_" + version + "_ota_light.img")
assert (r.variant, r.variant_source) == ("prod", "none"), version
# ... ein "-dev" vor einem Trennzeichen dagegen schon.
r = products.parse_request(
"/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1.1-dev.3_ota_light.img")
assert (r.variant, r.variant_source) == ("dev", "version")
def test_variant_is_classified_even_for_unknown_product_ids():
r = products.parse_request(
"/qcx_firmware_-acme.lightbulb.xxl_V9.9-dev_ota_light.img")
assert r.product is None
assert (r.variant, r.variant_source) == ("dev", "version")
def test_unknown_product_id_parses_but_has_no_product():
r = products.parse_request(
"/qcx_firmware_-acme.lightbulb.xxl_V9.9_ota_light.img")
+20
View File
@@ -33,6 +33,26 @@ def test_migration_shows_detected_lamp():
assert "13:04" in text
def test_migration_shows_where_the_dev_classification_came_from():
"""Der Hardwarebefund vom 2026-09-15: die Station lieferte ein falsch signiertes Image
aus, ohne dass am Panel etwas darauf hindeutete. Steht die Quelle der Einstufung
daneben, sieht der Bediener VOR der Freigabe, worauf sie beruht."""
def variant_line(last_seen):
state = {"mode": "migration", "phase": "listen", "served": 0, "recent": [],
"ssid": "qconnex-firmware", "last_seen": last_seen}
return [l for l in render.lines(state) if l.startswith("g95_cw (")][0]
base = {"product_name": "Klyqa G95 Vintage CW/WW", "target": "g95_cw",
"lamp_version": "V1.1.1.2357-dev"}
assert variant_line(dict(base, variant="dev", variant_source="version")) == "g95_cw (dev/Ver)"
assert variant_line(dict(base, variant="dev", variant_source="product_id")) == "g95_cw (dev/ID)"
assert variant_line(dict(base, variant="dev", variant_source="both")) == "g95_cw (dev/ID+Ver)"
# prod hat keine Quelle - da gibt es nirgends ein "-dev", das man anzeigen koennte.
assert variant_line(dict(base, variant="prod", variant_source="none")) == "g95_cw (prod)"
# Eine aeltere Serverfassung ohne das Feld bleibt darstellbar.
assert variant_line(dict(base, variant="dev")) == "g95_cw (dev)"
def test_recovery_mode_without_traffic():
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery",
"served": 0, "recent": []})
+34
View File
@@ -594,6 +594,40 @@ def test_migration_auto_serves_known_product(migration_server):
assert st["last_seen"]["variant"] == "dev"
def test_migration_dev_only_in_version_serves_the_dev_image(migration_server):
"""Der Hardwarebefund vom 2026-09-15: Produkt-ID OHNE "-dev", Version MIT. Frueher
ging hier das produktionssignierte Image raus (200, und die Lampe lehnte es ab) -
jetzt muss das dev-signierte gehen, und status.json muss die Quelle mitfuehren."""
srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"DEV" * 1000)
(imgdir / "migrate" / "g95_cw_prod.img").write_bytes(b"PROD" * 1000)
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1.1.1.2357-dev_ota_light.img")
assert r.status == 200
assert r.body == b"DEV" * 1000
st = srv.status(lambda s: s.get("last_seen") is not None)
assert st["last_seen"]["target"] == "g95_cw"
assert st["last_seen"]["variant"] == "dev"
assert st["last_seen"]["variant_source"] == "version"
assert st["recent"][-1]["variant"] == "dev"
assert st["recent"][-1]["variant_source"] == "version"
def test_migration_production_lamp_still_gets_the_prod_image(migration_server):
"""Die erfolgreich migrierte GU10: nirgends ein "-dev", also prod - und die Quelle
"none", damit am Panel/in status.json steht, worauf die Einstufung beruht."""
srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True)
(imgdir / "migrate" / "gu10_rgb_dev.img").write_bytes(b"DEV" * 1000)
(imgdir / "migrate" / "gu10_rgb_prod.img").write_bytes(b"PROD" * 1000)
r = srv.get("/qcx_firmware_-klyqa.lighting.rgb-cw-ww.gu10_V1.1.0.1138_ota_light.img")
assert r.status == 200
assert r.body == b"PROD" * 1000
st = srv.status(lambda s: s.get("last_seen") is not None)
assert st["last_seen"]["variant"] == "prod"
assert st["last_seen"]["variant_source"] == "none"
def test_migration_listen_records_but_serves_nothing(migration_server):
srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True)