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
+14 -7
View File
@@ -259,7 +259,8 @@ class Station:
def record_migration(self, ip: str, target: str | None, variant: str | None,
product_id: str, lamp_version: str, product_name: str | None,
version: str, nbytes: int, status: str) -> None:
version: str, nbytes: int, status: str,
variant_source: str | None = None) -> None:
"""Protokolliert eine Migrationsanfrage. Anders als record() oben wird hier auch
dann ein Eintrag angelegt (Status "listen"/"unknown"/"notfound"), wenn nichts
ausgeliefert wurde - das ist der ganze Zweck der Horchphase. last_seen wird nur bei
@@ -269,13 +270,15 @@ class Station:
if status == "ok":
self.served += 1
entry = {"ts": now_iso(), "ip": ip, "target": target, "variant": variant,
"variant_source": variant_source,
"product_id": product_id, "lamp_version": lamp_version,
"version": version, "bytes": nbytes, "status": status}
self.recent.append(entry)
del self.recent[:-RECENT_MAX]
if target is not None:
self.last_seen = {"product_id": product_id, "target": target,
"variant": variant, "product_name": product_name,
"variant": variant, "variant_source": variant_source,
"product_name": product_name,
"lamp_version": lamp_version, "ip": ip, "ts": entry["ts"]}
self.write_state()
@@ -395,7 +398,7 @@ class Handler(BaseHTTPRequestHandler):
self.reject(404, "not found")
self.log(404, 0, started)
return
pid, lamp_version, product = parsed
pid, lamp_version, product, variant, variant_source = parsed
ip = self.client_address[0]
if product is None:
self.station.record_migration(ip, None, None, pid, lamp_version, None, "?", 0,
@@ -404,7 +407,11 @@ class Handler(BaseHTTPRequestHandler):
self.log(404, 0, started)
return
target, variant, name = product.target, product.variant, product.name
# variant/variant_source kommen aus parse_request(), nicht mehr aus der
# Produkttabelle: das "-dev" kann in der Produkt-ID stehen, in der Version, oder in
# beiden (Hardwarebefund 2026-09-15, siehe products.py). Das entscheidet, welches
# der beiden unterschiedlich signierten Images ausgeliefert wird.
target, name = product.target, product.name
version = self.station.image_version(target, variant)
# claim_delivery() liest die Phase UND verbraucht eine armed-Freigabe atomar unter
# station.lock (Fixrunde 1, C1) - zwischen dem Lesen und dem Verbrauchen darf keine
@@ -415,7 +422,7 @@ class Handler(BaseHTTPRequestHandler):
# last_seen wird trotzdem gesetzt - das ist der ganze Zweck der Horchphase:
# die Anzeige soll zeigen, welches Modell da ist, auch wenn nichts rausgeht.
self.station.record_migration(ip, target, variant, pid, lamp_version, name,
version, 0, "listen")
version, 0, "listen", variant_source)
self.reject(404, "listening only")
self.log(404, 0, started)
return
@@ -427,7 +434,7 @@ class Handler(BaseHTTPRequestHandler):
# dass es gar kein Image gibt - das darf nicht auf Kosten der Freigabe gehen.
self.station.restore_armed(target)
self.station.record_migration(ip, target, variant, pid, lamp_version, name,
version, 0, "notfound")
version, 0, "notfound", variant_source)
self.reject(404, "no image for this device")
self.log(404, 0, started)
return
@@ -440,7 +447,7 @@ class Handler(BaseHTTPRequestHandler):
# (das ist der Punkt von C1) - bei einem Abbruch wird das hier zurueckgenommen.
self.station.restore_armed(target)
self.station.record_migration(ip, target, variant, pid, lamp_version, name, version,
sent, status)
sent, status, variant_source)
def _serve_file(self, path: Path, started: float) -> tuple[int, str]:
"""Sendet `path` mit Range-Unterstuetzung; liefert (gesendete Bytes, Status).