Compare commits
@@ -94,19 +94,25 @@ Update, oder scheitert der Start aus einem anderen Grund, antwortet sie mit `409
|
||||
(gleicher Wert wie `last_ota_error`). Läuft der Trailer- bzw. Hash-Check durch, resettet die
|
||||
Lampe und bootet mit der neuen Version. Wird das Image abgelehnt, gibt es **keinen** Reset — die Lampe läuft
|
||||
mit der alten Firmware weiter und trägt den Grund in `last_ota_error` (`GET system/info`) ein. Ein
|
||||
Verbindungsabbruch mitten im Download löst dagegen einen Wiederaufnahmeversuch per HTTP-Range aus (begrenzte
|
||||
Anzahl Runden); erst wenn auch das ausgeht, landet der Versuch bei `last_ota_error = download`.
|
||||
Verbindungsabbruch mitten im Download endet bei `last_ota_error = download`. Die Firmware kann einen
|
||||
sauber abgerissenen Transfer per HTTP-Range wieder aufnehmen (auf eine feste Rundenzahl begrenzt), aber
|
||||
darauf sollte man sich nicht verlassen: Stirbt die Gegenstelle mitten im Download, hängt der Lesevorgang
|
||||
am EVK gemessen rund **sieben Minuten** in einem einzigen TCP-Timeout — das `RECV_TIMEOUT` von 5 s des
|
||||
HTTP-Clients greift dort nicht — und bricht danach ohne Wiederaufnahme ab. Die Lampe bleibt in dieser Zeit
|
||||
über REST erreichbar und lässt sich weiter schalten, weist aber jeden weiteren `fw_update` mit `409` ab.
|
||||
|
||||
Werte von `last_ota_error` (`null`, solange nichts fehlgeschlagen ist):
|
||||
|
||||
| Wert | Bedeutung |
|
||||
|---|---|
|
||||
| `download` | Verbindung/Transfer gescheitert (auch nach ausgeschöpftem Wiederaufnahmeversuch) |
|
||||
| `download` | Verbindung/Transfer gescheitert |
|
||||
| `crc` | Prüfsumme des SDK-eigenen Update-Mechanismus stimmt nicht |
|
||||
| `trailer` | Trailer fehlt, ist zu kurz oder hat eine falsche Magic/Version |
|
||||
| `device_mismatch` | Trailer nennt einen anderen Gerätetyp |
|
||||
| `hash` | SHA256 im Trailer stimmt nicht mit dem übertragenen Image überein |
|
||||
| `busy` | es läuft bereits ein Update |
|
||||
| `busy` | die Firmware konnte den Download-Task nicht anlegen (Speichermangel) |
|
||||
|
||||
Ein `fw_update`, der abgewiesen wird, weil bereits ein Update läuft, beantwortet die API mit `409` und lässt `last_ota_error` unangetastet — dort steht weiterhin das Ergebnis des laufenden bzw. letzten Updates.
|
||||
|
||||
Ein erneuter `fw_update`-Aufruf mit einem gültigen Image funktioniert nach einer Ablehnung sofort, ohne
|
||||
manuellen Reboot.
|
||||
|
||||
+23
-14
@@ -39,22 +39,22 @@ Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.s
|
||||
- [x] C2: Onboarding ok, 60 s warten, dann Reboot und Router-Neustart → AP `ninlight-<mac4>` darf nicht wiederkommen (Scan am Mac) — 2026-09-12 (Reboot geprüft, Router-Neustart offen)
|
||||
|
||||
## OTA und Recovery (Plan 3)
|
||||
- [ ] OTA: Update über `fw_update` läuft durch, danach meldet `system/info` die neue Version
|
||||
- [ ] OTA: Image eines anderen Gerätetyps wird sofort abgelehnt (`last_ota_error` = `device_mismatch`), kein Reset, Lampe läuft mit der alten Firmware weiter
|
||||
- [ ] OTA: Image mit verfälschtem Byte wird abgelehnt (`last_ota_error` = `hash` oder `crc`), kein Reset
|
||||
- [ ] OTA: HTTP-Server während des Downloads abschalten → Wiederaufnahmeversuch (Range-Resume) sichtbar in der Konsole, nach Ausschöpfen der Runden `last_ota_error` = `download`, Lampe bootet normal
|
||||
- [ ] OTA: kurzer WLAN-Aussetzer während des Downloads (Verbindung kurz kappen und wiederherstellen) → Download wird fortgesetzt, kein sofortiger `download`-Fehler
|
||||
- [ ] Recovery: Lampe zieht beim Booten das Image von der Station `ninlight-recovery`, Konsole zeigt Scan-, Join- und Fetch-Meldungen
|
||||
- [ ] Recovery: ohne Station verlängert sich der Boot um höchstens ~3 s, Konsole loggt das Scan-Ergebnis (gefunden/nichts gefunden/Scan fehlgeschlagen)
|
||||
- [x] OTA: Update über `fw_update` läuft durch, danach meldet `system/info` die neue Version — 2026-09-12 (0.3.0 → 0.3.1 → 0.3.2)
|
||||
- [x] OTA: Image eines anderen Gerätetyps wird sofort abgelehnt (`last_ota_error` = `device_mismatch`), kein Reset, Lampe läuft mit der alten Firmware weiter — 2026-09-12 (Ablehnung nach ~4,5 s, Uptime lief durch)
|
||||
- [x] OTA: Image mit verfälschtem Byte wird abgelehnt (`last_ota_error` = `hash` oder `crc`), kein Reset — 2026-09-12 (`crc`, kein Reset)
|
||||
- [x] OTA: HTTP-Server während des Downloads abschalten → Lampe bleibt über REST erreichbar und schaltbar, weist weitere `fw_update` mit `409` ab, und beendet den Versuch mit `last_ota_error = download` — 2026-09-12 (dauerte ~7 min, kein Range-Resume; siehe README)
|
||||
- [ ] OTA: kurzer WLAN-Aussetzer während des Downloads (Verbindung kurz kappen und wiederherstellen) → Download wird fortgesetzt, kein sofortiger `download`-Fehler — *offen, WLAN am Teststandort nicht abschaltbar*
|
||||
- [x] Recovery: Lampe zieht beim Booten das Image von der Station `ninlight-recovery`, Konsole zeigt Scan-, Join- und Fetch-Meldungen — 2026-09-12 (Pi Zero 2W, NetworkManager-AP; Scan → join → fetch → flash → Reboot)
|
||||
- [x] Recovery: ohne Station verlängert sich der Boot um höchstens ~3 s, Konsole loggt das Scan-Ergebnis (gefunden/nichts gefunden/Scan fehlgeschlagen) — 2026-09-12 (`scan done after 1500 ms, 7 AP(s)`, danach normaler WLAN-Start)
|
||||
- [ ] Recovery: Gegenstelle bleibt verbunden, sendet aber keine Daten mehr (nicht: ein langsamer, aber laufender Download) → nach Ablauf der Zeitobergrenze bedient die Firmware den Watchdog nicht mehr, Chip startet neu statt hängen zu bleiben
|
||||
- [ ] Einstellungen bleiben über ein OTA hinweg erhalten (WLAN, Name, Fade-Zeiten)
|
||||
- [ ] Zweiter `fw_update` mit gültigem Image direkt nach einer Ablehnung, ohne zwischenzeitlichen Reboot → läuft durch, `system/info` meldet danach die neue Version
|
||||
- [ ] Lampe bleibt während und nach einer Ablehnung über REST erreichbar: `system/info` und `device/state` antworten beide weiterhin mit 200
|
||||
- [ ] Lichtverhalten über ein OTA hinweg (Spec §8.2, ein Update ist kein Stromausfall): einmal mit `power_on_behaviour = off`, einmal `= on` — die Lampe ist nach dem Update jeweils in demselben Zustand (an/aus, Farbe/Helligkeit), in dem sie vor dem Update war
|
||||
- [ ] `system/info.fw_version` nach einem OTA stimmt mit der Version im Trailer des geflashten Images überein (Trailer-Version z.B. mit `tools/mkimage` prüfen)
|
||||
- [x] Einstellungen bleiben über ein OTA hinweg erhalten (WLAN, Name, Fade-Zeiten) — 2026-09-12 (Name, Fade 1200/800, pob, Lichtzustand)
|
||||
- [x] Zweiter `fw_update` mit gültigem Image direkt nach einer Ablehnung, ohne zwischenzeitlichen Reboot → läuft durch, `system/info` meldet danach die neue Version — 2026-09-12 (erst nach dem SHA256-Kontext-Fix)
|
||||
- [x] Lampe bleibt während und nach einer Ablehnung über REST erreichbar: `system/info` und `device/state` antworten beide weiterhin mit 200 — 2026-09-12 (system/info und device/state je 200)
|
||||
- [x] Lichtverhalten über ein OTA hinweg (Spec §8.2, ein Update ist kein Stromausfall): einmal mit `power_on_behaviour = off`, einmal `= on` — die Lampe ist nach dem Update jeweils in demselben Zustand (an/aus, Farbe/Helligkeit), in dem sie vor dem Update war — 2026-09-12 (pob=off + Licht an → blieb an; pob=on + Licht aus → blieb aus)
|
||||
- [x] `system/info.fw_version` nach einem OTA stimmt mit der Version im Trailer des geflashten Images überein (Trailer-Version z.B. mit `tools/mkimage` prüfen) — 2026-09-12
|
||||
- [ ] Recovery im echten Rettungsfall: Lampe ohne gültige WLAN-Konfiguration bzw. im Onboarding-AP findet die Station, zieht das Image und startet mit der neuen Firmware
|
||||
- [ ] Rettungsstation nach erfolgreicher Recovery angelassen lassen, Lampe neu booten lassen → kein erneutes Flashen (Version stimmt bereits überein) und kein Werksreset (Power-Cycle-Zähler wurde vor dem Recovery-Versuch gelöscht)
|
||||
- [ ] OTA im AP-/Fallback-Modus (`mode=apsta`, kein Router erreichbar) und mit `auth_all = true` → Update läuft durch wie im normalen `sta`-Modus
|
||||
- [x] Rettungsstation nach erfolgreicher Recovery angelassen lassen, Lampe neu booten lassen → kein erneutes Flashen (Version stimmt bereits überein) und kein Werksreset (Power-Cycle-Zähler wurde vor dem Recovery-Versuch gelöscht) — 2026-09-12 (zweiter Boot: `image already carries the running version, not flashing`, kein Werksreset, beide Boots melden `boot #1`)
|
||||
- [ ] OTA im **AP-/Fallback-Modus** (`mode=apsta`, kein Router erreichbar) → Update läuft durch wie im normalen `sta`-Modus — *offen, braucht einen abschaltbaren Access Point*. Mit `auth_all = true` am 2026-09-12 geprüft: Update läuft durch, `device/*` antwortet ohne Token korrekt mit 401 (`system/info` bleibt laut Spec §7.2 immer offen)
|
||||
|
||||
Für den echten Flottenbau `NINLIGHT_RECOVERY_PASSWORD` und `NINLIGHT_TOKEN` in `config.local.cmake` setzen — die Defaults sind nur fürs EVK gedacht.
|
||||
|
||||
@@ -64,3 +64,12 @@ Für den echten Flottenbau `NINLIGHT_RECOVERY_PASSWORD` und `NINLIGHT_TOKEN` in
|
||||
- [ ] E27 CW: Gegenphase CW sichtbar flackerfrei
|
||||
- [ ] G95: Pinbelegung WW=PB15/CW=PB13 prüfen
|
||||
- [ ] Farbtemperaturbereiche aller Varianten
|
||||
|
||||
## Offene Beobachtung aus der EVK-Session vom 2026-09-12
|
||||
|
||||
Beim Test mit dem verfälschten Image meldete lwIP einmalig
|
||||
`Assertion "pbuf_free: p->ref > 0" failed at line 747 in pbuf.c` (10:29:23). Die Lampe lief
|
||||
unbeeinträchtigt weiter und meldete korrekt `last_ota_error=crc`. Bei den Läufen mit fremdem
|
||||
Gerätetyp und bei den erfolgreichen Updates trat die Meldung nicht auf. Ursache noch nicht
|
||||
geklärt — in einem Build ohne Assertions wäre das ein doppeltes Freigeben eines Netzwerkpuffers.
|
||||
Vor dem Einsatz auf echten Lampen reproduzieren und aufklären.
|
||||
|
||||
+3
-3
@@ -339,9 +339,9 @@ static void handle_command(const nl_api_ctx_t *ctx, const nl_http_request_t *req
|
||||
else if (ctx->fw_update(url) != 0) {
|
||||
/* fw_update() itself can genuinely fail now (busy, or a rejected URL) - 409 with the
|
||||
* reason, not a blanket "not implemented". */
|
||||
nl_sys_info_t in;
|
||||
ctx->get_info(&in);
|
||||
reply_error(resp, 409, in.last_ota_error ? in.last_ota_error : "fw_update busy or invalid url");
|
||||
/* Do not reuse last_ota_error for this message: it belongs to the running or last
|
||||
* update, not to the request we are rejecting right now. */
|
||||
reply_error(resp, 409, "an update is already running");
|
||||
} else
|
||||
reply(resp, 202, "{\"status\":\"updating\"}");
|
||||
} else if (strcmp(type, "reboot") == 0) {
|
||||
|
||||
@@ -162,8 +162,14 @@ void nl_recovery_try(void)
|
||||
tls_os_time_delay(HZ / 2);
|
||||
waited += 500;
|
||||
}
|
||||
if (feeding)
|
||||
printf("recovery: update failed (%s), continuing normal boot\r\n",
|
||||
nl_ota_last_error() ? nl_ota_last_error() : "unknown");
|
||||
if (feeding) {
|
||||
/* A skipped image (same version as the running firmware) is not a failure - saying so
|
||||
would leave a healthy lamp looking like it had rejected a broken image. */
|
||||
if (nl_ota_skipped())
|
||||
printf("recovery: image already up to date, continuing normal boot\r\n");
|
||||
else
|
||||
printf("recovery: update failed (%s), continuing normal boot\r\n",
|
||||
nl_ota_last_error() ? nl_ota_last_error() : "unknown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-3
@@ -23,6 +23,7 @@ static char s_url[NL_OTA_URL_MAX];
|
||||
static volatile bool s_busy;
|
||||
static volatile bool s_reject_running_version; /* recovery path only, see nl_ota_start_recovery() */
|
||||
static const char * volatile s_error;
|
||||
static volatile bool s_skipped;
|
||||
static u32 s_stack[NL_OTA_STACK];
|
||||
static char s_tail_version[NL_TRAILER_VERSION_LEN]; /* used by the hook, kept off its 2 KB stack */
|
||||
|
||||
@@ -33,6 +34,7 @@ void nl_ota_init(void)
|
||||
}
|
||||
|
||||
bool nl_ota_busy(void) { return s_busy; }
|
||||
bool nl_ota_skipped(void) { return s_skipped; }
|
||||
const char *nl_ota_last_error(void) { return s_error; }
|
||||
void nl_ota_set_error(const char *e) { s_error = e; }
|
||||
|
||||
@@ -61,7 +63,10 @@ bool nl_fwup_verify_tail(const uint8_t sha256[32], const uint8_t *tail, uint32_t
|
||||
if (strcmp(s_tail_version, NINLIGHT_FW_VERSION) == 0) {
|
||||
printf("ota: recovery image already carries the running version %s, not flashing\r\n",
|
||||
s_tail_version);
|
||||
s_error = "trailer";
|
||||
/* Deliberately skipped, not failed: leave last_ota_error alone. Reporting "trailer"
|
||||
* here used to make a perfectly healthy lamp look like it had rejected a malformed
|
||||
* image, which is exactly the wrong thing to read off a lamp in the field. */
|
||||
s_skipped = true;
|
||||
return false;
|
||||
}
|
||||
printf("ota: recovery image version %s (running %s)\r\n", s_tail_version, NINLIGHT_FW_VERSION);
|
||||
@@ -90,7 +95,7 @@ static void ota_task(void *arg)
|
||||
log_url(s_url);
|
||||
rc = t_http_fwup(s_url);
|
||||
/* Only reached on failure - the SDK resets the chip itself after a good image. */
|
||||
if (s_error == NULL)
|
||||
if (s_error == NULL && !s_skipped)
|
||||
s_error = "download";
|
||||
printf("ota: failed (rc=%d, %s)\r\n", rc, s_error);
|
||||
{
|
||||
@@ -115,11 +120,17 @@ static int ota_start(const char *url, bool reject_running_version)
|
||||
if (n <= 7 || n >= sizeof s_url) /* n == 7 is bare "http://" with no host */
|
||||
return -1;
|
||||
if (s_busy) {
|
||||
s_error = "busy";
|
||||
/* Do NOT touch s_error here. last_ota_error describes the outcome of the *running or last*
|
||||
* update; a request we reject because one is already in flight has no outcome of its own.
|
||||
* Writing "busy" into it used to poison the running attempt's slot: ota_task() only falls
|
||||
* back to "download" when s_error is still NULL, so a failed transfer was afterwards
|
||||
* reported as `busy` instead of `download`. Found on the EVK, 2026-09-12. The REST layer
|
||||
* reports the rejection to its own caller with 409. */
|
||||
return -1;
|
||||
}
|
||||
s_busy = true;
|
||||
s_error = NULL;
|
||||
s_skipped = false;
|
||||
s_reject_running_version = reject_running_version;
|
||||
memcpy(s_url, url, n + 1);
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ int nl_ota_start(const char *url);
|
||||
* forever. A deliberate re-flash of the same version stays possible over the REST API. */
|
||||
int nl_ota_start_recovery(const char *url);
|
||||
bool nl_ota_busy(void);
|
||||
/* True when the last recovery attempt deliberately skipped an image whose version already
|
||||
matches the running firmware - that is not a failure and sets no last_ota_error. */
|
||||
bool nl_ota_skipped(void);
|
||||
/* NULL, or one of: download, crc, trailer, device_mismatch, hash, busy */
|
||||
const char *nl_ota_last_error(void);
|
||||
void nl_ota_set_error(const char *e);
|
||||
|
||||
+1
-1
@@ -324,7 +324,7 @@ static void test_commands(void)
|
||||
|
||||
call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"fw_update\",\"url\":\"http://x/y.img\"}}");
|
||||
TEST_ASSERT_EQUAL_INT(409, resp.status);
|
||||
TEST_ASSERT_EQUAL_STRING("fw_update busy or invalid url", error_text());
|
||||
TEST_ASSERT_EQUAL_STRING("an update is already running", error_text());
|
||||
TEST_ASSERT_EQUAL_STRING("http://x/y.img", fake_fw_url);
|
||||
fake_fw_result = 0;
|
||||
call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"fw_update\",\"url\":\"http://x/y.img\"}}");
|
||||
|
||||
@@ -4,7 +4,19 @@
|
||||
Layout (512 bytes): "NINL" | version(1) | device[16] | fw_version[16] | sha256[32] | 0xFF padding.
|
||||
The hash covers the complete `_gz.img` (T_BOOTER header + payload), which is exactly what the
|
||||
firmware hashes while writing the image to flash.
|
||||
|
||||
Must run under Python 3.8.10: CMake invokes this script via plain `python3` inside the
|
||||
x86_64 build container (see docker/Dockerfile, cmake/run_mkimage.cmake) on every firmware
|
||||
build, and that container has not been upgraded past 3.8. This is independent of the
|
||||
"Python >= 3.11" requirement elsewhere in this repo, which applies only to the tooling that
|
||||
runs on the Mac and the Pi (tools/station, tools/mkimage's own pytest suite), never to this
|
||||
file. Keep `from __future__ import annotations` below so that PEP 604 unions (`str | None`)
|
||||
in annotations stay text and are never evaluated - without it, that syntax raises
|
||||
`TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'` on 3.8/3.9. See
|
||||
test_nl_mkimage.py::test_stays_python38_compatible, which fails the build if this drifts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import sys
|
||||
@@ -19,18 +31,75 @@ def field(value: str, name: str) -> bytes:
|
||||
raw = value.encode()
|
||||
if len(raw) >= FIELD:
|
||||
sys.exit(f"error: {name} '{value}' is too long (max {FIELD - 1} bytes)")
|
||||
if any(c.isspace() for c in value):
|
||||
sys.exit(f"error: {name} '{value}' contains whitespace")
|
||||
return raw.ljust(FIELD, b"\x00")
|
||||
|
||||
|
||||
def verify(path: Path, expect_device: str | None) -> int:
|
||||
"""Checks an _ota.img the way the firmware does before accepting it."""
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
if len(data) <= TRAILER_SIZE:
|
||||
print(f"error: {path} is too short to carry a trailer", file=sys.stderr)
|
||||
return 1
|
||||
payload, trailer = data[:-TRAILER_SIZE], data[-TRAILER_SIZE:]
|
||||
if trailer[0:4] != b"NINL":
|
||||
print(f"error: {path} has no ninlight trailer", file=sys.stderr)
|
||||
return 1
|
||||
if trailer[4] != TRAILER_VERSION:
|
||||
print(f"error: {path} has trailer version {trailer[4]}, expected {TRAILER_VERSION}",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
# Device field must be null-terminated within its 16 bytes (same as firmware check)
|
||||
device_field = trailer[5:21]
|
||||
if b"\x00" not in device_field:
|
||||
print(f"error: {path} has unterminated device field", file=sys.stderr)
|
||||
return 1
|
||||
device = device_field.rstrip(b"\x00").decode("ascii", "replace")
|
||||
version = trailer[21:37].rstrip(b"\x00").decode("ascii", "replace")
|
||||
if expect_device is not None and device != expect_device:
|
||||
print(f"error: {path} carries device '{device}', expected '{expect_device}'",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
if trailer[37:69] != hashlib.sha256(payload).digest():
|
||||
print(f"error: {path} fails its sha256 - the payload does not match the trailer",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
print(f"{device} {version} {len(data)}")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description="append the ninlight OTA trailer")
|
||||
p.add_argument("--image", required=True, type=Path, help="input _gz.img")
|
||||
p.add_argument("--device", required=True, help="device id, e.g. e27_rgb")
|
||||
p.add_argument("--version", required=True, help="firmware version")
|
||||
p.add_argument("--out", required=True, type=Path, help="output _ota.img")
|
||||
p.add_argument("--image", type=Path, help="input _gz.img")
|
||||
p.add_argument("--device", help="device id, e.g. e27_rgb")
|
||||
p.add_argument("--version", help="firmware version")
|
||||
p.add_argument("--out", type=Path, help="output _ota.img")
|
||||
p.add_argument("--verify", type=Path, help="check an existing _ota.img instead of building one")
|
||||
p.add_argument("--expect-device", help="with --verify: require this device id")
|
||||
a = p.parse_args()
|
||||
|
||||
data = a.image.read_bytes()
|
||||
if a.verify is not None:
|
||||
# Verify mode: check that build arguments are not provided
|
||||
build_args = ("image", "device", "version", "out")
|
||||
if any(getattr(a, name) is not None for name in build_args):
|
||||
p.error("--verify cannot be combined with build mode arguments (--image, --device, --version, --out)")
|
||||
raise SystemExit(verify(a.verify, a.expect_device))
|
||||
|
||||
# Build mode: require all arguments
|
||||
for name in ("image", "device", "version", "out"):
|
||||
if getattr(a, name) is None:
|
||||
p.error(f"--{name} is required unless --verify is given")
|
||||
|
||||
try:
|
||||
data = a.image.read_bytes()
|
||||
except OSError as e:
|
||||
sys.exit(f"error: {e}")
|
||||
|
||||
if len(data) > TRAILER_SIZE and data[-TRAILER_SIZE : -TRAILER_SIZE + 4] == b"NINL":
|
||||
sys.exit("error: image already carries a trailer")
|
||||
|
||||
|
||||
@@ -4,4 +4,4 @@ version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=8"]
|
||||
dev = ["pytest>=8", "vermin>=1.6"]
|
||||
|
||||
@@ -53,3 +53,226 @@ def test_refuses_image_that_already_has_a_trailer(tmp_path):
|
||||
)
|
||||
assert r.returncode != 0
|
||||
assert "trailer" in r.stderr
|
||||
|
||||
|
||||
def verify(path, *extra):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--verify", str(path), *extra],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_verify_accepts_a_good_image(tmp_path):
|
||||
_, out = build(tmp_path, device="e27_rgb", version="1.2.3")
|
||||
r = verify(out)
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert r.stdout.split()[:2] == ["e27_rgb", "1.2.3"]
|
||||
|
||||
|
||||
def test_verify_rejects_a_file_without_trailer(tmp_path):
|
||||
p = tmp_path / "plain.img"
|
||||
p.write_bytes(b"x" * 5000)
|
||||
r = verify(p)
|
||||
assert r.returncode == 1
|
||||
assert "trailer" in r.stderr.lower()
|
||||
|
||||
|
||||
def test_verify_rejects_a_corrupted_payload(tmp_path):
|
||||
_, out = build(tmp_path)
|
||||
d = bytearray(out.read_bytes())
|
||||
d[100] ^= 0x01
|
||||
out.write_bytes(d)
|
||||
r = verify(out)
|
||||
assert r.returncode == 1
|
||||
assert "sha256" in r.stderr.lower()
|
||||
|
||||
|
||||
def test_verify_checks_the_expected_device(tmp_path):
|
||||
_, out = build(tmp_path, device="evk")
|
||||
assert verify(out, "--expect-device", "evk").returncode == 0
|
||||
r = verify(out, "--expect-device", "g95_cw")
|
||||
assert r.returncode == 1
|
||||
assert "evk" in r.stderr and "g95_cw" in r.stderr
|
||||
|
||||
|
||||
def test_verify_rejects_a_file_shorter_than_the_trailer(tmp_path):
|
||||
p = tmp_path / "tiny.img"
|
||||
p.write_bytes(b"NINL" + b"\x00" * 10)
|
||||
r = verify(p)
|
||||
assert r.returncode == 1
|
||||
|
||||
|
||||
def test_verify_rejects_nonexistent_file(tmp_path):
|
||||
p = tmp_path / "does_not_exist.img"
|
||||
r = verify(p)
|
||||
assert r.returncode == 1
|
||||
assert r.stdout == ""
|
||||
assert len(r.stderr.splitlines()) == 1
|
||||
assert "error:" in r.stderr.lower()
|
||||
|
||||
|
||||
def test_verify_rejects_directory(tmp_path):
|
||||
d = tmp_path / "dir"
|
||||
d.mkdir()
|
||||
r = verify(d)
|
||||
assert r.returncode == 1
|
||||
assert r.stdout == ""
|
||||
assert len(r.stderr.splitlines()) == 1
|
||||
assert "error:" in r.stderr.lower()
|
||||
|
||||
|
||||
def test_verify_rejects_empty_file(tmp_path):
|
||||
p = tmp_path / "empty.img"
|
||||
p.write_bytes(b"")
|
||||
r = verify(p)
|
||||
assert r.returncode == 1
|
||||
assert "trailer" in r.stderr.lower()
|
||||
|
||||
|
||||
def test_verify_rejects_file_with_only_trailer_no_payload(tmp_path):
|
||||
p = tmp_path / "only_trailer.img"
|
||||
trailer = bytearray(b"\xff" * 512)
|
||||
trailer[0:4] = b"NINL"
|
||||
trailer[4] = 1
|
||||
trailer[5:21] = b"test\x00" + b"\x00" * 11
|
||||
trailer[21:37] = b"1.0.0\x00" + b"\x00" * 10
|
||||
trailer[37:69] = hashlib.sha256(b"").digest()
|
||||
p.write_bytes(bytes(trailer))
|
||||
r = verify(p)
|
||||
assert r.returncode == 1
|
||||
assert "trailer" in r.stderr.lower()
|
||||
|
||||
|
||||
def test_build_rejects_device_with_whitespace(tmp_path):
|
||||
src = tmp_path / "fw_gz.img"
|
||||
src.write_bytes(b"x")
|
||||
r = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--image", str(src), "--device", "dev ice",
|
||||
"--version", "1.0", "--out", str(tmp_path / "o.img")],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert r.returncode != 0
|
||||
assert "whitespace" in r.stderr
|
||||
|
||||
|
||||
def test_build_rejects_version_with_whitespace(tmp_path):
|
||||
src = tmp_path / "fw_gz.img"
|
||||
src.write_bytes(b"x")
|
||||
r = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--image", str(src), "--device", "device",
|
||||
"--version", "1.0 beta", "--out", str(tmp_path / "o.img")],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert r.returncode != 0
|
||||
assert "whitespace" in r.stderr
|
||||
|
||||
|
||||
def test_verify_rejects_unterminated_device_field(tmp_path):
|
||||
p = tmp_path / "bad_device.img"
|
||||
payload = b"x" * 1000
|
||||
trailer = bytearray(b"\xff" * 512)
|
||||
trailer[0:4] = b"NINL"
|
||||
trailer[4] = 1
|
||||
# Fill entire device field with no null terminator
|
||||
trailer[5:21] = b"a" * 16
|
||||
trailer[21:37] = b"1.0.0\x00" + b"\x00" * 10
|
||||
trailer[37:69] = hashlib.sha256(payload).digest()
|
||||
p.write_bytes(payload + bytes(trailer))
|
||||
r = verify(p)
|
||||
assert r.returncode == 1
|
||||
assert "unterminated" in r.stderr.lower()
|
||||
|
||||
|
||||
def test_verify_rejects_unterminated_device_field_without_expect_device(tmp_path):
|
||||
# Same as above but without --expect-device to ensure the check happens regardless
|
||||
p = tmp_path / "bad_device2.img"
|
||||
payload = b"x" * 1000
|
||||
trailer = bytearray(b"\xff" * 512)
|
||||
trailer[0:4] = b"NINL"
|
||||
trailer[4] = 1
|
||||
# Fill entire device field with no null terminator
|
||||
trailer[5:21] = b"b" * 16
|
||||
trailer[21:37] = b"2.0.0\x00" + b"\x00" * 10
|
||||
trailer[37:69] = hashlib.sha256(payload).digest()
|
||||
p.write_bytes(payload + bytes(trailer))
|
||||
r = verify(p)
|
||||
assert r.returncode == 1
|
||||
assert "unterminated" in r.stderr.lower()
|
||||
|
||||
|
||||
def test_verify_with_build_arguments_is_an_error(tmp_path):
|
||||
_, out = build(tmp_path)
|
||||
r = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--verify", str(out), "--device", "x"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert r.returncode != 0
|
||||
assert "cannot be combined" in r.stderr
|
||||
|
||||
|
||||
# --- Python 3.8 compatibility -------------------------------------------------------------
|
||||
#
|
||||
# CMake calls this script via plain `python3` inside the build container on every firmware
|
||||
# build (see docker/Dockerfile, cmake/run_mkimage.cmake), and that container is stuck on
|
||||
# Python 3.8.10. This is the opposite constraint from the rest of the repo's tooling (which
|
||||
# needs Python >= 3.11) - see the module docstring in nl_mkimage.py for the full story, and
|
||||
# git history around the "unsupported operand type(s) for |" break for what happens when it
|
||||
# regresses.
|
||||
#
|
||||
# Two checks, because neither alone covers the failure mode we actually hit:
|
||||
# 1. `from __future__ import annotations` must be the first statement after the module
|
||||
# docstring. As long as it's there, PEP 604 unions in annotations (`str | None`) are
|
||||
# never evaluated at runtime, so they can't raise on 3.8/3.9 no matter how many more of
|
||||
# them get added later.
|
||||
# 2. vermin's *default* analysis (annotations not evaluated - matching point 1) catches
|
||||
# everything else 3.9+/3.10+/3.11+ that could sneak in: walrus in odd positions, match
|
||||
# statements, dict `|=`, new stdlib members, etc. We deliberately do NOT enable vermin's
|
||||
# `union-types` feature: it flags `str | None` as needing 3.10 unconditionally, without
|
||||
# accounting for `from __future__ import annotations` deferring evaluation - which would
|
||||
# make this test fail on the very fix that makes the file 3.8-safe. Check 1 above is
|
||||
# what actually guards against that pattern.
|
||||
|
||||
|
||||
def test_future_annotations_import_is_present():
|
||||
"""Guards the PEP 604 union pattern that broke the Docker build (see module docstring)."""
|
||||
import ast
|
||||
|
||||
tree = ast.parse(SCRIPT.read_text())
|
||||
body = tree.body
|
||||
# Skip a leading module docstring, if any.
|
||||
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \
|
||||
and isinstance(body[0].value.value, str):
|
||||
body = body[1:]
|
||||
assert body, "nl_mkimage.py is unexpectedly empty"
|
||||
first = body[0]
|
||||
assert isinstance(first, ast.ImportFrom) and first.module == "__future__" \
|
||||
and any(alias.name == "annotations" for alias in first.names), (
|
||||
"nl_mkimage.py must start with `from __future__ import annotations` right after its "
|
||||
"module docstring - without it, PEP 604 unions like `str | None` in annotations are "
|
||||
"evaluated eagerly and crash under the Python 3.8.10 in the Docker build container "
|
||||
"(TypeError: unsupported operand type(s) for |: 'type' and 'NoneType')"
|
||||
)
|
||||
|
||||
|
||||
def test_stays_python38_compatible():
|
||||
"""Static check that nl_mkimage.py needs nothing newer than Python 3.8 to run.
|
||||
|
||||
Runs without a Python 3.8 interpreter installed: vermin infers the minimum required
|
||||
version from syntax/stdlib usage instead of actually executing the file.
|
||||
"""
|
||||
vermin = __import__("vermin")
|
||||
|
||||
config = vermin.Config()
|
||||
config.set_quiet(True)
|
||||
# Match nl_mkimage.py's own strategy: with `from __future__ import annotations` present
|
||||
# (checked separately above), annotations are never evaluated, so vermin shouldn't assume
|
||||
# they need runtime support either. This is vermin's default, spelled out here so it's
|
||||
# obvious why `union-types` (see the block comment above) is intentionally not enabled.
|
||||
config.set_eval_annotations(False)
|
||||
|
||||
source = SCRIPT.read_text()
|
||||
py2_min, py3_min = vermin.detect(source, config, str(SCRIPT))
|
||||
assert py3_min is not None and py3_min <= (3, 8), (
|
||||
f"nl_mkimage.py now needs Python {py3_min} - it must keep working on the "
|
||||
"Python 3.8.10 in the Docker build container (see module docstring)"
|
||||
)
|
||||
|
||||
Generated
+14
-1
@@ -28,12 +28,16 @@ source = { virtual = "." }
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "vermin" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8" }]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=8" },
|
||||
{ name = "vermin", specifier = ">=1.6" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
@@ -77,3 +81,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vermin"
|
||||
version = "1.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/7b/2c1b403f2a844e1acb36694fc336e323df742f7f752edb4188311ad43f9e/vermin-1.8.0.tar.gz", hash = "sha256:3621955ac2a2950175c5b4a9b2fc3bd24bd416da0388893c9eb6971264e4ca1f", size = 99913, upload-time = "2025-11-20T17:29:31.509Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/fd/6d2167767488e162efd01300a679c7f7d913dbb65baa0b15760ebb369854/vermin-1.8.0-py3-none-any.whl", hash = "sha256:6f2b98ad697a16ed3442e81a0f0baabfc38c185b78aaab1b1fecd87bdd6640b8", size = 98030, upload-time = "2025-11-20T17:29:29.931Z" },
|
||||
]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# ninlight-Recovery-Station
|
||||
|
||||
Ein Raspberry Pi spannt den Zugangspunkt `ninlight-recovery` auf und liefert unter
|
||||
`http://192.168.8.1/ninlight_<target>_ota.img` Firmware-Images aus.
|
||||
|
||||
Jede ninlight-Lampe sucht beim Booten bis zu drei Sekunden nach dieser Station. Findet sie
|
||||
sie, lädt sie das Image für ihren Gerätetyp und flasht es — ohne dass die Lampe im WLAN
|
||||
erreichbar sein muss. Das ist der Rettungsweg für Lampen, an die man sonst nur noch mit dem
|
||||
Lötkolben käme.
|
||||
|
||||
Trägt das angebotene Image dieselbe Version, die bereits läuft, lehnt die Lampe es ab und
|
||||
bootet normal weiter. Eine angelassene Station flasht also nicht in einer Endlosschleife.
|
||||
|
||||
## Bedienung
|
||||
|
||||
tools/station/setup_pi.sh <pi-host> einmalig einrichten
|
||||
tools/station/deploy.sh <pi-host> [target] Images ausliefern
|
||||
ssh <pi-host> ninlight-station on einschalten
|
||||
ssh <pi-host> ninlight-station status Zustand und letzte Auslieferungen
|
||||
ssh <pi-host> ninlight-station off ausschalten
|
||||
|
||||
SSID und Passwort stehen in `/etc/ninlight-station.conf` auf dem Pi und müssen zu den
|
||||
Build-Parametern `NINLIGHT_RECOVERY_SSID` und `NINLIGHT_RECOVERY_PASSWORD` aus
|
||||
`config.local.cmake` passen. Weicht eines ab, findet die Lampe die Station nicht.
|
||||
|
||||
## Vertrauensgrenze
|
||||
|
||||
Der Trailer eines Images prüft Gerätetyp und SHA256. Er ist **keine** kryptografische
|
||||
Signatur, und der Download läuft über `http://` ohne TLS. Die Station schützt allein ein
|
||||
WPA2-Passwort, dessen Default im Repo steht. Wer in Funkreichweite ist und es kennt, kann
|
||||
jeder Lampe beim nächsten Boot beliebige Firmware unterschieben.
|
||||
|
||||
Das ist gewollt: Die Signaturprüfung der alten Firmware auszuhebeln ist der Zweck dieses
|
||||
Projekts. Daraus folgen zwei Regeln.
|
||||
|
||||
**Setz ein eigenes Passwort.** In `config.local.cmake` und in
|
||||
`/etc/ninlight-station.conf` — an beiden Stellen denselben Wert.
|
||||
|
||||
**Lass die Station nur an, solange du sie brauchst.** Nicht nur wegen des Fremdzugriffs:
|
||||
Die Firmware löscht den Power-Cycle-Zähler, bevor sie einen Recovery-Versuch startet.
|
||||
Solange eine Station in Reichweite ist, funktioniert der Werksreset per fünf Stromzyklen
|
||||
bei **keiner** Lampe in Reichweite mehr.
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# Liefert Firmware-Images und den Stationscode an den Pi aus.
|
||||
#
|
||||
# Aufruf: tools/station/deploy.sh <pi-host> [target ...]
|
||||
# Ohne target-Argumente werden alle in build/*/ gefundenen _ota.img uebertragen.
|
||||
set -euo pipefail
|
||||
HOST=${1:?Aufruf: tools/station/deploy.sh <pi-host> [target ...]}
|
||||
shift || true
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
ROOT=$(cd "$HERE/../.." && pwd)
|
||||
MKIMAGE="$ROOT/tools/mkimage/nl_mkimage.py"
|
||||
# /usr/bin/ssh statt einem ssh im PATH, das sich anders verhaelt (siehe A3-Notiz im
|
||||
# Abnahmebericht). Ueberschreibbar, damit tests/test_deploy.sh gegen einen Stub statt
|
||||
# einen echten Pi laufen kann - dasselbe Prinzip wie bei NINLIGHT_STATION_ROOT unten.
|
||||
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
|
||||
|
||||
declare -a FILES=()
|
||||
if [ $# -gt 0 ]; then
|
||||
# Gezielter Deploy: nur die genannten Geraetetypen werden ersetzt, alle
|
||||
# anderen Images im Zielverzeichnis bleiben unangetastet (siehe FULL_SYNC
|
||||
# unten).
|
||||
FULL_SYNC=0
|
||||
for t in "$@"; do
|
||||
f="$ROOT/build/$t/ninlight_${t}_ota.img"
|
||||
[ -f "$f" ] || { echo "Fehler: $f fehlt - erst bauen" >&2; exit 1; }
|
||||
FILES+=("$f")
|
||||
done
|
||||
else
|
||||
FULL_SYNC=1
|
||||
while IFS= read -r f; do FILES+=("$f"); done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
|
||||
[ ${#FILES[@]} -gt 0 ] || { echo "Fehler: keine Images unter $ROOT/build gefunden" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
echo "== Images pruefen =="
|
||||
STAGE=$(mktemp -d)
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
printf '%-26s %-10s %s\n' GERAET VERSION BYTES
|
||||
for f in "${FILES[@]}"; do
|
||||
base=$(basename "$f")
|
||||
target=${base#ninlight_}; target=${target%_ota.img}
|
||||
# Ein Image, das die Pruefung nicht besteht, wuerde jede Lampe ablehnen -
|
||||
# das faellt sonst erst am Geraet auf.
|
||||
out=$(python3 "$MKIMAGE" --verify "$f" --expect-device "$target") || {
|
||||
echo "Fehler: $base besteht die Trailer-Pruefung nicht, nichts uebertragen" >&2; exit 1; }
|
||||
# shellcheck disable=SC2086
|
||||
set -- $out
|
||||
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
|
||||
cp "$f" "$STAGE/$base"
|
||||
done
|
||||
|
||||
echo "== uebertragen =="
|
||||
# -c (Inhaltsvergleich per Checksumme statt Groesse+Zeitstempel): ein neues
|
||||
# Image kann zufaellig dieselbe Byte-Groesse wie das alte haben, und bei
|
||||
# schnell aufeinanderfolgenden Deploys reicht rsyncs Sekundenaufloesung fuer
|
||||
# den Zeitstempel nicht, um das als Aenderung zu erkennen. Ohne -c wuerde
|
||||
# rsync ein solches Update dann stillschweigend ueberspringen.
|
||||
#
|
||||
# /tmp/ninlight-images auf dem Pi ist nur eine fluechtige Ablage fuer diesen
|
||||
# Lauf - hier darf --delete unbedingt ran, das betrifft nicht das live
|
||||
# ausgelieferte Verzeichnis.
|
||||
# -e: rsync soll fuer den Transport zum Pi denselben ssh wie oben benutzen.
|
||||
rsync -a -c --delete -e "$SSH_BIN" "$STAGE/" "$HOST:/tmp/ninlight-images/"
|
||||
rsync -a -e "$SSH_BIN" "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/"
|
||||
# Pfade als Variablen mit Default: so kann ein Test die Installation gegen
|
||||
# ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen.
|
||||
"$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" <<'REMOTE'
|
||||
set -e
|
||||
FULL_SYNC=$1
|
||||
ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station}
|
||||
BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station}
|
||||
UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service}
|
||||
|
||||
sudo install -d -m 0755 "$ROOT_DIR/images"
|
||||
if [ "$FULL_SYNC" -eq 1 ]; then
|
||||
# Vollabgleich: alle Images ausserhalb der aktuellen Auslieferung entfernen.
|
||||
echo " voller Abgleich - Images ausserhalb der Auswahl werden entfernt"
|
||||
sudo rsync -a -c --delete /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
||||
else
|
||||
# Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle
|
||||
# anderen Geraetetypen im Zielverzeichnis bleiben erhalten (kein --delete).
|
||||
echo " gezielter Deploy - andere Geraetetypen im Zielverzeichnis bleiben erhalten"
|
||||
sudo rsync -a -c /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
||||
fi
|
||||
sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py"
|
||||
sudo install -m 0755 /tmp/ninlight-station "$BIN_PATH"
|
||||
sudo install -m 0644 /tmp/ninlight-station.service "$UNIT_PATH"
|
||||
rm -rf /tmp/ninlight-images /tmp/server.py /tmp/ninlight-station /tmp/ninlight-station.service
|
||||
sudo systemctl daemon-reload
|
||||
if systemctl is-active --quiet ninlight-station; then
|
||||
if sudo systemctl restart ninlight-station; then
|
||||
echo " Dienst neu gestartet"
|
||||
else
|
||||
echo " Fehler: Dienst lief, Neustart nach dem Deploy ist aber fehlgeschlagen" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo " Dienst laeuft nicht (Station ist aus)"
|
||||
fi
|
||||
REMOTE
|
||||
|
||||
echo "fertig."
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
#!/bin/sh
|
||||
# Schaltet die ninlight-Recovery-Station an und aus.
|
||||
#
|
||||
# Die Station ist absichtlich nicht dauerhaft an: Solange sie laeuft, kann jeder
|
||||
# in Funkreichweite mit dem Passwort einer Lampe beim naechsten Boot beliebige
|
||||
# Firmware unterschieben, und der Werksreset per fuenf Stromzyklen funktioniert
|
||||
# bei keiner Lampe in Reichweite mehr (die Firmware loescht den Zaehler vor
|
||||
# jedem Recovery-Versuch).
|
||||
set -eu
|
||||
|
||||
CONF=/etc/ninlight-station.conf
|
||||
if [ -r "$CONF" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
. "$CONF"
|
||||
elif [ -e "$CONF" ]; then
|
||||
# $CONF ist 0600 root:root, damit das Passwort nicht fuer jeden lesbar
|
||||
# ist. Der dokumentierte Aufruf "ssh <pi-host> ninlight-station on" laeuft
|
||||
# aber als normaler Benutzer - "sudo cat" liest die Datei ein, ohne ihre
|
||||
# Rechte aufzuweichen.
|
||||
eval "$(sudo cat "$CONF")"
|
||||
else
|
||||
echo "Fehler: $CONF fehlt - zuerst tools/station/setup_pi.sh ausfuehren" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for _key in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION; do
|
||||
eval "test -n \"\${$_key-}\"" || {
|
||||
echo "Fehler: $CONF: Schluessel $_key fehlt oder ist leer - tools/station/setup_pi.sh ausfuehren" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
unset _key
|
||||
|
||||
need_profile() {
|
||||
nmcli -t -f NAME con show | grep -qx "$AP_CONNECTION" || {
|
||||
echo "Fehler: NetworkManager-Profil '$AP_CONNECTION' fehlt - tools/station/setup_pi.sh ausfuehren" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
images() {
|
||||
find "$IMAGE_DIR" -maxdepth 1 -name 'ninlight_*_ota.img' 2>/dev/null | sort
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
on)
|
||||
need_profile
|
||||
n=$(images | wc -l | tr -d ' ')
|
||||
[ "$n" -gt 0 ] || echo "Warnung: $IMAGE_DIR enthaelt kein Image - die Station antwortet auf alles mit 404" >&2
|
||||
sudo nmcli con up "$AP_CONNECTION" >/dev/null
|
||||
# Eine fruehere Absturzserie darf den naechsten ehrlichen Versuch nicht
|
||||
# blockieren (systemd verweigert "start" sonst sofort mit "start-limit-hit").
|
||||
sudo systemctl reset-failed ninlight-station 2>/dev/null || true
|
||||
if ! sudo systemctl start ninlight-station; then
|
||||
# Der Start-Aufruf selbst ist gescheitert (z.B. start-limit-hit) - unter
|
||||
# set -eu wuerde das Skript hier sonst abbrechen, bevor die
|
||||
# Lebendigkeitspruefung unten je erreicht wird, und der eben
|
||||
# hochgefahrene Zugangspunkt bliebe offen ohne Server dahinter stehen.
|
||||
sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true
|
||||
echo "Fehler: ninlight-station liess sich nicht starten - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2
|
||||
exit 1
|
||||
fi
|
||||
# "systemctl start" meldet bei Type=simple schon Erfolg, sobald der Prozess
|
||||
# gestartet wurde - nicht, sobald er ueberlebt hat. Kurz warten und wirklich
|
||||
# nachsehen, sonst bleibt ein offener AP ohne Server dahinter stehen.
|
||||
sleep 1
|
||||
if systemctl is-active --quiet ninlight-station; then
|
||||
echo "Station an: SSID $SSID auf 192.168.8.1, $n Image(s)"
|
||||
echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist."
|
||||
else
|
||||
# Mit Restart=always laeuft ein abgestuerzter Dienst sonst im Hintergrund
|
||||
# weiter in seiner Neustartschleife - erst stoppen, dann den AP runter,
|
||||
# sonst suggeriert die Meldung unten einen saubereren Zustand als er ist.
|
||||
sudo systemctl stop ninlight-station 2>/dev/null || true
|
||||
sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true
|
||||
echo "Fehler: ninlight-station ist nach dem Start nicht aktiv - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
off)
|
||||
sudo systemctl stop ninlight-station 2>/dev/null || true
|
||||
sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true
|
||||
if nmcli -t -f NAME con show | grep -qx "$HOME_CONNECTION"; then
|
||||
if sudo nmcli con up "$HOME_CONNECTION" >/dev/null; then
|
||||
echo "Station aus, wlan0 zurueck im Heim-WLAN ($HOME_CONNECTION)"
|
||||
else
|
||||
echo "Station aus. Zurueckschalten zu '$HOME_CONNECTION' fehlgeschlagen - wlan0-Status pruefen." >&2
|
||||
fi
|
||||
else
|
||||
echo "Station aus. Profil '$HOME_CONNECTION' fehlt, wlan0 bleibt ungenutzt - der Zugang ueber Kabel ist davon nicht betroffen." >&2
|
||||
fi
|
||||
;;
|
||||
status)
|
||||
# "systemctl is-active" schreibt den Zustand ("inactive", "failed", ...) auf stdout und
|
||||
# liefert dabei einen Rueckgabewert ungleich 0, sobald der Dienst nicht "active" ist -
|
||||
# ein "|| echo inaktiv" wuerde also zusaetzlich zur ersten Zeile ausgeloest und beide
|
||||
# landen in der Ausgabe. Der Zustand selbst ist die Auskunft; "status" ist damit
|
||||
# erfolgreich, sobald es ihn ermitteln konnte, unabhaengig vom Dienstzustand - deshalb
|
||||
# am Ende explizit exit 0.
|
||||
printf 'Dienst : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)"
|
||||
printf 'wlan0 : %s\n' "$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2)"
|
||||
printf 'Adresse: %s\n' "$(ip -4 -br addr show wlan0 2>/dev/null | awk '{print $3}')"
|
||||
echo "Images :"
|
||||
images | while read -r f; do
|
||||
printf ' %-34s %s\n' "$(basename "$f")" \
|
||||
"$(python3 - "$f" <<'PY'
|
||||
import pathlib, sys
|
||||
t = pathlib.Path(sys.argv[1]).read_bytes()[-512:]
|
||||
print(t[21:37].rstrip(b"\x00").decode("ascii", "replace") if t[:4] == b"NINL" else "kein Trailer")
|
||||
PY
|
||||
)"
|
||||
done
|
||||
# "status" soll mit 0 enden, sobald es seine Auskunft geben konnte, auch wenn noch nie
|
||||
# ausgeliefert wurde - ohne das "|| true" waere der Exit-Code des Skripts hier der des
|
||||
# fehlgeschlagenen Tests (1), weil das die letzte Anweisung im Zweig ist.
|
||||
[ -r /run/ninlight-station/status.json ] && { echo "Zuletzt ausgeliefert:"; sed 's/^/ /' /run/ninlight-station/status.json; }
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Aufruf: ninlight-station on|off|status" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,40 @@
|
||||
[Unit]
|
||||
Description=ninlight recovery station (HTTP image server)
|
||||
# Ohne den Zugangspunkt hat der Server beim Start keine Adresse zum Binden.
|
||||
# Bewusst kein BindsTo=NetworkManager.service: ein dadurch ausgeloester Stop
|
||||
# gilt fuer systemd als sauber und wuerde von KEINER Restart=-Politik erneut
|
||||
# gestartet - ein NetworkManager-Neustart wuerde den Server dann still und
|
||||
# dauerhaft mitreissen. Stattdessen nur Start-Reihenfolge vorgeben und den
|
||||
# Prozess bei jedem Absturz (z.B. Bind-Fehler nach einem NM-Neustart) per
|
||||
# Restart=always in kurzen Abstaenden neu versuchen lassen.
|
||||
After=network.target NetworkManager.service
|
||||
# Steht der Zugangspunkt nicht, kann server.py 192.168.8.1 nicht binden und
|
||||
# stirbt sofort; Restart=always versucht es alle RestartSec=2 erneut. Damit
|
||||
# das nicht endlos das Journal flutet, sondern nach kurzer Zeit ehrlich
|
||||
# aufgibt (und ninlight-station dann mit einer klaren Fehlermeldung reagieren
|
||||
# kann statt in einer stillen Neustartschleife zu haengen), hier explizit
|
||||
# gedeckelt statt sich auf systemds Vorgabewerte zu verlassen.
|
||||
StartLimitIntervalSec=30
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/ninlight-station.conf
|
||||
ExecStart=/usr/bin/python3 /opt/ninlight-station/server.py --image-dir ${IMAGE_DIR}
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
# Laeuft als root: Port 80 und - fuer 4c - die DHCP-Leases in
|
||||
# /var/lib/NetworkManager (Verzeichnis 0700 root:root) sind sonst nicht erreichbar.
|
||||
# Gegengewicht:
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RuntimeDirectory=ninlight-station
|
||||
ReadWritePaths=/run/ninlight-station
|
||||
RestrictAddressFamilies=AF_INET AF_UNIX
|
||||
MemoryMax=128M
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,8 @@
|
||||
[project]
|
||||
name = "ninlight-station"
|
||||
version = "0.1.0"
|
||||
description = "recovery station HTTP server for ninlight lamps"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=8"]
|
||||
Executable
+232
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ninlight recovery station.
|
||||
|
||||
Serves firmware images to lamps that look for a rescue station while booting. Deliberately
|
||||
minimal: it hands out nothing but files whose name matches ninlight_<target>_ota.img, so a
|
||||
stray file in the image directory can never leave the machine.
|
||||
|
||||
Standard library only - the Pi gets no extra packages.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
TARGETS = ("evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw")
|
||||
PATH_RE = re.compile(r"^/ninlight_(" + "|".join(TARGETS) + r")_ota\.img$")
|
||||
TRAILER_SIZE = 512
|
||||
TRAILER_MAGIC = b"NINL"
|
||||
VERSION_OFF = 21
|
||||
VERSION_LEN = 16
|
||||
RECENT_MAX = 8
|
||||
CHUNK = 32 * 1024
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def trailer_version(path: Path) -> str:
|
||||
"""Firmware version from the image trailer, or '?' if the file carries none."""
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
if path.stat().st_size < TRAILER_SIZE:
|
||||
return "?"
|
||||
f.seek(-TRAILER_SIZE, os.SEEK_END)
|
||||
tail = f.read(TRAILER_SIZE)
|
||||
except OSError:
|
||||
return "?"
|
||||
if tail[0:4] != TRAILER_MAGIC:
|
||||
return "?"
|
||||
return tail[VERSION_OFF:VERSION_OFF + VERSION_LEN].rstrip(b"\x00").decode("ascii", "replace")
|
||||
|
||||
|
||||
class Station:
|
||||
def __init__(self, image_dir: Path, state_path: Path, stall_after: int | None) -> None:
|
||||
self.image_dir = image_dir
|
||||
self.state_path = state_path
|
||||
self.stall_after = stall_after
|
||||
self.since = now_iso()
|
||||
self.served = 0
|
||||
self.recent: list[dict] = []
|
||||
# ThreadingHTTPServer runs one handler thread per connection, and several lamps
|
||||
# can be downloading at once. Every mutation of served/recent and the snapshot
|
||||
# that gets serialized to disk must happen under this same lock, or concurrent
|
||||
# requests lose increments and stomp on each other's status.json writes.
|
||||
self.lock = threading.RLock()
|
||||
self.write_state()
|
||||
|
||||
def image_path(self, target: str) -> Path:
|
||||
return self.image_dir / f"ninlight_{target}_ota.img"
|
||||
|
||||
def record(self, ip: str, target: str, version: str, nbytes: int, status: str) -> None:
|
||||
with self.lock:
|
||||
if status == "ok":
|
||||
self.served += 1
|
||||
self.recent.append({"ts": now_iso(), "ip": ip, "target": target,
|
||||
"version": version, "bytes": nbytes, "status": status})
|
||||
del self.recent[:-RECENT_MAX]
|
||||
self.write_state()
|
||||
|
||||
def write_state(self) -> None:
|
||||
with self.lock:
|
||||
payload = {"since": self.since, "served": self.served, "recent": list(self.recent)}
|
||||
try:
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-")
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
os.replace(tmp, self.state_path)
|
||||
except OSError as e:
|
||||
print(f"warning: cannot write {self.state_path}: {e}", flush=True)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
server_version = "ninlight-station"
|
||||
|
||||
@property
|
||||
def station(self) -> Station:
|
||||
return self.server.station # type: ignore[attr-defined]
|
||||
|
||||
def log_message(self, fmt: str, *args) -> None: # noqa: D102 - quieter default log
|
||||
pass
|
||||
|
||||
def log(self, status: int, nbytes: int, started: float) -> None:
|
||||
print(f"{now_iso()} {self.client_address[0]} {self.path} {status} "
|
||||
f"{nbytes}B {time.monotonic() - started:.1f}s", flush=True)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self.reject(405, "method not allowed")
|
||||
|
||||
do_PUT = do_DELETE = do_HEAD = do_POST
|
||||
|
||||
def reject(self, status: int, text: str) -> None:
|
||||
body = (text + "\n").encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
started = time.monotonic()
|
||||
m = PATH_RE.match(self.path)
|
||||
if not m:
|
||||
self.reject(404, "not found")
|
||||
self.log(404, 0, started)
|
||||
return
|
||||
target = m.group(1)
|
||||
path = self.station.image_path(target)
|
||||
if not path.is_file():
|
||||
# Record before responding: a client that sees the 404 complete may check
|
||||
# status.json right away, so the entry must already be on disk by then.
|
||||
self.station.record(self.client_address[0], target, "?", 0, "notfound")
|
||||
self.reject(404, "no image for this device")
|
||||
self.log(404, 0, started)
|
||||
return
|
||||
|
||||
size = path.stat().st_size
|
||||
start = 0
|
||||
rng = self.headers.get("Range", "")
|
||||
if rng.startswith("bytes="):
|
||||
first = rng.split("=", 1)[1].split("-", 1)[0]
|
||||
if first.isdigit() and int(first) < size:
|
||||
start = int(first)
|
||||
|
||||
version = trailer_version(path)
|
||||
self.send_response(206 if start else 200)
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
self.send_header("Content-Length", str(size - start))
|
||||
if start:
|
||||
self.send_header("Content-Range", f"bytes {start}-{size - 1}/{size}")
|
||||
self.send_header("Accept-Ranges", "bytes")
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
|
||||
sent = 0
|
||||
status = "ok"
|
||||
remaining = size - start
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
f.seek(start)
|
||||
while sent < remaining:
|
||||
if self.station.stall_after is not None and sent >= self.station.stall_after:
|
||||
# test aid: hold the connection open and send nothing more, so the
|
||||
# firmware's stall handling can be exercised on real hardware. We
|
||||
# still notice if the peer gives up and closes its side, so a test
|
||||
# (or an impatient client) that hangs up is recorded as "aborted"
|
||||
# instead of leaking the thread forever.
|
||||
while True:
|
||||
readable, _, _ = select.select([self.connection], [], [], 1.0)
|
||||
if readable:
|
||||
try:
|
||||
peek = self.connection.recv(1, socket.MSG_PEEK)
|
||||
except OSError:
|
||||
peek = b""
|
||||
if not peek:
|
||||
raise ConnectionResetError("peer closed during stall")
|
||||
read_size = CHUNK
|
||||
if self.station.stall_after is not None:
|
||||
# Don't overshoot the stall boundary within a single read: without
|
||||
# this a whole CHUNK could be sent before the check above ever
|
||||
# notices we've passed --stall-after.
|
||||
read_size = min(read_size, max(1, self.station.stall_after - sent))
|
||||
chunk = f.read(read_size)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
sent += len(chunk)
|
||||
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
||||
status = "aborted"
|
||||
# Record only now that the transfer has actually finished (or failed): "ok" must
|
||||
# never be written before the last byte is confirmed sent, or status.json can
|
||||
# claim success for a device that never received the end of the image.
|
||||
self.log(206 if start else 200, sent, started)
|
||||
self.station.record(self.client_address[0], target, version, sent, status)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="ninlight recovery station HTTP server")
|
||||
p.add_argument("--image-dir", required=True, type=Path)
|
||||
p.add_argument("--bind", default="192.168.8.1")
|
||||
p.add_argument("--port", type=int, default=80)
|
||||
p.add_argument("--state", type=Path, default=Path("/run/ninlight-station/status.json"))
|
||||
p.add_argument("--stall-after", type=int, default=None,
|
||||
help="test aid: stop sending after N bytes but keep the connection open")
|
||||
a = p.parse_args(argv)
|
||||
|
||||
if not a.image_dir.is_dir():
|
||||
print(f"error: image directory {a.image_dir} does not exist", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
station = Station(a.image_dir, a.state, a.stall_after)
|
||||
httpd = ThreadingHTTPServer((a.bind, a.port), Handler)
|
||||
httpd.daemon_threads = True
|
||||
httpd.station = station # type: ignore[attr-defined]
|
||||
images = sorted(p.name for p in a.image_dir.glob("ninlight_*_ota.img"))
|
||||
print(f"{now_iso()} listening on {a.bind}:{a.port}, {len(images)} image(s): "
|
||||
f"{', '.join(images) or 'none'}", flush=True)
|
||||
if a.stall_after is not None:
|
||||
print(f"{now_iso()} WARNING: --stall-after {a.stall_after} is a test aid", flush=True)
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
# Richtet einen Raspberry Pi als ninlight-Recovery-Station ein. Idempotent:
|
||||
# mehrfaches Ausfuehren aendert nichts und schadet nicht.
|
||||
#
|
||||
# Aufruf: tools/station/setup_pi.sh <pi-host>
|
||||
set -euo pipefail
|
||||
HOST=${1:?Aufruf: tools/station/setup_pi.sh <pi-host>}
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
# /usr/bin/ssh und /usr/bin/scp statt Varianten im PATH, die sich anders verhalten koennen.
|
||||
# Ueberschreibbar (wie NINLIGHT_STATION_ROOT in deploy.sh), damit ein Test gegen Stubs statt
|
||||
# einen echten Pi laufen kann.
|
||||
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
|
||||
SCP_BIN=${NINLIGHT_SCP_BIN:-/usr/bin/scp}
|
||||
|
||||
echo "== Voraussetzungen auf $HOST pruefen =="
|
||||
"$SSH_BIN" "$HOST" 'set -e
|
||||
systemctl is-active --quiet NetworkManager || { echo "NetworkManager laeuft nicht - dieser Plan setzt ihn voraus" >&2; exit 1; }
|
||||
nmcli -t -f DEVICE,STATE device | grep -q "^wlan0:" || { echo "wlan0 wird nicht von NetworkManager verwaltet" >&2; exit 1; }
|
||||
python3 -c "import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)" || { echo "Python 3.11+ noetig" >&2; exit 1; }
|
||||
echo " ok"'
|
||||
|
||||
echo "== Konfiguration =="
|
||||
"$SCP_BIN" -q "$HERE/station.conf.example" "$HOST:/tmp/ninlight-station.conf.example"
|
||||
"$SSH_BIN" "$HOST" 'set -e
|
||||
if [ -f /etc/ninlight-station.conf ]; then
|
||||
echo " /etc/ninlight-station.conf existiert bereits, bleibt unveraendert"
|
||||
else
|
||||
sudo install -m 0600 /tmp/ninlight-station.conf.example /etc/ninlight-station.conf
|
||||
echo " /etc/ninlight-station.conf aus der Vorlage angelegt"
|
||||
fi
|
||||
rm -f /tmp/ninlight-station.conf.example'
|
||||
|
||||
echo "== Server, CLI und Unit installieren =="
|
||||
"$SCP_BIN" -q "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/"
|
||||
"$SSH_BIN" "$HOST" 'set -e
|
||||
sudo install -d -m 0755 /opt/ninlight-station /opt/ninlight-station/images
|
||||
sudo install -m 0755 /tmp/server.py /opt/ninlight-station/server.py
|
||||
sudo install -m 0755 /tmp/ninlight-station /usr/local/bin/ninlight-station
|
||||
sudo install -m 0644 /tmp/ninlight-station.service /etc/systemd/system/ninlight-station.service
|
||||
rm -f /tmp/server.py /tmp/ninlight-station /tmp/ninlight-station.service
|
||||
sudo systemctl daemon-reload
|
||||
echo " installiert"'
|
||||
|
||||
echo "== NetworkManager-Profil =="
|
||||
# Zwei Dinge, die man an einer echten Lampenstation nicht falsch machen darf:
|
||||
# - /etc/ninlight-station.conf ist 0600 root:root (das Passwort soll nicht
|
||||
# jeder lesen koennen). Der SSH-Benutzer ist aber nicht root, direktes
|
||||
# ". /etc/ninlight-station.conf" wuerde also mit "Permission denied"
|
||||
# abbrechen. "sudo cat" + eval liest sie dennoch ein, ohne die Rechte der
|
||||
# Datei aufzuweichen.
|
||||
# - "nmcli ... wifi-sec.psk $PSK" wuerde das Passwort als Kommandozeilen-
|
||||
# argument an einen Prozess uebergeben - fuer jeden lokalen Benutzer per
|
||||
# "ps"/"/proc/<pid>/cmdline" sichtbar. Deshalb wird das Verbindungsprofil
|
||||
# stattdessen direkt als NetworkManager-Keyfile (0600 root:root)
|
||||
# geschrieben; das Passwort landet nur in einer Root-Datei, nie in einer
|
||||
# Prozessliste.
|
||||
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||
set -e
|
||||
eval "$(sudo cat /etc/ninlight-station.conf)"
|
||||
CONN_FILE="/etc/NetworkManager/system-connections/${AP_CONNECTION}.nmconnection"
|
||||
if sudo test -f "$CONN_FILE"; then
|
||||
UUID=$(sudo sed -n 's/^uuid=//p' "$CONN_FILE" | head -n1)
|
||||
ACTION="aktualisiert"
|
||||
else
|
||||
UUID=$(cat /proc/sys/kernel/random/uuid)
|
||||
ACTION="angelegt"
|
||||
fi
|
||||
TMP=$(mktemp)
|
||||
trap 'rm -f "$TMP"' EXIT
|
||||
{
|
||||
printf '%s\n' '[connection]'
|
||||
printf 'id=%s\n' "$AP_CONNECTION"
|
||||
printf 'uuid=%s\n' "$UUID"
|
||||
printf '%s\n' 'type=wifi'
|
||||
printf '%s\n' 'interface-name=wlan0'
|
||||
printf '%s\n' 'autoconnect=false'
|
||||
printf '\n%s\n' '[wifi]'
|
||||
printf '%s\n' 'mode=ap'
|
||||
printf 'ssid=%s\n' "$SSID"
|
||||
printf '%s\n' 'band=bg'
|
||||
printf '%s\n' 'channel=6'
|
||||
printf '\n%s\n' '[wifi-security]'
|
||||
printf '%s\n' 'key-mgmt=wpa-psk'
|
||||
printf '%s\n' 'proto=rsn'
|
||||
printf '%s\n' 'pairwise=ccmp;'
|
||||
printf '%s\n' 'group=ccmp;'
|
||||
printf 'psk=%s\n' "$PSK"
|
||||
printf '\n%s\n' '[ipv4]'
|
||||
printf '%s\n' 'method=shared'
|
||||
printf '%s\n' 'address1=192.168.8.1/24'
|
||||
printf '\n%s\n' '[ipv6]'
|
||||
printf '%s\n' 'method=disabled'
|
||||
} >"$TMP"
|
||||
sudo install -o root -g root -m 0600 "$TMP" "$CONN_FILE"
|
||||
rm -f "$TMP"
|
||||
sudo nmcli connection load "$CONN_FILE" >/dev/null
|
||||
echo " Profil $AP_CONNECTION $ACTION"
|
||||
REMOTE
|
||||
|
||||
cat <<'EOF'
|
||||
|
||||
Fertig. Die Station ist absichtlich noch AUS.
|
||||
|
||||
Naechste Schritte:
|
||||
tools/station/deploy.sh <pi-host> Images ausliefern
|
||||
ssh <pi-host> ninlight-station on Station einschalten
|
||||
ssh <pi-host> ninlight-station off wieder ausschalten (nicht vergessen)
|
||||
EOF
|
||||
@@ -0,0 +1,26 @@
|
||||
# /etc/ninlight-station.conf - von tools/station/setup_pi.sh angelegt.
|
||||
# Muss zu den Build-Parametern der Firmware passen (NINLIGHT_RECOVERY_SSID,
|
||||
# NINLIGHT_RECOVERY_PASSWORD in config.local.cmake). Weicht etwas ab, findet
|
||||
# die Lampe die Station nicht.
|
||||
#
|
||||
# Werte immer in einfachen Anführungszeichen: ninlight-station liest diese
|
||||
# Datei per POSIX ". datei" ein, und dabei wertet die Shell auch in doppelten
|
||||
# Anführungszeichen $ und ` aus - ein Passwort mit "$1" wuerde so lautlos
|
||||
# durch ein Shell-Argument ersetzt. Einfache Anführungszeichen unterbinden
|
||||
# das; systemds EnvironmentFile= entfernt sie ebenso wie doppelte und fuehrt
|
||||
# ohnehin nie eine Shell aus.
|
||||
|
||||
SSID='ninlight-recovery'
|
||||
|
||||
# Oeffentlicher Default. Fuer den echten Einsatz hier UND in config.local.cmake
|
||||
# einen eigenen Wert setzen - wer das Passwort kennt und in Funkreichweite ist,
|
||||
# kann jeder Lampe beim naechsten Boot beliebige Firmware unterschieben.
|
||||
PSK='ninlight-recovery'
|
||||
|
||||
IMAGE_DIR='/opt/ninlight-station/images'
|
||||
|
||||
# NetworkManager-Profil, in das "ninlight-station off" zurueckschaltet.
|
||||
HOME_CONNECTION='preconfigured'
|
||||
|
||||
# NetworkManager-Profil der Station selbst.
|
||||
AP_CONNECTION='ninlight-recovery'
|
||||
Executable
+284
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stub-Tests fuer deploy.sh - ohne Pi ausfuehrbar.
|
||||
#
|
||||
# ssh/rsync werden im PATH durch Stubs ersetzt: ssh fuehrt das per Heredoc
|
||||
# uebergebene Remote-Skript lokal aus (statt es an einen echten Pi zu
|
||||
# schicken), rsync uebersetzt "host:pfad"-Ziele auf den blossen lokalen Pfad
|
||||
# und ruft dann den echten rsync auf. So laeuft die komplette Logik von
|
||||
# deploy.sh - inklusive des Installationsschritts, der normalerweise per SSH
|
||||
# auf dem Pi passiert - tatsaechlich, aber vollstaendig gegen ein Sandbox-
|
||||
# Verzeichnis (NINLIGHT_STATION_ROOT/…) statt gegen echte Systempfade.
|
||||
#
|
||||
# Aufruf: tools/station/tests/test_deploy.sh
|
||||
set -euo pipefail
|
||||
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
STATION_DIR=$(cd "$HERE/.." && pwd)
|
||||
REAL_RSYNC=$(command -v rsync)
|
||||
|
||||
TMPROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPROOT"' EXIT
|
||||
|
||||
FAILED=0
|
||||
pass() { echo "ok - $1"; }
|
||||
fail() { echo "FEHLER - $1" >&2; FAILED=1; }
|
||||
|
||||
# --- Sandbox-Repo aufbauen: tools/station/deploy.sh + tools/mkimage/nl_mkimage.py + build/ ---
|
||||
setup_sandbox() {
|
||||
local sandbox=$1
|
||||
rm -rf "$sandbox"
|
||||
mkdir -p "$sandbox/tools/station" "$sandbox/tools/mkimage" "$sandbox/build"
|
||||
cp "$STATION_DIR/deploy.sh" "$sandbox/tools/station/deploy.sh"
|
||||
chmod +x "$sandbox/tools/station/deploy.sh"
|
||||
# Inhalt irrelevant, die Dateien muessen nur existieren (werden "uebertragen").
|
||||
echo "# dummy" >"$sandbox/tools/station/server.py"
|
||||
echo "# dummy" >"$sandbox/tools/station/ninlight-station"
|
||||
echo "# dummy" >"$sandbox/tools/station/ninlight-station.service"
|
||||
|
||||
# Fake nl_mkimage.py --verify: verhaelt sich wie das echte Tool (eine Zeile
|
||||
# "<geraet> <version> <bytes>" auf stdout bei Erfolg, Exit 0; eine Zeile auf
|
||||
# stderr bei Fehlschlag, Exit 1), aber steuerbar per Umgebungsvariable
|
||||
# MKIMAGE_FAIL_SUBSTR: enthaelt der zu pruefende Dateiname diesen Teilstring,
|
||||
# schlaegt die Pruefung fehl.
|
||||
cat >"$sandbox/tools/mkimage/nl_mkimage.py" <<'PY'
|
||||
import os
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
f = args[args.index("--verify") + 1]
|
||||
target = args[args.index("--expect-device") + 1]
|
||||
fail_substr = os.environ.get("MKIMAGE_FAIL_SUBSTR", "")
|
||||
if fail_substr and fail_substr in f:
|
||||
print(f"Fehler: {f} besteht die Trailer-Pruefung nicht (Test-Stub)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
size = os.path.getsize(f)
|
||||
print(f"{target} 1.0.0 {size}")
|
||||
PY
|
||||
}
|
||||
|
||||
# --- Stub-Kommandos fuer ssh/rsync im PATH ---
|
||||
setup_stubbin() {
|
||||
local bindir=$1 log=$2
|
||||
mkdir -p "$bindir"
|
||||
|
||||
cat >"$bindir/ssh" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
echo "STUB ssh \$*" >>"$log"
|
||||
host=\$1; shift
|
||||
"\$@"
|
||||
EOF
|
||||
|
||||
cat >"$bindir/rsync" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
echo "STUB rsync \$*" >>"$log"
|
||||
args=()
|
||||
for a in "\$@"; do
|
||||
case "\$a" in
|
||||
-*) args+=("\$a") ;;
|
||||
*:*) args+=("\${a#*:}") ;;
|
||||
*) args+=("\$a") ;;
|
||||
esac
|
||||
done
|
||||
exec "$REAL_RSYNC" "\${args[@]}"
|
||||
EOF
|
||||
|
||||
cat >"$bindir/scp" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
echo "STUB scp \$* - deploy.sh sollte scp nicht aufrufen" >>"$log"
|
||||
exit 1
|
||||
EOF
|
||||
|
||||
cat >"$bindir/sudo" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
echo "STUB sudo \$*" >>"$log"
|
||||
exec "\$@"
|
||||
EOF
|
||||
|
||||
cat >"$bindir/systemctl" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
echo "STUB systemctl \$*" >>"$log"
|
||||
case "\$1" in
|
||||
is-active) exit "\${FAKE_SYSTEMCTL_ACTIVE_EXIT:-1}" ;;
|
||||
restart) exit "\${FAKE_SYSTEMCTL_RESTART_EXIT:-0}" ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
EOF
|
||||
|
||||
chmod +x "$bindir"/ssh "$bindir"/rsync "$bindir"/scp "$bindir"/sudo "$bindir"/systemctl
|
||||
}
|
||||
|
||||
run_deploy() {
|
||||
# Fuehrt deploy.sh mit stubbtem PATH aus; Ausgabe landet in $OUT, Exit-Code in $?.
|
||||
local sandbox=$1; shift
|
||||
# Auf dem echten Pi existieren /usr/local/bin und /etc/systemd/system schon;
|
||||
# in der Sandbox muessen sie fuer "install" eigens angelegt werden.
|
||||
mkdir -p "$SANDBOX_STATE/usr/local/bin" "$SANDBOX_STATE/etc/systemd/system"
|
||||
PATH="$STUBBIN:$PATH" \
|
||||
NINLIGHT_SSH_BIN="$STUBBIN/ssh" \
|
||||
NINLIGHT_STATION_ROOT="$SANDBOX_STATE/opt/ninlight-station" \
|
||||
NINLIGHT_STATION_BIN="$SANDBOX_STATE/usr/local/bin/ninlight-station" \
|
||||
NINLIGHT_STATION_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-station.service" \
|
||||
"$sandbox/tools/station/deploy.sh" "$@"
|
||||
}
|
||||
|
||||
echo "=== Szenario 1: Image besteht die Trailer-Pruefung nicht -> nichts uebertragen ==="
|
||||
SANDBOX="$TMPROOT/s1"; STUBBIN="$TMPROOT/bin1"; LOG="$TMPROOT/s1.log"; SANDBOX_STATE="$TMPROOT/s1-state"
|
||||
setup_sandbox "$SANDBOX"
|
||||
setup_stubbin "$STUBBIN" "$LOG"
|
||||
mkdir -p "$SANDBOX/build/foo"
|
||||
echo "dummy" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
|
||||
: >"$LOG"
|
||||
set +e
|
||||
OUT=$(MKIMAGE_FAIL_SUBSTR=foo run_deploy "$SANDBOX" pi-test foo 2>&1)
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Exit-Code war 0"; fi
|
||||
if echo "$OUT" | grep -q "besteht die Trailer-Pruefung nicht"; then
|
||||
pass "Fehlermeldung zur Trailer-Pruefung vorhanden"
|
||||
else
|
||||
fail "Fehlermeldung zur Trailer-Pruefung fehlt"
|
||||
fi
|
||||
if ! grep -q "STUB ssh\|STUB rsync\|STUB scp" "$LOG"; then
|
||||
pass "kein ssh/rsync/scp aufgerufen - nichts wurde uebertragen"
|
||||
else
|
||||
fail "ssh/rsync/scp wurde trotz fehlgeschlagener Pruefung aufgerufen: $(cat "$LOG")"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== Szenario 2: keine Images unter build/ gefunden ==="
|
||||
SANDBOX="$TMPROOT/s2"; STUBBIN="$TMPROOT/bin2"; LOG="$TMPROOT/s2.log"; SANDBOX_STATE="$TMPROOT/s2-state"
|
||||
setup_sandbox "$SANDBOX"
|
||||
setup_stubbin "$STUBBIN" "$LOG"
|
||||
: >"$LOG"
|
||||
set +e
|
||||
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Exit-Code war 0"; fi
|
||||
if echo "$OUT" | grep -q "keine Images unter"; then
|
||||
pass "Fehlermeldung zu fehlenden Images vorhanden"
|
||||
else
|
||||
fail "Fehlermeldung zu fehlenden Images fehlt"
|
||||
fi
|
||||
if ! grep -q "STUB" "$LOG" 2>/dev/null; then
|
||||
pass "kein Transfer-Kommando aufgerufen"
|
||||
else
|
||||
fail "ein Transfer-Kommando wurde trotz fehlender Images aufgerufen: $(cat "$LOG")"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== Szenario 3: gezielter Deploy eines Geraetetyps laesst andere Images auf dem Ziel unangetastet (Critical 2) ==="
|
||||
SANDBOX="$TMPROOT/s3"; STUBBIN="$TMPROOT/bin3"; LOG="$TMPROOT/s3.log"; SANDBOX_STATE="$TMPROOT/s3-state"
|
||||
setup_sandbox "$SANDBOX"
|
||||
setup_stubbin "$STUBBIN" "$LOG"
|
||||
mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build/bar"
|
||||
echo "foo-image-neu" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
|
||||
echo "bar-image" >"$SANDBOX/build/bar/ninlight_bar_ota.img"
|
||||
# Zielverzeichnis simuliert einen vorherigen Voll-Deploy: Images fuer foo UND bar liegen schon da.
|
||||
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images"
|
||||
echo "foo-image-alt" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img"
|
||||
echo "bar-image" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_bar_ota.img"
|
||||
: >"$LOG"
|
||||
set +e
|
||||
OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1)
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi
|
||||
if echo "$OUT" | grep -q "andere Geraetetypen im Zielverzeichnis bleiben erhalten"; then
|
||||
pass "Ausgabe kuendigt gezielten Deploy ohne --delete an"
|
||||
else
|
||||
fail "Ausgabe erwaehnt den gezielten Deploy nicht"
|
||||
fi
|
||||
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_bar_ota.img" ]; then
|
||||
pass "Image des NICHT ausgewaehlten Geraetetyps (bar) blieb erhalten"
|
||||
else
|
||||
fail "Image des nicht ausgewaehlten Geraetetyps (bar) wurde geloescht - Critical 2 nicht behoben"
|
||||
fi
|
||||
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img" ] \
|
||||
&& [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img")" = "foo-image-neu" ]; then
|
||||
pass "Image des ausgewaehlten Geraetetyps (foo) wurde aktualisiert"
|
||||
else
|
||||
fail "Image des ausgewaehlten Geraetetyps (foo) wurde nicht aktualisiert"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== Szenario 4: Voll-Deploy (kein target) entfernt veraltete Images - zur Abgrenzung von Szenario 3 ==="
|
||||
SANDBOX="$TMPROOT/s4"; STUBBIN="$TMPROOT/bin4"; LOG="$TMPROOT/s4.log"; SANDBOX_STATE="$TMPROOT/s4-state"
|
||||
setup_sandbox "$SANDBOX"
|
||||
setup_stubbin "$STUBBIN" "$LOG"
|
||||
mkdir -p "$SANDBOX/build/foo"
|
||||
echo "foo-image-neu" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
|
||||
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images"
|
||||
echo "veraltet" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_veraltet_ota.img"
|
||||
: >"$LOG"
|
||||
set +e
|
||||
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi
|
||||
if [ ! -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_veraltet_ota.img" ]; then
|
||||
pass "veraltetes Image wurde beim Voll-Deploy entfernt (--delete wirkt weiterhin, wenn gewollt)"
|
||||
else
|
||||
fail "veraltetes Image wurde beim Voll-Deploy NICHT entfernt"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== Szenario 5: Dienst laeuft, Neustart nach dem Deploy schlaegt fehl -> Exit != 0 (Minor) ==="
|
||||
SANDBOX="$TMPROOT/s5"; STUBBIN="$TMPROOT/bin5"; LOG="$TMPROOT/s5.log"; SANDBOX_STATE="$TMPROOT/s5-state"
|
||||
setup_sandbox "$SANDBOX"
|
||||
setup_stubbin "$STUBBIN" "$LOG"
|
||||
mkdir -p "$SANDBOX/build/foo" "$SANDBOX_STATE/usr/local/bin" "$SANDBOX_STATE/etc/systemd/system"
|
||||
echo "foo-image" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
|
||||
: >"$LOG"
|
||||
set +e
|
||||
OUT=$(FAKE_SYSTEMCTL_ACTIVE_EXIT=0 FAKE_SYSTEMCTL_RESTART_EXIT=1 \
|
||||
PATH="$STUBBIN:$PATH" \
|
||||
NINLIGHT_SSH_BIN="$STUBBIN/ssh" \
|
||||
NINLIGHT_STATION_ROOT="$SANDBOX_STATE/opt/ninlight-station" \
|
||||
NINLIGHT_STATION_BIN="$SANDBOX_STATE/usr/local/bin/ninlight-station" \
|
||||
NINLIGHT_STATION_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-station.service" \
|
||||
"$SANDBOX/tools/station/deploy.sh" pi-test foo 2>&1)
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0, wenn der Neustart fehlschlaegt ($RC)"; else fail "Exit-Code war 0, obwohl der Neustart fehlschlug"; fi
|
||||
if echo "$OUT" | grep -q "Neustart nach dem Deploy ist aber fehlgeschlagen"; then
|
||||
pass "Fehlermeldung unterscheidet Neustart-Fehlschlag von 'Dienst laeuft nicht'"
|
||||
else
|
||||
fail "Fehlermeldung fehlt oder ist mehrdeutig"
|
||||
fi
|
||||
if echo "$OUT" | grep -q "Dienst laeuft nicht"; then
|
||||
fail "faelschlich 'Dienst laeuft nicht' gemeldet, obwohl der Dienst lief"
|
||||
else
|
||||
pass "'Dienst laeuft nicht' wird nicht faelschlich gemeldet"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== Szenario 6: deploy.sh/setup_pi.sh nutzen ohne Override /usr/bin/ssh (bzw. scp) ==="
|
||||
# Die anderen Szenarien laufen mit NINLIGHT_SSH_BIN auf einen Stub umgebogen - das prueft die
|
||||
# Logik, aber nicht mehr, WELCHEN ssh das Skript ohne Override tatsaechlich waehlt. Der Nutzer
|
||||
# verlangt ausdruecklich /usr/bin/ssh und /usr/bin/scp statt einer Variante im PATH; das hier
|
||||
# ist die Absicherung dagegen, dass der Default beim naechsten Umbau stillschweigend zurueck
|
||||
# auf ein PATH-"ssh" rutscht.
|
||||
if grep -q 'SSH_BIN=\${NINLIGHT_SSH_BIN:-/usr/bin/ssh}' "$STATION_DIR/deploy.sh"; then
|
||||
pass "deploy.sh: Default fuer SSH_BIN ist /usr/bin/ssh"
|
||||
else
|
||||
fail "deploy.sh: Default fuer SSH_BIN ist nicht mehr /usr/bin/ssh"
|
||||
fi
|
||||
if grep -q 'SSH_BIN=\${NINLIGHT_SSH_BIN:-/usr/bin/ssh}' "$STATION_DIR/setup_pi.sh"; then
|
||||
pass "setup_pi.sh: Default fuer SSH_BIN ist /usr/bin/ssh"
|
||||
else
|
||||
fail "setup_pi.sh: Default fuer SSH_BIN ist nicht mehr /usr/bin/ssh"
|
||||
fi
|
||||
if grep -q 'SCP_BIN=\${NINLIGHT_SCP_BIN:-/usr/bin/scp}' "$STATION_DIR/setup_pi.sh"; then
|
||||
pass "setup_pi.sh: Default fuer SCP_BIN ist /usr/bin/scp"
|
||||
else
|
||||
fail "setup_pi.sh: Default fuer SCP_BIN ist nicht mehr /usr/bin/scp"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAILED" -eq 0 ]; then
|
||||
echo "Alle Tests bestanden."
|
||||
else
|
||||
echo "Mindestens ein Test ist fehlgeschlagen." >&2
|
||||
fi
|
||||
exit "$FAILED"
|
||||
@@ -0,0 +1,284 @@
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SERVER = Path(__file__).resolve().parents[1] / "server.py"
|
||||
TRAILER_SIZE = 512
|
||||
RECENT_MAX = 8
|
||||
|
||||
|
||||
def make_image(path: Path, device: str, version: str, payload: bytes) -> bytes:
|
||||
"""Builds a file with the same trailer layout the firmware verifies."""
|
||||
trailer = bytearray(b"\xff" * TRAILER_SIZE)
|
||||
trailer[0:4] = b"NINL"
|
||||
trailer[4] = 1
|
||||
trailer[5:21] = device.encode().ljust(16, b"\x00")
|
||||
trailer[21:37] = version.encode().ljust(16, b"\x00")
|
||||
trailer[37:69] = hashlib.sha256(payload).digest()
|
||||
data = payload + bytes(trailer)
|
||||
path.write_bytes(data)
|
||||
return data
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
return port
|
||||
|
||||
|
||||
class Station:
|
||||
def __init__(self, tmp_path, *extra):
|
||||
self.image_dir = tmp_path / "images"
|
||||
self.image_dir.mkdir()
|
||||
self.state = tmp_path / "status.json"
|
||||
self.port = free_port()
|
||||
self.proc = subprocess.Popen(
|
||||
[sys.executable, str(SERVER), "--image-dir", str(self.image_dir),
|
||||
"--bind", "127.0.0.1", "--port", str(self.port),
|
||||
"--state", str(self.state), *extra],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
)
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
socket.create_connection(("127.0.0.1", self.port), 0.2).close()
|
||||
return
|
||||
except OSError:
|
||||
if self.proc.poll() is not None:
|
||||
raise RuntimeError("server died: " + (self.proc.stdout.read() or ""))
|
||||
time.sleep(0.05)
|
||||
raise RuntimeError("server did not come up")
|
||||
|
||||
def conn(self):
|
||||
return http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
|
||||
|
||||
def read_state(self, predicate=None):
|
||||
"""Reads status.json, optionally waiting for it to satisfy `predicate`.
|
||||
|
||||
The server only writes the entry for a request after that request's response has
|
||||
actually finished (or failed) on the wire - by design, so it never claims success
|
||||
before the last byte is confirmed sent (see server.py). A client that has just
|
||||
finished reading its own response can therefore still see a status.json that is a
|
||||
few milliseconds stale. Rather than have the server promise something before it is
|
||||
true, we wait here for the state to catch up.
|
||||
"""
|
||||
last = None
|
||||
for _ in range(100):
|
||||
if self.state.exists():
|
||||
try:
|
||||
last = json.loads(self.state.read_text())
|
||||
if predicate is None or predicate(last):
|
||||
return last
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
time.sleep(0.05)
|
||||
if last is None:
|
||||
raise AssertionError("no state file written")
|
||||
raise AssertionError(f"state never satisfied predicate, last seen: {last!r}")
|
||||
|
||||
def stop(self):
|
||||
self.proc.terminate()
|
||||
self.proc.wait(timeout=5)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def station(tmp_path):
|
||||
s = Station(tmp_path)
|
||||
yield s
|
||||
s.stop()
|
||||
|
||||
|
||||
def test_serves_a_known_image(station):
|
||||
data = make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.2.3", b"payload" * 500)
|
||||
c = station.conn()
|
||||
c.request("GET", "/ninlight_evk_ota.img")
|
||||
r = c.getresponse()
|
||||
body = r.read()
|
||||
assert r.status == 200
|
||||
assert int(r.getheader("Content-Length")) == len(data)
|
||||
assert body == data
|
||||
|
||||
|
||||
def test_missing_image_is_404(station):
|
||||
c = station.conn()
|
||||
c.request("GET", "/ninlight_g95_cw_ota.img")
|
||||
assert c.getresponse().status == 404
|
||||
|
||||
|
||||
def test_unknown_target_is_404(station):
|
||||
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"x" * 100)
|
||||
for path in ("/ninlight_nosuch_ota.img", "/ninlight_evk_ota.img.bak", "/", "/images/"):
|
||||
c = station.conn()
|
||||
c.request("GET", path)
|
||||
assert c.getresponse().status == 404, path
|
||||
|
||||
|
||||
def test_path_traversal_is_refused(station):
|
||||
secret = station.image_dir.parent / "secret.txt"
|
||||
secret.write_text("nicht ausliefern")
|
||||
c = station.conn()
|
||||
c.request("GET", "/../secret.txt")
|
||||
assert c.getresponse().status == 404
|
||||
|
||||
|
||||
def test_other_methods_are_405(station):
|
||||
c = station.conn()
|
||||
c.request("POST", "/ninlight_evk_ota.img", body=b"")
|
||||
assert c.getresponse().status == 405
|
||||
|
||||
|
||||
def test_range_request_returns_206_and_the_right_slice(station):
|
||||
data = make_image(station.image_dir / "ninlight_e27_rgb_ota.img", "e27_rgb", "2.0.0", b"abc" * 2000)
|
||||
c = station.conn()
|
||||
c.request("GET", "/ninlight_e27_rgb_ota.img", headers={"Range": "bytes=1000-"})
|
||||
r = c.getresponse()
|
||||
body = r.read()
|
||||
assert r.status == 206
|
||||
assert r.getheader("Content-Range") == f"bytes 1000-{len(data)-1}/{len(data)}"
|
||||
assert body == data[1000:]
|
||||
|
||||
|
||||
def test_state_file_records_a_delivery(station):
|
||||
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "0.3.4", b"z" * 1000)
|
||||
c = station.conn()
|
||||
c.request("GET", "/ninlight_evk_ota.img")
|
||||
c.getresponse().read()
|
||||
state = station.read_state(lambda s: s["served"] >= 1)
|
||||
assert state["served"] == 1
|
||||
entry = state["recent"][-1]
|
||||
assert entry["target"] == "evk"
|
||||
assert entry["version"] == "0.3.4"
|
||||
assert entry["status"] == "ok"
|
||||
assert entry["ip"] == "127.0.0.1"
|
||||
assert entry["bytes"] == 1000 + TRAILER_SIZE
|
||||
|
||||
|
||||
def test_state_file_records_a_missing_image(station):
|
||||
c = station.conn()
|
||||
c.request("GET", "/ninlight_g95_cw_ota.img")
|
||||
c.getresponse().read()
|
||||
state = station.read_state(lambda s: len(s["recent"]) >= 1)
|
||||
assert state["served"] == 0
|
||||
assert state["recent"][-1]["status"] == "notfound"
|
||||
assert state["recent"][-1]["target"] == "g95_cw"
|
||||
|
||||
|
||||
def test_recent_is_capped_at_eight(station):
|
||||
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"q" * 10)
|
||||
for _ in range(12):
|
||||
c = station.conn()
|
||||
c.request("GET", "/ninlight_evk_ota.img")
|
||||
c.getresponse().read()
|
||||
state = station.read_state(lambda s: s["served"] >= 12)
|
||||
assert state["served"] == 12
|
||||
assert len(state["recent"]) == RECENT_MAX
|
||||
|
||||
|
||||
def test_aborted_transfer_is_recorded(tmp_path):
|
||||
s = Station(tmp_path, "--stall-after", "256")
|
||||
try:
|
||||
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 100000)
|
||||
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
||||
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
|
||||
sock.recv(4096)
|
||||
time.sleep(0.3)
|
||||
sock.close()
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
entry = json.loads(s.state.read_text())["recent"][-1]
|
||||
if entry["status"] == "aborted":
|
||||
assert entry["bytes"] < 100000
|
||||
return
|
||||
except (OSError, json.JSONDecodeError, IndexError, KeyError):
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
raise AssertionError("abort was not recorded")
|
||||
finally:
|
||||
s.stop()
|
||||
|
||||
|
||||
def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path):
|
||||
s = Station(tmp_path, "--stall-after", "512")
|
||||
try:
|
||||
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
|
||||
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
||||
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
|
||||
got = b""
|
||||
sock.settimeout(2)
|
||||
try:
|
||||
while len(got) < 200000:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
got += chunk
|
||||
except socket.timeout:
|
||||
pass
|
||||
# far less than the full image
|
||||
assert len(got) < 20000
|
||||
# and the connection is genuinely still open: a server that (wrongly) closed it
|
||||
# after the stall boundary would make this recv() return b"" (EOF) instead of
|
||||
# timing out. Just counting received bytes above would not catch that - a server
|
||||
# closing the connection right after --stall-after also sends far less than
|
||||
# 20000 bytes.
|
||||
sock.settimeout(1)
|
||||
with pytest.raises(socket.timeout):
|
||||
sock.recv(1)
|
||||
sock.close()
|
||||
finally:
|
||||
s.stop()
|
||||
|
||||
|
||||
def test_concurrent_downloads_are_all_recorded(station):
|
||||
"""Several lamps hitting the station at once must not lose or corrupt status.json.
|
||||
|
||||
Station.served is a shared counter and Station.recent a shared list, mutated from one
|
||||
handler thread per connection under ThreadingHTTPServer. Without a lock around the
|
||||
read-modify-write of `served` and the recent-list mutation + serialize-to-disk step,
|
||||
concurrent requests can lose increments (two threads read the same old value) or have
|
||||
one thread's status.json write clobber another's.
|
||||
"""
|
||||
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"c" * 5000)
|
||||
expected_len = 5000 + TRAILER_SIZE
|
||||
n_workers = 20
|
||||
errors = []
|
||||
results_lock = threading.Lock()
|
||||
|
||||
def worker():
|
||||
try:
|
||||
c = station.conn()
|
||||
c.request("GET", "/ninlight_evk_ota.img")
|
||||
r = c.getresponse()
|
||||
body = r.read()
|
||||
if r.status != 200 or len(body) != expected_len:
|
||||
with results_lock:
|
||||
errors.append((r.status, len(body)))
|
||||
except Exception as e: # pragma: no cover - surfaced via the errors list below
|
||||
with results_lock:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(n_workers)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
|
||||
assert not errors, f"some downloads failed or returned the wrong body: {errors}"
|
||||
|
||||
state = station.read_state(lambda s: s["served"] >= n_workers)
|
||||
assert state["served"] == n_workers
|
||||
assert len(state["recent"]) == min(n_workers, RECENT_MAX)
|
||||
for entry in state["recent"]:
|
||||
assert entry["status"] == "ok"
|
||||
assert entry["target"] == "evk"
|
||||
assert entry["bytes"] == expected_len
|
||||
Generated
+79
@@ -0,0 +1,79 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ninlight-station"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8" }]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user