Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
031830454a | ||
|
|
19a0d6539a | ||
|
|
41088b13ae | ||
|
|
09ee206906 | ||
|
|
8294688e37 | ||
|
|
a3a48cdf68 | ||
|
|
1c960ad5cc | ||
|
|
cc14efea01 | ||
|
|
ea711b6264 | ||
|
|
71a579c0f9 | ||
|
|
f4dd1386ef | ||
|
|
c482194503 | ||
|
|
fa93259426 | ||
|
|
bbe2e842b6 | ||
|
|
95ae51bc1d | ||
|
|
c296e2626e | ||
|
|
4e1a965cbb | ||
|
|
300edafc3e | ||
|
|
3642672c71 | ||
|
|
2fe2f7d9f4 | ||
|
|
6325ce073f | ||
|
|
6f0137908c | ||
|
|
24b37ee58f | ||
|
|
82a3942554 | ||
|
|
f0f6894bc9 | ||
|
|
d1535433ce | ||
|
|
5f3cf25e41 | ||
|
|
1f01b29b8e | ||
|
|
f105a0eb69 | ||
|
|
e77d3da292 | ||
|
|
03d6328dd5 | ||
|
|
bb39105ac4 | ||
|
|
f138e88f4e | ||
|
|
46b0a198d9 | ||
|
|
47532a6820 | ||
|
|
7fdccbce72 | ||
|
|
cc398ef57a | ||
|
|
22f92988dd | ||
|
|
b8c7febb8f | ||
|
|
38c70ac5ce | ||
|
|
7a5f26d717 | ||
|
|
eb9efa12f6 | ||
|
|
9b158c8c4f | ||
|
|
4a029a5d1c | ||
|
|
1cb5359754 | ||
|
|
ad2f1b8486 | ||
|
|
59c8c150bd | ||
|
|
74198f6d31 | ||
|
|
1c077a15d0 | ||
|
|
1d3627bacd | ||
|
|
d3e608dca0 | ||
|
|
b41ed51018 | ||
|
|
e897164960 | ||
|
|
78cf01ca6c | ||
|
|
e50659d954 | ||
|
|
00d5a4c532 | ||
|
|
62a2b43175 | ||
|
|
f32216d17f | ||
|
|
9768b25d10 | ||
|
|
1d7d087cff | ||
|
|
435884039b | ||
|
|
2dfb1fd1c2 | ||
|
|
d464bb167e | ||
|
|
6daf36540c | ||
|
|
b169fae89d | ||
|
|
3d69412f5a | ||
|
|
3f27ef4b26 | ||
|
|
2f25714248 |
+1
-1
@@ -10,7 +10,7 @@ if(NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release)
|
||||
endif()
|
||||
|
||||
set(NINLIGHT_DEVICES evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw)
|
||||
set(NINLIGHT_DEVICES evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
|
||||
set(NINLIGHT_DEVICE "" CACHE STRING "Target device")
|
||||
if(NOT NINLIGHT_DEVICE IN_LIST NINLIGHT_DEVICES)
|
||||
message(FATAL_ERROR "Set -DNINLIGHT_DEVICE=<one of: ${NINLIGHT_DEVICES}>")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
An open source replacement firmware for the no longer supported Klyqa smart wifi light bulbs based on the Winner Micro W600 chip
|
||||
@@ -24,7 +24,7 @@ Native Builds (mit lokal installierter ARM-Toolchain):
|
||||
cmake -S . -B build/evk -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake -DNINLIGHT_DEVICE=evk
|
||||
cmake --build build/evk
|
||||
|
||||
**Geräte:** evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw.
|
||||
**Geräte:** evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw, ledshield_rgb (Montana Black LED Shield, nur RGB).
|
||||
|
||||
**Lokale Einstellungen** (Token, später WLAN): `config.local.cmake.example` → `config.local.cmake`.
|
||||
|
||||
@@ -95,11 +95,19 @@ Update, oder scheitert der Start aus einem anderen Grund, antwortet sie mit `409
|
||||
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 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.
|
||||
abgerissenen Transfer per HTTP-Range wieder aufnehmen (auf eine feste Rundenzahl begrenzt) — was dabei
|
||||
passiert, hängt aber davon ab, wie die Gegenstelle abbricht:
|
||||
|
||||
- **Gegenstelle bleibt erreichbar, liefert aber nichts mehr** (Station hängt, Netzwerkpfad lebt): Das
|
||||
`RECV_TIMEOUT` von 5 s des HTTP-Clients greift wie vorgesehen, die Lampe gibt die Verbindung auf, baut
|
||||
eine neue auf und setzt per `Range` genau dort fort, wo sie aufgehört hat. Am EVK gemessen funktionierte
|
||||
das über **fünf** aufeinanderfolgende Reconnects hinweg, bis das Image vollständig übertragen war — auf
|
||||
diesen Fall darf man sich also verlassen.
|
||||
- **Gegenstelle stirbt komplett** (Prozess weg, nicht mehr erreichbar): Der Lesevorgang hängt am EVK
|
||||
gemessen rund **sieben Minuten** in einem einzigen TCP-Timeout, den das 5-s-`RECV_TIMEOUT` der Firmware
|
||||
nicht abfängt, und bricht danach **ohne** Wiederaufnahme ab. Das bleibt der unangenehme Fall: Die Lampe
|
||||
ist während der sieben Minuten über REST erreichbar und lässt sich weiter schalten, weist aber jeden
|
||||
weiteren `fw_update` mit `409` ab, bis der Download endgültig als gescheitert gilt.
|
||||
|
||||
Werte von `last_ota_error` (`null`, solange nichts fehlgeschlagen ist):
|
||||
|
||||
|
||||
+30
-4
@@ -96,7 +96,7 @@ items:
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
- info:
|
||||
name: Color Red
|
||||
name: Color Red + Transition
|
||||
type: http
|
||||
seq: 5
|
||||
http:
|
||||
@@ -110,7 +110,8 @@ items:
|
||||
"r": 255,
|
||||
"g": 0,
|
||||
"b": 0
|
||||
}
|
||||
},
|
||||
"transition": 1500
|
||||
}
|
||||
settings:
|
||||
encodeUrl: true
|
||||
@@ -118,7 +119,7 @@ items:
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
- info:
|
||||
name: Color Temp 2700
|
||||
name: Color Temp 2700 + Transition
|
||||
type: http
|
||||
seq: 6
|
||||
http:
|
||||
@@ -128,7 +129,8 @@ items:
|
||||
type: json
|
||||
data: |-
|
||||
{
|
||||
"color_temp": 2700
|
||||
"color_temp": 2700,
|
||||
"transition": 1500
|
||||
}
|
||||
settings:
|
||||
encodeUrl: true
|
||||
@@ -192,6 +194,25 @@ items:
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
- info:
|
||||
name: Default Transition + Wake Switch
|
||||
type: http
|
||||
seq: 10
|
||||
http:
|
||||
method: POST
|
||||
url: '{{device_address}}:{{device_port}}/api/v1/device/state'
|
||||
body:
|
||||
type: json
|
||||
data: |-
|
||||
{
|
||||
"transition_default": 300,
|
||||
"wake_on_change": false
|
||||
}
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
- info:
|
||||
name: System
|
||||
type: folder
|
||||
@@ -225,6 +246,11 @@ items:
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
docs: |-
|
||||
`wifi.source` ist `"stored"`, solange ein per PUT onboardetes WLAN gespeichert ist,
|
||||
sonst `"compiled"` - dann laeuft die Lampe auf den einkompilierten
|
||||
NINLIGHT_WIFI_SSID/-PASSWORD, `wifi.ssid` ist leer, und `GET system/info` kann trotzdem
|
||||
eine verbundene SSID zeigen.
|
||||
- info:
|
||||
name: Set WiFi
|
||||
type: http
|
||||
|
||||
@@ -59,11 +59,11 @@ Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.s
|
||||
Für den echten Flottenbau `NINLIGHT_RECOVERY_PASSWORD` und `NINLIGHT_TOKEN` in `config.local.cmake` setzen — die Defaults sind nur fürs EVK gedacht.
|
||||
|
||||
## Später (Lampen)
|
||||
- [ ] SM2135EH: Standby-Bit bei allen Kanälen 0 prüfen (Spec §4; alte Firmware setzte es praktisch nie)
|
||||
- [ ] E27 Color (SM2135EH): RGB und WY getrennt, Ströme plausibel (nicht zu heiß)
|
||||
- [x] SM2135EH: Standby-Bit bei allen Kanälen 0 — 2026-09-13 an migrierter GU10 RGB geprüft: **war ein echter Fehler**, die Lampe glomm im Aus weiter. Bit 5 im Kanal-Register wird jetzt gesetzt (Commit zu v0.3.8); danach beide Ausschaltwege (aus Weiß und aus Farbe) vollständig dunkel
|
||||
- [ ] E27 Color (SM2135EH): RGB und WY getrennt, Ströme plausibel (nicht zu heiß) — *derselbe Treiber ist am 2026-09-13 an einer GU10 RGB geprüft (rot/grün/blau sauber getrennt, warm- und kaltweiß deutlich unterschieden); für die E27 selbst steht die Prüfung noch aus*
|
||||
- [ ] E27 CW: Gegenphase CW sichtbar flackerfrei
|
||||
- [ ] G95: Pinbelegung WW=PB15/CW=PB13 prüfen
|
||||
- [ ] Farbtemperaturbereiche aller Varianten
|
||||
- [x] G95: Pinbelegung WW=PB15/CW=PB13 — 2026-09-13 an einer migrierten G95 geprüft: unteres Bereichsende deutlich warm, oberes deutlich kühler (damals noch 2500–5000 K, inzwischen 2000–5000 K), Dimmen auf 10 % flackerfrei, weiches Ausblenden
|
||||
- [ ] Farbtemperaturbereiche aller Varianten — *die Bereiche stammen seit 2026-09-13 von Michael aus den Produktdaten und stehen so in `nl_board.c`: G95 Vintage **2000–5000 K**, alle übrigen **2700–6500 K**. Vorher standen die vier Weiß-Varianten flach auf 2500–5000 K, was für die E27/E14/GU10 CW den oberen Bereich abgeschnitten und für die G95 den unteren verschenkt hat. Am Gerät gegengeprüft ist bisher nur gu10_rgb; die G95 wurde mit den alten Grenzen getestet und gehört an den neuen wiederholt.*
|
||||
|
||||
## Offene Beobachtung aus der EVK-Session vom 2026-09-12
|
||||
|
||||
@@ -73,3 +73,63 @@ unbeeinträchtigt weiter und meldete korrekt `last_ota_error=crc`. Bei den Läuf
|
||||
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.
|
||||
|
||||
## Recovery gegen die installierte Pi-Station (2026-09-13)
|
||||
|
||||
- [x] Station installiert per `tools/station/setup_pi.sh`, idempotent (zweiter Lauf ließ die Konfiguration in Ruhe) — 2026-09-13
|
||||
- [x] `ninlight-station on` schaltet Zugangspunkt und Dienst gemeinsam, `status` meldet Dienst, Adresse, Image und Version, Exit 0 — 2026-09-13
|
||||
- [x] Recovery zieht das Image und flasht: `recovery image version 0.3.7 (running 0.3.6)` → `trailer ok` → Neustart mit der neuen Version — 2026-09-13
|
||||
- [x] Station angelassen, erneut gebootet → `already carries the running version 0.3.7, not flashing`, `ota: skipped`, kein Werksreset (beide Boots melden `boot #1`), `last_ota_error` bleibt leer — 2026-09-13
|
||||
- [x] Zustandsdatei der Station protokolliert beide Auslieferungen mit `status: ok`, Gerätetyp und Version — 2026-09-13
|
||||
- [x] Fehlendes Image für den Gerätetyp → Station antwortet `404`, Eintrag `notfound`, Lampe bootet normal weiter — 2026-09-13 (Lampe meldete `update failed (download)`)
|
||||
- [x] Stillstand: Server hält die Verbindung, sendet nichts mehr → nach Ablauf der Zeitobergrenze startet der Chip neu — 2026-09-13 (Download 00:42:17, ~120 Wiederaufnahmeversuche im 5-s-Takt mit je null Bytes, Obergrenze exakt bei 600000 ms um 00:52:17, Reset um 00:52:56 nach dem 20-s-Watchdog, danach `boot #1` ohne Werksreset)
|
||||
- [x] Wiederaufnahme nach Abriss: Gegenstelle erreichbar, sendet aber nichts mehr → Lampe setzt per `Range` fort — 2026-09-13 (fünf Fortsetzungen 263700 → 198164 → 132628 → 67092 → 1556, Image vollständig; gilt **nicht** für eine tote Gegenstelle, siehe README)
|
||||
- [x] Echter Rettungsfall: Lampe ohne gültige WLAN-Konfiguration findet die Station und wird geflasht — 2026-09-13, zweistufig belegt: (1) Station aus, Build ohne Zugangsdaten seriell aufgespielt → `ninlight 0.3.7-4-gb169fa`, Scan ohne Treffer, Lampe geht in den Onboarding-AP, über Netz nicht erreichbar. (2) Station an, ein Stromzyklus → `station found` → `recovery image version 0.3.7 (running 0.3.7-4-gb169fa)` → `trailer ok` → Neustart → `connecting to ninIOT`. 23 Sekunden vom Einschalten bis zur geretteten Lampe.
|
||||
- [x] `ninlight-station off`, Pi neu starten → Pi kommt im Heim-WLAN hoch, kein Zugangspunkt, Dienst inaktiv — 2026-09-13
|
||||
|
||||
**Bekannte Eigenheit:** Die Lampe lädt bei jedem Boot das komplette Image (~256 KB), auch wenn sie es danach
|
||||
wegen gleicher Version verwirft — die Version steht im Trailer am Dateiende. Kein Fehler, aber die Station
|
||||
sieht pro Boot einen vollen Transfer.
|
||||
|
||||
## Migration echter Lampen (Plan 4b, 2026-09-13)
|
||||
|
||||
- [x] G95 (Dev-Build, Product-ID `-klyqa.lighting.cw-ww.g95-dev`) mit dem **Entwicklungs**schlüssel signiert → angenommen, geflasht, läuft ninlight als `g95_cw`
|
||||
- [x] GU10 (Produktionsbuild, Product-ID `-klyqa.lighting.rgb-cw-ww.gu10`) mit dem **Produktions**schlüssel signiert → angenommen, geflasht, läuft ninlight als `gu10_rgb`
|
||||
- [x] Migrierte Lampe lässt sich anschließend über das normale ninlight-OTA aktualisieren (GU10 auf 0.3.8)
|
||||
|
||||
## Migrationsstation (Plan 4c) — Abnahme 2026-09-14
|
||||
|
||||
Station: Raspberry Pi Zero W Rev 1.1, Raspberry Pi OS Bookworm 12 (Python 3.11.2, Pillow 9.4.0,
|
||||
armv6), Waveshare 2,13" E-Paper **Revision V3** als HAT, ein Taster auf GPIO23 (noch ohne Funktion).
|
||||
|
||||
- [x] `setup_pi.sh` richtet einen **nackten** Pi vollständig ein — Pakete, SPI, beide
|
||||
NetworkManager-Profile, Server, CLI, Anzeigedienst. Zweimal von Grund auf durchgelaufen.
|
||||
- [x] SPI wird automatisch eingeschaltet, der Neustart-Hinweis erscheint nur bei echter Änderung.
|
||||
- [x] `deploy.sh` liefert 8 OTA-Images und 16 signierte Migrationsimages aus.
|
||||
- [x] Panel-Revision am Testbild bestimmt: **V3**.
|
||||
- [x] Horchphase: GU10 RGB meldet sich, wird erkannt, bekommt **404** — es floss kein Byte.
|
||||
Protokoll: `/qcx_firmware_-klyqa.lighting.rgb-cw-ww.gu10_V1.1.1.103_ota_light.img 404 0B`
|
||||
Daraus abgeleitet: Ziel `gu10_rgb`, Variante **prod** (kein `-dev`), Lampenversion V1.1.1.103.
|
||||
- [x] Erkennung am Panel sichtbar, vom Bediener bestätigt.
|
||||
- [x] Zeitschalter holt die Station selbsttätig ins Heim-WLAN zurück (3 und 15 Minuten geprüft).
|
||||
- [x] `arm gu10_rgb` freigegeben, Lampe migriert, startet neu, läuft mit `0.3.8-24-geaaef`,
|
||||
per mDNS auffindbar und über REST steuerbar. `last_ota_error: null`.
|
||||
- [x] Migrierte Lampe meldet `color: true`, `color_temp: [2700, 6500]`, `transition_default: 300`,
|
||||
`wake_on_change: false`.
|
||||
- [x] Ausschaltfade ohne Farbrest an der frisch migrierten Lampe — 2026-09-14 bestätigt.
|
||||
- [ ] **Nicht geprüft und weiterhin offen: E27 RGB, E14 (RGB und CW), GU10 CW.** Von diesen
|
||||
Varianten lag kein Exemplar vor. Ihre Pinbelegung stammt aus der alten Firmware und war
|
||||
nie an echter Hardware verifiziert.
|
||||
|
||||
**Befund während der Abnahme, behoben in `0fe9ca6`:** `ninlight-station off` stoppte den
|
||||
Anzeigedienst, aber kein `on`-Pfad startete ihn wieder — nach dem ersten `off` (das auch der
|
||||
Zeitschalter auslöst) blieb das Panel bis zum Neustart des Pi dunkel. `off` fasst den
|
||||
Anzeigedienst seitdem gar nicht mehr an: er läuft durchgehend und erkennt den ausgeschalteten
|
||||
Zustand selbst (`systemctl is-active ninlight-station`), zeichnet also von sich aus
|
||||
**Station aus**. Der ausdrückliche Diagnosebefehl `ninlight-station display off` bleibt erhalten.
|
||||
Abgesichert durch die Fälle D4 und D4b in `tools/station/tests/test_cli.sh`.
|
||||
|
||||
**Hardware-Lehre:** Vier Kernel-Paniken an einem Tag, alle mit derselben Fehleradresse und
|
||||
unabhängig von Karte und Konfiguration, kamen von einem Mehrfach-Netzteil, das sein Budget
|
||||
dynamisch neu verhandelt. Mit einem eigenen Netzteil: `throttled=0x0` auch unter Schreiblast.
|
||||
Die zerschossene erste SD-Karte war die Folge, nicht die Ursache.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""The Ninlight integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_TOKEN, DEFAULT_PORT, PLATFORMS
|
||||
from .coordinator import NinlightConfigEntry, NinlightCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> bool:
|
||||
"""Set up a Ninlight bulb from a config entry."""
|
||||
coordinator = NinlightCoordinator(hass, entry)
|
||||
|
||||
# Register the teardown before the first refresh: HA runs the on_unload callbacks both
|
||||
# when setup fails and when the entry unloads, and awaits the coroutine they return.
|
||||
# That covers every exit path, so no bulb client can be left with an open connection
|
||||
# slot. The bulb serves one request per connection, so this matters.
|
||||
entry.async_on_unload(coordinator.async_shutdown)
|
||||
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except Exception:
|
||||
# ConfigEntryNotReady / ConfigEntryAuthFailed propagate to HA, which retries or
|
||||
# raises reauth. Close now rather than waiting for the retry.
|
||||
await coordinator.async_shutdown()
|
||||
raise
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Migrate an entry written by an earlier version of this integration."""
|
||||
if config_entry.version > 1:
|
||||
return False
|
||||
if "port" not in config_entry.data or CONF_TOKEN not in config_entry.data:
|
||||
new = dict(config_entry.data)
|
||||
new.setdefault("port", DEFAULT_PORT)
|
||||
new.setdefault(CONF_TOKEN, new.pop("api_key", ""))
|
||||
hass.config_entries.async_update_entry(config_entry, data=new, version=1)
|
||||
_LOGGER.debug("Migrated ninlight entry %s to v1", config_entry.entry_id)
|
||||
return True
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Asynchronous client for the Ninlight REST API.
|
||||
|
||||
The firmware (src/net/nl_api.c) speaks plain HTTP/1.1 on port 3333 with a raw token in
|
||||
the `Authorization` header - no "Bearer" scheme prefix, no TLS, no sessions. The bulb
|
||||
serves one request per connection and accepts at most four queued connections, so the
|
||||
client must not keep a hot connection pool open.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace as _dc_replace
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .const import DEFAULT_PORT
|
||||
|
||||
|
||||
class NinlightError(Exception):
|
||||
"""Base error, raised on connection problems and non-JSON replies."""
|
||||
|
||||
|
||||
class NinlightAuthError(NinlightError):
|
||||
"""The bulb rejected our token (HTTP 401)."""
|
||||
|
||||
|
||||
class NinlightUnavailable(NinlightError):
|
||||
"""The bulb could not be reached at all (connection refused or timeout).
|
||||
|
||||
A bulb that is switched off or has lost Wi-Fi is an expected state, not a fault,
|
||||
so the coordinator treats this differently from protocol errors.
|
||||
"""
|
||||
|
||||
|
||||
class NinlightBadRequest(NinlightError):
|
||||
"""The bulb rejected the payload (HTTP 400) with the reason in its body."""
|
||||
|
||||
def __init__(self, message: str, reason: str | None) -> None:
|
||||
super().__init__(f"{message}: {reason}" if reason else message)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NinlightState:
|
||||
"""A parsed `GET /api/v1/device/state` plus the identity from `system/info`."""
|
||||
|
||||
on: bool
|
||||
brightness: int # 1..100, the firmware's native unit
|
||||
mode: str # "color" | "white"
|
||||
r: int
|
||||
g: int
|
||||
b: int
|
||||
color_temp: int # kelvin
|
||||
ct_min: int
|
||||
ct_max: int
|
||||
has_rgb: bool
|
||||
# Settings echoed by the same reply; the coordinator exposes them as entities.
|
||||
power_on_behaviour: str
|
||||
fade_in: int
|
||||
fade_out: int
|
||||
transition_default: int
|
||||
wake_on_change: bool
|
||||
# From system/info, merged in by the coordinator.
|
||||
device_type: str | None = None
|
||||
product_name: str | None = None
|
||||
fw_version: str | None = None
|
||||
mac: str | None = None
|
||||
name: str | None = None
|
||||
rssi: int | None = None
|
||||
ip: str | None = None
|
||||
# Segment model only (ninstrip): the raw segment list; the light maps onto
|
||||
# segment 0 while a scene across the remaining segments stays untouched.
|
||||
segments: list[dict[str, Any]] = field(default_factory=list)
|
||||
# True for ninstrip firmware, whose light state lives in segments. Decided by
|
||||
# presence of `segment_count` in device/state and `product` in system/info -
|
||||
# both keys are ninstrip-only (nl_api_device.c / nl_api_system.c). Bulb
|
||||
# device_types are board ids (nl_boards[] in ninlight-w600: "evk", ...), so the
|
||||
# name is not a signal, but the keys are.
|
||||
strip: bool = False
|
||||
# False when the last poll could not reach the bulb (powered off / offline).
|
||||
# Kept separate from `on` so the entity can show "unavailable" instead of an
|
||||
# error, while the light keeps its last known appearance.
|
||||
available: bool = True
|
||||
|
||||
@classmethod
|
||||
def unavailable_copy(cls, last: "NinlightState") -> "NinlightState":
|
||||
"""The last known state, flagged unavailable.
|
||||
|
||||
Keeps the light's appearance (on/off, color) at its last real value while the
|
||||
entity shows as unavailable, instead of falling back to defaults.
|
||||
"""
|
||||
return _dc_replace(last, available=False)
|
||||
|
||||
@classmethod
|
||||
def from_api(
|
||||
cls,
|
||||
state: dict[str, Any],
|
||||
info: dict[str, Any] | None = None,
|
||||
segments: list[dict[str, Any]] | None = None,
|
||||
) -> NinlightState:
|
||||
color = state.get("color") or {}
|
||||
caps = state.get("capabilities") or {}
|
||||
ct = caps.get("color_temp") or [2700, 6500]
|
||||
wifi = (info or {}).get("wifi") or {}
|
||||
seg = (segments or [None])[0] or {}
|
||||
# `segment_count` and `product` exist only on ninstrip firmware; the bulb's
|
||||
# device_type is a board id and carries no signal either way.
|
||||
strip = "segment_count" in state or "product" in (info or {})
|
||||
# On a strip the light lives in segment 0; mode "off" carries no color at all,
|
||||
# so mode/color fall back to white there and is_on decides darkness anyway.
|
||||
seg_mode = seg.get("mode")
|
||||
return cls(
|
||||
on=bool(state.get("on", False)),
|
||||
brightness=int(state.get("brightness", 0)),
|
||||
mode="color" if seg_mode == "solid" else str(state.get("mode", "white")),
|
||||
r=int((seg.get("color") or color).get("r", 0)),
|
||||
g=int((seg.get("color") or color).get("g", 0)),
|
||||
b=int((seg.get("color") or color).get("b", 0)),
|
||||
color_temp=int(seg.get("color_temp", state.get("color_temp", 2700))),
|
||||
ct_min=int(ct[0]),
|
||||
ct_max=int(ct[1]),
|
||||
has_rgb=bool(caps.get("color", True)) if strip else bool(caps.get("color", False)),
|
||||
power_on_behaviour=str(state.get("power_on_behaviour", "last")),
|
||||
fade_in=int(state.get("fade_in", 0)),
|
||||
fade_out=int(state.get("fade_out", 0)),
|
||||
transition_default=int(state.get("transition_default", 0)),
|
||||
wake_on_change=bool(state.get("wake_on_change", False)),
|
||||
device_type=(info or {}).get("device_type"),
|
||||
product_name=(info or {}).get("product_name"),
|
||||
fw_version=(info or {}).get("fw_version"),
|
||||
mac=(info or {}).get("mac"),
|
||||
name=(info or {}).get("name"),
|
||||
rssi=wifi.get("rssi"),
|
||||
ip=wifi.get("sta_ip") or wifi.get("ip"),
|
||||
segments=list(segments or []),
|
||||
strip=strip,
|
||||
)
|
||||
|
||||
|
||||
class NinlightClient:
|
||||
"""One client per bulb (per config entry)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
token: str,
|
||||
*,
|
||||
port: int = DEFAULT_PORT,
|
||||
session: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
self._base = f"http://{host}:{port}/api/v1"
|
||||
# Raw token, not "Bearer <token>": nl_http_parse hands the header value straight to
|
||||
# token_ok(), which compares it against the stored token byte for byte.
|
||||
self._headers = {"Authorization": token}
|
||||
self._owns_session = session is None
|
||||
self._session: httpx.AsyncClient = session or httpx.AsyncClient(
|
||||
headers=self._headers,
|
||||
timeout=httpx.Timeout(8.0, connect=4.0),
|
||||
# keepalive_expiry of ~0 makes httpx close after each response instead of
|
||||
# holding one of the bulb's four listen-queue slots open between polls.
|
||||
limits=httpx.Limits(max_connections=1, max_keepalive_connections=0, keepalive_expiry=0.1),
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._owns_session:
|
||||
await self._session.aclose()
|
||||
|
||||
async def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
resp = await self._session.request(method, f"{self._base}{path}", json=payload)
|
||||
except httpx.InvalidURL as err: # programming error, not a transient one
|
||||
raise NinlightError(f"invalid bulb URL: {err}") from err
|
||||
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as err:
|
||||
# No answer at all: bulb powered off, rebooting, or gone from the WLAN.
|
||||
raise NinlightUnavailable(f"bulb unreachable: {err}") from err
|
||||
except httpx.HTTPError as err:
|
||||
raise NinlightError(f"request to bulb failed: {err}") from err
|
||||
|
||||
if resp.status_code == 401:
|
||||
raise NinlightAuthError("bulb rejected the API token")
|
||||
if resp.status_code == 400:
|
||||
raise NinlightBadRequest("bulb rejected the request", _error_field(resp))
|
||||
if resp.status_code != 200:
|
||||
raise NinlightError(f"bulb answered {resp.status_code}: {_error_field(resp)}")
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError as err:
|
||||
raise NinlightError("bulb sent a non-JSON body") from err
|
||||
if not isinstance(data, dict):
|
||||
raise NinlightError("bulb sent an unexpected JSON shape")
|
||||
return data
|
||||
|
||||
async def get_state(self) -> dict[str, Any]:
|
||||
return await self._request("GET", "/device/state")
|
||||
|
||||
async def get_segments(self) -> dict[str, Any]:
|
||||
"""Segment model of the ninstrip firmware (bulbs have no such endpoint)."""
|
||||
return await self._request("GET", "/device/segments")
|
||||
|
||||
async def set_segments(self, segments: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Replace the whole segment list. ninstrip answers with `{"status": "ok"}`."""
|
||||
return await self._request("PUT", "/device/segments", {"segments": segments})
|
||||
|
||||
async def patch_segment(self, index: int, patch: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Update one segment; omitted fields keep their current value."""
|
||||
return await self._request("PUT", f"/device/segments/{index}", patch)
|
||||
|
||||
async def get_info(self) -> dict[str, Any]:
|
||||
return await self._request("GET", "/system/info")
|
||||
|
||||
async def get_settings(self) -> dict[str, Any]:
|
||||
"""`system/settings` is the one endpoint that always requires the token.
|
||||
|
||||
`system/info` and a state GET answer without auth unless `auth_all` is set
|
||||
(nl_api.c: the state branch tests `s.auth_all && !authed`, the info branch tests
|
||||
nothing), so only settings proves the token is right.
|
||||
"""
|
||||
return await self._request("GET", "/system/settings")
|
||||
|
||||
async def set_state(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return await self._request("POST", "/device/state", payload)
|
||||
|
||||
async def ping(self) -> dict[str, Any]:
|
||||
return await self._request("PUT", "/system/command", {"command": {"type": "ping"}})
|
||||
|
||||
async def reboot(self) -> dict[str, Any]:
|
||||
return await self._request("PUT", "/system/command", {"command": {"type": "reboot"}})
|
||||
|
||||
|
||||
def _error_field(resp: httpx.Response) -> str | None:
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return resp.text[:120] or None
|
||||
if isinstance(body, dict):
|
||||
err = body.get("error")
|
||||
return str(err) if err is not None else None
|
||||
return None
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Config flow for Ninlight."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .client import NinlightAuthError, NinlightClient, NinlightError
|
||||
from .const import CONF_TOKEN, DEFAULT_PORT, DEFAULT_TOKEN, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("host"): str,
|
||||
vol.Optional("port", default=DEFAULT_PORT): vol.All(vol.Coerce(int), vol.Range(1, 65535)),
|
||||
vol.Optional(CONF_TOKEN, default=DEFAULT_TOKEN): vol.All(str, vol.Length(min=1, max=64)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _normalize_mac(mac: str) -> str:
|
||||
"""Keep one canonical form for the unique id (zeroconf and system/info both use `aa:bb:...`)."""
|
||||
return mac.strip().lower()
|
||||
|
||||
|
||||
def _is_full_mac(mac: str) -> bool:
|
||||
"""True for a colon-separated 12-hex-digit MAC, the only TXT form usable as unique id.
|
||||
|
||||
ninstrip devices publish the 4-byte suffix (`eb4b98`, nl_wifi.c's mac4) in their TXT
|
||||
record; two bulbs sharing a last octet would collide, so those need a probe instead.
|
||||
"""
|
||||
parts = mac.strip().lower().split(":")
|
||||
return len(parts) == 6 and all(len(p) == 2 and all(c in "0123456789abcdef" for c in p) for p in parts)
|
||||
|
||||
|
||||
class NinlightConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Ninlight."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def _probe_mac(self, host: str, port: int) -> str:
|
||||
"""Return the bulb's MAC. Needs no token: `system/info` is unauthenticated."""
|
||||
client = NinlightClient(host, DEFAULT_TOKEN, port=port)
|
||||
try:
|
||||
info = await client.get_info()
|
||||
finally:
|
||||
await client.close()
|
||||
mac = info.get("mac")
|
||||
if not isinstance(mac, str) or not mac:
|
||||
raise NinlightError("bulb did not report a MAC address")
|
||||
return _normalize_mac(mac)
|
||||
|
||||
async def _probe_mac_and_token(self, host: str, port: int, token: str) -> str:
|
||||
"""As above, plus verify the token against `system/settings`.
|
||||
|
||||
Only that endpoint authenticates, so a probe that stops at system/info would
|
||||
happily accept a wrong token (nl_api.c's info branch never checks `authed`).
|
||||
"""
|
||||
client = NinlightClient(host, token, port=port)
|
||||
try:
|
||||
info = await client.get_info()
|
||||
await client.get_settings()
|
||||
finally:
|
||||
await client.close()
|
||||
mac = info.get("mac")
|
||||
if not isinstance(mac, str) or not mac:
|
||||
raise NinlightError("bulb did not report a MAC address")
|
||||
return _normalize_mac(mac)
|
||||
|
||||
async def _entry_from_data(self, data: dict[str, Any], title: str) -> ConfigFlowResult:
|
||||
await self.async_set_unique_id(data["mac"])
|
||||
# Addresses come from DHCP, so rediscovering a known MAC at a new IP must move the
|
||||
# existing entry rather than abort a re-add.
|
||||
self._abort_if_unique_id_configured(updates=data)
|
||||
return self.async_create_entry(title=title, data=data)
|
||||
|
||||
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
host = user_input["host"].strip()
|
||||
port = int(user_input["port"])
|
||||
token = user_input[CONF_TOKEN].strip()
|
||||
try:
|
||||
mac = await self._probe_mac_and_token(host, port, token)
|
||||
except NinlightAuthError:
|
||||
errors[CONF_TOKEN] = "invalid_auth"
|
||||
except NinlightError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
return await self._entry_from_data(
|
||||
{"host": host, "port": port, CONF_TOKEN: token, "mac": mac}, title=host
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle discovery of a `_ninlight._tcp` service."""
|
||||
props = {str(k).lower(): str(v) for k, v in discovery_info.properties.items()}
|
||||
port = discovery_info.port or DEFAULT_PORT
|
||||
mac = props.get("mac", "")
|
||||
# A TXT record may hold no MAC at all (bulb still announcing) or only the short
|
||||
# 4-byte suffix form ninstrip publishes (`eb4b98`). Unique ids are full MACs, so
|
||||
# anything that is not one colon-form is replaced by a probe of system/info.
|
||||
if not _is_full_mac(mac):
|
||||
try:
|
||||
mac = await self._probe_mac(discovery_info.host, port)
|
||||
except NinlightError:
|
||||
raise AbortFlow("cannot_connect") from None
|
||||
mac = _normalize_mac(mac)
|
||||
|
||||
await self.async_set_unique_id(mac)
|
||||
updates = {
|
||||
"host": discovery_info.host,
|
||||
"port": port,
|
||||
"mac": mac,
|
||||
"device_type": props.get("type", ""),
|
||||
}
|
||||
self._abort_if_unique_id_configured(updates=updates)
|
||||
|
||||
# Instance name is `ninlight-<mac4>` until someone names the bulb; a name set on
|
||||
# the device is published in the TXT record and wins.
|
||||
title = props.get("name") or discovery_info.name.split(".", 1)[0]
|
||||
self.context["title_placeholders"] = {"name": title}
|
||||
# Create rather than prompt. A confirm step would leave the flow parked in
|
||||
# `in_progress`, where it blocks a manual add of the same bulb with
|
||||
# already_in_progress - and a bulb that is reachable but not announcing mDNS
|
||||
# (two of ours) has to be added manually, so that collision is not theoretical.
|
||||
return self.async_create_entry(title=title, data={**updates, CONF_TOKEN: DEFAULT_TOKEN})
|
||||
|
||||
async def async_step_reauth(self, entry_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
|
||||
assert entry is not None
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
token = user_input[CONF_TOKEN].strip()
|
||||
try:
|
||||
await self._probe_mac_and_token(entry.data["host"], entry.data.get("port", DEFAULT_PORT), token)
|
||||
except NinlightAuthError:
|
||||
errors[CONF_TOKEN] = "invalid_auth"
|
||||
except NinlightError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
self.hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_TOKEN: token})
|
||||
await self.hass.config_entries.async_reload(entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({vol.Required(CONF_TOKEN, default=entry.data.get(CONF_TOKEN, "")): str}),
|
||||
errors=errors,
|
||||
description_placeholders={"name": entry.title},
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Constants for the Ninlight integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "ninlight"
|
||||
|
||||
PLATFORMS: Final = [Platform.LIGHT]
|
||||
|
||||
CONF_TOKEN = "token"
|
||||
|
||||
DEFAULT_PORT = 3333
|
||||
DEFAULT_TOKEN = "aabbccddeeff0011223344"
|
||||
|
||||
# The firmware has no push channel, so state is polled. A bulb answers in well under
|
||||
# a second on the LAN; 30 s keeps the UI honest without adding noticeable REST traffic
|
||||
# for a nine bulb setup.
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
# Mirror of NL_MAX_TRANSITION_MS in src/light/nl_control.h.
|
||||
MAX_TRANSITION_MS = 60000
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Data coordinator for a single Ninlight bulb."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from typing import Any, TypeAlias
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .client import (
|
||||
NinlightAuthError,
|
||||
NinlightBadRequest,
|
||||
NinlightClient,
|
||||
NinlightError,
|
||||
NinlightState,
|
||||
NinlightUnavailable,
|
||||
)
|
||||
from .const import CONF_TOKEN, DEFAULT_PORT, DOMAIN, SCAN_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NinlightCoordinator(DataUpdateCoordinator[NinlightState]):
|
||||
"""Polls one bulb. One coordinator, and one HTTP client, per config entry.
|
||||
|
||||
`system/info` is fetched on the first successful refresh only. It carries identity
|
||||
(MAC, product name, firmware) that does not change while the bulb runs, and the bulb
|
||||
accepts at most four queued TCP connections (listen(,4) in nl_http_server.c), so
|
||||
skipping it on every poll keeps one of those slots free for the UI.
|
||||
"""
|
||||
|
||||
config_entry: NinlightConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: NinlightConfigEntry) -> None:
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=f"{DOMAIN} {entry.title}",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
self.client = NinlightClient(
|
||||
entry.data["host"],
|
||||
entry.data.get(CONF_TOKEN, ""),
|
||||
port=entry.data.get("port", DEFAULT_PORT),
|
||||
)
|
||||
self.info: dict[str, Any] | None = None
|
||||
self._identity_ok = False
|
||||
self._is_strip = False
|
||||
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Close the HTTP client alongside the coordinator's own teardown."""
|
||||
await self.client.close()
|
||||
await super().async_shutdown()
|
||||
|
||||
async def _async_update_data(self) -> NinlightState:
|
||||
try:
|
||||
state = await self.client.get_state()
|
||||
except NinlightAuthError as err:
|
||||
# Only possible when the bulb has `auth_all` set; a wrong token will not fix
|
||||
# itself, so surface a re-auth flow instead of retrying every interval.
|
||||
raise ConfigEntryAuthFailed(str(err)) from err
|
||||
except NinlightUnavailable as err:
|
||||
# A bulb that is switched off or off the WLAN is an expected state, not a
|
||||
# fault. Keep the last known data with `available=False` so the entity shows
|
||||
# "unavailable" and the config entry stays loaded - no error log, no retry
|
||||
# backoff, no reload. The next poll picks the bulb up when it answers again.
|
||||
_LOGGER.debug("%s unreachable (assumed powered off): %s", self.name, err)
|
||||
if isinstance(self.data, NinlightState):
|
||||
return NinlightState.unavailable_copy(self.data)
|
||||
# No successful poll yet (e.g. HA started while the bulb was off): a default
|
||||
# state carries no identity, but the entry still loads cleanly and the
|
||||
# identity fetch below reruns on the first poll that reaches the bulb.
|
||||
return NinlightState.unavailable_copy(NinlightState.from_api({}))
|
||||
except NinlightBadRequest as err:
|
||||
# A poll sends no body, so this can only mean a firmware we do not match.
|
||||
raise UpdateFailed(str(err)) from err
|
||||
except NinlightError as err:
|
||||
raise UpdateFailed(str(err)) from err
|
||||
|
||||
if not self._identity_ok:
|
||||
# One extra pair of requests, then never again: the bulb accepts at most four
|
||||
# queued TCP connections (listen(,4) in nl_http_server.c), so keeping the poll
|
||||
# path to a single GET leaves slots free for the UI.
|
||||
with suppress(NinlightError):
|
||||
# system/info carries identity that only changes on a reboot or an OTA.
|
||||
self.info = await self.client.get_info()
|
||||
try:
|
||||
# system/settings always demands the token, so it is the only call that
|
||||
# proves ours is right. A failure here on a bulb whose state endpoint
|
||||
# answered happily means the token is wrong while `auth_all` is off - the
|
||||
# light would work and silently never be able to be renamed or updated.
|
||||
await self.client.get_settings()
|
||||
except NinlightAuthError as err:
|
||||
raise ConfigEntryAuthFailed(str(err)) from err
|
||||
# `product` is an ninstrip-only key in system/info (bulbs send
|
||||
# product_name); once known, the heavier segment poll follows every refresh.
|
||||
self._is_strip = "product" in (self.info or {})
|
||||
self._identity_ok = True
|
||||
|
||||
segments: list[dict[str, Any]] | None = None
|
||||
if self._is_strip:
|
||||
# The light lives in the segment list; the plain state GET above carries
|
||||
# only on/brightness for it. A firmware without the endpoint (misdetected
|
||||
# strip) must not fail the whole poll for that.
|
||||
try:
|
||||
segments = (await self.client.get_segments()).get("segments")
|
||||
except NinlightError as err:
|
||||
_LOGGER.debug("segment poll failed: %s", err)
|
||||
|
||||
return NinlightState.from_api(state, self.info, segments)
|
||||
|
||||
|
||||
# Declared with TypeAlias (not the 3.12 `type` statement) so the same file lints on the
|
||||
# project's 3.11 tooling and runs on HA's 3.14.
|
||||
NinlightConfigEntry: TypeAlias = ConfigEntry[NinlightCoordinator]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Base entity for Ninlight devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DEFAULT_PORT, DOMAIN
|
||||
from .coordinator import NinlightCoordinator, NinlightConfigEntry
|
||||
|
||||
|
||||
class NinlightEntity(CoordinatorEntity[NinlightCoordinator]):
|
||||
"""Common device info for every entity of one bulb."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: NinlightCoordinator, entry: NinlightConfigEntry) -> None:
|
||||
super().__init__(coordinator, context=entry.entry_id)
|
||||
mac = (entry.unique_id or "unknown").lower()
|
||||
info = coordinator.info or {}
|
||||
# Deliberately no `connections={("mac", mac)}`: HA treats a MAC in `connections`
|
||||
# as a network-topology link, so a bulb the FRITZ!Box tracker already knows merged
|
||||
# into that router-adjacent device and its entity id fell back to the IP. The MAC
|
||||
# still identifies the device via unique_id; identifiers are the sole identity here.
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, mac)},
|
||||
# The bulb itself has no user-visible name field that survives rediscovery
|
||||
# cleanly, so the entry title (set from mDNS or by the user) names the device.
|
||||
name=entry.title,
|
||||
manufacturer="Klyqa",
|
||||
model=info.get("product_name") or info.get("product"),
|
||||
model_id=info.get("device_type"),
|
||||
sw_version=info.get("fw_version"),
|
||||
configuration_url=f"http://{entry.data['host']}:{entry.data.get('port', DEFAULT_PORT)}/",
|
||||
)
|
||||
|
||||
|
||||
def _entry_mac(entry: NinlightConfigEntry) -> str:
|
||||
mac: str = entry.unique_id or "unknown"
|
||||
return mac.lower()
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Light platform for Ninlight bulbs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_RGB_COLOR,
|
||||
ATTR_TRANSITION,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
LightEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .client import NinlightBadRequest, NinlightError, NinlightState
|
||||
from .const import MAX_TRANSITION_MS
|
||||
from .coordinator import NinlightConfigEntry, NinlightCoordinator
|
||||
from .entity import NinlightEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: NinlightConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the light for a Ninlight bulb config entry."""
|
||||
async_add_entities([NinlightLight(entry.runtime_data, entry)])
|
||||
|
||||
|
||||
def _to_ha_brightness(pct: int) -> int:
|
||||
"""Bulb brightness is 1..100; HA brightness is 0..255."""
|
||||
return max(1, min(255, round(pct * 255 / 100)))
|
||||
|
||||
|
||||
def _to_bulb_brightness(level: int) -> int:
|
||||
"""The bulb rejects brightness 0 and anything above 100 (invalid brightness)."""
|
||||
return max(1, min(100, round(level * 100 / 255)))
|
||||
|
||||
|
||||
class NinlightLight(NinlightEntity, LightEntity):
|
||||
"""A Klyqa bulb running the Ninlight firmware."""
|
||||
|
||||
# No translation_key: with has_entity_name and a single entity per device, HA names the
|
||||
# light after the device, giving light.schreibtisch_links rather than ..._bulb.
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, coordinator: NinlightCoordinator, entry: NinlightConfigEntry) -> None:
|
||||
super().__init__(coordinator, entry)
|
||||
self._attr_unique_id = f"{(entry.unique_id or '').lower()}-light"
|
||||
self._attr_supported_features = LightEntityFeature.TRANSITION
|
||||
|
||||
# Capabilities come from the polled state, not from a one-time __init__ snapshot: an
|
||||
# entry set up while the bulb was powered off starts from defaults and must pick up
|
||||
# the real RGB/CT range as soon as the bulb answers again.
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
# An unreachable (powered-off) bulb is an expected state: the entity goes
|
||||
# unavailable instead of the config entry erroring.
|
||||
return self.coordinator.last_update_success and self.coordinator.data.available
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[ColorMode]:
|
||||
state = self.coordinator.data
|
||||
# Both modes for RGB bulbs: the firmware keeps the last color and the last color
|
||||
# temperature and picks between them with `mode`, exactly how HA models a
|
||||
# color_temp + rgb light.
|
||||
return {ColorMode.COLOR_TEMP, ColorMode.RGB} if state.has_rgb else {ColorMode.COLOR_TEMP}
|
||||
|
||||
@property
|
||||
def min_color_temp_kelvin(self) -> int:
|
||||
return self.coordinator.data.ct_min
|
||||
|
||||
@property
|
||||
def max_color_temp_kelvin(self) -> int:
|
||||
return self.coordinator.data.ct_max
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
return self.coordinator.data.on
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
return _to_ha_brightness(self.coordinator.data.brightness)
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
state = self.coordinator.data
|
||||
if not state.on:
|
||||
# Nothing is lit; reporting a mode would contradict is_on=False.
|
||||
return ColorMode.UNKNOWN
|
||||
if state.has_rgb and state.mode == "color":
|
||||
return ColorMode.RGB
|
||||
return ColorMode.COLOR_TEMP
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int | None:
|
||||
state = self.coordinator.data
|
||||
return state.color_temp if self.color_mode == ColorMode.COLOR_TEMP else None
|
||||
|
||||
@property
|
||||
def rgb_color(self) -> tuple[int, int, int] | None:
|
||||
state = self.coordinator.data
|
||||
if self.color_mode == ColorMode.RGB:
|
||||
return (state.r, state.g, state.b)
|
||||
return None
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
state = self.coordinator.data
|
||||
payload: dict[str, Any] = {"on": True}
|
||||
|
||||
if (level := kwargs.get(ATTR_BRIGHTNESS)) is not None:
|
||||
payload["brightness"] = _to_bulb_brightness(int(level))
|
||||
elif not state.on:
|
||||
# HA expects turn_on without a brightness to restore the previous level. The
|
||||
# firmware already does that (it keeps `brightness` while off), so sending it
|
||||
# would only re-assert a value the bulb may have clamped itself - but a bulb
|
||||
# that has never been on reports brightness 0, which its API rejects. Mirror
|
||||
# the last known value only when the bulb actually has one.
|
||||
if state.brightness < 1:
|
||||
payload["brightness"] = 100
|
||||
|
||||
# The firmware rejects color and color_temp in one request with
|
||||
# "color and color_temp are exclusive", so pick one. HA only sends both when a
|
||||
# caller passes them together; color is the more specific intent and wins.
|
||||
kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
|
||||
rgb = kwargs.get(ATTR_RGB_COLOR)
|
||||
seg: dict[str, Any] | None = None
|
||||
if rgb is not None and state.has_rgb:
|
||||
r, g, b = (int(max(0, min(255, v))) for v in rgb)
|
||||
if state.strip:
|
||||
seg = {"mode": "solid", "color": {"r": r, "g": g, "b": b}}
|
||||
else:
|
||||
payload["color"] = {"r": r, "g": g, "b": b}
|
||||
elif kelvin is not None:
|
||||
ct = max(state.ct_min, min(state.ct_max, int(kelvin)))
|
||||
if state.strip:
|
||||
seg = {"mode": "white", "color_temp": ct}
|
||||
else:
|
||||
payload["color_temp"] = ct
|
||||
|
||||
if (transition := kwargs.get(ATTR_TRANSITION)) is not None:
|
||||
payload["transition"] = max(0, min(MAX_TRANSITION_MS, int(float(transition) * 1000)))
|
||||
|
||||
await self._apply(payload, seg)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
payload: dict[str, Any] = {"on": False}
|
||||
if (transition := kwargs.get(ATTR_TRANSITION)) is not None:
|
||||
payload["transition"] = max(0, min(MAX_TRANSITION_MS, int(float(transition) * 1000)))
|
||||
await self._apply(payload, None)
|
||||
|
||||
async def _apply(self, payload: dict[str, Any], seg_patch: dict[str, Any] | None) -> None:
|
||||
"""Send one command and update immediately.
|
||||
|
||||
POST /device/state answers with the full new state, so the coordinator does not
|
||||
need a refresh round-trip and the UI settles on the real bulb state at once.
|
||||
|
||||
On the segment-model firmware the color goes into segment 0 as a separate PATCH
|
||||
(`PUT /device/segments/0`, fields omitted keep their value); the master on and
|
||||
brightness stay in the state POST, because the firmware's fade rides on them.
|
||||
Segment brightness stays untouched so master scaling remains the single knob.
|
||||
"""
|
||||
try:
|
||||
data = await self.coordinator.client.set_state(payload)
|
||||
if seg_patch is not None:
|
||||
segs = self.coordinator.data.segments
|
||||
start = segs[0]["start"] if segs else 0
|
||||
end = segs[0]["end"] if segs else max(0, int(data.get("pixel_count", 1)) - 1)
|
||||
await self.coordinator.client.patch_segment(
|
||||
0, {**seg_patch, "start": start, "end": end}
|
||||
)
|
||||
except NinlightBadRequest as err:
|
||||
# A rejected command is a bug or a firmware mismatch, not a network fault:
|
||||
# surface it to the caller and leave the entity's state alone.
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
except NinlightError as err:
|
||||
self.coordinator.async_set_update_error(err)
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
|
||||
# Push the device's own reply through the coordinator so every entity of this
|
||||
# device (light, sensors) settles on the real state at once.
|
||||
segments = None
|
||||
if self.coordinator.data.strip:
|
||||
try:
|
||||
segments = (await self.coordinator.client.get_segments()).get("segments")
|
||||
except NinlightError:
|
||||
pass # the next poll recovers it; the state reply above is still right
|
||||
self.coordinator.async_set_updated_data(
|
||||
NinlightState.from_api(data, self.coordinator.info, segments)
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "ninlight",
|
||||
"name": "Ninlight",
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://github.com/ninharp/ninlight-w600",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": [],
|
||||
"version": "0.1.0",
|
||||
"zeroconf": ["_ninlight._tcp.local.", "_ninstrip._tcp.local."]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"config": {
|
||||
"steps": {
|
||||
"user": {
|
||||
"title": "Ninlight bulb",
|
||||
"description": "Connect to a bulb running the Ninlight firmware. Use the address shown in your router, or `ninlight-<mac>.local`.",
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"token": "API token"
|
||||
},
|
||||
"data_description": {
|
||||
"token": "The bulb's api.token setting. Defaults to the firmware's built-in token."
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Reauthenticate {name}",
|
||||
"description": "The bulb rejected the stored API token. Enter its current token.",
|
||||
"data": {
|
||||
"token": "API token"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Could not reach the bulb on port 3333. Check the address and that the bulb is powered.",
|
||||
"invalid_auth": "Wrong API token."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This bulb is already configured.",
|
||||
"already_in_progress": "A setup flow for this bulb is already running.",
|
||||
"cannot_connect": "Could not reach the bulb.",
|
||||
"reauth_successful": "The API token was updated."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"config": {
|
||||
"steps": {
|
||||
"user": {
|
||||
"title": "Ninlight bulb",
|
||||
"description": "Connect to a bulb running the Ninlight firmware. Use the address shown in your router, or `ninlight-<mac>.local`.",
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"token": "API token"
|
||||
},
|
||||
"data_description": {
|
||||
"token": "The bulb's api.token setting. Defaults to the firmware's built-in token."
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Reauthenticate {name}",
|
||||
"description": "The bulb rejected the stored API token. Enter its current token.",
|
||||
"data": {
|
||||
"token": "API token"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Could not reach the bulb on port 3333. Check the address and that the bulb is powered.",
|
||||
"invalid_auth": "Wrong API token."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This bulb is already configured.",
|
||||
"already_in_progress": "A setup flow for this bulb is already running.",
|
||||
"cannot_connect": "Could not reach the bulb.",
|
||||
"reauth_successful": "The API token was updated."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Laufzeitarteifakte
|
||||
qcx-requests.log
|
||||
hostapd.log
|
||||
*.pid
|
||||
images/
|
||||
@@ -0,0 +1,11 @@
|
||||
# Ninlight Bulbs — MAC → Raum (identifiziert 2026-09-21)
|
||||
f7ce Küche (grün)
|
||||
fd1e Küche (rot)
|
||||
fadb Küche (magenta; erster Converter vom 20.9.)
|
||||
f860 Schlafzimmer
|
||||
faed Schlafzimmer
|
||||
fb38 Schlafzimmer (blau)
|
||||
63e9 Sofa (hellblau)
|
||||
10a7 unbekannt (on:false, IP .29)
|
||||
1fe9 unbekannt (on:false, IP .31)
|
||||
f960 Montana Black LED-Shield (28:6d:ce:3c:f9:60, ledshield_rgb, 25.9. migriert)
|
||||
@@ -0,0 +1,13 @@
|
||||
# dnsmasq for the qconnex-firmware listener (no service integration)
|
||||
interface=wlan0
|
||||
bind-interfaces
|
||||
listen-address=192.168.8.1
|
||||
no-resolv
|
||||
server=192.168.178.1
|
||||
address=/#/192.168.8.1
|
||||
dhcp-range=192.168.8.10,192.168.8.200,255.255.255.0,10m
|
||||
dhcp-option=3,192.168.8.1
|
||||
dhcp-option=6,192.168.8.1
|
||||
log-dhcp
|
||||
log-queries
|
||||
no-ping
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tear down the qconnex-firmware migration listener.
|
||||
set -u
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
SUDO=${SUDO:-sudo -n}
|
||||
[ -f "$HERE/otaserve.pid" ] && $SUDO pkill -f "python3 $HERE/otaserve.py" 2>/dev/null; rm -f "$HERE/otaserve.pid"
|
||||
[ -f "$HERE/dnsmasq.pid" ] && $SUDO kill "$(cat "$HERE/dnsmasq.pid")" 2>/dev/null && rm -f "$HERE/dnsmasq.pid"
|
||||
$SUDO pkill -f "hostapd -B -f $HERE/hostapd.log" 2>/dev/null
|
||||
$SUDO ip addr flush dev wlan0 2>/dev/null
|
||||
$SUDO nmcli dev set wlan0 managed yes 2>/dev/null || true
|
||||
echo "listener down"
|
||||
@@ -0,0 +1,15 @@
|
||||
# qconnex-firmware listener AP — temporary experiment, do not enable as a service
|
||||
interface=wlan0
|
||||
driver=nl80211
|
||||
ssid=qconnex-firmware
|
||||
hw_mode=g
|
||||
channel=6
|
||||
ieee80211n=0
|
||||
auth_algs=1
|
||||
# WPA2 with the password the firmware's bootup-OTA STA config expects.
|
||||
# (Open AP worked for the W600 bulbs; ESP32-C3 STA threshold is WPA_PSK.)
|
||||
wpa=2
|
||||
wpa_passphrase=qconnex-firmware
|
||||
wpa_pairwise=CCMP
|
||||
rsn_pairwise=CCMP
|
||||
wmm_enabled=0
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Logging HTTP server for the qconnex-firmware listener experiment.
|
||||
|
||||
Logs every request (method, path, headers, body snippet) to qcx-requests.log
|
||||
and answers 404 for everything. No images served yet — this run only watches
|
||||
what the original Klyqa firmware asks for after joining the AP.
|
||||
"""
|
||||
import http.server
|
||||
import sys
|
||||
|
||||
LOG = sys.path[0] + "/qcx-requests.log"
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, fmt, *args): # keep our own log format only
|
||||
pass
|
||||
|
||||
def handle_one_request_log(self):
|
||||
body = b""
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
if n:
|
||||
body = self.rfile.read(min(n, 2048))
|
||||
with open(LOG, "a") as f:
|
||||
f.write("=== %s\n" % self.command)
|
||||
f.write("url: %s\n" % self.path)
|
||||
f.write("client: %s:%s\n" % self.client_address)
|
||||
for k, v in self.headers.items():
|
||||
f.write("%s: %s\n" % (k, v))
|
||||
if body:
|
||||
f.write("body: %r\n" % body)
|
||||
f.flush()
|
||||
print("%s %s %s" % (self.command, self.client_address[0], self.path), flush=True)
|
||||
payload = b"not found\n"
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
do_GET = do_POST = do_HEAD = do_PUT = do_DELETE = do_OPTIONS = handle_one_request_log
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
srv = http.server.ThreadingHTTPServer(("0.0.0.0", 80), Handler)
|
||||
print("qcx log server on 0.0.0.0:80", flush=True)
|
||||
srv.serve_forever()
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Onboard a qconnex/KLYQA bulb via its softAP TLS onboarding socket (port 2223).
|
||||
|
||||
Usage: onboard.py <bulb-ip> <ssid> <wifi-password> [access-token]
|
||||
|
||||
Wire framing: every message is a 4-byte header (u16 BE payload length + 2
|
||||
reserved bytes) followed by the JSON payload. Sends
|
||||
{"type":"configure","ssid":"<hex>","password":"***","access_token":...}
|
||||
and prints replies. Device IP is 10.42.42.10 while its KLYQA-xxxxxx softAP is up.
|
||||
"""
|
||||
import socket, ssl, sys, json, struct
|
||||
|
||||
def frame(obj):
|
||||
b = json.dumps(obj).encode()
|
||||
return struct.pack(">HH", len(b), 0) + b
|
||||
|
||||
def main():
|
||||
ip, ssid, pswd = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
token = sys.argv[4] if len(sys.argv) > 4 else "aabbccddeeff0011223344"
|
||||
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
ctx.options |= 0x4 # OP_LEGACY_SERVER_CONNECT (mbedTLS server)
|
||||
|
||||
s = ctx.wrap_socket(socket.create_connection((ip, 2223), timeout=10))
|
||||
print(f"connected {ip}:2223 {s.cipher()[0]}", file=sys.stderr)
|
||||
|
||||
buf = b""
|
||||
def drain(sec, label):
|
||||
nonlocal buf
|
||||
s.settimeout(sec)
|
||||
n = 0
|
||||
try:
|
||||
while True:
|
||||
chunk = s.recv(4096)
|
||||
if not chunk: break
|
||||
buf += chunk
|
||||
while len(buf) >= 4:
|
||||
ln = struct.unpack(">H", buf[:2])[0]
|
||||
if len(buf) < 4 + ln: break
|
||||
msg, buf = buf[4+ln:], buf[4+ln:]
|
||||
n += 1
|
||||
print(f"{label}{msg.decode(errors='replace')[:400]}")
|
||||
except socket.timeout:
|
||||
pass
|
||||
return n
|
||||
|
||||
drain(2, "recv: ")
|
||||
cfg = {"type": "configure",
|
||||
"ssid": ssid.encode().hex(),
|
||||
"password": pswd.encode().hex(),
|
||||
"access_token": token}
|
||||
s.sendall(frame(cfg))
|
||||
print("sent configure", file=sys.stderr)
|
||||
if not drain(10, "recv: "):
|
||||
print("no reply (device may just proceed to join the WLAN)", file=sys.stderr)
|
||||
s.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Serve qcx OTA images from ./images with HTTP Range support, plus request logging."""
|
||||
import http.server, os, re, sys, datetime
|
||||
|
||||
ROOT = os.path.join(sys.path[0], "images")
|
||||
LOG = os.path.join(sys.path[0], "qcx-requests.log")
|
||||
|
||||
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.lstrip("/")
|
||||
with open(LOG, "a") as f:
|
||||
f.write("=== GET %s (%s) %s\nPATH: %s\n" % (datetime.datetime.now(), self.client_address[0],
|
||||
dict(self.headers), path))
|
||||
fp = os.path.join(ROOT, path)
|
||||
# any bootup-OTA request for a qcx image that doesn't exist on purpose
|
||||
# (unknown product/version combo) -> serve the ninlight migrate image
|
||||
if not os.path.isfile(fp) and path.startswith("qcx_firmware_"):
|
||||
if "ledshield" in path:
|
||||
guess = "ninlight_ledshield_rgb_migrate_dev.img"
|
||||
elif "rgbc3" in path:
|
||||
guess = "ninlight_ninstrip-rgbc3_migrate_dev.img"
|
||||
elif ".gu10" in path:
|
||||
guess = "ninlight_gu10_rgb_migrate_dev.img"
|
||||
else:
|
||||
guess = "ninlight_e27_rgb_migrate_dev.img"
|
||||
fp = os.path.join(ROOT, guess)
|
||||
if not os.path.isfile(fp):
|
||||
p = b"not found\n"
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Length", str(len(p)))
|
||||
self.end_headers()
|
||||
self.wfile.write(p)
|
||||
return
|
||||
size = os.path.getsize(fp)
|
||||
start, rng = 0, self.headers.get("Range")
|
||||
if rng and (m := re.match(r"bytes=(\d+)-(\d*)$", rng)):
|
||||
start = int(m.group(1))
|
||||
end = int(m.group(2)) if m.group(2) else size - 1
|
||||
else:
|
||||
end = size - 1
|
||||
end = min(end, size - 1)
|
||||
length = max(0, end - start + 1)
|
||||
self.send_response(206 if rng else 200)
|
||||
self.send_header("Content-Length", str(length))
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
if rng:
|
||||
self.send_header("Content-Range", "bytes %d-%d/%d" % (start, end, size))
|
||||
self.end_headers()
|
||||
with open(fp, "rb") as f:
|
||||
f.seek(start)
|
||||
left = length
|
||||
while left > 0:
|
||||
chunk = f.read(min(8192, left))
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
left -= len(chunk)
|
||||
print("served %s %d-%d to %s" % (path, start, end, self.client_address[0]), flush=True)
|
||||
|
||||
do_POST = do_HEAD = do_PUT = do_GET
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.makedirs(ROOT, exist_ok=True)
|
||||
print("qcx ota server on 0.0.0.0:80 root=%s" % ROOT, flush=True)
|
||||
http.server.ThreadingHTTPServer(("0.0.0.0", 80), H).serve_forever()
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dump raw bytes clients send to 192.168.8.1:3333 (what would ninlight poll?)."""
|
||||
import socket, sys, datetime, binascii
|
||||
s = socket.socket()
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind(("192.168.8.1", 3333))
|
||||
s.listen(8)
|
||||
print("raw 3333 listener", flush=True)
|
||||
while True:
|
||||
c, a = s.accept()
|
||||
try:
|
||||
c.settimeout(5)
|
||||
data = b""
|
||||
try:
|
||||
while len(data) < 65536:
|
||||
ch = c.recv(2048)
|
||||
if not ch:
|
||||
break
|
||||
data += ch
|
||||
except socket.timeout:
|
||||
pass
|
||||
print("=== %s %s %d bytes\n%s" % (datetime.datetime.now(), a[0], len(data), repr(data)), flush=True)
|
||||
finally:
|
||||
c.close()
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env python3
|
||||
"""TCP 443 sniffer: accept, read first bytes, log hex+printable, close.
|
||||
|
||||
For an HTTPS client this captures the TLS ClientHello in plaintext — the SNI
|
||||
extension names the host the bulb actually wants to talk to.
|
||||
"""
|
||||
import socket, sys, datetime, binascii
|
||||
|
||||
LOG = sys.path[0] + "/qcx-tls.log"
|
||||
s = socket.socket()
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind(("0.0.0.0", 443))
|
||||
s.listen(8)
|
||||
print("qcx tls sniffer on 0.0.0.0:443", flush=True)
|
||||
while True:
|
||||
c, addr = s.accept()
|
||||
try:
|
||||
c.settimeout(3)
|
||||
data = b""
|
||||
try:
|
||||
while len(data) < 4096:
|
||||
chunk = c.recv(2048)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
except socket.timeout:
|
||||
pass
|
||||
with open(LOG, "a") as f:
|
||||
f.write("=== %s %s:%s %d bytes\n" % (datetime.datetime.now(), addr[0], addr[1], len(data)))
|
||||
f.write(binascii.hexlify(data).decode() + "\n")
|
||||
f.write(repr(data) + "\n")
|
||||
print("tls from %s: %d bytes" % (addr[0], len(data)), flush=True)
|
||||
finally:
|
||||
c.close()
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bring up the qconnex-firmware migration listener (AP + DHCP/DNS + OTA server).
|
||||
# Takes wlan0 as an open AP "qconnex-firmware" on 192.168.8.1. Machine stays
|
||||
# online via ethernet. Teardown: ./down.sh
|
||||
set -euo pipefail
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
SUDO=${SUDO:-sudo -n}
|
||||
|
||||
# wlan0 must be free of any NetworkManager claim
|
||||
$SUDO nmcli dev set wlan0 managed no 2>/dev/null || true
|
||||
$SUDO ip link set wlan0 up
|
||||
$SUDO ip addr replace 192.168.8.1/24 dev wlan0
|
||||
|
||||
$SUDO dnsmasq --conf-file="$HERE/dnsmasq-qcx.conf" --pid-file="$HERE/dnsmasq.pid"
|
||||
$SUDO hostapd -B -f "$HERE/hostapd.log" "$HERE/hostapd.conf"
|
||||
sleep 1
|
||||
|
||||
# serve images on :80 (needs root to bind <1024)
|
||||
nohup $SUDO python3 "$HERE/otaserve.py" >/dev/null 2>&1 &
|
||||
echo $! > "$HERE/otaserve.pid"
|
||||
|
||||
echo "AP qconnex-firmware up on 192.168.8.1; serving $HERE/images (log: $HERE/qcx-requests.log)"
|
||||
+2
-1
@@ -202,7 +202,8 @@ static void app_task(void *arg)
|
||||
after_ota);
|
||||
nl_app_lock();
|
||||
nl_engine_init(&cfg);
|
||||
nl_ctrl_init(board, s_settings.fade_in_ms, s_settings.fade_out_ms);
|
||||
nl_ctrl_init(board, s_settings.fade_in_ms, s_settings.fade_out_ms, s_settings.transition_ms,
|
||||
s_settings.wake_on_change != 0);
|
||||
nl_ctrl_boot(&initial);
|
||||
nl_app_unlock();
|
||||
|
||||
|
||||
+12
-4
@@ -21,16 +21,24 @@ const nl_board_t nl_boards[] = {
|
||||
{NONE, NONE, NONE, NONE, NONE}, NL_PB(8), NL_PB(13), 0x73},
|
||||
{"gu10_rgb", "Klyqa GU10 RGB CW/WW", NL_DRV_SM2135EH, true, 2700, 6500, 0,
|
||||
{NONE, NONE, NONE, NONE, NONE}, NL_PB(8), NL_PB(13), 0x63},
|
||||
{"e27_cw", "Klyqa E27 CW/WW", NL_DRV_PWM, false, 2500, 5000, 600,
|
||||
{"e27_cw", "Klyqa E27 CW/WW", NL_DRV_PWM, false, 2700, 6500, 600,
|
||||
{NONE, NONE, NONE, CHA(1, NL_PB(13)), CH(4, NL_PB(8))}, NO_SM},
|
||||
{"e14_cw", "Klyqa E14 CW/WW", NL_DRV_PWM, false, 2500, 5000, 600,
|
||||
{"e14_cw", "Klyqa E14 CW/WW", NL_DRV_PWM, false, 2700, 6500, 600,
|
||||
{NONE, NONE, NONE, CH(2, NL_PB(16)), CH(4, NL_PB(8))}, NO_SM},
|
||||
{"gu10_cw", "Klyqa GU10 CW/WW", NL_DRV_PWM, false, 2500, 5000, 600,
|
||||
{"gu10_cw", "Klyqa GU10 CW/WW", NL_DRV_PWM, false, 2700, 6500, 600,
|
||||
{NONE, NONE, NONE, CH(2, NL_PB(16)), CH(4, NL_PB(8))}, NO_SM},
|
||||
/* G95: current table: CW=PB13, WW=PB15 (new assignment); old bulbs may use
|
||||
* PB16=WW, PB15=CW - verify on a series bulb. */
|
||||
{"g95_cw", "Klyqa G95 Vintage CW/WW", NL_DRV_PWM, false, 2500, 5000, 600,
|
||||
{"g95_cw", "Klyqa G95 Vintage CW/WW", NL_DRV_PWM, false, 2000, 5000, 600,
|
||||
{NONE, NONE, NONE, CH(1, NL_PB(13)), CH(3, NL_PB(15))}, NO_SM},
|
||||
/* Montana Black LED Shield (Klyqa LED-Strippen-Steuergeraet,
|
||||
product_id @qcx.montana.black.ledshield.rgb): analog RGB-Streifen an
|
||||
lowseitigen MOSFETs, kein CW/WW-Zweig. PIN-Kanaele und 800 Hz aus der
|
||||
Original-Firmware (fw-w600 qcl_build_config_lamp.h, HARDWARE_MOBL_LED_SHIELD);
|
||||
dort invertiert init mit invertiertem Duty, effektive Wirkung wie hier
|
||||
active-high. ct_min == ct_max markiert "weiss = RGB-Kombination". */
|
||||
{"ledshield_rgb", "Montana Black RGB LED Shield", NL_DRV_PWM, true, 4000, 4000, 800,
|
||||
{CH(1, NL_PB(13)), CH(2, NL_PB(16)), CH(3, NL_PB(15)), NONE, NONE}, NO_SM},
|
||||
};
|
||||
|
||||
const size_t nl_boards_count = sizeof(nl_boards) / sizeof(nl_boards[0]);
|
||||
|
||||
@@ -47,6 +47,8 @@ void nl_settings_defaults(nl_settings_t *s)
|
||||
s->power_on_behaviour = NL_POB_LAST;
|
||||
s->fade_in_ms = 500;
|
||||
s->fade_out_ms = 500;
|
||||
s->transition_ms = 300;
|
||||
s->wake_on_change = 0;
|
||||
}
|
||||
|
||||
void nl_settings_init(uint32_t addr_a, uint32_t addr_b)
|
||||
|
||||
@@ -19,6 +19,15 @@ typedef struct {
|
||||
uint16_t fade_in_ms;
|
||||
uint16_t fade_out_ms;
|
||||
uint8_t ota_pending; /* set before an OTA; the next boot restores the last state */
|
||||
/* Appended fields: read_rec() in nl_settings.c pre-fills the struct with
|
||||
* nl_settings_defaults() and only overwrites the bytes actually stored, so records
|
||||
* written by an older firmware pick up the real default here, not zero. Keep adding new
|
||||
* fields at the end for that to keep working. */
|
||||
uint16_t transition_ms; /* default transition (ms) for a color/brightness/CT change
|
||||
* while the lamp stays on; fade_in/fade_out still win when
|
||||
* switching on or off. */
|
||||
uint8_t wake_on_change; /* if set, such a change also switches the lamp on when it was
|
||||
* off, unless the same call says "on": false. */
|
||||
} nl_settings_t;
|
||||
|
||||
void nl_settings_defaults(nl_settings_t *s);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#define SM_ADDR_CURRENT 0xC0u
|
||||
#define SM_ADDR_WY 0xC5u
|
||||
#define SM_CHANNEL_WY 0x80u /* channel select bit 7: 0 = RGB, 1 = WY */
|
||||
#define SM_CHANNEL_STBY 0x20u /* standby bit 5: powers the outputs down */
|
||||
|
||||
extern void delay_us(unsigned int us); /* provided by libwlan.a */
|
||||
|
||||
@@ -76,12 +77,35 @@ void nl_sm2135eh_set(const uint8_t ch[NL_CH_COUNT])
|
||||
s_have_last = true;
|
||||
|
||||
if (!white) {
|
||||
uint8_t d[6] = {SM_ADDR_CURRENT, s_current, 0x00, ch[NL_CH_B], ch[NL_CH_G], ch[NL_CH_R]};
|
||||
/* All channels dark: put the driver into standby instead of just writing zero duty.
|
||||
Without bit 5 the chip stays active and leaks enough current to keep the lamp
|
||||
faintly glowing - measured on a migrated GU10 RGB, 2026-09-13. The old QConnex
|
||||
firmware had this logic but left it commented out (qcl_lamp.c), which is why the
|
||||
behaviour was inherited. Lamps driven straight from PWM pins (G95, the CW variants)
|
||||
are unaffected. Turning back on writes 0x00 or SM_CHANNEL_WY here and clears it. */
|
||||
bool dark = !ch[NL_CH_R] && !ch[NL_CH_G] && !ch[NL_CH_B];
|
||||
uint8_t chan = dark ? SM_CHANNEL_STBY : 0x00u;
|
||||
uint8_t d[6] = {SM_ADDR_CURRENT, s_current, chan, ch[NL_CH_B], ch[NL_CH_G], ch[NL_CH_R]};
|
||||
sm_send(d, 6);
|
||||
/* The WY data registers are cleared here too, even though this is the RGB branch:
|
||||
the chip keeps whatever white value was last written, and a direct white -> colour
|
||||
change never passes through the all-dark frame above. The stale white then bleeds
|
||||
through as soon as the colour dims - the same defect as the colour -> white case
|
||||
below (fixed in 00d5a4c, 2026-09-13), just mirrored: that fix made the WY branch
|
||||
also write the (always-zero) RGB registers, this makes the RGB branch also write
|
||||
the (always-zero) WY registers. Both directions had it; only one was caught first. */
|
||||
uint8_t w[3] = {SM_ADDR_WY, 0, 0};
|
||||
sm_send(w, 3);
|
||||
} else {
|
||||
uint8_t h[3] = {SM_ADDR_CURRENT, s_current, SM_CHANNEL_WY};
|
||||
/* The RGB data registers are written along with the channel select, even though they
|
||||
are always zero here: the chip keeps whatever colour was last written, and a direct
|
||||
colour -> white change never passes through the all-dark frame above. The stale
|
||||
colour then bleeds through as soon as the white dims - visible as a coloured
|
||||
fade-out on a migrated GU10 RGB, 2026-09-13. */
|
||||
uint8_t h[6] = {SM_ADDR_CURRENT, s_current, SM_CHANNEL_WY,
|
||||
ch[NL_CH_B], ch[NL_CH_G], ch[NL_CH_R]};
|
||||
uint8_t d[3] = {SM_ADDR_WY, ch[NL_CH_WW], ch[NL_CH_CW]};
|
||||
sm_send(h, 3);
|
||||
sm_send(h, 6);
|
||||
sm_send(d, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,11 @@ void nl_visual_to_channels(const nl_visual_t *v, uint16_t ct_min, uint16_t ct_ma
|
||||
/* keep the lowest brightness step visible */
|
||||
if (!out[NL_CH_R] && !out[NL_CH_G] && !out[NL_CH_B] && lin[maxi] > 0)
|
||||
out[NL_CH_R + maxi] = 1;
|
||||
} else if (ct_min == ct_max) {
|
||||
/* RGB-only board (ledshield_rgb): no white channels wired, ct range
|
||||
* collapsed to a point. White shows as an equal RGB mix. */
|
||||
uint8_t w = to8(level);
|
||||
out[NL_CH_R] = out[NL_CH_G] = out[NL_CH_B] = w ? w : 1;
|
||||
} else {
|
||||
uint32_t cw = level * nl_ct_cw_fraction(v->ct, ct_min, ct_max) / 65535u;
|
||||
uint32_t ww = level - cw;
|
||||
|
||||
+17
-4
@@ -7,23 +7,29 @@ static const nl_board_t *s_board;
|
||||
static nl_light_state_t s_state;
|
||||
static uint16_t s_fade_in;
|
||||
static uint16_t s_fade_out;
|
||||
static uint16_t s_transition;
|
||||
static bool s_wake_on_change;
|
||||
static bool s_dirty;
|
||||
static uint32_t s_changed_at;
|
||||
|
||||
void nl_ctrl_init(const nl_board_t *board, uint16_t fade_in_ms, uint16_t fade_out_ms)
|
||||
void nl_ctrl_init(const nl_board_t *board, uint16_t fade_in_ms, uint16_t fade_out_ms,
|
||||
uint16_t transition_ms, bool wake_on_change)
|
||||
{
|
||||
s_board = board;
|
||||
s_state = nl_boot_default_state(board);
|
||||
s_state.on = false;
|
||||
s_dirty = false;
|
||||
s_changed_at = 0;
|
||||
nl_ctrl_set_fades(fade_in_ms, fade_out_ms);
|
||||
nl_ctrl_set_options(fade_in_ms, fade_out_ms, transition_ms, wake_on_change);
|
||||
}
|
||||
|
||||
void nl_ctrl_set_fades(uint16_t fade_in_ms, uint16_t fade_out_ms)
|
||||
void nl_ctrl_set_options(uint16_t fade_in_ms, uint16_t fade_out_ms, uint16_t transition_ms,
|
||||
bool wake_on_change)
|
||||
{
|
||||
s_fade_in = fade_in_ms > NL_MAX_FADE_MS ? NL_MAX_FADE_MS : fade_in_ms;
|
||||
s_fade_out = fade_out_ms > NL_MAX_FADE_MS ? NL_MAX_FADE_MS : fade_out_ms;
|
||||
s_transition = transition_ms > NL_MAX_TRANSITION_MS ? NL_MAX_TRANSITION_MS : transition_ms;
|
||||
s_wake_on_change = wake_on_change;
|
||||
}
|
||||
|
||||
void nl_ctrl_boot(const nl_light_state_t *initial)
|
||||
@@ -62,6 +68,13 @@ nl_ctrl_err_t nl_ctrl_apply(const nl_light_change_t *c, uint32_t now_ms)
|
||||
n.mode = NL_MODE_WHITE;
|
||||
}
|
||||
|
||||
/* Weckschalter: a color/CT/brightness change arriving while the lamp is off switches it
|
||||
* on, unless this same call also says "on": false explicitly - that always wins. Applied
|
||||
* before the duration below so an off->on transition here uses fade_in like any other. */
|
||||
if (s_wake_on_change && !s_state.on && !n.on && !(c->has_on && !c->on) &&
|
||||
(c->has_color || c->has_ct || c->has_brightness))
|
||||
n.on = true;
|
||||
|
||||
if (c->has_transition)
|
||||
dur = c->transition_ms;
|
||||
else if (!s_state.on && n.on)
|
||||
@@ -69,7 +82,7 @@ nl_ctrl_err_t nl_ctrl_apply(const nl_light_change_t *c, uint32_t now_ms)
|
||||
else if (s_state.on && !n.on)
|
||||
dur = s_fade_out;
|
||||
else
|
||||
dur = 0;
|
||||
dur = s_transition;
|
||||
|
||||
s_state = n;
|
||||
nl_engine_set(&s_state, dur);
|
||||
|
||||
+11
-3
@@ -32,9 +32,17 @@ typedef enum {
|
||||
NL_CTRL_ERR_TRANSITION
|
||||
} nl_ctrl_err_t;
|
||||
|
||||
/* The engine must be initialised before. Not thread safe: callers serialise access. */
|
||||
void nl_ctrl_init(const nl_board_t *board, uint16_t fade_in_ms, uint16_t fade_out_ms);
|
||||
void nl_ctrl_set_fades(uint16_t fade_in_ms, uint16_t fade_out_ms);
|
||||
/* The engine must be initialised before. Not thread safe: callers serialise access.
|
||||
*
|
||||
* transition_ms: default transition applied to a color/brightness/CT change while the lamp
|
||||
* stays on and the caller did not send its own "transition" (fade_in/fade_out still take
|
||||
* precedence when the lamp is switching on or off).
|
||||
* wake_on_change: if true, a color/CT/brightness change arriving while the lamp is off
|
||||
* switches it on (using fade_in), unless the same call also says "on": false. */
|
||||
void nl_ctrl_init(const nl_board_t *board, uint16_t fade_in_ms, uint16_t fade_out_ms,
|
||||
uint16_t transition_ms, bool wake_on_change);
|
||||
void nl_ctrl_set_options(uint16_t fade_in_ms, uint16_t fade_out_ms, uint16_t transition_ms,
|
||||
bool wake_on_change);
|
||||
void nl_ctrl_boot(const nl_light_state_t *initial);
|
||||
nl_ctrl_err_t nl_ctrl_apply(const nl_light_change_t *c, uint32_t now_ms);
|
||||
void nl_ctrl_get(nl_light_state_t *out);
|
||||
|
||||
@@ -115,6 +115,11 @@ static cJSON *state_json(const nl_api_ctx_t *ctx)
|
||||
POB_NAMES[s.power_on_behaviour <= NL_POB_OFF ? s.power_on_behaviour : NL_POB_LAST]);
|
||||
cJSON_AddNumberToObject(o, "fade_in", s.fade_in_ms);
|
||||
cJSON_AddNumberToObject(o, "fade_out", s.fade_out_ms);
|
||||
/* Named "transition_default", not "transition": the latter is already the per-call field
|
||||
* above the settings section of this same request/response and a second field with the
|
||||
* same key in one JSON object would collide with it. */
|
||||
cJSON_AddNumberToObject(o, "transition_default", s.transition_ms);
|
||||
cJSON_AddBoolToObject(o, "wake_on_change", s.wake_on_change != 0);
|
||||
cap = cJSON_AddObjectToObject(o, "capabilities");
|
||||
cJSON_AddBoolToObject(cap, "color", ctx->board->has_rgb);
|
||||
ct[0] = ctx->board->ct_min;
|
||||
@@ -193,6 +198,17 @@ static void handle_post_state(const nl_api_ctx_t *ctx, const nl_http_request_t *
|
||||
case 1: s.fade_out_ms = (uint16_t)v; settings = true; break;
|
||||
default: break;
|
||||
}
|
||||
switch (get_int(j, "transition_default", 0, NL_MAX_TRANSITION_MS, &v)) {
|
||||
case -1: err = "invalid transition_default"; goto out;
|
||||
case 1: s.transition_ms = (uint16_t)v; settings = true; break;
|
||||
default: break;
|
||||
}
|
||||
it = cJSON_GetObjectItemCaseSensitive(j, "wake_on_change");
|
||||
if (it) {
|
||||
if (!cJSON_IsBool(it)) { err = "invalid wake_on_change"; goto out; }
|
||||
s.wake_on_change = cJSON_IsTrue(it) ? 1u : 0u;
|
||||
settings = true;
|
||||
}
|
||||
|
||||
if (light) {
|
||||
switch (ctx->apply(&ch)) {
|
||||
@@ -262,6 +278,13 @@ static void handle_get_settings(const nl_api_ctx_t *ctx, nl_api_response_t *resp
|
||||
cJSON_AddStringToObject(o, "name", s.name);
|
||||
w = cJSON_AddObjectToObject(o, "wifi");
|
||||
cJSON_AddStringToObject(w, "ssid", s.wifi_ssid);
|
||||
/* "ssid" alone can't be told apart from "kein WLAN konfiguriert": ein leerer
|
||||
* gespeicherter Datensatz laesst app_main.c beim Boot auf die einkompilierten
|
||||
* NINLIGHT_WIFI_SSID/-PASSWORD zurueckfallen (siehe dort), und die Lampe kommt
|
||||
* trotzdem zuverlaessig ins WLAN. Wer nur "ssid" sieht, kann das nicht
|
||||
* unterscheiden - "source" macht additiv sichtbar, woher die Zugangsdaten
|
||||
* kommen, ohne die Bedeutung von "ssid" zu aendern. */
|
||||
cJSON_AddStringToObject(w, "source", s.wifi_ssid[0] ? "stored" : "compiled");
|
||||
cJSON_AddBoolToObject(o, "auth_all", s.auth_all != 0);
|
||||
reply_json(resp, 200, o);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ static int save_settings(const nl_settings_t *s)
|
||||
memcpy(merged.wifi_pass, cur->wifi_pass, sizeof merged.wifi_pass);
|
||||
*cur = merged;
|
||||
}
|
||||
nl_ctrl_set_fades(cur->fade_in_ms, cur->fade_out_ms);
|
||||
nl_ctrl_set_options(cur->fade_in_ms, cur->fade_out_ms, cur->transition_ms, cur->wake_on_change != 0);
|
||||
memcpy(name, cur->name, sizeof name);
|
||||
nl_app_unlock();
|
||||
if (name_changed)
|
||||
|
||||
@@ -78,7 +78,7 @@ static void exec(const nl_cmd_t *c)
|
||||
nl_app_lock();
|
||||
cfg->fade_in_ms = (uint16_t)c->arg[0];
|
||||
cfg->fade_out_ms = (uint16_t)c->arg[1];
|
||||
nl_ctrl_set_fades(cfg->fade_in_ms, cfg->fade_out_ms);
|
||||
nl_ctrl_set_options(cfg->fade_in_ms, cfg->fade_out_ms, cfg->transition_ms, cfg->wake_on_change != 0);
|
||||
nl_app_unlock();
|
||||
printf(nl_app_save_settings() == 0 ? "ok\r\n" : "error: save failed\r\n");
|
||||
print_state();
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ uint8_t nl_crc8(const void *data, size_t len)
|
||||
while (len--) {
|
||||
crc ^= *p++;
|
||||
for (int k = 0; k < 8; k++)
|
||||
crc = (uint8_t)((crc & 0x80u) ? (crc << 1) ^ 0x07u : (crc << 1));
|
||||
crc = (uint8_t)((crc & 0x80u) ? (int)(((unsigned)crc << 1) ^ 0x07u) : (int)((unsigned)crc << 1));
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ static void test_get_state(void)
|
||||
TEST_ASSERT_EQUAL_STRING("last", cJSON_GetStringValue(field("power_on_behaviour")));
|
||||
TEST_ASSERT_EQUAL_INT(700, field("fade_in")->valueint);
|
||||
TEST_ASSERT_EQUAL_INT(500, field("fade_out")->valueint);
|
||||
TEST_ASSERT_EQUAL_INT(300, field("transition_default")->valueint);
|
||||
TEST_ASSERT_TRUE(cJSON_IsFalse(field("wake_on_change")));
|
||||
cap = field("capabilities");
|
||||
TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItem(cap, "color")));
|
||||
TEST_ASSERT_EQUAL_INT(2700, cJSON_GetArrayItem(cJSON_GetObjectItem(cap, "color_temp"), 0)->valueint);
|
||||
@@ -114,6 +116,8 @@ static void test_post_validation(void)
|
||||
{"{\"power_on_behaviour\":\"maybe\"}", "invalid power_on_behaviour"},
|
||||
{"{\"fade_in\":10001}", "invalid fade_in"},
|
||||
{"{\"fade_out\":-1}", "invalid fade_out"},
|
||||
{"{\"transition_default\":60001}", "invalid transition_default"},
|
||||
{"{\"wake_on_change\":\"ja\"}", "invalid wake_on_change"},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof CASES / sizeof CASES[0]; i++) {
|
||||
call(NL_HTTP_POST, "/api/v1/device/state", NULL, CASES[i].body);
|
||||
@@ -151,6 +155,33 @@ static void test_post_settings_fields(void)
|
||||
TEST_ASSERT_EQUAL_STRING("on", cJSON_GetStringValue(field("power_on_behaviour")));
|
||||
}
|
||||
|
||||
static void test_post_transition_default_and_wake(void)
|
||||
{
|
||||
call(NL_HTTP_POST, "/api/v1/device/state", NULL,
|
||||
"{\"transition_default\":1200,\"wake_on_change\":true}");
|
||||
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||
TEST_ASSERT_EQUAL_INT(1, fake_save_calls);
|
||||
TEST_ASSERT_EQUAL_UINT16(1200, fake_settings.transition_ms);
|
||||
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.wake_on_change);
|
||||
TEST_ASSERT_EQUAL_INT(1200, field("transition_default")->valueint);
|
||||
TEST_ASSERT_TRUE(cJSON_IsTrue(field("wake_on_change")));
|
||||
}
|
||||
|
||||
/* A colour and a per-call transition travel in the same request - the light change is
|
||||
applied, and nothing is treated as a settings write. */
|
||||
static void test_post_color_with_transition(void)
|
||||
{
|
||||
call(NL_HTTP_POST, "/api/v1/device/state", NULL,
|
||||
"{\"color\":{\"r\":255,\"g\":0,\"b\":0},\"transition\":2000}");
|
||||
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||
TEST_ASSERT_EQUAL_INT(1, fake_apply_calls);
|
||||
TEST_ASSERT_EQUAL_INT(0, fake_save_calls);
|
||||
TEST_ASSERT_TRUE(fake_last_change.has_transition);
|
||||
TEST_ASSERT_EQUAL_UINT32(2000, fake_last_change.transition_ms);
|
||||
TEST_ASSERT_TRUE(fake_last_change.has_color);
|
||||
TEST_ASSERT_EQUAL_UINT8(255, fake_last_change.r);
|
||||
}
|
||||
|
||||
static void test_post_save_failure(void)
|
||||
{
|
||||
fake_save_result = -1;
|
||||
@@ -233,6 +264,7 @@ static void test_settings_auth(void)
|
||||
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||
TEST_ASSERT_EQUAL_STRING("Wohnzimmer", cJSON_GetStringValue(field("name")));
|
||||
TEST_ASSERT_EQUAL_STRING("ninHome", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "ssid")));
|
||||
TEST_ASSERT_EQUAL_STRING("stored", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "source")));
|
||||
TEST_ASSERT_TRUE(cJSON_IsFalse(field("auth_all")));
|
||||
TEST_ASSERT_NULL(cJSON_GetObjectItem(field("wifi"), "password")); /* never exposed */
|
||||
fake_open = true;
|
||||
@@ -240,6 +272,19 @@ static void test_settings_auth(void)
|
||||
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||
}
|
||||
|
||||
/* F1: nie onboardet -> gespeicherte ssid ist leer, die Lampe laeuft auf den
|
||||
* einkompilierten Zugangsdaten (siehe app_main.c). "source" muss das additiv
|
||||
* sichtbar machen, ohne "ssid" umzudeuten - sonst widerspricht die Antwort
|
||||
* scheinbar system/info, das die tatsaechlich verbundene SSID zeigt. */
|
||||
static void test_settings_wifi_source_reflects_compiled_fallback(void)
|
||||
{
|
||||
fake_settings.wifi_ssid[0] = '\0';
|
||||
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||
TEST_ASSERT_EQUAL_STRING("", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "ssid")));
|
||||
TEST_ASSERT_EQUAL_STRING("compiled", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "source")));
|
||||
}
|
||||
|
||||
static void test_settings_wifi(void)
|
||||
{
|
||||
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"wifi\",\"ssid\":\"ninIOT\",\"password\":\"secret12\"}");
|
||||
@@ -350,12 +395,15 @@ int main(void)
|
||||
RUN_TEST(test_post_validation);
|
||||
RUN_TEST(test_post_control_errors);
|
||||
RUN_TEST(test_post_settings_fields);
|
||||
RUN_TEST(test_post_transition_default_and_wake);
|
||||
RUN_TEST(test_post_color_with_transition);
|
||||
RUN_TEST(test_post_save_failure);
|
||||
RUN_TEST(test_auth_all_protects_device_state);
|
||||
RUN_TEST(test_auth_too_long_is_unauthenticated);
|
||||
RUN_TEST(test_info_is_open);
|
||||
RUN_TEST(test_info_with_sta_ip_and_error);
|
||||
RUN_TEST(test_settings_auth);
|
||||
RUN_TEST(test_settings_wifi_source_reflects_compiled_fallback);
|
||||
RUN_TEST(test_settings_wifi);
|
||||
RUN_TEST(test_settings_name_token_auth);
|
||||
RUN_TEST(test_commands);
|
||||
|
||||
+22
-7
@@ -5,12 +5,12 @@
|
||||
void setUp(void) {}
|
||||
void tearDown(void) {}
|
||||
|
||||
static const char *IDS[] = {"evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw"};
|
||||
static const char *IDS[] = {"evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw", "ledshield_rgb"};
|
||||
|
||||
static void test_all_devices_present(void)
|
||||
{
|
||||
TEST_ASSERT_EQUAL_UINT(8, nl_boards_count);
|
||||
for (size_t i = 0; i < 8; i++) {
|
||||
TEST_ASSERT_EQUAL_UINT(9, nl_boards_count);
|
||||
for (size_t i = 0; i < 9; i++) {
|
||||
const nl_board_t *b = nl_board_find(IDS[i]);
|
||||
TEST_ASSERT_NOT_NULL_MESSAGE(b, IDS[i]);
|
||||
TEST_ASSERT_EQUAL_STRING(IDS[i], b->id);
|
||||
@@ -29,9 +29,17 @@ static void test_rgb_flags_and_ct_ranges(void)
|
||||
for (size_t i = 0; i < nl_boards_count; i++) {
|
||||
const nl_board_t *b = &nl_boards[i];
|
||||
bool rgb = strstr(b->id, "rgb") != NULL || strcmp(b->id, "evk") == 0;
|
||||
/* Measured on real bulbs, 2026-09-13: every lamp but the G95 covers 2700..6500 K.
|
||||
The G95 Vintage is a warm bulb and reaches 2000 K at the bottom but stops at
|
||||
5000 K - hard-coded per board rather than derived, because "warm variant" is a
|
||||
property of that one product, not of a class of them. */
|
||||
bool g95 = strcmp(b->id, "g95_cw") == 0;
|
||||
/* ledshield_rgb has no white LEDs at all: ct_min == ct_max is the
|
||||
sentinel "white mode renders as an RGB mix" (see nl_color.c). */
|
||||
bool shield = strcmp(b->id, "ledshield_rgb") == 0;
|
||||
TEST_ASSERT_EQUAL_MESSAGE(rgb, b->has_rgb, b->id);
|
||||
TEST_ASSERT_EQUAL_UINT16(rgb ? 2700 : 2500, b->ct_min);
|
||||
TEST_ASSERT_EQUAL_UINT16(rgb ? 6500 : 5000, b->ct_max);
|
||||
TEST_ASSERT_EQUAL_UINT16_MESSAGE(shield ? 4000 : (g95 ? 2000 : 2700), b->ct_min, b->id);
|
||||
TEST_ASSERT_EQUAL_UINT16_MESSAGE(shield ? 4000 : (g95 ? 5000 : 6500), b->ct_max, b->id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +50,15 @@ static void test_pwm_boards_consistent(void)
|
||||
if (b->driver != NL_DRV_PWM)
|
||||
continue;
|
||||
TEST_ASSERT_TRUE(b->pwm_freq_hz > 0);
|
||||
TEST_ASSERT_TRUE(b->pwm[NL_CH_CW].pwm_ch >= 0);
|
||||
TEST_ASSERT_TRUE(b->pwm[NL_CH_WW].pwm_ch >= 0);
|
||||
if (b->ct_min == b->ct_max) {
|
||||
/* RGB-only board: white is an RGB mix, no white channels wired. */
|
||||
TEST_ASSERT_TRUE(b->has_rgb);
|
||||
TEST_ASSERT_EQUAL(-1, b->pwm[NL_CH_CW].pwm_ch);
|
||||
TEST_ASSERT_EQUAL(-1, b->pwm[NL_CH_WW].pwm_ch);
|
||||
} else {
|
||||
TEST_ASSERT_TRUE(b->pwm[NL_CH_CW].pwm_ch >= 0);
|
||||
TEST_ASSERT_TRUE(b->pwm[NL_CH_WW].pwm_ch >= 0);
|
||||
}
|
||||
for (int c = NL_CH_R; c <= NL_CH_B; c++)
|
||||
TEST_ASSERT_EQUAL_MESSAGE(b->has_rgb, b->pwm[c].pwm_ch >= 0, b->id);
|
||||
for (int c = 0; c < NL_CH_COUNT; c++) {
|
||||
|
||||
@@ -55,11 +55,15 @@ static void test_off_forces_off(void)
|
||||
|
||||
static void test_sanitizes_stored_values(void)
|
||||
{
|
||||
const nl_board_t *b = nl_board_find("e27_cw");
|
||||
nl_light_state_t last = {true, 0, NL_MODE_COLOR, 1, 2, 3, 9000};
|
||||
nl_light_state_t s = nl_boot_state(NL_POB_LAST, &last, nl_board_find("e27_cw"), false);
|
||||
nl_light_state_t s = nl_boot_state(NL_POB_LAST, &last, b, false);
|
||||
TEST_ASSERT_EQUAL_UINT8(100, s.brightness); /* invalid 0 -> 100 */
|
||||
TEST_ASSERT_EQUAL(NL_MODE_WHITE, s.mode); /* no RGB on this board */
|
||||
TEST_ASSERT_EQUAL_UINT16(5000, s.ct); /* clamped */
|
||||
/* Clamped to whatever this board can do. Deliberately read from the board instead of
|
||||
repeating the number: the range itself is nailed down in test_board.c, and this test
|
||||
is about the clamping, not about the constant. */
|
||||
TEST_ASSERT_EQUAL_UINT16(b->ct_max, s.ct);
|
||||
}
|
||||
|
||||
/* Spec 8.2: a reboot caused by an OTA is not a power cut - the light comes back effectively as
|
||||
@@ -84,12 +88,13 @@ static void test_after_ota_ignores_pob_on(void)
|
||||
|
||||
static void test_after_ota_still_sanitizes(void)
|
||||
{
|
||||
const nl_board_t *b = nl_board_find("e27_cw");
|
||||
nl_light_state_t last = {true, 200, NL_MODE_COLOR, 1, 2, 3, 9000};
|
||||
nl_light_state_t s = nl_boot_state(NL_POB_OFF, &last, nl_board_find("e27_cw"), true);
|
||||
nl_light_state_t s = nl_boot_state(NL_POB_OFF, &last, b, true);
|
||||
TEST_ASSERT_TRUE(s.on);
|
||||
TEST_ASSERT_EQUAL_UINT8(100, s.brightness);
|
||||
TEST_ASSERT_EQUAL(NL_MODE_WHITE, s.mode);
|
||||
TEST_ASSERT_EQUAL_UINT16(5000, s.ct);
|
||||
TEST_ASSERT_EQUAL_UINT16(b->ct_max, s.ct);
|
||||
}
|
||||
|
||||
/* Nothing stored (first boot after a migration): there is no "last" to restore, so the normal
|
||||
|
||||
@@ -88,6 +88,22 @@ static void test_one_percent_is_visible(void)
|
||||
TEST_ASSERT_EQUAL_INT(1, ch[NL_CH_CW] + ch[NL_CH_WW]);
|
||||
}
|
||||
|
||||
static void test_rgb_only_white_mode(void)
|
||||
{
|
||||
uint8_t ch[NL_CH_COUNT];
|
||||
/* ct_min == ct_max: RGB-only board, white = equal RGB mix, no division. */
|
||||
nl_visual_t v = vis(10000, NL_MODE_WHITE, 0, 0, 0, 4000);
|
||||
nl_visual_to_channels(&v, 4000, 4000, ch);
|
||||
TEST_ASSERT_EQUAL_UINT8(255, ch[NL_CH_R]);
|
||||
TEST_ASSERT_EQUAL_UINT8(255, ch[NL_CH_G]);
|
||||
TEST_ASSERT_EQUAL_UINT8(255, ch[NL_CH_B]);
|
||||
TEST_ASSERT_EQUAL_UINT8(0, ch[NL_CH_CW]);
|
||||
TEST_ASSERT_EQUAL_UINT8(0, ch[NL_CH_WW]);
|
||||
v.bri = 100; /* 1 % stays visible */
|
||||
nl_visual_to_channels(&v, 4000, 4000, ch);
|
||||
TEST_ASSERT_EQUAL_UINT8(1, ch[NL_CH_R]);
|
||||
}
|
||||
|
||||
static void test_zero_brightness_is_dark(void)
|
||||
{
|
||||
uint8_t ch[NL_CH_COUNT];
|
||||
@@ -107,6 +123,7 @@ int main(void)
|
||||
RUN_TEST(test_white_mix_keeps_total);
|
||||
RUN_TEST(test_color_mode);
|
||||
RUN_TEST(test_one_percent_is_visible);
|
||||
RUN_TEST(test_rgb_only_white_mode);
|
||||
RUN_TEST(test_zero_brightness_is_dark);
|
||||
return UNITY_END();
|
||||
}
|
||||
|
||||
+115
-3
@@ -10,18 +10,24 @@ static void out(const uint8_t ch[NL_CH_COUNT], void *ctx)
|
||||
(void)ctx;
|
||||
}
|
||||
|
||||
static void start(const char *board_id, bool on)
|
||||
static void start_opts(const char *board_id, bool on, uint16_t transition_ms, bool wake)
|
||||
{
|
||||
const nl_board_t *b = nl_board_find(board_id);
|
||||
nl_engine_cfg_t cfg = {b->ct_min, b->ct_max, out, NULL};
|
||||
nl_engine_init(&cfg);
|
||||
nl_ctrl_init(b, 500, 800);
|
||||
nl_ctrl_init(b, 500, 800, transition_ms, wake);
|
||||
nl_light_state_t s = nl_boot_default_state(b);
|
||||
s.on = on;
|
||||
nl_ctrl_boot(&s);
|
||||
nl_engine_tick(10000); /* finish a possible boot fade */
|
||||
}
|
||||
|
||||
/* Most tests want the historical behaviour: no default transition, no wake switch. */
|
||||
static void start(const char *board_id, bool on)
|
||||
{
|
||||
start_opts(board_id, on, 0, false);
|
||||
}
|
||||
|
||||
static uint16_t bri(void)
|
||||
{
|
||||
nl_visual_t v;
|
||||
@@ -188,13 +194,112 @@ static void test_boot_fades_in_when_on(void)
|
||||
const nl_board_t *b = nl_board_find("evk");
|
||||
nl_engine_cfg_t cfg = {b->ct_min, b->ct_max, out, NULL};
|
||||
nl_engine_init(&cfg);
|
||||
nl_ctrl_init(b, 500, 800);
|
||||
nl_ctrl_init(b, 500, 800, 0, false);
|
||||
nl_light_state_t s = nl_boot_default_state(b);
|
||||
nl_ctrl_boot(&s);
|
||||
nl_engine_tick(250);
|
||||
TEST_ASSERT_EQUAL_UINT16(5000, bri());
|
||||
}
|
||||
|
||||
static void test_default_transition_applies_while_on(void)
|
||||
{
|
||||
start_opts("evk", true, 400, false);
|
||||
nl_light_change_t c = chg();
|
||||
c.has_brightness = true;
|
||||
c.brightness = 50;
|
||||
nl_ctrl_apply(&c, 0);
|
||||
TEST_ASSERT_EQUAL_UINT16(10000, bri()); /* not instant any more */
|
||||
nl_engine_tick(200);
|
||||
TEST_ASSERT_EQUAL_UINT16(7500, bri());
|
||||
nl_engine_tick(200);
|
||||
TEST_ASSERT_EQUAL_UINT16(5000, bri());
|
||||
}
|
||||
|
||||
static void test_explicit_transition_beats_default(void)
|
||||
{
|
||||
start_opts("evk", true, 5000, false);
|
||||
nl_light_change_t c = chg();
|
||||
c.has_brightness = true;
|
||||
c.brightness = 50;
|
||||
c.has_transition = true;
|
||||
c.transition_ms = 100;
|
||||
nl_ctrl_apply(&c, 0);
|
||||
nl_engine_tick(100);
|
||||
TEST_ASSERT_EQUAL_UINT16(5000, bri());
|
||||
}
|
||||
|
||||
static void test_fades_beat_default_transition_when_switching(void)
|
||||
{
|
||||
start_opts("evk", false, 5000, false);
|
||||
nl_light_change_t c = chg();
|
||||
c.has_on = true;
|
||||
c.on = true;
|
||||
nl_ctrl_apply(&c, 0);
|
||||
nl_engine_tick(500); /* fade_in, not the 5 s default */
|
||||
TEST_ASSERT_EQUAL_UINT16(10000, bri());
|
||||
|
||||
c = chg();
|
||||
c.has_on = true;
|
||||
c.on = false;
|
||||
nl_ctrl_apply(&c, 1000);
|
||||
nl_engine_tick(800); /* fade_out */
|
||||
TEST_ASSERT_EQUAL_UINT16(0, bri());
|
||||
}
|
||||
|
||||
static void test_wake_switch_off_leaves_lamp_off(void)
|
||||
{
|
||||
start_opts("evk", false, 300, false);
|
||||
nl_light_change_t c = chg();
|
||||
c.has_ct = true;
|
||||
c.ct = 3000;
|
||||
nl_ctrl_apply(&c, 0);
|
||||
nl_light_state_t s;
|
||||
nl_ctrl_get(&s);
|
||||
TEST_ASSERT_FALSE(s.on);
|
||||
}
|
||||
|
||||
static void test_wake_switch_turns_lamp_on_with_fade_in(void)
|
||||
{
|
||||
start_opts("evk", false, 300, true);
|
||||
nl_light_change_t c = chg();
|
||||
c.has_ct = true;
|
||||
c.ct = 3000;
|
||||
nl_ctrl_apply(&c, 0);
|
||||
nl_light_state_t s;
|
||||
nl_ctrl_get(&s);
|
||||
TEST_ASSERT_TRUE(s.on);
|
||||
nl_engine_tick(250); /* fade_in = 500, not the 300 ms default transition */
|
||||
TEST_ASSERT_EQUAL_UINT16(5000, bri());
|
||||
}
|
||||
|
||||
static void test_wake_switch_also_reacts_to_brightness(void)
|
||||
{
|
||||
start_opts("evk", false, 300, true);
|
||||
nl_light_change_t c = chg();
|
||||
c.has_brightness = true;
|
||||
c.brightness = 40;
|
||||
nl_ctrl_apply(&c, 0);
|
||||
nl_light_state_t s;
|
||||
nl_ctrl_get(&s);
|
||||
TEST_ASSERT_TRUE(s.on);
|
||||
TEST_ASSERT_EQUAL_UINT8(40, s.brightness);
|
||||
}
|
||||
|
||||
static void test_explicit_off_beats_wake_switch(void)
|
||||
{
|
||||
start_opts("evk", false, 300, true);
|
||||
nl_light_change_t c = chg();
|
||||
c.has_on = true;
|
||||
c.on = false;
|
||||
c.has_brightness = true;
|
||||
c.brightness = 40;
|
||||
nl_ctrl_apply(&c, 0);
|
||||
nl_light_state_t s;
|
||||
nl_ctrl_get(&s);
|
||||
TEST_ASSERT_FALSE(s.on);
|
||||
TEST_ASSERT_EQUAL_UINT8(40, s.brightness);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
UNITY_BEGIN();
|
||||
@@ -209,5 +314,12 @@ int main(void)
|
||||
RUN_TEST(test_persist_debounce);
|
||||
RUN_TEST(test_change_during_write_stays_dirty);
|
||||
RUN_TEST(test_boot_fades_in_when_on);
|
||||
RUN_TEST(test_default_transition_applies_while_on);
|
||||
RUN_TEST(test_explicit_transition_beats_default);
|
||||
RUN_TEST(test_fades_beat_default_transition_when_switching);
|
||||
RUN_TEST(test_wake_switch_off_leaves_lamp_off);
|
||||
RUN_TEST(test_wake_switch_turns_lamp_on_with_fade_in);
|
||||
RUN_TEST(test_wake_switch_also_reacts_to_brightness);
|
||||
RUN_TEST(test_explicit_off_beats_wake_switch);
|
||||
return UNITY_END();
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ static void test_defaults(void)
|
||||
TEST_ASSERT_EQUAL_UINT8(NL_POB_LAST, s.power_on_behaviour);
|
||||
TEST_ASSERT_EQUAL_UINT16(500, s.fade_in_ms);
|
||||
TEST_ASSERT_EQUAL_UINT16(500, s.fade_out_ms);
|
||||
TEST_ASSERT_EQUAL_UINT16(300, s.transition_ms);
|
||||
TEST_ASSERT_EQUAL_UINT8(0, s.wake_on_change);
|
||||
}
|
||||
|
||||
static void test_save_and_load(void)
|
||||
@@ -108,6 +110,19 @@ static void test_load_accepts_shorter_record(void)
|
||||
TEST_ASSERT_EQUAL_UINT16(500, out.fade_in_ms);
|
||||
TEST_ASSERT_EQUAL_UINT16(500, out.fade_out_ms);
|
||||
TEST_ASSERT_EQUAL_UINT8(0, out.ota_pending);
|
||||
TEST_ASSERT_EQUAL_UINT16(300, out.transition_ms);
|
||||
TEST_ASSERT_EQUAL_UINT8(0, out.wake_on_change);
|
||||
}
|
||||
|
||||
static void test_transition_and_wake_roundtrip(void)
|
||||
{
|
||||
nl_settings_t in = sample("neu"), out;
|
||||
in.transition_ms = 1500;
|
||||
in.wake_on_change = 1;
|
||||
TEST_ASSERT_EQUAL_INT(0, nl_settings_save(&in));
|
||||
TEST_ASSERT_TRUE(nl_settings_load(&out));
|
||||
TEST_ASSERT_EQUAL_UINT16(1500, out.transition_ms);
|
||||
TEST_ASSERT_EQUAL_UINT8(1, out.wake_on_change);
|
||||
}
|
||||
|
||||
static void test_load_rejects_longer_record(void)
|
||||
@@ -171,6 +186,7 @@ int main(void)
|
||||
{
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_defaults);
|
||||
RUN_TEST(test_transition_and_wake_roundtrip);
|
||||
RUN_TEST(test_save_and_load);
|
||||
RUN_TEST(test_alternates_slots);
|
||||
RUN_TEST(test_power_loss_keeps_previous);
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
set -euo pipefail
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
OUT=${BUILD_ROOT:-$ROOT/build}
|
||||
DEVICES=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw)
|
||||
DEVICES=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
|
||||
for d in "${DEVICES[@]}"; do
|
||||
echo "== $d"
|
||||
cmake -S "$ROOT" -B "$OUT/$d" -G Ninja \
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# Baut Firmwareabzuege OHNE einkompilierte WLAN-Zugangsdaten: die Lampen starten damit
|
||||
# im Onboarding-AP (ninlight-<mac4>), statt sich in ein fremdes WLAN einzuwaehlen.
|
||||
#
|
||||
# Aufruf: tools/build_nocreds.sh [ziel ...] (ohne Argumente: alle Ziele)
|
||||
#
|
||||
# Ergebnis: je Ziel ein Build-Verzeichnis unter $NINLIGHT_NOCREDS_BUILD_REL (Vorgabe
|
||||
# "build-nocreds", relativ zum Repo-Wurzelverzeichnis) mit den ueblichen Artefakten
|
||||
# (ninlight_<ziel>_gz.img, ninlight_<ziel>_ota.img, ...). Bewusst NICHT build/: die
|
||||
# dortigen Abzuege sind die des Entwicklers - mit seinem WLAN - und bleiben unangetastet.
|
||||
#
|
||||
# Dieses Skript ist ausdruecklich zum Wiederverwenden da: tools/release.sh baut damit das
|
||||
# Auslieferungsarchiv, und die Migrationsstation wird damit die Wahl "Lampe startet im
|
||||
# Onboarding-AP" bedienen. Deshalb steht die Logik hier und nicht in release.sh.
|
||||
#
|
||||
# Umgebungsvariablen:
|
||||
# NINLIGHT_DOCKER Build-Vorspann (Vorgabe tools/docker.sh); Tests stubben ihn
|
||||
# NINLIGHT_NOCREDS_BUILD_REL Ausgabeverzeichnis relativ zur Repo-Wurzel
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
DOCKER=${NINLIGHT_DOCKER:-$ROOT/tools/docker.sh}
|
||||
BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
|
||||
|
||||
DEVICES=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
|
||||
if [ $# -gt 0 ]; then
|
||||
TARGETS=("$@")
|
||||
else
|
||||
TARGETS=("${DEVICES[@]}")
|
||||
fi
|
||||
for t in "${TARGETS[@]}"; do
|
||||
ok=0
|
||||
for d in "${DEVICES[@]}"; do [ "$t" = "$d" ] && ok=1; done
|
||||
[ "$ok" -eq 1 ] || { echo "Fehler: unbekanntes Ziel '$t' (bekannt: ${DEVICES[*]})" >&2; exit 1; }
|
||||
done
|
||||
|
||||
# config.local.cmake wird in CMakeLists.txt mit gewoehnlichem set() eingebunden und
|
||||
# SCHLAEGT damit jedes -D auf der Kommandozeile. Ein Bau ohne Zugangsdaten ist deshalb
|
||||
# nur moeglich, wenn die Datei fuer die Dauer des Baus beiseite liegt. In ihr stehen die
|
||||
# echte SSID, das WLAN-Passwort und das API-Token - sie muss auch dann zurueckkommen,
|
||||
# wenn der Bau abbricht oder jemand Strg-C drueckt. Darum ein trap auf EXIT UND auf
|
||||
# INT/TERM (EXIT allein laeuft bei einem Signal zwar auch, aber der Exit-Code soll das
|
||||
# Signal widerspiegeln, statt als Erfolg durchzugehen).
|
||||
LOCAL="$ROOT/config.local.cmake"
|
||||
STASH_DIR=""
|
||||
restore_local() {
|
||||
[ -n "$STASH_DIR" ] || return 0
|
||||
if [ -f "$STASH_DIR/config.local.cmake" ]; then
|
||||
cp -p "$STASH_DIR/config.local.cmake" "$LOCAL" || {
|
||||
echo "ACHTUNG: config.local.cmake konnte nicht zurueckgelegt werden - Kopie liegt unter $STASH_DIR" >&2
|
||||
STASH_DIR=""
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
rm -rf "$STASH_DIR"
|
||||
STASH_DIR=""
|
||||
}
|
||||
if [ -f "$LOCAL" ]; then
|
||||
STASH_DIR=$(mktemp -d)
|
||||
cp -p "$LOCAL" "$STASH_DIR/config.local.cmake"
|
||||
trap 'restore_local' EXIT
|
||||
trap 'restore_local; trap - INT; kill -INT $$' INT
|
||||
trap 'restore_local; exit 143' TERM
|
||||
rm -f "$LOCAL"
|
||||
echo "config.local.cmake liegt fuer diesen Bau beiseite (keine WLAN-Zugangsdaten im Abzug)"
|
||||
fi
|
||||
|
||||
for t in "${TARGETS[@]}"; do
|
||||
echo "== $t (ohne Zugangsdaten)"
|
||||
# Relative Pfade, weil der Bau ueblicherweise im Container laeuft, der das Repo unter
|
||||
# /work einhaengt - absolute Hostpfade gaebe es dort nicht.
|
||||
"$DOCKER" cmake -S . -B "$BUILD_REL/$t" -G Ninja \
|
||||
-DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake -DNINLIGHT_DEVICE="$t" >/dev/null
|
||||
"$DOCKER" cmake --build "$BUILD_REL/$t"
|
||||
done
|
||||
|
||||
echo "fertig - Abzuege ohne Zugangsdaten unter $ROOT/$BUILD_REL"
|
||||
+1
-1
@@ -2,6 +2,6 @@
|
||||
# Runs a command inside the ninlight build container (x86_64, gcc-arm-none-eabi 4.9-2014q4).
|
||||
# Usage: tools/docker.sh cmake --build build/evk
|
||||
set -euo pipefail
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
ROOT=$(cd -P "$(dirname "$0")/.." >/dev/null && pwd -P)
|
||||
docker image inspect ninlight-build >/dev/null 2>&1 || docker build --platform linux/amd64 -t ninlight-build "$ROOT/docker"
|
||||
exec docker run --rm --platform linux/amd64 -v "$ROOT":/work -w /work ninlight-build "$@"
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Signiert ein W600-Firmwareimage so, wie es die alte Klyqa-Firmware erwartet.
|
||||
|
||||
Format: RSA-4096 PKCS#1 v1.5 ueber den SHA256 der GESAMTEN Datei, angehaengt als
|
||||
exakt 512 Byte. Belegt durch Check_OTA_Image() in qc_ssl_layer.c der alten Firmware
|
||||
und durch sign_w600_fw.py im Schluesselverzeichnis.
|
||||
|
||||
Die 512 Byte belegen denselben Block wie der ninlight-Trailer. Ein Migrationsimage
|
||||
traegt deshalb KEINEN Trailer - die alte Firmware prueft die Signatur, die neue
|
||||
prueft an dieser Stelle nichts mehr, weil sie dann schon laeuft.
|
||||
|
||||
Muss unter Python 3.8.10 laufen (siehe nl_mkimage.py fuer den Hintergrund): keine
|
||||
`str | None`-Annotationen ohne `from __future__ import annotations`, kein
|
||||
`list[str]` zur Laufzeit.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
SIG_LEN = 512
|
||||
TRAILER_MAGIC = b"NINL"
|
||||
KEY_BITS = 4096
|
||||
|
||||
|
||||
class SignError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TestKey(NamedTuple):
|
||||
private_pem: bytes
|
||||
public_der: bytes
|
||||
|
||||
|
||||
def _backend():
|
||||
"""Liefert ein einheitliches (sign, verify, keygen)-Tripel, egal ob `cryptography`
|
||||
oder `pycryptodome`/`pycryptodomex` installiert ist - auf keinem Rechner ist sicher
|
||||
dieselbe Bibliothek vorhanden."""
|
||||
try:
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
|
||||
def sign(private_pem: bytes, data: bytes) -> bytes:
|
||||
try:
|
||||
key = serialization.load_pem_private_key(private_pem, password=None)
|
||||
except (ValueError, TypeError):
|
||||
raise SignError(
|
||||
"privater Schluessel ist kein gueltiger PEM-Schluessel") from None
|
||||
if key.key_size != KEY_BITS:
|
||||
raise SignError(
|
||||
f"Schluessel hat {key.key_size} Bit, benoetigt werden {KEY_BITS}")
|
||||
return key.sign(data, padding.PKCS1v15(), hashes.SHA256())
|
||||
|
||||
def verify(public_key_bytes: bytes, data: bytes, signature: bytes) -> bool:
|
||||
# akzeptiert sowohl DER als auch PEM, wie im CLI-Interface vorgesehen
|
||||
try:
|
||||
key = serialization.load_der_public_key(public_key_bytes)
|
||||
except ValueError:
|
||||
try:
|
||||
key = serialization.load_pem_public_key(public_key_bytes)
|
||||
except (ValueError, TypeError):
|
||||
raise SignError(
|
||||
"oeffentlicher Schluessel ist weder gueltiges DER noch PEM") from None
|
||||
try:
|
||||
key.verify(signature, data, padding.PKCS1v15(), hashes.SHA256())
|
||||
return True
|
||||
except InvalidSignature:
|
||||
return False
|
||||
|
||||
def keygen() -> TestKey:
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=KEY_BITS)
|
||||
private_pem = key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
public_der = key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
return TestKey(private_pem, public_der)
|
||||
|
||||
return sign, verify, keygen
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
try:
|
||||
from Cryptodome.Signature import pkcs1_15
|
||||
from Cryptodome.Hash import SHA256
|
||||
from Cryptodome.PublicKey import RSA
|
||||
except ImportError:
|
||||
from Crypto.Signature import pkcs1_15
|
||||
from Crypto.Hash import SHA256
|
||||
from Crypto.PublicKey import RSA
|
||||
|
||||
def sign(private_pem: bytes, data: bytes) -> bytes:
|
||||
try:
|
||||
key = RSA.import_key(private_pem)
|
||||
except (ValueError, TypeError, IndexError):
|
||||
raise SignError(
|
||||
"privater Schluessel ist kein gueltiger PEM/DER-Schluessel") from None
|
||||
if key.size_in_bits() != KEY_BITS:
|
||||
raise SignError(
|
||||
f"Schluessel hat {key.size_in_bits()} Bit, benoetigt werden {KEY_BITS}")
|
||||
digest = SHA256.new(data)
|
||||
return pkcs1_15.new(key).sign(digest)
|
||||
|
||||
def verify(public_der: bytes, data: bytes, signature: bytes) -> bool:
|
||||
try:
|
||||
key = RSA.import_key(public_der)
|
||||
except (ValueError, TypeError, IndexError):
|
||||
raise SignError(
|
||||
"oeffentlicher Schluessel ist kein gueltiger PEM/DER-Schluessel") from None
|
||||
digest = SHA256.new(data)
|
||||
try:
|
||||
pkcs1_15.new(key).verify(digest, signature)
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
def keygen() -> TestKey:
|
||||
key = RSA.generate(KEY_BITS)
|
||||
private_pem = key.export_key("PEM")
|
||||
public_der = key.publickey().export_key("DER")
|
||||
return TestKey(private_pem, public_der)
|
||||
|
||||
return sign, verify, keygen
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
raise SignError(
|
||||
"weder 'cryptography' noch 'pycryptodome'/'pycryptodomex' ist installiert")
|
||||
|
||||
|
||||
def generate_test_key() -> TestKey:
|
||||
"""Nur fuer Tests: erzeugt einen frischen 4096-Bit-Schluessel im Speicher.
|
||||
Schreibt niemals eine Datei."""
|
||||
_sign, _verify, keygen = _backend()
|
||||
return keygen()
|
||||
|
||||
|
||||
def _check_raw(raw: bytes) -> None:
|
||||
if len(raw) < 1024:
|
||||
raise SignError(f"Eingabe ist mit {len(raw)} Byte kuerzer als die Mindestlaenge 1024")
|
||||
if len(raw) % 4 != 0:
|
||||
raise SignError(f"Eingabe ist mit {len(raw)} Byte nicht durch 4 teilbar")
|
||||
if raw[-SIG_LEN:][:4] == TRAILER_MAGIC:
|
||||
raise SignError(
|
||||
"Eingabe traegt bereits einen ninlight-Trailer - das ist ein OTA-Image, "
|
||||
"kein Rohbild")
|
||||
|
||||
|
||||
def sign_image(raw: bytes, key_pem: bytes) -> bytes:
|
||||
"""Haengt eine 512-Byte-PKCS#1-v1.5-Signatur ueber den SHA256 von `raw` an."""
|
||||
_check_raw(raw)
|
||||
sign, _verify, _keygen = _backend()
|
||||
signature = sign(key_pem, raw)
|
||||
if len(signature) != SIG_LEN:
|
||||
raise SignError(
|
||||
f"Signatur ist {len(signature)} Byte lang, erwartet werden {SIG_LEN}")
|
||||
return raw + signature
|
||||
|
||||
|
||||
def verify_image(signed: bytes, pub_der: bytes) -> bool:
|
||||
"""Prueft eine mit `sign_image` erzeugte Signatur. Gibt bei ungueltiger Signatur
|
||||
False zurueck; wirft nur bei echten Formatfehlern."""
|
||||
if len(signed) <= SIG_LEN:
|
||||
raise SignError(f"Eingabe ist mit {len(signed)} Byte zu kurz fuer eine Signatur")
|
||||
payload, signature = signed[:-SIG_LEN], signed[-SIG_LEN:]
|
||||
_sign, verify, _keygen = _backend()
|
||||
return verify(pub_der, payload, signature)
|
||||
|
||||
|
||||
def _cmd_sign(args: argparse.Namespace) -> int:
|
||||
key_pem = Path(args.key).read_bytes()
|
||||
raw = Path(args.in_).read_bytes()
|
||||
try:
|
||||
signed = sign_image(raw, key_pem)
|
||||
except SignError as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
Path(args.out).write_bytes(signed)
|
||||
print(f"{args.out} ({len(signed)} bytes)")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_verify(args: argparse.Namespace) -> int:
|
||||
pub_der = Path(args.pub).read_bytes()
|
||||
signed = Path(args.in_).read_bytes()
|
||||
try:
|
||||
ok = verify_image(signed, pub_der)
|
||||
except SignError as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
if ok:
|
||||
print(f"{args.in_}: signature valid")
|
||||
return 0
|
||||
print(f"error: {args.in_}: signature invalid", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(
|
||||
description="signs/verifies a W600 image the way the old Klyqa firmware expects")
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_sign = sub.add_parser("sign", help="sign a raw _gz.img")
|
||||
p_sign.add_argument("--key", required=True, help="private key, PEM")
|
||||
p_sign.add_argument("--in", dest="in_", required=True, help="input _gz.img")
|
||||
p_sign.add_argument("--out", required=True, help="output signed image")
|
||||
p_sign.set_defaults(func=_cmd_sign)
|
||||
|
||||
p_verify = sub.add_parser("verify", help="verify a signed image")
|
||||
p_verify.add_argument("--pub", required=True, help="public key, PEM or DER")
|
||||
p_verify.add_argument("--in", dest="in_", required=True, help="signed image to check")
|
||||
p_verify.set_defaults(func=_cmd_verify)
|
||||
|
||||
args = p.parse_args()
|
||||
raise SystemExit(args.func(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
TOOL = HERE.parent / "nl_sign.py"
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
import nl_sign # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def key():
|
||||
"""Ein eigener 4096-Bit-Schluessel, damit kein echter Signierschluessel in die Tests
|
||||
gerät. Einmal pro Modul, weil das Erzeugen ein paar Sekunden dauert."""
|
||||
return nl_sign.generate_test_key()
|
||||
|
||||
|
||||
def test_signature_is_512_bytes(key, tmp_path):
|
||||
raw = b"\xa5" * 4096
|
||||
signed = nl_sign.sign_image(raw, key.private_pem)
|
||||
assert len(signed) == len(raw) + 512
|
||||
assert signed[: len(raw)] == raw
|
||||
|
||||
|
||||
def test_signature_verifies(key):
|
||||
raw = b"firmware" * 512
|
||||
signed = nl_sign.sign_image(raw, key.private_pem)
|
||||
assert nl_sign.verify_image(signed, key.public_der) is True
|
||||
|
||||
|
||||
def test_modified_payload_fails_verification(key):
|
||||
raw = bytearray(b"firmware" * 512)
|
||||
signed = bytearray(nl_sign.sign_image(bytes(raw), key.private_pem))
|
||||
signed[17] ^= 0xFF
|
||||
assert nl_sign.verify_image(bytes(signed), key.public_der) is False
|
||||
|
||||
|
||||
def test_signing_is_deterministic(key):
|
||||
raw = b"x" * 1024
|
||||
assert nl_sign.sign_image(raw, key.private_pem) == nl_sign.sign_image(raw, key.private_pem)
|
||||
|
||||
|
||||
def test_cli_roundtrip(key, tmp_path):
|
||||
src = tmp_path / "in_gz.img"
|
||||
dst = tmp_path / "out.img"
|
||||
pub = tmp_path / "pub.der"
|
||||
priv = tmp_path / "key.pem"
|
||||
src.write_bytes(b"\x01\x02\x03" * 1000)
|
||||
priv.write_bytes(key.private_pem)
|
||||
pub.write_bytes(key.public_der)
|
||||
subprocess.run([sys.executable, str(TOOL), "sign", "--key", str(priv),
|
||||
"--in", str(src), "--out", str(dst)], check=True)
|
||||
assert dst.stat().st_size == src.stat().st_size + 512
|
||||
subprocess.run([sys.executable, str(TOOL), "verify", "--pub", str(pub),
|
||||
"--in", str(dst)], check=True)
|
||||
|
||||
|
||||
def test_cli_verify_rejects_tampered(key, tmp_path):
|
||||
src = tmp_path / "in_gz.img"
|
||||
dst = tmp_path / "out.img"
|
||||
pub = tmp_path / "pub.der"
|
||||
priv = tmp_path / "key.pem"
|
||||
src.write_bytes(b"\x07" * 2000)
|
||||
priv.write_bytes(key.private_pem)
|
||||
pub.write_bytes(key.public_der)
|
||||
subprocess.run([sys.executable, str(TOOL), "sign", "--key", str(priv),
|
||||
"--in", str(src), "--out", str(dst)], check=True)
|
||||
data = bytearray(dst.read_bytes())
|
||||
data[0] ^= 0x01
|
||||
dst.write_bytes(bytes(data))
|
||||
r = subprocess.run([sys.executable, str(TOOL), "verify", "--pub", str(pub),
|
||||
"--in", str(dst)])
|
||||
assert r.returncode != 0
|
||||
|
||||
|
||||
def test_refuses_already_signed_looking_input(key, tmp_path):
|
||||
"""Ein Image mit ninlight-Trailer ist ein OTA-Image, kein Rohbild - zweimal 512 Byte
|
||||
anzuhaengen ergaebe eine Datei, die die alte Firmware ablehnt."""
|
||||
raw = b"\x00" * 1024 + b"NINL" + b"\x01" + b"\x00" * 507
|
||||
with pytest.raises(nl_sign.SignError):
|
||||
nl_sign.sign_image(raw, key.private_pem)
|
||||
|
||||
|
||||
def test_verify_rejects_garbage_pub_key(key):
|
||||
"""Ein kaputter --pub-Inhalt (kein gueltiges DER/PEM) muss als SignError gemeldet
|
||||
werden, nicht als roher ValueError/Traceback - und darf niemals True ergeben."""
|
||||
raw = b"x" * 1024
|
||||
signed = nl_sign.sign_image(raw, key.private_pem)
|
||||
with pytest.raises(nl_sign.SignError):
|
||||
nl_sign.verify_image(signed, b"das ist definitiv kein Schluessel")
|
||||
|
||||
|
||||
def test_sign_rejects_garbage_private_key():
|
||||
"""Ein kaputter --key-Inhalt muss ebenso als SignError gemeldet werden."""
|
||||
raw = b"x" * 1024
|
||||
with pytest.raises(nl_sign.SignError):
|
||||
nl_sign.sign_image(raw, b"das ist definitiv kein Schluessel")
|
||||
|
||||
|
||||
def test_cli_verify_rejects_garbage_pub_key(key, tmp_path):
|
||||
src = tmp_path / "in_gz.img"
|
||||
dst = tmp_path / "out.img"
|
||||
pub = tmp_path / "pub.der"
|
||||
priv = tmp_path / "key.pem"
|
||||
src.write_bytes(b"\x01\x02\x03" * 1000)
|
||||
priv.write_bytes(key.private_pem)
|
||||
pub.write_bytes(b"das ist definitiv kein Schluessel")
|
||||
subprocess.run([sys.executable, str(TOOL), "sign", "--key", str(priv),
|
||||
"--in", str(src), "--out", str(dst)], check=True)
|
||||
r = subprocess.run([sys.executable, str(TOOL), "verify", "--pub", str(pub),
|
||||
"--in", str(dst)], capture_output=True, text=True)
|
||||
assert r.returncode != 0
|
||||
assert "Traceback" not in r.stderr
|
||||
assert "error:" in r.stderr.lower()
|
||||
|
||||
|
||||
def test_cli_sign_rejects_garbage_private_key(tmp_path):
|
||||
src = tmp_path / "in_gz.img"
|
||||
dst = tmp_path / "out.img"
|
||||
priv = tmp_path / "key.pem"
|
||||
src.write_bytes(b"\x01\x02\x03" * 1000)
|
||||
priv.write_bytes(b"das ist definitiv kein Schluessel")
|
||||
r = subprocess.run([sys.executable, str(TOOL), "sign", "--key", str(priv),
|
||||
"--in", str(src), "--out", str(dst)], capture_output=True, text=True)
|
||||
assert r.returncode != 0
|
||||
assert "Traceback" not in r.stderr
|
||||
assert "error:" in r.stderr.lower()
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Wache dagegen, dass eine Testdatei aus allen Suiten herausfaellt.
|
||||
|
||||
Genau das war schon einmal passiert: tools/test_host.sh rief pytest auf
|
||||
tools/mkimage/tests und tools/station/tests auf - tools/mkimage/test_nl_mkimage.py
|
||||
liegt aber DIREKT in tools/mkimage/ und lief damit in keiner Suite mehr mit. 19 Tests
|
||||
waren still verschwunden, darunter test_stays_python38_compatible, die Wache fuer die
|
||||
bindende 3.8-Randbedingung des Docker-Build-Containers (`str | None` in nl_mkimage.py
|
||||
hatte den Build schon einmal gebrochen).
|
||||
|
||||
Eine stumm ausgefallene Testdatei ist schlimmer als eine fehlende: die Suite bleibt
|
||||
gruen und behauptet damit eine Deckung, die es nicht gibt. Deshalb wird hier nicht ein
|
||||
einzelner Pfad festgenagelt, sondern die Regel selbst - JEDE test_*.py unter tools/
|
||||
muss von einem der Pfade erfasst sein, die tools/test_host.sh an pytest uebergibt.
|
||||
Eine neue Testdatei an einer neuen Stelle faellt damit sofort auf, statt erst dann,
|
||||
wenn jemand sie vermisst.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
TOOLS_DIR = Path(__file__).resolve().parents[2]
|
||||
REPO_ROOT = TOOLS_DIR.parent
|
||||
TEST_HOST = TOOLS_DIR / "test_host.sh"
|
||||
|
||||
# Verzeichnisse, die keine Tests DIESES Projekts enthalten: virtuelle Umgebungen und
|
||||
# installierte Fremdpakete bringen eigene test_*.py mit, die hier nichts zu suchen haben.
|
||||
IGNORED_PARTS = (".venv", "__pycache__", "site-packages", "node_modules", ".git")
|
||||
|
||||
|
||||
def pytest_paths():
|
||||
"""Die Pfade, die tools/test_host.sh an pytest uebergibt, als aufgeloeste Pfade."""
|
||||
text = TEST_HOST.read_text()
|
||||
match = re.search(r"^\s*python3 -m pytest (.+?)(?:\s+-\S+)*\s*$", text, re.M)
|
||||
assert match, "in tools/test_host.sh ist kein 'python3 -m pytest'-Aufruf zu finden"
|
||||
paths = []
|
||||
for raw in match.group(1).split():
|
||||
arg = raw.strip('"')
|
||||
if arg.startswith("-"):
|
||||
continue
|
||||
paths.append(Path(arg.replace("$ROOT", str(REPO_ROOT))).resolve())
|
||||
assert paths, "der pytest-Aufruf in tools/test_host.sh uebergibt keine Pfade"
|
||||
return paths
|
||||
|
||||
|
||||
def own_test_files(suffix=".py"):
|
||||
return sorted(
|
||||
p.resolve()
|
||||
for p in TOOLS_DIR.rglob("test_*" + suffix)
|
||||
if not any(part in IGNORED_PARTS for part in p.parts)
|
||||
)
|
||||
|
||||
|
||||
def listed_shell_suites():
|
||||
"""Die Eintraege des SHELL_SUITES-Feldes in tools/test_host.sh, aufgeloest.
|
||||
|
||||
Ausdruecklich gegen das FELD geprueft und nicht per Textsuche nach Dateinamen: eine
|
||||
Textsuche ist schon durch einen nachgestellten Kommentar ("echo x # test_deploy.sh"),
|
||||
ein ": \"test_deploy.sh\"" oder einen Heredoc-Inhalt erfuellt - sie belegt nur, dass
|
||||
der Name irgendwo vorkommt, nicht dass die Suite laeuft (Task 6b F2).
|
||||
"""
|
||||
text = TEST_HOST.read_text()
|
||||
block = re.search(r"^SHELL_SUITES=\((.*?)^\)", text, re.M | re.S)
|
||||
assert block, (
|
||||
"in tools/test_host.sh gibt es kein 'SHELL_SUITES=(...)'-Feld mehr - die "
|
||||
"Vollstaendigkeitspruefung der Shell-Suiten haengt daran"
|
||||
)
|
||||
# Und das Feld muss auch wirklich abgearbeitet werden, sonst ist es nur Deko.
|
||||
assert re.search(r'for\s+\w+\s+in\s+"\$\{SHELL_SUITES\[@\]\}"', text), (
|
||||
"tools/test_host.sh arbeitet SHELL_SUITES nicht in einer Schleife ab - die "
|
||||
"aufgefuehrten Suiten wuerden gar nicht laufen"
|
||||
)
|
||||
paths = []
|
||||
for raw in block.group(1).split():
|
||||
arg = raw.strip('"')
|
||||
if not arg or arg.startswith("#"):
|
||||
continue
|
||||
paths.append(Path(arg.replace("$ROOT", str(REPO_ROOT))).resolve())
|
||||
return paths
|
||||
|
||||
|
||||
def test_every_test_file_is_reached_by_test_host():
|
||||
roots = pytest_paths()
|
||||
orphans = []
|
||||
for test_file in own_test_files():
|
||||
if not any(test_file == r or r in test_file.parents for r in roots):
|
||||
orphans.append(test_file.relative_to(REPO_ROOT))
|
||||
assert not orphans, (
|
||||
"diese Testdateien laufen in keiner Suite mit - tools/test_host.sh sammelt sie "
|
||||
"nicht ein: {}. Entweder den Pfad in test_host.sh erweitern oder die Datei "
|
||||
"dorthin verschieben, wo sie eingesammelt wird.".format(
|
||||
", ".join(str(o) for o in orphans)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_every_shell_suite_is_called_by_test_host():
|
||||
"""Dieselbe Regel fuer die Shell-Suiten (Task 6b F1, I3).
|
||||
|
||||
test_deploy.sh war in tools/test_host.sh nicht aufgefuehrt und lief damit nur bei
|
||||
Handaufruf - 95 Zusagen, die niemand mehr sah. Die .py-Regel oben deckt genau diese
|
||||
Haelfte nicht ab, weil die Shell-Suiten nicht ueber pytest laufen, sondern einzeln
|
||||
aufgerufen werden.
|
||||
"""
|
||||
listed = listed_shell_suites()
|
||||
# Symmetrisch zur .py-Regel: das GANZE tools/ wird durchsucht, nicht nur
|
||||
# tools/station/tests - eine kuenftige Shell-Suite an einer neuen Stelle faellt sonst
|
||||
# weiterhin durch (Task 6b F2).
|
||||
# tools/test_host.sh heisst selbst "test_*.sh", ist aber der Aufrufer und keine
|
||||
# Suite - es kann sich schlecht selbst auffuehren.
|
||||
missing = [
|
||||
s.relative_to(REPO_ROOT)
|
||||
for s in own_test_files(".sh")
|
||||
if s != TEST_HOST.resolve() and s not in listed
|
||||
]
|
||||
assert not missing, (
|
||||
"diese Shell-Suiten stehen nicht in SHELL_SUITES von tools/test_host.sh, sie "
|
||||
"laufen also nur bei Handaufruf: {}".format(", ".join(str(m) for m in missing))
|
||||
)
|
||||
# Und andersherum: ein Eintrag, den es nicht mehr gibt, laesst test_host.sh beim
|
||||
# naechsten Lauf hart scheitern - das faellt hier schon vorher auf.
|
||||
verschwunden = [str(p) for p in listed if not p.exists()]
|
||||
assert not verschwunden, (
|
||||
"SHELL_SUITES in tools/test_host.sh nennt Dateien, die es nicht gibt: {}".format(
|
||||
", ".join(verschwunden)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_python38_guard_is_among_them():
|
||||
"""Die konkrete Datei, deren Ausfall den Build-Bruch damals durchgelassen haette."""
|
||||
guard = (TOOLS_DIR / "mkimage" / "test_nl_mkimage.py").resolve()
|
||||
assert guard.exists(), "tools/mkimage/test_nl_mkimage.py fehlt"
|
||||
roots = pytest_paths()
|
||||
assert any(guard == r or r in guard.parents for r in roots), (
|
||||
"tools/mkimage/test_nl_mkimage.py wird von tools/test_host.sh nicht eingesammelt "
|
||||
"- darin steht test_stays_python38_compatible, die einzige Wache gegen erneute "
|
||||
"3.10-Syntax in nl_mkimage.py (der Docker-Build-Container hat Python 3.8.10)"
|
||||
)
|
||||
|
||||
|
||||
# Kein eigener "ist vermin installiert"-Test hier: test_nl_mkimage.py importiert vermin
|
||||
# ohnehin hart (kein Skip), und ein fehlendes Paket faellt dort mit derselben Deutlichkeit
|
||||
# auf. Zwei Tests fuer dieselbe Zusage sind nur zwei Stellen zum Pflegen.
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# docker.sh equivalent using podman (same image, same mount, no emulation flag needed if native)
|
||||
set -euo pipefail
|
||||
ROOT=$(cd -P "$(dirname "$0")/.." >/dev/null && pwd -P)
|
||||
podman image exists ninlight-build || podman build --platform linux/amd64 -t ninlight-build "$ROOT/docker"
|
||||
exec podman run --rm --platform linux/amd64 -v "$ROOT":/work -w /work ninlight-build "$@"
|
||||
Executable
+309
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env bash
|
||||
# Baut ein Auslieferungsarchiv: alle Ziele, OHNE einkompilierte WLAN-Zugangsdaten
|
||||
# (die Lampen starten also im Onboarding-AP), in allen drei Bildformen - OTA, Migration
|
||||
# (dev und prod signiert) und serielle Erstbespielung.
|
||||
#
|
||||
# Aufruf: tools/release.sh <version>
|
||||
# Ergebnis: dist/ninlight-<version>.tar.gz
|
||||
#
|
||||
# Umgebungsvariablen:
|
||||
# NINLIGHT_SIGN_DIR Schluesselverzeichnis (Vorgabe ~/workspace/fw-sign)
|
||||
# NINLIGHT_NOCREDS_BUILD_REL Build-Verzeichnis relativ zur Repo-Wurzel (s. build_nocreds.sh)
|
||||
# NINLIGHT_DOCKER Build-Vorspann (s. build_nocreds.sh); Tests stubben ihn
|
||||
set -euo pipefail
|
||||
|
||||
VERSION=${1:?Aufruf: tools/release.sh <version>}
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
TARGETS=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
|
||||
BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
|
||||
BUILD_DIR="$ROOT/$BUILD_REL"
|
||||
NAME="ninlight-$VERSION"
|
||||
TGZ="$ROOT/dist/$NAME.tar.gz"
|
||||
|
||||
# Ein vorhandenes Archiv wird NICHT ueberschrieben: eine schon weitergegebene Version
|
||||
# still durch einen anderen Inhalt zu ersetzen, ist der Fehler, den man am spaetesten
|
||||
# bemerkt.
|
||||
[ -e "$TGZ" ] && { echo "Fehler: $TGZ existiert bereits - Version erhoehen oder Datei entfernen" >&2; exit 1; }
|
||||
|
||||
# sha256: GNU zuerst (Debian/Pi), BSD/macOS als Rueckfall.
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
SHA_CMD="sha256sum"
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
SHA_CMD="shasum -a 256"
|
||||
else
|
||||
echo "Fehler: weder sha256sum noch shasum gefunden" >&2; exit 1
|
||||
fi
|
||||
|
||||
echo "== Firmware ohne Zugangsdaten bauen =="
|
||||
# Der Bauweg liegt bewusst in einem eigenen, wiederverwendbaren Skript (die
|
||||
# Migrationsstation braucht dieselben Abzuege) - inklusive des Beiseitelegens von
|
||||
# config.local.cmake, das jedes -D schlaegt.
|
||||
"$ROOT/tools/build_nocreds.sh" "${TARGETS[@]}"
|
||||
|
||||
STAGE=$(mktemp -d)
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
OUT="$STAGE/$NAME"
|
||||
mkdir -p "$OUT/ota" "$OUT/flash"
|
||||
|
||||
echo "== Images einsammeln =="
|
||||
for t in "${TARGETS[@]}"; do
|
||||
ota="$BUILD_DIR/$t/ninlight_${t}_ota.img"
|
||||
gz="$BUILD_DIR/$t/ninlight_${t}_gz.img"
|
||||
[ -f "$ota" ] || { echo "Fehler: $ota fehlt" >&2; exit 1; }
|
||||
[ -f "$gz" ] || { echo "Fehler: $gz fehlt" >&2; exit 1; }
|
||||
cp "$ota" "$OUT/ota/"
|
||||
cp "$gz" "$OUT/flash/"
|
||||
done
|
||||
|
||||
echo "== Migrationsimages signieren =="
|
||||
# Wie tools/station/deploy.sh: die Schluessel werden nur gelesen, sie gehoeren nie ins
|
||||
# Repo, nie auf den Pi und schon gar nicht ins Archiv. Fehlt das Verzeichnis oder ein
|
||||
# einzelner Schluessel, entfaellt die jeweilige Variante mit einer Meldung - das Archiv
|
||||
# entsteht trotzdem, und README.md sagt dann, warum migrate/ fehlt.
|
||||
# ${HOME:-} statt $HOME, damit `set -u` unter env -i/cron nicht unnoetig abbricht.
|
||||
SIGN_DIR=${NINLIGHT_SIGN_DIR:-${HOME:-}/workspace/fw-sign}
|
||||
KEY_PROD="$SIGN_DIR/QCX_Device_CA/qcx_firmware_signing_key.pem"
|
||||
KEY_DEV="$SIGN_DIR/fw_sign.pem"
|
||||
NL_SIGN="$ROOT/tools/mkimage/nl_sign.py"
|
||||
MIGRATE_NOTE=""
|
||||
HAVE_PROD=0
|
||||
HAVE_DEV=0
|
||||
if [ -d "$SIGN_DIR" ]; then
|
||||
if [ -f "$KEY_PROD" ]; then HAVE_PROD=1; else
|
||||
echo "Hinweis: Produktionsschluessel fehlt - keine _prod.img" >&2
|
||||
fi
|
||||
if [ -f "$KEY_DEV" ]; then HAVE_DEV=1; else
|
||||
echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img" >&2
|
||||
fi
|
||||
else
|
||||
echo "Hinweis: Schluesselverzeichnis $SIGN_DIR nicht gefunden - keine Migrationsimages erzeugt" >&2
|
||||
fi
|
||||
if [ "$HAVE_PROD" -eq 1 ] || [ "$HAVE_DEV" -eq 1 ]; then
|
||||
mkdir -p "$OUT/migrate"
|
||||
for t in "${TARGETS[@]}"; do
|
||||
# Signiert wird IMMER das rohe _gz.img, niemals das fertige _ota.img: beide belegen
|
||||
# denselben 512-Byte-Block am Dateiende (Signatur bzw. Trailer).
|
||||
gz="$BUILD_DIR/$t/ninlight_${t}_gz.img"
|
||||
if [ "$HAVE_PROD" -eq 1 ]; then
|
||||
python3 "$NL_SIGN" sign --key "$KEY_PROD" --in "$gz" --out "$OUT/migrate/${t}_prod.img" \
|
||||
|| { echo "Fehler: Signieren von $t (prod) fehlgeschlagen" >&2; exit 1; }
|
||||
fi
|
||||
if [ "$HAVE_DEV" -eq 1 ]; then
|
||||
python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$OUT/migrate/${t}_dev.img" \
|
||||
|| { echo "Fehler: Signieren von $t (dev) fehlgeschlagen" >&2; exit 1; }
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ "$HAVE_PROD" -eq 0 ] && [ "$HAVE_DEV" -eq 0 ]; then
|
||||
MIGRATE_NOTE="**\`migrate/\` fehlt in diesem Archiv.** Beim Bauen war kein Signierschluessel
|
||||
erreichbar (\`$SIGN_DIR\`). Ohne Signatur nimmt die alte Klyqa-Firmware kein Image an;
|
||||
alte Lampen lassen sich mit diesem Archiv also nicht migrieren. Die Schluessel gehoeren
|
||||
nie in ein Archiv - wer sie hat, baut sich das Archiv mit \`tools/release.sh\` neu."
|
||||
elif [ "$HAVE_PROD" -eq 0 ]; then
|
||||
MIGRATE_NOTE="**In \`migrate/\` fehlen die \`_prod.img\`.** Beim Bauen war nur der
|
||||
Entwicklungsschluessel erreichbar. Lampen mit Produktionssignatur lassen sich damit nicht migrieren."
|
||||
elif [ "$HAVE_DEV" -eq 0 ]; then
|
||||
MIGRATE_NOTE="**In \`migrate/\` fehlen die \`_dev.img\`.** Beim Bauen war nur der
|
||||
Produktionsschluessel erreichbar. Lampen mit Entwicklungssignatur lassen sich damit nicht migrieren."
|
||||
fi
|
||||
|
||||
echo "== Bruno-Collection, Onboarding-Skript und README =="
|
||||
mkdir -p "$OUT/bruno"
|
||||
cp "$ROOT/bruno/ninlight.yml" "$OUT/bruno/"
|
||||
|
||||
cat >"$OUT/onboard.sh" <<'ONBOARD'
|
||||
#!/usr/bin/env bash
|
||||
# Bringt eine frisch geflashte ninlight-Lampe ins WLAN.
|
||||
#
|
||||
# Aufruf: ./onboard.sh <ssid> [wlan-passwort]
|
||||
#
|
||||
# VORHER VON HAND (das kann dir kein Skript portabel abnehmen - sich per Skript mit
|
||||
# einem WLAN zu verbinden ist auf jedem Betriebssystem anders):
|
||||
# 1. Lampe einschalten. Ohne gespeicherte Zugangsdaten pulsiert sie warmweiss und
|
||||
# spannt den Zugangspunkt "ninlight-<mac4>" auf (Passwort: ninlight).
|
||||
# 2. Diesen Rechner mit genau diesem WLAN verbinden.
|
||||
# 3. Dieses Skript aufrufen.
|
||||
# 4. Danach wieder ins normale WLAN wechseln; die Lampe ist per mDNS
|
||||
# (ninlight-<mac4>.local, Port 3333) erreichbar.
|
||||
#
|
||||
# Optionen:
|
||||
# --host <ip> Adresse der Lampe im AP (Vorgabe 192.168.4.1)
|
||||
# --port <port> REST-Port (Vorgabe 3333)
|
||||
# --token <token> API-Token. Im unkonfigurierten Onboarding-AP NICHT noetig (der
|
||||
# Zugang ist dort offen). Noetig im Fallback-AP, den eine Lampe
|
||||
# aufspannt, die bereits Zugangsdaten hat (Router > 5 min weg) -
|
||||
# dort ist system/settings wie ueberall tokengeschuetzt.
|
||||
# Vorgabewert der Firmware: aabbccddeeff0011223344
|
||||
set -euo pipefail
|
||||
|
||||
command -v curl >/dev/null 2>&1 || { echo "Fehler: curl wird gebraucht" >&2; exit 1; }
|
||||
command -v python3 >/dev/null 2>&1 || { echo "Fehler: python3 wird gebraucht (JSON-Body)" >&2; exit 1; }
|
||||
|
||||
HOST=192.168.4.1
|
||||
PORT=3333
|
||||
TOKEN=""
|
||||
SSID=""
|
||||
PASSWORD=""
|
||||
# Hilfetext ist der Kommentarblock oben - keine feste Zeilennummer, die beim naechsten
|
||||
# Satz im Kopf stillschweigend falsch wird.
|
||||
usage() { awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0"; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--host) HOST=${2:?--host braucht einen Wert}; shift 2 ;;
|
||||
--port) PORT=${2:?--port braucht einen Wert}; shift 2 ;;
|
||||
--token) TOKEN=${2:?--token braucht einen Wert}; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-*) echo "Unbekannte Option: $1" >&2; usage >&2; exit 2 ;;
|
||||
*) if [ -z "$SSID" ]; then SSID=$1; else PASSWORD=$1; fi; shift ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$SSID" ] || { echo "Fehler: SSID fehlt" >&2; usage >&2; exit 2; }
|
||||
|
||||
URL="http://$HOST:$PORT/api/v1/system/settings"
|
||||
BODY=$(SSID="$SSID" PASSWORD="$PASSWORD" python3 -c '
|
||||
import json, os
|
||||
print(json.dumps({"type": "wifi", "ssid": os.environ["SSID"], "password": os.environ["PASSWORD"]}))')
|
||||
|
||||
set -- -X PUT "$URL" -H "Content-Type: application/json" -d "$BODY" --max-time 15
|
||||
[ -n "$TOKEN" ] && set -- "$@" -H "Authorization: $TOKEN"
|
||||
echo "== WLAN setzen ($URL) =="
|
||||
curl -fsS "$@" || {
|
||||
echo "Fehler: die Lampe hat die Zugangsdaten nicht angenommen." >&2
|
||||
echo " 401 -> Fallback-AP: --token <token> mitgeben." >&2
|
||||
echo " keine Verbindung -> bist du wirklich im WLAN ninlight-<mac4>?" >&2
|
||||
exit 1
|
||||
}
|
||||
echo
|
||||
echo "== Verbindung pruefen (system/info) =="
|
||||
# Die Lampe braucht ein paar Sekunden, bis sie im neuen WLAN haengt; danach schliesst
|
||||
# der AP nach 60 s. wifi.sta_ip zeigt die neue Adresse, wifi.error den Grund, falls es
|
||||
# nicht geklappt hat (z.B. falsches Passwort). Wartezeit fuer Tests ueberschreibbar.
|
||||
sleep "${NINLIGHT_ONBOARD_WAIT:-8}"
|
||||
curl -fsS "http://$HOST:$PORT/api/v1/system/info" --max-time 15 || {
|
||||
echo "Hinweis: system/info nicht mehr erreichbar - meist ein gutes Zeichen (der AP ist" >&2
|
||||
echo " zu, die Lampe haengt im neuen WLAN). Im normalen WLAN nachsehen." >&2
|
||||
}
|
||||
echo
|
||||
ONBOARD
|
||||
chmod +x "$OUT/onboard.sh"
|
||||
|
||||
cat >"$OUT/README.md" <<README
|
||||
# ninlight $VERSION
|
||||
|
||||
Ersatzfirmware fuer die abgekuendigten Klyqa-Lampen (WinnerMicro W600). Alle Images in
|
||||
diesem Archiv sind **ohne einkompilierte WLAN-Zugangsdaten** gebaut: eine damit bespielte
|
||||
Lampe startet im Onboarding-Zugangspunkt \`ninlight-<mac4>\` (Passwort \`ninlight\`) und
|
||||
wird mit \`onboard.sh\` ins WLAN gebracht.
|
||||
|
||||
Ziele: evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw, ledshield_rgb.
|
||||
|
||||
## Was liegt wo
|
||||
|
||||
| Verzeichnis | Inhalt | wofuer |
|
||||
|---|---|---|
|
||||
| \`ota/\` | \`ninlight_<ziel>_ota.img\` | Update einer Lampe, auf der ninlight schon laeuft (REST \`fw_update\`), und die Auslieferung an die Rettungsstation |
|
||||
| \`migrate/\` | \`<ziel>_prod.img\`, \`<ziel>_dev.img\` | Erstmigration einer Lampe, auf der noch die alte Klyqa-Firmware laeuft |
|
||||
| \`flash/\` | \`ninlight_<ziel>_gz.img\` | serielle Erstbespielung ueber die Platine |
|
||||
| \`bruno/\` | \`ninlight.yml\` | REST-Schnittstelle als Bruno-Collection |
|
||||
|
||||
\`SHA256SUMS\` deckt jede Datei ab: \`sha256sum -c SHA256SUMS\` (macOS: \`shasum -a 256 -c SHA256SUMS\`).
|
||||
|
||||
$MIGRATE_NOTE
|
||||
|
||||
## Alte Klyqa-Lampe migrieren
|
||||
|
||||
Die alte Firmware nimmt nur signierte Images an; welche Signatur (\`_prod\` oder \`_dev\`)
|
||||
gilt, haengt an Produkt-ID und Version der Lampe. Der Weg laeuft ueber die
|
||||
Migrationsstation (\`tools/station/\` im Repo, ein Raspberry Pi mit zwei Zugangspunkten):
|
||||
|
||||
1. Station einschalten und \`ninlight-station migrate on\` (oder den Taster druecken).
|
||||
2. Lampe einschalten - sie waehlt sich in den Klyqa-Zugangspunkt ein und holt das
|
||||
passende Image aus \`migrate/\`.
|
||||
3. Nach dem Neustart laeuft ninlight; die Lampe spannt ihren Onboarding-AP auf.
|
||||
|
||||
## Frisch geflashte Lampe onboarden
|
||||
|
||||
./onboard.sh "MeinWLAN" "meinpasswort"
|
||||
|
||||
Vorher von Hand: Lampe einschalten (sie pulsiert warmweiss), diesen Rechner mit dem WLAN
|
||||
\`ninlight-<mac4>\` verbinden (Passwort \`ninlight\`). \`./onboard.sh --help\` zeigt die
|
||||
Schritte und die Optionen (\`--host\`, \`--port\`, \`--token\`). Im unkonfigurierten
|
||||
Onboarding-AP ist der Zugang offen, im Fallback-AP einer bereits onboardeten Lampe ist das
|
||||
Token noetig.
|
||||
|
||||
Serielle Erstbespielung: \`flash/ninlight_<ziel>_gz.img\` mit \`tools/flash.sh\` aus dem Repo
|
||||
bzw. \`wm_tool\` auf die Platine schreiben.
|
||||
|
||||
## Vertrauensgrenze
|
||||
|
||||
Der 512-Byte-Trailer der \`_ota.img\` prueft Geraetetyp und SHA256 - das ist eine
|
||||
Integritaets- und Geraetetyppruefung, **kein kryptografisches Signaturverfahren**. Der
|
||||
Download laeuft ueber \`http://\` ohne TLS, und der Rettungskanal ist nur durch ein WLAN-
|
||||
Passwort geschuetzt, das mit einem oeffentlichen Default im Repo steht. Wer im selben WLAN
|
||||
ist und das API-Token kennt (bzw. beim Recovery in Funkreichweite ist und das Passwort
|
||||
kennt), kann der Lampe beliebige Firmware unterschieben. Das ist eine bewusste
|
||||
Entscheidung dieses Projekts: Ziel ist, die Signaturpruefung der alten, abgekuendigten
|
||||
Original-Firmware zu umgehen, damit die Lampen ueberhaupt weiter nutzbar bleiben - nicht,
|
||||
ein neues, gehaertetes Sicherheitsmodell zu bauen.
|
||||
|
||||
Die RSA-Signatur der \`migrate/\`-Images ist die der ALTEN Firmware und existiert nur, damit
|
||||
die alte Firmware das Image ueberhaupt annimmt. Die privaten Schluessel liegen nie in
|
||||
diesem Archiv.
|
||||
README
|
||||
|
||||
echo "== Auf Zugangsdaten pruefen =="
|
||||
# Doppelter Boden zum Beiseitelegen von config.local.cmake: bevor irgendetwas gepackt
|
||||
# wird, wird JEDE Datei des Archivs binaer gegen die echten Werte aus
|
||||
# config.local.cmake geprueft. Lieber ein abgebrochener Release als ein Archiv mit
|
||||
# Michaels WLAN-Passwort bei Dritten.
|
||||
LOCAL="$ROOT/config.local.cmake"
|
||||
if [ -f "$LOCAL" ]; then
|
||||
python3 - "$OUT" "$LOCAL" <<'PY' || exit 1
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
stage, local = sys.argv[1], sys.argv[2]
|
||||
text = open(local, encoding="utf-8", errors="replace").read()
|
||||
# Nur die drei Werte, die wirklich geheim sind. NINLIGHT_AP_PASSWORD und der
|
||||
# Vorgabetoken stehen oeffentlich im Repo (README, Bruno) - sie hier mitzupruefen
|
||||
# haette nur falsche Alarme erzeugt.
|
||||
SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN")
|
||||
PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"}
|
||||
needles = []
|
||||
for name in SECRETS:
|
||||
m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M)
|
||||
if m and m.group(1) not in PUBLIC:
|
||||
needles.append((name, m.group(1).encode()))
|
||||
|
||||
hits = []
|
||||
for root, _dirs, files in os.walk(stage):
|
||||
for fn in sorted(files):
|
||||
path = os.path.join(root, fn)
|
||||
with open(path, "rb") as fh:
|
||||
data = fh.read()
|
||||
for name, needle in needles:
|
||||
if needle in data:
|
||||
hits.append("%s in %s" % (name, os.path.relpath(path, stage)))
|
||||
if hits:
|
||||
sys.stderr.write(
|
||||
"Fehler: Zugangsdaten aus config.local.cmake im Archiv gefunden, nichts gepackt:\n "
|
||||
+ "\n ".join(hits) + "\n")
|
||||
sys.exit(1)
|
||||
print(" sauber (%d geprueft)" % len(needles))
|
||||
PY
|
||||
fi
|
||||
|
||||
echo "== SHA256SUMS =="
|
||||
(
|
||||
cd "$OUT"
|
||||
# $SHA_CMD absichtlich ohne Anfuehrungszeichen: "shasum -a 256" sind drei Woerter.
|
||||
# shellcheck disable=SC2086
|
||||
find . -type f ! -name SHA256SUMS | sed 's|^\./||' | sort | xargs $SHA_CMD >SHA256SUMS
|
||||
)
|
||||
|
||||
echo "== packen =="
|
||||
mkdir -p "$ROOT/dist"
|
||||
tar czf "$TGZ" -C "$STAGE" "$NAME"
|
||||
echo "fertig: $TGZ"
|
||||
@@ -11,14 +11,126 @@ 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.
|
||||
|
||||
## Voraussetzung
|
||||
|
||||
Der SSH-Benutzer auf dem Pi braucht passwortloses `sudo` — beide Skripte und die CLI
|
||||
(`ninlight-station on|off|status`) rufen `sudo` nicht-interaktiv auf; ein Passwort-Prompt
|
||||
würde dort nur mit einer Fehlermeldung enden.
|
||||
|
||||
## 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 bildsatz welcher Migrations-Bildsatz gilt
|
||||
ssh <pi-host> ninlight-station off ausschalten
|
||||
|
||||
## Bildsatz: mit oder ohne WLAN-Zugangsdaten
|
||||
|
||||
Die WLAN-Zugangsdaten stecken **zur Bauzeit** in der Firmware (`config.local.cmake`). Es
|
||||
gibt deshalb zwei Abzüge je Ziel, und die Station hält beide vor:
|
||||
|
||||
| Umschalterstellung | Verzeichnis auf dem Pi | gebaut mit | die migrierte Lampe … |
|
||||
|---|---|---|---|
|
||||
| `ohne-wlan` (**Vorgabe**) | `images/migrate-nocreds/` | `tools/build_nocreds.sh` | startet im eigenen Onboarding-Zugangspunkt und wartet auf ein WLAN |
|
||||
| `mit-wlan` | `images/migrate-wlan/` | `build/` | hängt sofort im WLAN aus `config.local.cmake` |
|
||||
|
||||
Umgeschaltet wird per `ninlight-station bildsatz ohne-wlan|mit-wlan` oder am Taster
|
||||
(Menüeintrag „Umschalten: …"). Das ist ein **Umschalter, keine Abfrage je Migration**: der
|
||||
Zustand steht in `/var/lib/ninlight-station/imageset`, überlebt Neustarts und bleibt, bis
|
||||
ihn jemand ändert. Genau deshalb steht er auch **dauerhaft in der Fußzeile des Panels** —
|
||||
in jedem Modus, auch bei ausgeschalteter Station: man soll vor jedem Einschalten sehen,
|
||||
was gilt, statt zwanzig Lampen mit den eigenen Zugangsdaten zu flashen, weil die
|
||||
Einstellung von vorletzter Woche noch stand.
|
||||
|
||||
**Warum `ohne-wlan` die Vorgabe ist** (und warum jeder Zweifelsfall — Datei fehlt,
|
||||
unlesbar, unbekannter Inhalt — dorthin führt): im Automatikmodus wird jede alte
|
||||
Klyqa-Lampe in Funkreichweite migriert, auch fremde. Mit dem Abzug ohne Zugangsdaten ist
|
||||
ein Fehlgriff ärgerlich, aber harmlos — die Lampe steht im Onboarding-AP und wartet. Mit
|
||||
dem Abzug mit Zugangsdaten steht das eigene WLAN-Passwort unwiderruflich in einem fremden
|
||||
Gerät. Der teurere Fehler darf nie der sein, der ohne Zutun passiert.
|
||||
|
||||
**Fehlt der gewählte Satz** (nie ausgeliefert, oder `tools/build_nocreds.sh` nie gelaufen),
|
||||
wird **nichts** ausgeliefert — kein stilles Ausweichen auf den anderen Satz. Der Server
|
||||
antwortet mit 404 und `status: noimageset`, `ninlight-station status` sagt „FEHLT", und auf
|
||||
dem Panel steht „SATZ FEHLT". „Vorhanden" heißt dabei überall dasselbe: Verzeichnis da
|
||||
**und** mindestens ein Image darin.
|
||||
|
||||
`deploy.sh` liefert beide Sätze aus. Fehlt `build-nocreds/`, bleibt der Satz `ohne-wlan`
|
||||
auf dem Pi unangetastet (statt geleert zu werden) und der Lauf meldet, womit er entsteht.
|
||||
|
||||
## Taster am Gerät
|
||||
|
||||
Sobald die Station läuft, wird `wlan0` zum Zugangspunkt und **jede SSH-Sitzung reißt ab**.
|
||||
Für genau diesen Zustand gibt es einen Taster auf **GPIO23**; die Rückmeldung kommt aufs
|
||||
E-Paper. Ein Taster, zwei Zusammenhänge:
|
||||
|
||||
| | kurz (unter 1 s) | gehalten |
|
||||
|----------------------|------------------|-------------------------------------------------------|
|
||||
| Ruhe (Statusbild) | Menü öffnen | 3 s → Abbruch (Station aus), weiter 10 s → Herunterfahren |
|
||||
| Menü offen | nächster Eintrag | 1,5 s → Eintrag auswählen |
|
||||
| Rückfrage | abbrechen | 1,5 s → bestätigen |
|
||||
|
||||
Der Abbruch feuert **bei 3 s**, nicht erst beim Loslassen — danach läuft auf dem Panel ein
|
||||
Countdown bis zum Herunterfahren, den Loslassen jederzeit abbricht. Das Menü schließt nach
|
||||
20 s ohne Eingabe, eine Rückfrage verfällt nach 10 s als Abbruch. Die Einträge hängen vom
|
||||
Zustand ab (Station aus / Rettung läuft / Migration läuft); jede verändernde Auswahl
|
||||
bekommt eine Rückfrage.
|
||||
|
||||
**Die beiden Migrationsarten sind getrennte Einträge.** „Migration: horchen"
|
||||
(`migrate on --listen`) steht zuerst und ist der naheliegende: der Zugangspunkt steht,
|
||||
aber es geht nichts raus, bis du eine erkannte Lampe freigibst. „Migration: AUTOMATIK"
|
||||
(`migrate on`) folgt danach und bekommt eine **andere, deutlichere Rückfrage** — der
|
||||
Migrations-Zugangspunkt hat ein öffentlich bekanntes Passwort (es steht in der alten
|
||||
Klyqa-Firmware), und im Automatikmodus wird jede alte Klyqa-Lampe in Funkreichweite
|
||||
ungefragt migriert, auch fremde. Die Rückfrage benennt genau das, statt nur „Wirklich?"
|
||||
zu fragen.
|
||||
|
||||
Der Taster ist nirgends Voraussetzung: fehlt `gpiozero` oder der Taster, protokolliert der
|
||||
Dienst eine Zeile und beendet sich ohne Fehler. Er fasst den SPI-Bus **nicht** an (zwei
|
||||
Prozesse auf denselben Leitungen ergeben `GPIO busy`), sondern schreibt seinen
|
||||
Bedienzustand nach `/run/ninlight-button/ui.json`; der Anzeigedienst liest ihn und
|
||||
zeichnet. Ausgeführt wird ausschließlich über das CLI.
|
||||
|
||||
Dienst und Unit liegen in `tools/station/button/` bzw.
|
||||
`tools/station/ninlight-button.service`. `setup_pi.sh` installiert, aktiviert und startet
|
||||
den Tasterdienst wie den Anzeigedienst; `deploy.sh` liefert `button/` bei jedem Deploy mit
|
||||
aus. Ein fehlgeschlagenes Aktivieren/Starten ist dabei nur eine Warnung — eine Station
|
||||
ohne Taster muss vollständig benutzbar bleiben.
|
||||
|
||||
## Abschlussbildschirm beim Herunterfahren und beim Neustart
|
||||
|
||||
E-Paper behält sein Bild **dauerhaft**, auch stromlos. Fährt der Pi herunter, wird der
|
||||
Anzeigedienst mit abgeräumt, und das zuletzt gezeichnete Bild bleibt für immer stehen —
|
||||
bei der Abnahme stand dort die **Rückfrage** des Tastermenüs, und der ausgeschaltete Pi
|
||||
sah aus, als warte er noch auf eine Eingabe.
|
||||
|
||||
Dafür gibt es zwei systemd-Units, `ninlight-poweroff.service` (`WantedBy=poweroff.target`)
|
||||
und `ninlight-reboot.service` (`WantedBy=reboot.target`). Sie hängen am **System**, nicht
|
||||
am Tasterdienst: so greifen sie bei jedem Herunterfahren — über das Menü, über `poweroff`
|
||||
per SSH, über `reboot` — und nicht nur auf einem von drei Wegen.
|
||||
|
||||
**Die Texte unterscheiden sich, und das ist der Punkt:**
|
||||
|
||||
| | Panel |
|
||||
|---|---|
|
||||
| Herunterfahren | „AUSGESCHALTET — Strom kann getrennt werden" |
|
||||
| Neustart | „NEUSTART — Strom NICHT trennen!" plus der Hinweis, dass es rund 2 min dauert |
|
||||
|
||||
Ein Zero W braucht gut anderthalb Minuten, bis der Anzeigedienst wieder zeichnet. Stünde
|
||||
dort währenddessen „Strom kann getrennt werden", wäre das eine Aufforderung zu genau dem
|
||||
Griff, der an diesem Pi schon eine SD-Karte gekostet hat.
|
||||
|
||||
**Reihenfolge:** der Anzeigedienst hält die GPIO-/SPI-Leitungen; zwei Prozesse darauf
|
||||
ergeben `lgpio.error: 'GPIO busy'`. Die Units ordnen sich deshalb per
|
||||
`After=ninlight-display.service` hinter dessen Stop ein, und `ninlight-station display
|
||||
final <poweroff|reboot>` wartet zusätzlich, bis `systemctl is-active ninlight-display`
|
||||
nicht mehr anschlägt — dasselbe Muster wie `display_handover()` bei `display test`, nur
|
||||
ohne Wiederanfahren. Gibt der Dienst die Leitungen nicht frei, wird **nicht** gezeichnet,
|
||||
sondern eine Zeile ins Journal geschrieben: ein Fehler an der Anzeige darf das
|
||||
Herunterfahren nie aufhalten.
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,673 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tasterdienst der ninlight-Station: EIN Taster auf GPIO23 (active low, interner
|
||||
Pull-up), Rueckmeldung ueber das E-Paper.
|
||||
|
||||
Wozu das gut ist: "ninlight-station on"/"migrate on" macht wlan0 zum Zugangspunkt und
|
||||
reisst damit JEDE SSH-Sitzung ueber WLAN ab. Genau in diesem Zustand hatte der Bediener
|
||||
am Geraet bisher keinerlei Eingriff - es blieb warten oder Stecker ziehen, und Letzteres
|
||||
hat an diesem Pi schon eine SD-Karte gekostet. Der Taster ist der Ausweg: Abbruch
|
||||
(Station aus) und, ueber eine sichtbare Zwischenstufe, ein sauberes Herunterfahren.
|
||||
|
||||
Bedienung (ein Taster, zwei Zusammenhaenge - die Bedeutung haengt daran, ob das Menue
|
||||
offen ist):
|
||||
|
||||
kurz (< 1 s) gehalten
|
||||
Ruhe Menue oeffnen 3 s -> Abbruch, weiter 10 s -> Herunterfahren
|
||||
Menue offen naechster Eintrag 1,5 s -> Eintrag auswaehlen
|
||||
Rueckfrage abbrechen 1,5 s -> bestaetigen
|
||||
|
||||
Der Abbruch feuert BEI 3 s, nicht erst beim Loslassen: der Bediener soll die Wirkung am
|
||||
Panel sehen. Haelt er weiter, laeuft dort ein Countdown bis 10 s (Herunterfahren);
|
||||
Loslassen bricht diese zweite Stufe jederzeit ab. Das Herunterfahren ist damit nur ueber
|
||||
eine sichtbare Zustandsaenderung hinweg erreichbar - nicht aus Versehen.
|
||||
|
||||
DER TASTERDIENST FASST DEN SPI-BUS NICHT AN. Zwei Prozesse auf denselben GPIO-/SPI-
|
||||
Leitungen enden in "lgpio.error: 'GPIO busy'" (am Geraet passiert, als
|
||||
"ninlight-station display test" neben dem laufenden Anzeigedienst lief). Deshalb schreibt
|
||||
dieser Dienst nur seinen Bedienzustand nach UI_FILE, und der Anzeigedienst liest ihn und
|
||||
zeichnet (siehe display.read_ui()/render.blocks()).
|
||||
|
||||
Alle veraendernden Aktionen laufen ueber das vorhandene CLI (/usr/local/bin/
|
||||
ninlight-station), nie ueber nachgebaute Logik: nur das CLI kennt die Profilumschaltung,
|
||||
die Nachpruefungen ("es darf nie mehr als ein Stationsprofil aktiv sein") und den
|
||||
Zeitschalter.
|
||||
|
||||
SICHERHEITSREGEL: die beiden Migrationseintraege sind GETRENNT und namentlich
|
||||
unterscheidbar. "Migration: horchen" ruft "migrate on --listen" - der Zugangspunkt steht,
|
||||
aber es geht nichts raus, bis der Bediener eine erkannte Lampe freigibt; das ist der
|
||||
erste und naheliegende Eintrag. "Migration: AUTOMATIK" ruft "migrate on" ohne "--listen"
|
||||
und bekommt eine ANDERE, deutlichere Rueckfrage (siehe action_migrate_auto()): der
|
||||
Migrations-Zugangspunkt hat ein oeffentlich bekanntes Passwort (es steht in der alten
|
||||
Klyqa-Firmware), und im Automatikmodus wird jede alte Klyqa-Lampe in Funkreichweite
|
||||
ungefragt migriert, auch fremde. Die bequeme Geste bleibt damit die sichere, ohne dass
|
||||
der Automatikmodus - fuer eine ganze Kiste eigener Lampen am Stueck - nur noch ueber die
|
||||
Kommandozeile erreichbar waere, die im Zugangspunktbetrieb gerade nicht zur Verfuegung
|
||||
steht.
|
||||
|
||||
Der Taster ist NIRGENDS Voraussetzung: fehlt gpiozero, fehlt der Taster oder ist GPIO23
|
||||
belegt, schreibt main() GENAU EINE Protokollzeile und beendet sich mit sys.exit(0) -
|
||||
dieselbe Zusage (und derselbe Grund: Restart=on-failure darf daraus keine Neustartschleife
|
||||
machen) wie bei der Anzeige, siehe display.py's Modul-Docstring. Und ein Fehler am Taster
|
||||
darf niemals eine laufende Migration stoeren oder das Ein-/Ausschalten aufhalten - deshalb
|
||||
faengt jeder Schreib- und Ausfuehrungspfad hier seine Fehler selbst ab.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("ninlight-button")
|
||||
|
||||
# --- Zeiten und Pfade ---------------------------------------------------------------
|
||||
|
||||
BUTTON_PIN = int(os.environ.get("NINLIGHT_BUTTON_PIN", "23"))
|
||||
# 50 ms statt der naheliegenden 20: ein sehr kurzer Tipper kam am Geraet durch eine
|
||||
# 20-ms-Entprellung noch durch und oeffnete ungewollt das Menue. Die Entprellung macht
|
||||
# gpiozero selbst (bounce_time), nicht diese Schleife - siehe open_button().
|
||||
DEBOUNCE_S = 0.05
|
||||
# Abtastrate der Hauptschleife. Deutlich feiner als die Entprellung, damit eine Flanke
|
||||
# nicht erst eine halbe Entprellzeit spaeter gesehen wird; ein Durchlauf ohne Aenderung
|
||||
# kostet nur einen gpiozero-Registerzugriff und einen Woerterbuchvergleich (UiWriter
|
||||
# schreibt nur bei Aenderung).
|
||||
TICK_S = 0.02
|
||||
|
||||
SHORT_PRESS_MAX_S = 1.0
|
||||
SELECT_HOLD_S = 1.5 # Menue: Eintrag waehlen / Rueckfrage: bestaetigen
|
||||
CANCEL_HOLD_S = 3.0 # Ruhe: Abbruch (Station aus)
|
||||
SHUTDOWN_HOLD_S = 10.0 # Ruhe: Herunterfahren (nur ueber die Abbruchstufe hinweg)
|
||||
MENU_TIMEOUT_S = 20.0 # ohne Eingabe zurueck aufs Statusbild
|
||||
CONFIRM_TIMEOUT_S = 10.0 # Rueckfrage verfaellt als Abbruch
|
||||
RESULT_S = 4.0 # wie lange die Rueckmeldung einer Aktion stehen bleibt
|
||||
|
||||
# Dieselben Umgebungsvariablen wie ninlight-station/display.py, damit Tests und
|
||||
# Handbetrieb ohne echtes /run bzw. /etc auskommen.
|
||||
RUN_DIR = Path(os.environ.get("NINLIGHT_RUN_DIR", "/run/ninlight-station"))
|
||||
# BEWUSST NICHT /run/ninlight-station/ui.json (das war der Vorschlag): dieses Verzeichnis
|
||||
# ist das RuntimeDirectory der Hauptunit, und systemd raeumt es beim regulaeren Stop von
|
||||
# ninlight-station auf - also ausgerechnet im Zustand "Station aus", in dem das Menue am
|
||||
# dringendsten gebraucht wird (dort liegen "Rettung starten"/"Migration starten"). Die
|
||||
# Datei liegt deshalb im eigenen RuntimeDirectory dieses Dienstes; sie verschwindet damit
|
||||
# genau dann, wenn dieser Dienst endet - und ein stehengebliebenes Menue kann den
|
||||
# Anzeigedienst nicht ueberdauern.
|
||||
UI_FILE = Path(os.environ.get("NINLIGHT_UI_FILE", "/run/ninlight-button/ui.json"))
|
||||
STATION_CLI = os.environ.get("NINLIGHT_STATION_BIN", "/usr/local/bin/ninlight-station")
|
||||
# Der Umschalter "Migrationsimages mit oder ohne WLAN-Zugangsdaten" - dieselbe Datei, die
|
||||
# das CLI schreibt, der Server liest und der Anzeigedienst auf dem Panel zeigt. Hier wird
|
||||
# sie NUR GELESEN (fuer die Beschriftung des Menueeintrags); umgeschaltet wird wie jede
|
||||
# andere Aktion ueber das CLI.
|
||||
IMAGESET_FILE = Path(os.environ.get("NINLIGHT_IMAGESET_FILE",
|
||||
"/var/lib/ninlight-station/imageset"))
|
||||
IMAGESET_NAMES = ("ohne-wlan", "mit-wlan")
|
||||
IMAGESET_DEFAULT = "ohne-wlan"
|
||||
STATION_SERVICE = os.environ.get("NINLIGHT_STATION_SERVICE", "ninlight-station")
|
||||
# Das Herunterfahren geht NICHT ueber das CLI (das kennt es nicht) - es ist die einzige
|
||||
# Aktion dieses Dienstes, die an systemd selbst geht.
|
||||
SHUTDOWN_ARGV = ("systemctl", "poweroff")
|
||||
# Obergrenze fuer einen CLI-Aufruf. "recovery on"/"migrate on" schalten NetworkManager-
|
||||
# Profile um und pruefen danach nach; das dauert Sekunden, nicht Minuten. Ohne Deckel
|
||||
# wuerde ein haengender NetworkManager die Bedienung dauerhaft einfrieren - mit ihm
|
||||
# bleibt der Taster spaetestens danach wieder bedienbar.
|
||||
ACTION_TIMEOUT_S = 120
|
||||
|
||||
|
||||
class ButtonUnavailable(Exception):
|
||||
"""Kein Taster da (keine Bibliothek, kein Pin, Pin belegt) - main() macht daraus
|
||||
genau eine Protokollzeile und sys.exit(0)."""
|
||||
|
||||
|
||||
# --- Aktionen -----------------------------------------------------------------------
|
||||
|
||||
|
||||
class Action:
|
||||
"""Was ein Menueeintrag bzw. die Haltegeste ausloest.
|
||||
|
||||
`confirm` = braucht eine Rueckfrage. Zusage: JEDE veraendernde Auswahl bekommt eine
|
||||
("Anzeige auffrischen" veraendert nichts und braucht keine).
|
||||
|
||||
`confirm_title`/`confirm_details` machen die Rueckfrage bei Bedarf DEUTLICHER als die
|
||||
uebliche. Die Vorgabe ("RUECKFRAGE" + die zwei Bedienhinweise) reicht fuer eine
|
||||
Auswahl, deren Tragweite im Eintragsnamen steht ("Station aus", "Herunterfahren").
|
||||
Sie reicht NICHT fuer den Automatikmodus: was dort passiert - jede alte Klyqa-Lampe
|
||||
in Funkreichweite wird ungefragt migriert, auch fremde - steht in keinem
|
||||
Eintragsnamen, und ein blosses "Wirklich?" fragt nach etwas, das der Bediener in
|
||||
diesem Moment gar nicht vor Augen hat."""
|
||||
|
||||
def __init__(self, label: str, argv=(), kind: str = "cli", confirm: bool = True,
|
||||
done_text: str | None = None, confirm_title: str = "RUECKFRAGE",
|
||||
confirm_details=None) -> None:
|
||||
self.label = label
|
||||
self.argv = tuple(argv)
|
||||
self.kind = kind
|
||||
self.confirm = confirm
|
||||
self.done_text = done_text or label
|
||||
self.confirm_title = confirm_title
|
||||
self.confirm_details = list(confirm_details) if confirm_details else None
|
||||
|
||||
def __repr__(self) -> str: # nur fuer Testausgaben
|
||||
return "Action({!r}, {!r}, kind={!r})".format(self.label, self.argv, self.kind)
|
||||
|
||||
|
||||
def action_shutdown() -> Action:
|
||||
return Action("Herunterfahren", kind="shutdown", confirm=True,
|
||||
done_text="Faehrt herunter")
|
||||
|
||||
|
||||
def action_migrate_listen() -> Action:
|
||||
"""Der naheliegende Weg in die Migration: Zugangspunkt an, aber es geht NICHTS raus,
|
||||
bis der Bediener eine erkannte Lampe freigibt ("arm"). Deshalb steht dieser Eintrag
|
||||
vor dem Automatikmodus und traegt die uebliche Rueckfrage."""
|
||||
return Action("Migration: horchen", ("migrate", "on", "--listen"),
|
||||
done_text="Migration: horcht")
|
||||
|
||||
|
||||
def action_migrate_auto() -> Action:
|
||||
"""Der Automatikmodus - fuer die eigene Kiste Lampen am Stueck, ohne jede einzeln
|
||||
freizugeben. Er steht hier, weil das Verabreden ueber die Kommandozeile genau dann
|
||||
nicht geht, wenn man ihn braucht: "on" macht wlan0 zum Zugangspunkt und reisst jede
|
||||
SSH-Sitzung ueber WLAN ab.
|
||||
|
||||
Seine Rueckfrage ist ABSICHTLICH eine andere als die der uebrigen Eintraege. Der
|
||||
Migrations-Zugangspunkt hat ein oeffentlich bekanntes Passwort (es steht in der alten
|
||||
Klyqa-Firmware), und im Automatikmodus wird jede alte Klyqa-Lampe in Funkreichweite
|
||||
ungefragt migriert - auch die des Nachbarn, der davon nichts weiss und dessen Lampe
|
||||
danach nicht mehr mit seiner App spricht. Ein "Wirklich?" fragt nach etwas, das der
|
||||
Bediener in diesem Moment gar nicht vor Augen hat; deshalb steht die Folge in der
|
||||
Rueckfrage selbst."""
|
||||
return Action("Migration: AUTOMATIK", ("migrate", "on"),
|
||||
done_text="Migration: automatisch",
|
||||
confirm_title="ACHTUNG",
|
||||
confirm_details=["Flasht JEDE alte Klyqa-Lampe",
|
||||
"in Reichweite - auch fremde"])
|
||||
|
||||
|
||||
def action_imageset(current: str) -> Action:
|
||||
"""Der Umschalter zwischen den beiden Migrations-Bildsaetzen. Der Eintrag nennt, WOHIN
|
||||
umgeschaltet wird, nicht was gerade gilt: waehrend das Menue offen ist, ersetzt es das
|
||||
Statusbild samt Fusszeile - "Bildsatz: mit WLAN" waere dann nicht zu entscheiden.
|
||||
|
||||
Der Weg nach "mit-wlan" bekommt die deutlichere Rueckfrage, der Weg zurueck die
|
||||
uebliche: nur die eine Richtung schreibt das WLAN-Passwort des Bedieners in jede
|
||||
Lampe, die sie erwischt."""
|
||||
target = "mit-wlan" if current != "mit-wlan" else "ohne-wlan"
|
||||
if target == "mit-wlan":
|
||||
return Action("Umschalten: mit WLAN", ("bildsatz", "mit-wlan"),
|
||||
done_text="Bildsatz: mit WLAN",
|
||||
confirm_title="ACHTUNG",
|
||||
confirm_details=["Jede migrierte Lampe bekommt",
|
||||
"dein WLAN-Passwort"])
|
||||
return Action("Umschalten: ohne WLAN", ("bildsatz", "ohne-wlan"),
|
||||
done_text="Bildsatz: ohne WLAN")
|
||||
|
||||
|
||||
def action_cancel() -> Action:
|
||||
"""Die Haltegeste in Ruhe: Station aus. Bewusst ohne Rueckfrage - sie IST die
|
||||
Rueckfrage (3 s halten), und sie ist der Notausgang, der ohne Umweg wirken muss."""
|
||||
return Action("Station aus", ("off",), confirm=False, done_text="Station aus")
|
||||
|
||||
|
||||
def menu_actions(state: dict) -> list[Action]:
|
||||
"""Die Menueeintraege fuer den gegebenen Stationszustand - nur das, was gerade
|
||||
sinnvoll ist. Reine Funktion ueber dem von station_state() gelesenen Zustand."""
|
||||
running = bool(state.get("running"))
|
||||
mode = state.get("mode")
|
||||
target = state.get("target")
|
||||
imageset = state.get("imageset") or IMAGESET_DEFAULT
|
||||
|
||||
if not running:
|
||||
return [
|
||||
Action("Rettung starten", ("recovery", "on"), done_text="Rettung laeuft"),
|
||||
# Reihenfolge ist Absicht: der horchende Modus zuerst, weil er der
|
||||
# naheliegende und der harmlose ist - wer nur blaettert und irgendwo
|
||||
# loslaesst, landet nicht im Automatikmodus.
|
||||
action_migrate_listen(),
|
||||
action_migrate_auto(),
|
||||
# Der beste Zeitpunkt zum Umschalten ist VOR dem Einschalten - deshalb steht
|
||||
# der Eintrag auch (und gerade) hier.
|
||||
action_imageset(imageset),
|
||||
action_shutdown(),
|
||||
]
|
||||
|
||||
items: list[Action] = []
|
||||
if mode == "migration" and target:
|
||||
# Nur wenn last_seen wirklich ein Ziel kennt - ohne erkannte Lampe gibt es
|
||||
# nichts freizugeben, und "arm" ohne Ziel wuerde das CLI ohnehin abweisen.
|
||||
items.append(Action("Freigabe: {}".format(target), ("arm", str(target)),
|
||||
done_text="Freigegeben: {}".format(target)))
|
||||
items.append(Action("Station aus", ("off",), done_text="Station aus"))
|
||||
items.append(action_imageset(imageset))
|
||||
# Veraendert nichts am Betrieb - einzige Auswahl ohne Rueckfrage.
|
||||
items.append(Action("Anzeige auffrischen", ("display", "test"), confirm=False,
|
||||
done_text="Anzeige aufgefrischt"))
|
||||
items.append(action_shutdown())
|
||||
return items
|
||||
|
||||
|
||||
class CliActions:
|
||||
"""Fuehrt eine Action aus - ueber das CLI bzw. systemctl, nie durch Nachbauen deren
|
||||
Logik. Gibt zurueck, ob es geklappt hat; wirft nie: ein Fehler hier darf weder den
|
||||
Tasterdienst beenden noch eine laufende Migration stoeren."""
|
||||
|
||||
def __init__(self, cli: str = STATION_CLI, shutdown_argv=SHUTDOWN_ARGV,
|
||||
runner=subprocess.run) -> None:
|
||||
self.cli = cli
|
||||
self.shutdown_argv = tuple(shutdown_argv)
|
||||
self.runner = runner
|
||||
|
||||
def argv_for(self, action: Action) -> list[str]:
|
||||
if action.kind == "shutdown":
|
||||
return list(self.shutdown_argv)
|
||||
return [self.cli] + list(action.argv)
|
||||
|
||||
def run(self, action: Action) -> bool:
|
||||
argv = self.argv_for(action)
|
||||
log.info("Taster fuehrt aus: %s", " ".join(argv))
|
||||
try:
|
||||
result = self.runner(argv, check=False, timeout=ACTION_TIMEOUT_S)
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
log.warning("Aktion '%s' fehlgeschlagen: %s", action.label, e)
|
||||
return False
|
||||
rc = getattr(result, "returncode", 0)
|
||||
if rc != 0:
|
||||
log.warning("Aktion '%s' endete mit Rueckgabewert %s", action.label, rc)
|
||||
return rc == 0
|
||||
|
||||
|
||||
# --- Stationszustand (nur lesen) ----------------------------------------------------
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict:
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _service_active(service_name: str = STATION_SERVICE) -> bool:
|
||||
try:
|
||||
result = subprocess.run(["systemctl", "is-active", "--quiet", service_name],
|
||||
timeout=5, check=False)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
# Ohne systemd (Handlauf) lieber "laeuft" annehmen als das Menue mit
|
||||
# "Rettung starten" anzubieten, waehrend die Station in Wahrheit laeuft.
|
||||
return True
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _read_imageset(path: Path = IMAGESET_FILE) -> str:
|
||||
"""Der eingestellte Bildsatz, oder IMAGESET_DEFAULT bei fehlender/unlesbarer Datei
|
||||
bzw. unbekanntem Inhalt - dieselbe Vorgabe wie in server.py und im CLI."""
|
||||
try:
|
||||
value = path.read_text().strip()
|
||||
except OSError:
|
||||
return IMAGESET_DEFAULT
|
||||
return value if value in IMAGESET_NAMES else IMAGESET_DEFAULT
|
||||
|
||||
|
||||
def station_state(run_dir: Path = RUN_DIR, service_name: str = STATION_SERVICE,
|
||||
imageset_path: Path = IMAGESET_FILE) -> dict:
|
||||
"""Der Zustand, von dem die Menueeintraege abhaengen. Liest dieselben Quellen wie
|
||||
display.build_state() - RUN_DIR/mode (nicht status.json's "mode"-Feld, siehe dort)
|
||||
und status.json fuer das zuletzt erkannte Ziel - dupliziert aber keine Logik des
|
||||
CLI: geschaltet wird ausschliesslich ueber das CLI."""
|
||||
running = _service_active(service_name)
|
||||
try:
|
||||
mode = (run_dir / "mode").read_text().strip()
|
||||
except OSError:
|
||||
mode = "recovery"
|
||||
if mode not in ("recovery", "migration"):
|
||||
mode = "recovery"
|
||||
status = _read_json(run_dir / "status.json")
|
||||
last_seen = status.get("last_seen")
|
||||
target = last_seen.get("target") if isinstance(last_seen, dict) else None
|
||||
return {"running": running, "mode": mode,
|
||||
"target": target if isinstance(target, str) and target else None,
|
||||
"imageset": _read_imageset(imageset_path)}
|
||||
|
||||
|
||||
# --- Bedienzustand in eine Datei schreiben ------------------------------------------
|
||||
|
||||
|
||||
class UiWriter:
|
||||
"""Schreibt den Bedienzustand atomar mit 0644 nach UI_FILE - dasselbe Muster und
|
||||
dieselben Rechte wie status.json/control.json (Temp-Datei im selben Verzeichnis,
|
||||
Rechte setzen, dann umbenennen). Beides sind Muster, deren Verletzung in diesem
|
||||
Projekt schon mehrfach Fehler verursacht hat: ohne 0644 kann der Anzeigedienst die
|
||||
Datei nicht lesen, ohne das Umbenennen sieht er sie halb geschrieben.
|
||||
|
||||
Schreibt nur bei Aenderung (die Schleife laeuft alle TICK_S) und wirft nie."""
|
||||
|
||||
def __init__(self, path: Path = UI_FILE) -> None:
|
||||
self.path = Path(path)
|
||||
self._last: dict | None = None
|
||||
self._failed_logged = False
|
||||
|
||||
def write(self, view: dict) -> bool:
|
||||
"""True, wenn tatsaechlich geschrieben wurde."""
|
||||
if view == self._last:
|
||||
return False
|
||||
payload = json.dumps(view, ensure_ascii=False, sort_keys=True)
|
||||
tmp = self.path.with_name("{}.tmp-{}".format(self.path.name, os.getpid()))
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp.write_text(payload)
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, self.path)
|
||||
except OSError as e:
|
||||
# Nur einmal protokollieren: die Schleife kaeme sonst alle TICK_S wieder
|
||||
# hierher und wuerde das Journal fluten. Der Taster bleibt trotzdem
|
||||
# bedienbar, nur die Rueckmeldung am Panel fehlt.
|
||||
if not self._failed_logged:
|
||||
log.warning("Bedienzustand konnte nicht geschrieben werden (%s): %s",
|
||||
self.path, e)
|
||||
self._failed_logged = True
|
||||
try:
|
||||
tmp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
self._last = dict(view)
|
||||
return True
|
||||
|
||||
|
||||
# --- Die Zustandsfuehrung -----------------------------------------------------------
|
||||
|
||||
|
||||
def _countdown(total_s: float, held_s: float) -> int:
|
||||
"""Ganze Sekunden bis `total_s`, aufgerundet und nie negativ - "noch 6" solange auch
|
||||
nur eine Zehntelsekunde davon uebrig ist."""
|
||||
return max(0, int(math.ceil(total_s - held_s)))
|
||||
|
||||
|
||||
class ButtonUI:
|
||||
"""Die gesamte Bedienlogik - welcher Druck was bedeutet, welche Zeitschwelle was
|
||||
ausloest, was auf dem Panel steht - als reine Zustandsmaschine ohne Schleife, ohne
|
||||
time.sleep() und ohne GPIO. Dasselbe Muster wie display.RefreshPlanner, aus demselben
|
||||
Grund: so laesst sich ein kompletter Bedienverlauf in Millisekunden durchspielen.
|
||||
|
||||
Benutzung pro Runde: update(now, pressed) -> Action oder None; eine zurueckgegebene
|
||||
Action fuehrt der Aufrufer aus und meldet das Ergebnis per done() zurueck. view(now)
|
||||
liefert jederzeit den Bedienzustand fuer die Anzeige.
|
||||
|
||||
`pressed` ist der ENTPRELLTE Pegel (gpiozero, bounce_time), nicht eine Flanke - alle
|
||||
Schwellen rechnen deshalb gegen den Zeitpunkt der letzten steigenden Flanke. Das ist
|
||||
auch der Grund, warum ein blockierender CLI-Aufruf mitten in einer Haltegeste nichts
|
||||
kaputtmacht: nach der Rueckkehr wird derselbe Pegel wieder gegen dieselbe Uhr
|
||||
gemessen."""
|
||||
|
||||
def __init__(self, now: float = 0.0, read_state=station_state) -> None:
|
||||
self._read_state = read_state
|
||||
self._pressed = False
|
||||
self._press_t = now
|
||||
# Diese Haltegeste hat schon etwas ausgeloest - ein spaeteres Loslassen darf
|
||||
# dann nicht zusaetzlich als "kurzer Druck" gelten.
|
||||
self._consumed = False
|
||||
# 0 = nichts, 1 = Abbruch gefeuert, zweite Stufe (Herunterfahren) laeuft.
|
||||
self._hold_stage = 0
|
||||
# In welchem Zusammenhang diese Haltegeste BEGONNEN hat ("idle", "menu",
|
||||
# "confirm"). Die Haltegeste wird danach beurteilt, NICHT nach dem inzwischen
|
||||
# erreichten Bildschirm: waehlt man im Menue einen Eintrag (1,5 s), steht danach
|
||||
# "Bitte warten"/die Rueckfrage - haelt der Bediener einfach weiter, duerfte das
|
||||
# nicht plotzlich als die Ruhe-Haltegeste gelten und bei 3 s die Station
|
||||
# abschalten. Genau das tat eine fruehere Fassung.
|
||||
self._press_screen = "idle"
|
||||
self.screen = "idle"
|
||||
self.items: list[Action] = []
|
||||
self.index = 0
|
||||
self.pending: Action | None = None
|
||||
self.deadline: float | None = None
|
||||
self.busy: Action | None = None
|
||||
self.result: tuple[str, bool] | None = None
|
||||
self.result_until: float | None = None
|
||||
|
||||
# -- Eingabe ---------------------------------------------------------------------
|
||||
|
||||
def update(self, now: float, pressed: bool) -> Action | None:
|
||||
action = None
|
||||
if pressed and not self._pressed:
|
||||
self._pressed = True
|
||||
self._press_t = now
|
||||
self._consumed = False
|
||||
self._press_screen = self.screen if self.screen in ("menu", "confirm") else "idle"
|
||||
elif pressed and self._pressed:
|
||||
action = self._held(now)
|
||||
elif self._pressed and not pressed:
|
||||
self._pressed = False
|
||||
held = now - self._press_t
|
||||
if not self._consumed and held < SHORT_PRESS_MAX_S:
|
||||
action = self._short(now)
|
||||
# Loslassen bricht die zweite Stufe (Herunterfahren) jederzeit ab.
|
||||
self._hold_stage = 0
|
||||
self._expire(now)
|
||||
else:
|
||||
self._expire(now)
|
||||
if action is not None:
|
||||
self.busy = action
|
||||
self.screen = "busy"
|
||||
self.pending = None
|
||||
self.deadline = None
|
||||
return action
|
||||
|
||||
def _short(self, now: float) -> Action | None:
|
||||
self.result = None
|
||||
self.result_until = None
|
||||
if self.screen == "menu":
|
||||
if self.items:
|
||||
self.index = (self.index + 1) % len(self.items)
|
||||
self.deadline = now + MENU_TIMEOUT_S
|
||||
return None
|
||||
if self.screen == "confirm":
|
||||
# Abbrechen - zurueck ins Menue, nicht aufs Statusbild: der Bediener ist
|
||||
# noch am Bedienen und will meist einen anderen Eintrag.
|
||||
self.pending = None
|
||||
self.screen = "menu"
|
||||
self.deadline = now + MENU_TIMEOUT_S
|
||||
return None
|
||||
# Ruhe (auch waehrend eine Rueckmeldung steht): Menue oeffnen.
|
||||
self._open_menu(now)
|
||||
return None
|
||||
|
||||
def _open_menu(self, now: float) -> None:
|
||||
try:
|
||||
state = self._read_state()
|
||||
except Exception as e: # noqa: BLE001 - siehe Modul-Docstring: nie stoeren
|
||||
log.warning("Stationszustand nicht lesbar, Menue bleibt zu: %s", e)
|
||||
return
|
||||
self.items = menu_actions(state)
|
||||
if not self.items:
|
||||
return
|
||||
self.index = 0
|
||||
self.screen = "menu"
|
||||
self.deadline = now + MENU_TIMEOUT_S
|
||||
|
||||
def _held(self, now: float) -> Action | None:
|
||||
held = now - self._press_t
|
||||
# Nach dem Zusammenhang BEIM DRUCK beurteilt, nicht nach dem aktuellen
|
||||
# Bildschirm - siehe _press_screen.
|
||||
if self._press_screen == "menu":
|
||||
if held >= SELECT_HOLD_S and not self._consumed and self.items:
|
||||
self._consumed = True
|
||||
return self._select(now, self.items[self.index])
|
||||
return None
|
||||
if self._press_screen == "confirm":
|
||||
if held >= SELECT_HOLD_S and not self._consumed:
|
||||
self._consumed = True
|
||||
return self.pending
|
||||
return None
|
||||
# Ruhe (oder eine stehende Rueckmeldung/ein gerade gelaufener Abbruch):
|
||||
# zweistufige Haltegeste.
|
||||
if self._hold_stage == 1 and held >= SHUTDOWN_HOLD_S and not self._consumed:
|
||||
self._consumed = True
|
||||
return action_shutdown()
|
||||
if self._hold_stage == 0 and held >= CANCEL_HOLD_S:
|
||||
# Feuert BEI 3 s, nicht erst beim Loslassen - und setzt _consumed NICHT:
|
||||
# die zweite Stufe muss aus derselben Geste noch erreichbar bleiben.
|
||||
self._hold_stage = 1
|
||||
self.result = None
|
||||
self.result_until = None
|
||||
return action_cancel()
|
||||
return None
|
||||
|
||||
def _select(self, now: float, action: Action) -> Action | None:
|
||||
if not action.confirm:
|
||||
return action
|
||||
self.pending = action
|
||||
self.screen = "confirm"
|
||||
self.deadline = now + CONFIRM_TIMEOUT_S
|
||||
return None
|
||||
|
||||
def _expire(self, now: float) -> None:
|
||||
if self.result_until is not None and now >= self.result_until:
|
||||
self.result = None
|
||||
self.result_until = None
|
||||
if self.deadline is None or now < self.deadline:
|
||||
return
|
||||
if self.screen == "menu":
|
||||
# Ein Geraet, das im Menue stehenbleibt, soll nicht aussehen, als sei es
|
||||
# beschaeftigt - zurueck aufs Statusbild.
|
||||
self.screen = "idle"
|
||||
self.items = []
|
||||
self.deadline = None
|
||||
elif self.screen == "confirm":
|
||||
# Verfaellt als ABBRUCH - eine Rueckfrage, die von selbst zustimmt, waere
|
||||
# das Gegenteil einer Rueckfrage.
|
||||
self.pending = None
|
||||
self.screen = "idle"
|
||||
self.items = []
|
||||
self.deadline = None
|
||||
|
||||
def done(self, now: float, action: Action, ok: bool) -> None:
|
||||
"""Ergebnis einer ausgefuehrten Aktion - setzt die Rueckmeldung fuers Panel."""
|
||||
self.busy = None
|
||||
self.screen = "idle"
|
||||
self.items = []
|
||||
self.pending = None
|
||||
self.deadline = None
|
||||
self.result = (action.done_text if ok else "{}: Fehler".format(action.label), ok)
|
||||
self.result_until = now + RESULT_S
|
||||
|
||||
# -- Ausgabe ---------------------------------------------------------------------
|
||||
|
||||
def view(self, now: float) -> dict:
|
||||
"""Der Bedienzustand, wie ihn der Anzeigedienst zeichnet. "active" sagt ihm, dass
|
||||
gerade eine Bedienung laeuft (er sieht dann schneller nach)."""
|
||||
if (self._pressed and self._press_screen == "idle"
|
||||
and self.screen in ("idle", "busy")):
|
||||
held = now - self._press_t
|
||||
if self._hold_stage == 1:
|
||||
return _view("hold", "STATION AUS",
|
||||
"Herunterfahren in {} s".format(
|
||||
_countdown(SHUTDOWN_HOLD_S, held)),
|
||||
["Weiter halten - Loslassen bricht ab"])
|
||||
if held >= SHORT_PRESS_MAX_S:
|
||||
return _view("hold", "HALTEN",
|
||||
"Abbruch in {} s".format(_countdown(CANCEL_HOLD_S, held)),
|
||||
["Weiter halten - Loslassen bricht ab"])
|
||||
if self.screen == "menu" and self.items:
|
||||
selected = self.items[self.index]
|
||||
rest = self.items[self.index + 1:] + self.items[:self.index]
|
||||
return _view("menu", "MENUE", "> " + selected.label,
|
||||
[a.label for a in rest] + ["kurz: weiter, halten: waehlen"])
|
||||
if self.screen == "confirm" and self.pending is not None:
|
||||
# Die Bedienhinweise stehen IMMER und IMMER ZULETZT - eine Rueckfrage, bei
|
||||
# der man raten muss, wie man ablehnt, ist keine. Eine eigene Warnung des
|
||||
# Eintrags (confirm_details) kommt davor, damit sie zuerst gelesen wird.
|
||||
details = list(self.pending.confirm_details or [])
|
||||
details += ["halten (1,5 s): ja", "kurz: nein"]
|
||||
return _view("confirm", self.pending.confirm_title, self.pending.label,
|
||||
details)
|
||||
if self.screen == "busy" and self.busy is not None:
|
||||
return _view("busy", "BITTE WARTEN", self.busy.label, [])
|
||||
if self.result is not None:
|
||||
text, ok = self.result
|
||||
return _view("result", "FERTIG" if ok else "FEHLER", text, [])
|
||||
return {"view": "idle", "active": False}
|
||||
|
||||
|
||||
def _view(view: str, title: str, hero: str, details: list[str]) -> dict:
|
||||
return {"view": view, "title": title, "hero": hero, "details": list(details),
|
||||
"active": True}
|
||||
|
||||
|
||||
# --- Hardware und Hauptschleife -----------------------------------------------------
|
||||
|
||||
|
||||
def open_button(pin: int = BUTTON_PIN):
|
||||
"""Der einzige Hardwarezugriff dieses Dienstes: ein Eingangspin. KEIN SPI, kein
|
||||
Zeichenpin - siehe Modul-Docstring ("GPIO busy").
|
||||
|
||||
Entprellt gpiozero selbst (bounce_time): ein Tipper, der kuerzer ist als
|
||||
DEBOUNCE_S, kommt gar nicht erst als Druck an."""
|
||||
try:
|
||||
import gpiozero
|
||||
except ImportError as e:
|
||||
raise ButtonUnavailable("gpiozero fehlt ({})".format(e))
|
||||
try:
|
||||
return gpiozero.Button(pin, pull_up=True, bounce_time=DEBOUNCE_S)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Bewusst breit: ein belegter Pin kommt je nach Pin-Factory als
|
||||
# gpiozero.GPIOPinInUse, als roher lgpio.error ('GPIO busy') oder als OSError
|
||||
# zurueck - keine gemeinsame Oberklasse, die hier greifen wuerde. Alles davon
|
||||
# heisst dasselbe: kein Taster, also lautlos ohne Fehler beenden, statt eine
|
||||
# Neustartschleife auszuloesen.
|
||||
raise ButtonUnavailable("GPIO{} nicht verfuegbar ({})".format(pin, e))
|
||||
|
||||
|
||||
def run_daemon(button=None, ui: "ButtonUI | None" = None, writer: "UiWriter | None" = None,
|
||||
actions: "CliActions | None" = None, clock=time,
|
||||
read_state=station_state) -> None:
|
||||
"""Endlosschleife: Pegel lesen, ButtonUI fragen, eine zurueckgegebene Aktion
|
||||
ausfuehren, Ergebnis zurueckmelden, Bedienzustand schreiben.
|
||||
|
||||
`button`, `ui`, `writer`, `actions` und `clock` sind einschleusbar, damit die
|
||||
VERDRAHTUNG hier selbst pruefbar ist und nicht nur eine Kopie davon (display.py hatte
|
||||
genau diesen Fehler: die Tests trieben eine nachgebaute Schleife, und Mutationen an
|
||||
den echten Zeilen blieben gruen). Im Betrieb bleiben die Vorgaben.
|
||||
|
||||
Die Reihenfolge ist wesentlich: der Bedienzustand wird VOR dem (sekundenlangen)
|
||||
CLI-Aufruf geschrieben, sonst steht auf dem Panel bis zu dessen Ende noch das Menue
|
||||
und der Bediener haelt das Geraet fuer eingefroren."""
|
||||
if button is None:
|
||||
button = open_button()
|
||||
if writer is None:
|
||||
writer = UiWriter()
|
||||
if actions is None:
|
||||
actions = CliActions()
|
||||
if ui is None:
|
||||
ui = ButtonUI(clock.monotonic(), read_state=read_state)
|
||||
|
||||
log.info("Taster bereit (GPIO%s), Bedienzustand in %s", BUTTON_PIN, writer.path)
|
||||
writer.write(ui.view(clock.monotonic()))
|
||||
|
||||
while True:
|
||||
now = clock.monotonic()
|
||||
action = ui.update(now, bool(button.is_pressed))
|
||||
if action is not None:
|
||||
writer.write(ui.view(now))
|
||||
ok = actions.run(action)
|
||||
ui.done(clock.monotonic(), action, ok)
|
||||
writer.write(ui.view(clock.monotonic()))
|
||||
clock.sleep(TICK_S)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
try:
|
||||
run_daemon()
|
||||
except ButtonUnavailable as e:
|
||||
# GENAU EINE Zeile, dann sys.exit(0) - siehe Modul-Docstring. Kein Fehler, damit
|
||||
# Restart=on-failure daraus keine Neustartschleife macht und das Journal flutet.
|
||||
log.info("Taster nicht verfuegbar, beende ohne Fehler: %s", e)
|
||||
sys.exit(0)
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+445
-7
@@ -27,7 +27,23 @@ if [ $# -gt 0 ]; then
|
||||
done
|
||||
else
|
||||
FULL_SYNC=1
|
||||
while IFS= read -r f; do FILES+=("$f"); done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
|
||||
# Zwei Build-Verzeichnisse koennen denselben Image-Dateinamen liefern (z.B.
|
||||
# build/evk/ und ein Nebenschauplatz wie build/evk-onb/, das ohne WLAN-
|
||||
# Zugangsdaten gebaut wurde). Landen beide flach im selben STAGE/$base,
|
||||
# entscheidet allein die Sortierreihenfolge, welches gewinnt - und die
|
||||
# Trailer-Pruefung merkt nichts, weil in beiden derselbe Geraetetyp steht.
|
||||
# Ein zweiter Fundort fuer denselben Dateinamen ist deshalb ein Abbruch,
|
||||
# kein Zufall.
|
||||
declare -A SEEN_AT=()
|
||||
while IFS= read -r f; do
|
||||
base=$(basename "$f")
|
||||
if [ -n "${SEEN_AT[$base]:-}" ]; then
|
||||
echo "Fehler: $base mehrfach gefunden - ${SEEN_AT[$base]} und $f. Voller Deploy abgebrochen, welches Bild echt ist, ist nicht entscheidbar. Nicht benoetigtes Build-Verzeichnis loeschen oder gezielt deployen." >&2
|
||||
exit 1
|
||||
fi
|
||||
SEEN_AT[$base]=$f
|
||||
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
|
||||
|
||||
@@ -35,6 +51,7 @@ echo "== Images pruefen =="
|
||||
STAGE=$(mktemp -d)
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
printf '%-26s %-10s %s\n' GERAET VERSION BYTES
|
||||
declare -A NINL_VERSION=()
|
||||
for f in "${FILES[@]}"; do
|
||||
base=$(basename "$f")
|
||||
target=${base#ninlight_}; target=${target%_ota.img}
|
||||
@@ -45,9 +62,243 @@ for f in "${FILES[@]}"; do
|
||||
# shellcheck disable=SC2086
|
||||
set -- $out
|
||||
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
|
||||
NINL_VERSION[$target]="$2"
|
||||
cp "$f" "$STAGE/$base"
|
||||
done
|
||||
|
||||
echo "== Migrationsimages signieren =="
|
||||
# Die Signierschluessel gehoeren nie ins Repo und nie auf den Pi - sie werden hier nur
|
||||
# gelesen, unter $NINLIGHT_SIGN_DIR (Vorgabe ~/workspace/fw-sign auf DIESEM Rechner).
|
||||
# Fehlt das Verzeichnis oder ein einzelner Schluessel, entfaellt die jeweilige
|
||||
# Migrationsvariante mit einer Meldung - die Rettungsimages (_ota.img) werden trotzdem
|
||||
# ausgeliefert (Exit-Code bleibt 0).
|
||||
# ${HOME:-} statt $HOME: unter `set -u` (z.B. via cron oder env -i) waere ein
|
||||
# unbound-variable-Abbruch hier voellig unnoetig, denn selbst ohne Migrationsimages
|
||||
# sollen die Rettungsimages ausgeliefert werden (Fixrunde 1, Kleinigkeit 1).
|
||||
SIGN_DIR=${NINLIGHT_SIGN_DIR:-${HOME:-}/workspace/fw-sign}
|
||||
KEY_PROD="$SIGN_DIR/QCX_Device_CA/qcx_firmware_signing_key.pem"
|
||||
KEY_DEV="$SIGN_DIR/fw_sign.pem"
|
||||
NL_SIGN="$ROOT/tools/mkimage/nl_sign.py"
|
||||
# MIGRATE_COMPLETE=1 bedeutet: dieser Lauf hat mit BEIDEN Schluesseln signiert, das
|
||||
# lokale STAGE/migrate beschreibt also den vollstaendigen Satz an Migrationsvarianten
|
||||
# fuer FILES. Nur dann darf der Abgleich auf dem Pi mit --delete arbeiten bzw. das
|
||||
# Manifest blind ersetzen - sonst wuerden Images/Eintraege einer Variante verschwinden,
|
||||
# die dieser Lauf mangels Schluessel gar nicht neu bauen konnte (Fixrunde 1, C2/I1).
|
||||
MIGRATE_COMPLETE=0
|
||||
# Dasselbe fuer den Satz OHNE WLAN-Zugangsdaten (siehe unten): "vollstaendig" heisst hier
|
||||
# zusaetzlich, dass fuer JEDES Ziel ein Abzug aus build-nocreds/ vorlag - fehlt auch nur
|
||||
# einer, darf --delete auf dem Pi nicht laufen.
|
||||
NOCREDS_COMPLETE=0
|
||||
# Wo die Abzuege OHNE einkompilierte WLAN-Zugangsdaten liegen (tools/build_nocreds.sh,
|
||||
# dieselbe Variable wie dort und in tools/release.sh).
|
||||
NOCREDS_BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
|
||||
|
||||
# Signiert einen kompletten Satz Migrationsimages und schreibt sein manifest.json.
|
||||
# $1 = Quellverzeichnis der Abzuege (build bzw. build-nocreds), absolut
|
||||
# $2 = Zielverzeichnis unterhalb von $STAGE (migrate-wlan bzw. migrate-nocreds)
|
||||
# $3 = "strict": ein fehlendes _gz.img ist ein Abbruch. "lax": es wird uebersprungen.
|
||||
# Gibt 0 zurueck, wenn der Satz VOLLSTAENDIG entstanden ist (kein Ziel uebersprungen),
|
||||
# sonst 1. Legt $STAGE/$2 nur an, wenn wirklich etwas darin landet - ein leeres
|
||||
# Verzeichnis wuerde auf dem Pi mit --delete einen vorhandenen Satz leerraeumen.
|
||||
sign_set() {
|
||||
local src_root=$1 stage_sub=$2 strictness=$3
|
||||
local complete=1 produced=0
|
||||
local f base target gz
|
||||
for f in "${FILES[@]}"; do
|
||||
base=$(basename "$f")
|
||||
target=${base#ninlight_}; target=${target%_ota.img}
|
||||
# Signiert wird IMMER das rohe _gz.img, niemals das fertige _ota.img: beide
|
||||
# belegen denselben 512-Byte-Block am Dateiende (Trailer bzw. Signatur), ein
|
||||
# signiertes _ota.img waere ein Image, das die alte Lampe ablehnt oder halb
|
||||
# annimmt.
|
||||
gz="$src_root/$target/ninlight_${target}_gz.img"
|
||||
if [ ! -f "$gz" ]; then
|
||||
if [ "$strictness" = "strict" ]; then
|
||||
echo "Fehler: $gz fehlt - kein Migrationsimage fuer $target moeglich" >&2; exit 1
|
||||
fi
|
||||
echo "Hinweis: $gz fehlt - $target fehlt im Bildsatz '$stage_sub'" >&2
|
||||
complete=0
|
||||
continue
|
||||
fi
|
||||
mkdir -p "$STAGE/$stage_sub"
|
||||
if [ "$HAVE_PROD" -eq 1 ]; then
|
||||
python3 "$NL_SIGN" sign --key "$KEY_PROD" --in "$gz" --out "$STAGE/$stage_sub/${target}_prod.img" \
|
||||
|| { echo "Fehler: Signieren von $target (prod, $stage_sub) fehlgeschlagen" >&2; exit 1; }
|
||||
produced=1
|
||||
fi
|
||||
if [ "$HAVE_DEV" -eq 1 ]; then
|
||||
python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$STAGE/$stage_sub/${target}_dev.img" \
|
||||
|| { echo "Fehler: Signieren von $target (dev, $stage_sub) fehlgeschlagen" >&2; exit 1; }
|
||||
produced=1
|
||||
fi
|
||||
done
|
||||
[ "$produced" -eq 1 ] || return 1
|
||||
local VERSION_ARGS=()
|
||||
local t
|
||||
for t in "${!NINL_VERSION[@]}"; do
|
||||
VERSION_ARGS+=("$t=${NINL_VERSION[$t]}")
|
||||
done
|
||||
# manifest.json aus den entstandenen Dateien - Groesse und sha256 werden hier neu
|
||||
# berechnet statt aus dem Signierschritt uebernommen, damit die Datei die Wahrheit
|
||||
# ueber das tatsaechlich abgelegte Image sagt. Form haelt sich an die Spec
|
||||
# (docs/superpowers/spec, Abschnitt 4) und damit an das, was server.py tatsaechlich
|
||||
# liest. Fixrunde 2, N2: "ninlight_version" steht PRO EINTRAG, nicht (mehr nur)
|
||||
# manifestweit - ein einziges Feld fuer das ganze Manifest war schon falsch, sobald
|
||||
# der Merge unten (bei nur einem Schluessel oder gezieltem Deploy) alte und neue
|
||||
# Eintraege mit unterschiedlichen Firmware-Versionen zusammenfuehrt. Das
|
||||
# manifestweite Feld bleibt nur als Bestandskompatibilitaet erhalten und wird nur
|
||||
# gesetzt, wenn wirklich alle Eintraege dieselbe Version tragen.
|
||||
python3 - "$STAGE/$stage_sub" "${VERSION_ARGS[@]}" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
migrate_dir = sys.argv[1]
|
||||
version_map = dict(a.split("=", 1) for a in sys.argv[2:])
|
||||
entries = []
|
||||
for name in sorted(os.listdir(migrate_dir)):
|
||||
if name.endswith("_prod.img"):
|
||||
target, variant = name[: -len("_prod.img")], "prod"
|
||||
elif name.endswith("_dev.img"):
|
||||
target, variant = name[: -len("_dev.img")], "dev"
|
||||
else:
|
||||
continue
|
||||
with open(os.path.join(migrate_dir, name), "rb") as fh:
|
||||
data = fh.read()
|
||||
entries.append({
|
||||
"target": target,
|
||||
"variant": variant,
|
||||
"file": name,
|
||||
"bytes": len(data),
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"ninlight_version": version_map.get(target, ""),
|
||||
})
|
||||
|
||||
manifest = {
|
||||
"built": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"images": entries,
|
||||
}
|
||||
versions = {e["ninlight_version"] for e in entries}
|
||||
if len(versions) == 1:
|
||||
(version,) = versions
|
||||
if version:
|
||||
manifest["ninlight_version"] = version
|
||||
with open(os.path.join(migrate_dir, "manifest.json"), "w") as fh:
|
||||
json.dump(manifest, fh, indent=2, sort_keys=True)
|
||||
fh.write("\n")
|
||||
PY
|
||||
[ "$complete" -eq 1 ]
|
||||
}
|
||||
|
||||
HAVE_PROD=0
|
||||
HAVE_DEV=0
|
||||
if [ -d "$SIGN_DIR" ]; then
|
||||
if [ -f "$KEY_PROD" ]; then
|
||||
HAVE_PROD=1
|
||||
else
|
||||
echo "Hinweis: Produktionsschluessel fehlt - keine _prod.img-Migrationsimages" >&2
|
||||
fi
|
||||
if [ -f "$KEY_DEV" ]; then
|
||||
HAVE_DEV=1
|
||||
else
|
||||
echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img-Migrationsimages" >&2
|
||||
fi
|
||||
if [ "$HAVE_PROD" -eq 1 ] || [ "$HAVE_DEV" -eq 1 ]; then
|
||||
BOTH_KEYS=0
|
||||
[ "$HAVE_PROD" -eq 1 ] && [ "$HAVE_DEV" -eq 1 ] && BOTH_KEYS=1
|
||||
|
||||
# ZWEI Bildsaetze, weil die WLAN-Zugangsdaten zur BAUZEIT in der Firmware stecken
|
||||
# und die Station beide vorhalten soll (der Bediener schaltet per
|
||||
# "ninlight-station bildsatz" bzw. am Taster um):
|
||||
#
|
||||
# migrate-wlan/ aus build/ - Lampe haengt sofort im eigenen WLAN
|
||||
# migrate-nocreds/ aus build-nocreds/ - Lampe startet im Onboarding-AP
|
||||
#
|
||||
# Der Satz aus build/ ist streng: dort liegen die _ota.img, die oben schon geprueft
|
||||
# wurden, ein fehlendes _gz.img daneben ist ein kaputter Baum. Der Satz aus
|
||||
# build-nocreds/ ist nachsichtig: er entsteht aus einem EIGENEN, optionalen Bau
|
||||
# (tools/build_nocreds.sh), und ein Deploy darf nicht daran scheitern, dass der noch
|
||||
# nicht gelaufen ist - dann fehlt der Satz eben, und die Station liefert in dieser
|
||||
# Stellung sichtbar nichts aus, statt heimlich den anderen Satz zu nehmen.
|
||||
echo " Bildsatz migrate-wlan (aus build/)"
|
||||
if sign_set "$ROOT/build" migrate-wlan strict; then
|
||||
[ "$BOTH_KEYS" -eq 1 ] && MIGRATE_COMPLETE=1
|
||||
fi
|
||||
echo " Bildsatz migrate-nocreds (aus $NOCREDS_BUILD_REL/)"
|
||||
if sign_set "$ROOT/$NOCREDS_BUILD_REL" migrate-nocreds lax; then
|
||||
[ "$BOTH_KEYS" -eq 1 ] && NOCREDS_COMPLETE=1
|
||||
else
|
||||
if [ ! -d "$STAGE/migrate-nocreds" ]; then
|
||||
echo "Hinweis: keine Abzuege unter $ROOT/$NOCREDS_BUILD_REL - der Bildsatz 'ohne-wlan' bleibt auf dem Pi unveraendert. Erzeugen mit tools/build_nocreds.sh." >&2
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Hinweis: $SIGN_DIR enthaelt weder Produktions- noch Entwicklungsschluessel - keine Migrationsimages erzeugt" >&2
|
||||
fi
|
||||
else
|
||||
echo "Hinweis: Schluesselverzeichnis $SIGN_DIR nicht gefunden - keine Migrationsimages erzeugt" >&2
|
||||
fi
|
||||
|
||||
# Der Bildsatz "ohne-wlan" ist die SICHERE VORGABE: er geht im Automatikmodus an jede
|
||||
# alte Klyqa-Lampe in Funkreichweite, auch an fremde, und das Flashen ist nicht
|
||||
# umkehrbar. tools/release.sh prueft sein fertiges Archiv deshalb binaer gegen die Werte
|
||||
# aus config.local.cmake (dort "Auf Zugangsdaten pruefen") - deploy.sh signierte
|
||||
# dagegen ungeprueft, was in build-nocreds/ lag.
|
||||
#
|
||||
# Der Weg dorthin ist Bedienfehler, nicht Programmfehler: ein von Hand konfiguriertes
|
||||
# build-nocreds/<ziel>, oder ein gesetztes NINLIGHT_NOCREDS_BUILD_REL=build. Das
|
||||
# Ergebnis waere, dass das Panel "ohne WLAN" anzeigt, waehrend das WLAN-Passwort des
|
||||
# Bedieners unwiderruflich in fremde Lampen wandert - der teure, nicht rueckholbare
|
||||
# Fehler. Lieber ein abgebrochener Deploy.
|
||||
#
|
||||
# Geprueft wird VOR dem Uebertragen, es ist also noch nichts beim Pi angekommen. Nur
|
||||
# dieser eine Satz: migrate-wlan/ SOLL die Zugangsdaten enthalten, das ist sein Zweck.
|
||||
# Der Pruefkoerper ist bis auf die Meldung derselbe wie in tools/release.sh.
|
||||
if [ -d "$STAGE/migrate-nocreds" ]; then
|
||||
echo "== Bildsatz 'ohne-wlan' auf Zugangsdaten pruefen =="
|
||||
LOCAL="$ROOT/config.local.cmake"
|
||||
if [ -f "$LOCAL" ]; then
|
||||
python3 - "$STAGE/migrate-nocreds" "$LOCAL" <<'CHECKPY' || exit 1
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
stage, local = sys.argv[1], sys.argv[2]
|
||||
text = open(local, encoding="utf-8", errors="replace").read()
|
||||
# Nur die drei Werte, die wirklich geheim sind. NINLIGHT_AP_PASSWORD und der
|
||||
# Vorgabetoken stehen oeffentlich im Repo (README, Bruno) - sie hier mitzupruefen
|
||||
# haette nur falsche Alarme erzeugt.
|
||||
SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN")
|
||||
PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"}
|
||||
needles = []
|
||||
for name in SECRETS:
|
||||
m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M)
|
||||
if m and m.group(1) not in PUBLIC:
|
||||
needles.append((name, m.group(1).encode()))
|
||||
|
||||
hits = []
|
||||
for root, _dirs, files in os.walk(stage):
|
||||
for fn in sorted(files):
|
||||
path = os.path.join(root, fn)
|
||||
with open(path, "rb") as fh:
|
||||
data = fh.read()
|
||||
for name, needle in needles:
|
||||
if needle in data:
|
||||
hits.append("%s in %s" % (name, os.path.relpath(path, stage)))
|
||||
if hits:
|
||||
sys.stderr.write(
|
||||
"Fehler: Zugangsdaten aus config.local.cmake im Bildsatz 'ohne-wlan' gefunden, "
|
||||
"nichts uebertragen:\n " + "\n ".join(hits) + "\n")
|
||||
sys.exit(1)
|
||||
print(" sauber (%d geprueft)" % len(needles))
|
||||
CHECKPY
|
||||
else
|
||||
echo " uebersprungen - $LOCAL nicht vorhanden" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "== uebertragen =="
|
||||
# -c (Inhaltsvergleich per Checksumme statt Groesse+Zeitstempel): ein neues
|
||||
# Image kann zufaellig dieselbe Byte-Groesse wie das alte haben, und bei
|
||||
@@ -60,31 +311,193 @@ echo "== uebertragen =="
|
||||
# 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/"
|
||||
rsync -a -e "$SSH_BIN" "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" \
|
||||
"$HERE/ninlight-station.service" "$HERE/ninlight-display.service" "$HERE/ninlight-button.service" \
|
||||
"$HERE/ninlight-poweroff.service" "$HERE/ninlight-reboot.service" \
|
||||
"$HOST:/tmp/"
|
||||
# Das Anzeigeverzeichnis als Ganzes (render.py, display.py, epdconfig.py, die beiden
|
||||
# uebernommenen Panel-Treiber und HERKUNFT.md - der Herkunftsnachweis gehoert mit auf
|
||||
# den Pi, nicht nur ins Repo). --delete gilt hier nur fuer die fluechtige Ablage unter
|
||||
# /tmp; __pycache__ bleibt draussen, sonst landen die .pyc dieses Rechners (andere
|
||||
# Python-Version als auf dem Pi) im Zielverzeichnis und bleiben dort liegen.
|
||||
rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/display/" "$HOST:/tmp/ninlight-display/"
|
||||
# Das Tasterverzeichnis genauso wie das Anzeigeverzeichnis: als Ganzes, ohne
|
||||
# __pycache__ dieses Rechners.
|
||||
rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/button/" "$HOST:/tmp/ninlight-button/"
|
||||
# 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'
|
||||
"$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" "$MIGRATE_COMPLETE" "$NOCREDS_COMPLETE" <<'REMOTE'
|
||||
set -e
|
||||
FULL_SYNC=$1
|
||||
MIGRATE_COMPLETE=$2
|
||||
NOCREDS_COMPLETE=$3
|
||||
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}
|
||||
DISPLAY_UNIT_PATH=${NINLIGHT_DISPLAY_UNIT:-/etc/systemd/system/ninlight-display.service}
|
||||
BUTTON_UNIT_PATH=${NINLIGHT_BUTTON_UNIT:-/etc/systemd/system/ninlight-button.service}
|
||||
POWEROFF_UNIT_PATH=${NINLIGHT_POWEROFF_UNIT:-/etc/systemd/system/ninlight-poweroff.service}
|
||||
REBOOT_UNIT_PATH=${NINLIGHT_REBOOT_UNIT:-/etc/systemd/system/ninlight-reboot.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/"
|
||||
# Vorher zeigen, WELCHE das sind - sonst faellt ein geloeschtes Image erst
|
||||
# auf, wenn jemand mit einer toten Lampe vor der Station steht. Ermittelt
|
||||
# per einfachem Namensvergleich (portabel, kein Verlass auf rsyncs
|
||||
# "deleting"-Ausgabe, die ohne -v gar nicht erscheint).
|
||||
OLD_LIST=$(find "$ROOT_DIR/images" -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; 2>/dev/null | sort)
|
||||
NEW_LIST=$(find /tmp/ninlight-images -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; | sort)
|
||||
REMOVED=""
|
||||
if [ -n "$OLD_LIST" ]; then
|
||||
REMOVED=$(comm -23 <(printf '%s\n' "$OLD_LIST") <(printf '%s\n' "$NEW_LIST"))
|
||||
fi
|
||||
if [ -n "$REMOVED" ]; then
|
||||
echo " voller Abgleich - folgende Images werden entfernt:"
|
||||
echo "$REMOVED" | sed 's/^/ /'
|
||||
else
|
||||
echo " voller Abgleich - keine Images werden entfernt"
|
||||
fi
|
||||
# migrate/ wird gleich in einem eigenen Schritt behandelt (siehe unten) - hier
|
||||
# ausklammern, sonst wuerde --delete es leeren, wenn dieser Deploy ohne
|
||||
# Schluesselverzeichnis lief und /tmp/ninlight-images/migrate deshalb gar nicht
|
||||
# existiert.
|
||||
sudo rsync -a -c --delete --exclude=/migrate-wlan --exclude=/migrate-nocreds /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/"
|
||||
sudo rsync -a -c --exclude=/migrate-wlan --exclude=/migrate-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
||||
fi
|
||||
# $1 = Name des Bildsatzverzeichnisses, $2 = 1, wenn dieser Lauf den Satz VOLLSTAENDIG
|
||||
# beschreibt (beide Schluessel, alle Ziele) und --delete deshalb erlaubt ist.
|
||||
#
|
||||
# Migrationsimages getrennt behandeln: fehlt migrate/ in der Uebertragung (weil auf
|
||||
# dem Entwicklungsrechner kein Schluesselverzeichnis oder gar kein Schluessel
|
||||
# vorhanden war), darf ein vorhandenes migrate/ auf dem Pi NICHT geleert werden -
|
||||
# sonst zerstoert ein Deploy ohne Schluesselzugriff die Bestueckung frueherer Deploys
|
||||
# (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur diesmal beim Fehlen
|
||||
# statt beim Ersetzen).
|
||||
sync_migrate_set() {
|
||||
set=$1
|
||||
complete=$2
|
||||
if [ -d "/tmp/ninlight-images/$set" ]; then
|
||||
sudo install -d -m 0755 "$ROOT_DIR/images/$set"
|
||||
if [ "$FULL_SYNC" -eq 1 ] && [ "$complete" -eq 1 ]; then
|
||||
# Nur wenn dieser Lauf mit BEIDEN Schluesseln signiert hat, beschreibt
|
||||
# STAGE/migrate den vollstaendigen Soll-Zustand - erst dann darf --delete
|
||||
# ueberzaehlige Dateien wegraeumen und manifest.json blind ersetzt werden.
|
||||
sudo rsync -a -c --delete "/tmp/ninlight-images/$set/" "$ROOT_DIR/images/$set/"
|
||||
else
|
||||
# Gezielter Deploy ODER ein Voll-Deploy mit nur einem der beiden Schluessel:
|
||||
# in beiden Faellen kennt dieser Lauf nur einen Teil dessen, was auf dem Pi liegen
|
||||
# soll. rsync ohne --delete laesst alles Nicht-Uebertragene unangetastet (Fixrunde
|
||||
# 1, C2 - sonst raeumt ein Voll-Deploy mit nur dem dev-Schluessel saemtliche
|
||||
# _prod.img weg). manifest.json wird deshalb auch nicht einfach ueberschrieben,
|
||||
# sondern mit dem, was schon da ist, zusammengefuehrt (Fixrunde 1, I1 - sonst
|
||||
# verschwinden die Eintraege aller Ziele/Varianten, die dieser Lauf nicht neu
|
||||
# gebaut hat, obwohl ihre Images unveraendert liegen bleiben).
|
||||
sudo rsync -a -c --exclude=/manifest.json "/tmp/ninlight-images/$set/" "$ROOT_DIR/images/$set/"
|
||||
if [ -f "/tmp/ninlight-images/$set/manifest.json" ]; then
|
||||
sudo python3 - "$ROOT_DIR/images/$set/manifest.json" "/tmp/ninlight-images/$set/manifest.json" <<'MERGE'
|
||||
import json
|
||||
import sys
|
||||
|
||||
manifest_path, new_manifest_path = sys.argv[1], sys.argv[2]
|
||||
with open(new_manifest_path) as fh:
|
||||
new_manifest = json.load(fh)
|
||||
if not isinstance(new_manifest, dict):
|
||||
new_manifest = {}
|
||||
try:
|
||||
with open(manifest_path) as fh:
|
||||
old_manifest = json.load(fh)
|
||||
except (OSError, ValueError):
|
||||
old_manifest = {}
|
||||
# Fixrunde 2, N1: ein BESTEHENDES manifest.json kann gueltiges JSON sein und trotzdem
|
||||
# die falsche Form haben (z.B. "[]", "null", ein String, oder ein Eintrag, der selbst
|
||||
# kein Objekt ist) - das darf niemals mitten im Deploy mit AttributeError abbrechen,
|
||||
# denn dieser Merge steht VOR der Installation von server.py/products.py/CLI/Unit und
|
||||
# vor dem Neustart: ein Abbruch hier liesse den Pi mit neuen Images, aber altem
|
||||
# Stationscode zurueck.
|
||||
if not isinstance(old_manifest, dict):
|
||||
old_manifest = {}
|
||||
old_images = old_manifest.get("images")
|
||||
if not isinstance(old_images, list):
|
||||
old_images = []
|
||||
by_key = {(e.get("target"), e.get("variant")): e for e in old_images if isinstance(e, dict)}
|
||||
new_images = new_manifest.get("images")
|
||||
if not isinstance(new_images, list):
|
||||
new_images = []
|
||||
for e in new_images:
|
||||
if isinstance(e, dict):
|
||||
by_key[(e.get("target"), e.get("variant"))] = e
|
||||
merged_images = sorted(by_key.values(), key=lambda e: (e.get("target") or "", e.get("variant") or ""))
|
||||
merged = {
|
||||
"built": new_manifest.get("built"),
|
||||
"images": merged_images,
|
||||
}
|
||||
# Fixrunde 2, N2: das manifestweite "ninlight_version" darf nach dem Merge nicht mehr
|
||||
# einfach vom neuen Manifest uebernommen werden - die uebernommenen Alt-Eintraege
|
||||
# koennen eine andere Version tragen (das war die alte Version dieses Bugs: ein
|
||||
# Voll-Deploy mit nur dem dev-Schluessel hat die neue Firmware-Version ueber ein
|
||||
# unveraendert liegen gebliebenes altes _prod.img geschrieben). Jeder Eintrag traegt
|
||||
# seine eigene Version; das Top-Level-Feld ist nur noch eine Bequemlichkeit fuer den
|
||||
# Fall, dass wirklich alle Eintraege uebereinstimmen.
|
||||
versions = {e.get("ninlight_version") for e in merged_images}
|
||||
if len(versions) == 1:
|
||||
(version,) = versions
|
||||
if isinstance(version, str) and version:
|
||||
merged["ninlight_version"] = version
|
||||
with open(manifest_path, "w") as fh:
|
||||
json.dump(merged, fh, indent=2, sort_keys=True)
|
||||
fh.write("\n")
|
||||
MERGE
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# Weder "kein Schluesselverzeichnis" noch "Verzeichnis vorhanden, aber leer" laesst
|
||||
# sich von hier aus unterscheiden - beide Faelle liefern ein leeres migrate/ von
|
||||
# diesem Rechner. Die lokale Meldung (deploy.sh, "Hinweis: ...") ist bereits genau;
|
||||
# diese hier behauptet absichtlich keinen bestimmten Grund (Fixrunde 2, Kleinigkeit).
|
||||
echo " keine Migrationsimages fuer $set von diesem Rechner - $set/ auf dem Pi bleibt unangetastet"
|
||||
fi
|
||||
}
|
||||
|
||||
# Beide Bildsaetze. Sie sind voneinander unabhaengig: liegt nur einer von diesem Rechner
|
||||
# vor (z.B. weil tools/build_nocreds.sh noch nicht gelaufen ist), bleibt der andere auf
|
||||
# dem Pi unveraendert - der Umschalter zeigt dann eben auf einen Satz, der fehlt, und die
|
||||
# Station liefert in dieser Stellung sichtbar nichts aus.
|
||||
sync_migrate_set migrate-wlan "$MIGRATE_COMPLETE"
|
||||
sync_migrate_set migrate-nocreds "$NOCREDS_COMPLETE"
|
||||
# Altbestand: bis zum Umschalter hiess das einzige Verzeichnis "migrate" und enthielt die
|
||||
# Abzuege MIT WLAN-Zugangsdaten. Es wird von niemandem mehr gelesen; liegen bleiben darf
|
||||
# es trotzdem nicht, sonst glaubt der naechste Mensch am Pi, es sei der gueltige Satz.
|
||||
if [ -d "$ROOT_DIR/images/migrate" ]; then
|
||||
sudo rm -rf "$ROOT_DIR/images/migrate"
|
||||
echo " Altbestand entfernt: images/migrate (heisst jetzt images/migrate-wlan)"
|
||||
fi
|
||||
sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py"
|
||||
sudo install -m 0644 /tmp/products.py "$ROOT_DIR/products.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
|
||||
# Anzeige: Verzeichnis und Unit. --delete ist hier richtig (anders als bei den Images):
|
||||
# display/ ist reiner Code aus dem Repo, es gibt keinen Bestand, der einen Deploy
|
||||
# ueberleben muesste - eine im Repo geloeschte Datei muss auch hier verschwinden.
|
||||
sudo install -d -m 0755 "$ROOT_DIR/display"
|
||||
sudo rsync -a -c --delete /tmp/ninlight-display/ "$ROOT_DIR/display/"
|
||||
sudo install -m 0644 /tmp/ninlight-display.service "$DISPLAY_UNIT_PATH"
|
||||
# Taster: dieselbe Begruendung wie bei der Anzeige - button/ ist reiner Code aus dem
|
||||
# Repo, es gibt keinen Bestand, der einen Deploy ueberleben muesste.
|
||||
sudo install -d -m 0755 "$ROOT_DIR/button"
|
||||
sudo rsync -a -c --delete /tmp/ninlight-button/ "$ROOT_DIR/button/"
|
||||
sudo install -m 0644 /tmp/ninlight-button.service "$BUTTON_UNIT_PATH"
|
||||
# Abschlussbild beim Herunterfahren/Neustart. Nur die Dateien - aktiviert werden sie in
|
||||
# setup_pi.sh (ein Deploy soll keine Units einschalten, die vorher aus waren).
|
||||
sudo install -m 0644 /tmp/ninlight-poweroff.service "$POWEROFF_UNIT_PATH"
|
||||
sudo install -m 0644 /tmp/ninlight-reboot.service "$REBOOT_UNIT_PATH"
|
||||
rm -rf /tmp/ninlight-images /tmp/server.py /tmp/products.py /tmp/ninlight-station /tmp/ninlight-station.service \
|
||||
/tmp/ninlight-display /tmp/ninlight-display.service /tmp/ninlight-button /tmp/ninlight-button.service \
|
||||
/tmp/ninlight-poweroff.service /tmp/ninlight-reboot.service
|
||||
sudo systemctl daemon-reload
|
||||
if systemctl is-active --quiet ninlight-station; then
|
||||
if sudo systemctl restart ninlight-station; then
|
||||
@@ -96,6 +509,31 @@ if systemctl is-active --quiet ninlight-station; then
|
||||
else
|
||||
echo " Dienst laeuft nicht (Station ist aus)"
|
||||
fi
|
||||
# Die Anzeige laeuft mit dem gerade ersetzten Code weiter, bis sie neu gestartet wird.
|
||||
# Ein Fehlschlag ist hier aber ausdruecklich KEIN Deploy-Fehler (anders als oben beim
|
||||
# Hauptdienst): die Anzeige ist nirgends Voraussetzung fuer eine Migration, und ein
|
||||
# Deploy, der wegen des Panels rot wird, verleitet nur dazu, den echten Fehler oben zu
|
||||
# uebersehen.
|
||||
if systemctl is-active --quiet ninlight-display; then
|
||||
if sudo systemctl restart ninlight-display; then
|
||||
echo " Anzeigedienst neu gestartet"
|
||||
else
|
||||
echo " Warnung: Anzeigedienst lief, Neustart ist fehlgeschlagen - journalctl -u ninlight-display" >&2
|
||||
fi
|
||||
else
|
||||
echo " Anzeigedienst laeuft nicht"
|
||||
fi
|
||||
# Taster: dieselbe weiche Behandlung wie die Anzeige (siehe deren Begruendung oben) - er
|
||||
# ist nirgends Voraussetzung fuer eine Migration, ein Deploy darf daran nicht scheitern.
|
||||
if systemctl is-active --quiet ninlight-button; then
|
||||
if sudo systemctl restart ninlight-button; then
|
||||
echo " Tasterdienst neu gestartet"
|
||||
else
|
||||
echo " Warnung: Tasterdienst lief, Neustart ist fehlgeschlagen - journalctl -u ninlight-button" >&2
|
||||
fi
|
||||
else
|
||||
echo " Tasterdienst laeuft nicht"
|
||||
fi
|
||||
REMOTE
|
||||
|
||||
echo "fertig."
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Herkunft der Panel-Treiber
|
||||
|
||||
`epd2in13_V2.py` und `epd2in13_V3.py` sind unveraendert aus dem offiziellen
|
||||
Waveshare-Repository uebernommen:
|
||||
|
||||
- Quelle: https://github.com/waveshareteam/e-Paper
|
||||
- Pfad: `RaspberryPi_JetsonNano/python/lib/waveshare_epd/epd2in13_V2.py` bzw.
|
||||
`.../epd2in13_V3.py`
|
||||
- Commit: `85802dee4b938212b0ae069a34492c54a3d99878` (2023-02-15), abgerufen am
|
||||
2026-09-13 per `git log --follow` auf diesen beiden Dateien im Repository.
|
||||
- `epd2in13_V2.py`: Treiber-Version laut Dateikopf V4.0 (2019-06-20).
|
||||
- `epd2in13_V3.py`: Treiber-Version laut Dateikopf V1.2 (2022-08-09).
|
||||
|
||||
## Was geaendert wurde
|
||||
|
||||
Ausschliesslich die Importzeile:
|
||||
|
||||
```
|
||||
from . import epdconfig
|
||||
```
|
||||
|
||||
wurde zu
|
||||
|
||||
```
|
||||
import epdconfig
|
||||
```
|
||||
|
||||
weil `display/` in diesem Repository kein Python-Package ist (kein `__init__.py`,
|
||||
`sys.path`-Import wie bei `products.py` in `server.py`), sondern flach importiert wird -
|
||||
sowohl von `display.py` als auch von `tools/station/tests/test_render.py`. Ein relativer
|
||||
Import wuerde in dieser Umgebung mit `ImportError: attempted relative import with no known
|
||||
parent package` fehlschlagen.
|
||||
|
||||
Init-Sequenz, LUT-Tabellen, Timings und der Urheberrechtsvermerk der Originaldateien
|
||||
bleiben unangetastet. Ein kurzer Herkunftsvermerk mit denselben Angaben wie oben steht
|
||||
zusaetzlich im Kopf jeder der beiden Dateien.
|
||||
|
||||
## Was NICHT uebernommen wurde
|
||||
|
||||
`epdconfig.py` in diesem Verzeichnis ist eine eigene Neuimplementierung (siehe deren
|
||||
Modul-Docstring), keine Uebernahme aus dem Waveshare-Repository: Waveshares eigenes
|
||||
`epdconfig.py` fuer den Raspberry Pi setzt `RPi.GPIO` voraus, das auf Debian 13 (trixie)
|
||||
mit Python 3.13 nicht mehr baut/importiert. Unser `epdconfig.py` implementiert dieselbe
|
||||
von den beiden Treibern erwartete Schnittstelle (Pin-Konstanten, `digital_write`,
|
||||
`digital_read`, `delay_ms`, `spi_writebyte`, `spi_writebyte2`, `module_init`,
|
||||
`module_exit`) gegen `gpiozero`/`spidev`, die auf dem Ziel-Pi vorhanden sind.
|
||||
@@ -0,0 +1,689 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Daemon fuer die E-Paper-Anzeige der ninlight-Station: alle 5 s status.json,
|
||||
control.json und die Konfigurationsdatei lesen, daraus einen Text zeichnen und - nur bei
|
||||
Aenderung - aufs Panel bringen.
|
||||
|
||||
Panel: Waveshare 2,13" (250x122 px), SPI. Ob das verbaute Panel Revision V2 oder V3 ist,
|
||||
laesst sich am Geraet selbst nicht auslesen - die Revision kommt deshalb aus der
|
||||
Umgebungsvariable EPD_REVISION (Vorgabe "V3", von setup_pi.sh in
|
||||
/etc/ninlight-station.conf geschrieben und von der systemd-Unit als Environment=
|
||||
weitergereicht).
|
||||
|
||||
So bestimmst du die richtige Revision empirisch: erst
|
||||
EPD_REVISION=V3 python3 display.py --test
|
||||
laufen lassen und das Panel ansehen. Zeigt es ein sauberes, seitenrichtiges Testbild ohne
|
||||
Geisterbilder/Streifen, ist V3 richtig. Zeigt es nichts, ein invertiertes/verschobenes Bild
|
||||
oder haengt beim SWRESET (ReadBusy() kehrt nie zurueck), mit
|
||||
EPD_REVISION=V2 python3 display.py --test
|
||||
wiederholen. Zwischen den beiden Versuchen kurz die Stromversorgung des Panels trennen
|
||||
(--clear reicht nicht immer, falsche Revision kann das Panel in einem Zwischenzustand
|
||||
lassen).
|
||||
|
||||
Die Anzeige ist nirgends Voraussetzung fuer die Migration: fehlt das Panel, SPI oder eine
|
||||
Bibliothek (gpiozero/spidev/Pillow), faengt main() das als DisplayUnavailable bzw.
|
||||
ImportError ab, protokolliert GENAU EINE Zeile und beendet sich mit sys.exit(0) - nicht mit
|
||||
einem Fehler, sonst wuerde systemd (Restart=on-failure) den Dienst endlos neu starten und
|
||||
das Journal fluten. Ein Display-Fehler darf niemals eine Migration verhindern.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Flacher Import wie bei server.py/products.py: dieses Verzeichnis ist kein Package,
|
||||
# display.py kann aber von ueberall aus gestartet werden (die systemd-Unit ruft es mit
|
||||
# vollem Pfad auf).
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import render # noqa: E402
|
||||
from epdconfig import DisplayUnavailable # noqa: E402
|
||||
|
||||
log = logging.getLogger("ninlight-display")
|
||||
|
||||
# Dieselben Umgebungsvariablen wie tools/station/ninlight-station verwendet, damit Tests
|
||||
# und Handbetrieb ohne echtes /run bzw. /etc auskommen.
|
||||
RUN_DIR = Path(os.environ.get("NINLIGHT_RUN_DIR", "/run/ninlight-station"))
|
||||
CONF_PATH = Path(os.environ.get("NINLIGHT_STATION_CONF", "/etc/ninlight-station.conf"))
|
||||
SERVICE_NAME = os.environ.get("NINLIGHT_STATION_SERVICE", "ninlight-station")
|
||||
# Bedienzustand des Tasterdienstes (tools/station/button/button.py). NUR GELESEN: der
|
||||
# Tasterdienst fasst den SPI-Bus nicht an und dieser Dienst kein GPIO23 - zwei Prozesse
|
||||
# auf denselben Leitungen enden in "lgpio.error: 'GPIO busy'". Die Datei liegt im
|
||||
# RuntimeDirectory DES TASTERDIENSTES (nicht in RUN_DIR): RUN_DIR ist das
|
||||
# RuntimeDirectory der Hauptunit und verschwindet beim regulaeren "off" - also
|
||||
# ausgerechnet dann, wenn das Menue "Rettung starten"/"Migration starten" anbietet.
|
||||
UI_FILE = Path(os.environ.get("NINLIGHT_UI_FILE", "/run/ninlight-button/ui.json"))
|
||||
# Der Umschalter "Migrationsimages mit oder ohne WLAN-Zugangsdaten" (siehe
|
||||
# ninlight-station, bildsatz_cmd, und server.py:read_image_set). NUR GELESEN, und
|
||||
# absichtlich aus derselben Datei wie der Server - nicht aus status.json: der Zustand
|
||||
# muss auch dann auf dem Panel stehen, wenn die Station AUS ist. Genau davor soll er
|
||||
# schuetzen ("man soll vor jedem Einschalten sehen, was gilt"), und ausgeschaltet
|
||||
# schreibt server.py nichts mehr.
|
||||
IMAGESET_FILE = Path(os.environ.get("NINLIGHT_IMAGESET_FILE",
|
||||
"/var/lib/ninlight-station/imageset"))
|
||||
# Dieselben Namen und dieselbe Vorgabe wie in server.py und im CLI - im Zweifel der
|
||||
# Satz OHNE Zugangsdaten (dort steht die Begruendung).
|
||||
IMAGESET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"}
|
||||
IMAGESET_DEFAULT = "ohne-wlan"
|
||||
|
||||
POLL_INTERVAL_S = 5
|
||||
# Solange eine Bedienung laeuft, waere ein 5-Sekunden-Takt unbenutzbar (ein Tastendruck
|
||||
# bis zu 5 s ohne Reaktion). Die Schleife tickt deshalb in diesem Takt und sieht bei
|
||||
# JEDEM Tick in der Bedienzustandsdatei nach - auch im Ruhezustand, sonst waere
|
||||
# ausgerechnet der erste Druck (der das Menue oeffnet) der langsamste. Das kostet eine
|
||||
# kleine Dateilesung je halber Sekunde; der teure Teil des Zustands (build_state() mit
|
||||
# seinem "systemctl is-active"-Fork) bleibt beim 5-Sekunden-Takt.
|
||||
#
|
||||
# Am Panel gemessen kommt zu dieser halben Sekunde noch der Teilaufbau (rund 0,3 s) -
|
||||
# also grob 0,5-0,8 s je Druck. Das ist spuerbar, aber bedienbar: die Geste ist
|
||||
# "druecken und hinsehen", nicht "tippen wie auf einer Tastatur", und die Haltegesten
|
||||
# (1,5/3/10 s) liegen alle weit ueber dieser Verzoegerung.
|
||||
UI_POLL_INTERVAL_S = 0.5
|
||||
# E-Paper altert mit jedem Bildaufbau - ein Vollaufbau nur bei Moduswechsel und
|
||||
# mindestens alle 30 Minuten, sonst Teilaktualisierung (siehe Modul-Docstring der
|
||||
# Panel-Treiber: haeufige Teilaktualisierungen ohne gelegentlichen Vollaufbau lassen
|
||||
# Geisterbilder stehen).
|
||||
FULL_REFRESH_INTERVAL_S = 30 * 60
|
||||
|
||||
# Panel erst schlafen legen, wenn seit dem letzten Bildaufbau schon eine Weile nichts
|
||||
# mehr passiert ist - NICHT nach jedem einzelnen Bild (Fixrunde 2, N1/N2/N3: das liess
|
||||
# V3 beim naechsten Teilaufbau abstuerzen und machte V2s Deep-Sleep-Aufwachsequenz
|
||||
# unbrauchbar, siehe Panel-Klasse). Migrationsereignisse kommen oft in kurzen Buendeln
|
||||
# (mehrere Lampen kurz nacheinander) - das Panel dafuer jedesmal schlafen zu legen und
|
||||
# wieder aufzuwecken waere nur zusaetzlicher Verschleiss ohne Nutzen fuer die
|
||||
# Bias-Alterung, die I4 eigentlich vermeiden soll. 10 Minuten ohne Aenderung sind lang
|
||||
# genug, dass ein Buendel zusammenhaengender Ereignisse das Panel nicht staendig
|
||||
# schlafen und wieder aufwachen laesst, aber kurz genug, dass es zwischen zwei
|
||||
# tatsaechlich seltenen Ereignissen (z.B. stundenlanger Rettungsmodus ohne Anfrage)
|
||||
# nicht dauerhaft unter Bias steht.
|
||||
SLEEP_AFTER_IDLE_S = 10 * 60
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict:
|
||||
"""Geparster Inhalt von `path`, oder {} wenn er fehlt/unlesbar/kaputt/kein Objekt
|
||||
ist - status.json und control.json koennen jederzeit in genau diesem Zustand sein
|
||||
(frisch aufgesetzter Pi, Schreibfehler mitten im Schreiben)."""
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _read_conf(path: Path) -> dict[str, str]:
|
||||
"""Minimaler Parser fuer /etc/ninlight-station.conf: KEY='VALUE'-Zeilen, wie
|
||||
setup_pi.sh sie schreibt, optional mit einem "export "-Praefix (setup_pi.sh prueft
|
||||
beim Nachtragen fehlender Schluessel ausdruecklich auf "^(export[[:space:]]+)?KEY=" -
|
||||
ein bereits vorhandenes "export SSID=..." muss also erkannt werden, nicht nur die
|
||||
unpraefigierte Form). Kein echtes Shell-Sourcing (das CLI braucht das fuer $- und
|
||||
`-Escaping in Passwoertern, siehe dessen Kommentar - display.py liest hier nur
|
||||
SSID/MIGRATE_SSID, reine Bezeichner ohne Sonderzeichen)."""
|
||||
values: dict[str, str] = {}
|
||||
try:
|
||||
text = path.read_text()
|
||||
except OSError:
|
||||
return values
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
if line.startswith("export ") or line.startswith("export\t"):
|
||||
line = line[len("export"):].lstrip()
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||
value = value[1:-1]
|
||||
if key:
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def _read_autooff_minutes(run_dir: Path, now: float | None = None):
|
||||
"""Restzeit des optionalen Zeitschalters in ganzen Minuten (aufgerundet), oder None,
|
||||
wenn gerade keiner laeuft.
|
||||
|
||||
Quelle ist RUN_DIR/autooff.json, die "ninlight-station --for/renew" schreibt und
|
||||
"hold"/"off" wieder loescht (siehe dort). Ein Zeitschalter, den man am Panel nicht
|
||||
sieht, waere schlimmer als keiner: man glaubt, die Station laeuft, und sie ist
|
||||
laengst aus.
|
||||
|
||||
"deadline" ist eine Unix-Zeit (der Zeitschalter selbst haengt an systemds monotoner
|
||||
Uhr, die Anzeige rechnet in Wanduhrzeit) - eine Zeitverstellung verschiebt also die
|
||||
angezeigte Restzeit, nicht das tatsaechliche Abschalten. Rundet auf: solange noch
|
||||
eine Sekunde uebrig ist, steht dort "1 min" und nicht "0 min"."""
|
||||
data = _read_json(run_dir / "autooff.json")
|
||||
deadline = data.get("deadline")
|
||||
if isinstance(deadline, bool) or not isinstance(deadline, (int, float)):
|
||||
return None
|
||||
remaining_s = deadline - (time.time() if now is None else now)
|
||||
if remaining_s <= 0:
|
||||
# Der Termin ist da (oder der Lauf verschiebt gerade) - "0 min" ist hier die
|
||||
# ehrliche Auskunft, nicht das Verschwinden der Zeile.
|
||||
return 0
|
||||
whole, rest = divmod(remaining_s, 60)
|
||||
return int(whole) + (1 if rest else 0)
|
||||
|
||||
|
||||
def _read_mode_file(run_dir: Path) -> str:
|
||||
"""Modus, in dem der Dienst laut RUN_DIR/mode tatsaechlich gestartet wurde. Fehlt die
|
||||
Datei oder ist ihr Inhalt kein bekannter Modus, gilt "recovery" - derselbe Vorgabewert
|
||||
wie in server.py:resolve_mode()."""
|
||||
try:
|
||||
value = (run_dir / "mode").read_text().strip()
|
||||
except OSError:
|
||||
return "recovery"
|
||||
return value if value in ("recovery", "migration") else "recovery"
|
||||
|
||||
|
||||
# Nur beim ersten Mal protokollieren, dass systemctl selbst nicht erreichbar war (siehe
|
||||
# _service_active) - das passiert bei jedem der Aufrufe alle 5 s erneut, und eine Zeile
|
||||
# alle 5 s wuerde das Journal fluten, obwohl es sich nie aendert (Fixrunde 1, Kleinigkeit).
|
||||
_service_active_fallback_logged = False
|
||||
|
||||
|
||||
def _service_active(service_name: str) -> bool:
|
||||
"""Laeuft der Hauptdienst (server.py) gerade? "ninlight-station off" stoppt ihn und
|
||||
faehrt den Zugangspunkt herunter, laesst status.json dabei aber unangetastet liegen -
|
||||
ein reines Lesen von status.json wuerde nach "off" also faelschlich weiter den letzten
|
||||
Modus vor dem Abschalten zeigen. Deshalb ist die Dienstaktivitaet selbst (nicht das
|
||||
Vorhandensein von status.json) die Quelle fuer den Anzeigemodus "off"."""
|
||||
global _service_active_fallback_logged
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "is-active", "--quiet", service_name],
|
||||
timeout=5, check=False)
|
||||
return result.returncode == 0
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
# systemctl selbst nicht erreichbar (z.B. Handlauf ohne systemd) - dann anhand
|
||||
# der Statusdatei entscheiden statt "off" zu behaupten, ohne es zu wissen.
|
||||
if not _service_active_fallback_logged:
|
||||
log.warning("systemctl nicht erreichbar - Anzeige raet den Modus anhand von "
|
||||
"RUN_DIR/mode statt der echten Dienstaktivitaet")
|
||||
_service_active_fallback_logged = True
|
||||
return True
|
||||
|
||||
|
||||
def _read_imageset(imageset_path: Path, conf: dict[str, str]) -> tuple[str, bool]:
|
||||
"""(Name des eingestellten Bildsatzes, liegt er ueberhaupt auf dem Pi?).
|
||||
|
||||
Fehlt die Datei, ist sie unlesbar oder steht etwas Unbekanntes darin, gilt
|
||||
IMAGESET_DEFAULT - dieselbe Vorgabe wie in server.py und im CLI, sonst wuerde das
|
||||
Panel etwas anderes behaupten als der Server tut.
|
||||
|
||||
`ok` ist False, wenn der GEWAEHLTE Satz nicht auf dem Pi liegt - nach derselben
|
||||
Regel wie server.py:migrate_set_present(): Verzeichnis vorhanden UND mindestens ein
|
||||
Image darin. Dann liefert server.py nichts aus (Status "noimageset"), und genau das
|
||||
muss am Panel stehen, statt dass der Bediener vor einer Lampe steht, die nichts
|
||||
bekommt. Ohne bekanntes IMAGE_DIR (Konfigurationsdatei nicht lesbar) wird nichts
|
||||
behauptet: dann gilt "ok", denn eine unbelegte Warnung ist schlechter als keine."""
|
||||
try:
|
||||
value = imageset_path.read_text().strip()
|
||||
except OSError:
|
||||
value = ""
|
||||
name = value if value in IMAGESET_DIRS else IMAGESET_DEFAULT
|
||||
image_dir = conf.get("IMAGE_DIR")
|
||||
if not image_dir:
|
||||
return name, True
|
||||
set_dir = Path(image_dir) / IMAGESET_DIRS[name]
|
||||
try:
|
||||
return name, any(p.name.endswith(".img") for p in set_dir.iterdir())
|
||||
except OSError:
|
||||
return name, False
|
||||
|
||||
|
||||
def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
|
||||
service_name: str = SERVICE_NAME,
|
||||
imageset_path: Path = IMAGESET_FILE) -> dict:
|
||||
"""Baut den zusammengefuehrten Zustand, den render.lines()/compose() erwarten.
|
||||
|
||||
Modus kommt vorrangig aus RUN_DIR/mode (Vorgabe "recovery", wie
|
||||
server.py:resolve_mode()) - das ist das, was "ninlight-station on"/"migrate on"
|
||||
zuletzt geschrieben haben und unabhaengig davon verfuegbar, ob/wann der Serverprozess
|
||||
seit dem letzten Start schon einmal status.json geschrieben hat. status.json selbst
|
||||
liefert nur served/recent/last_seen; sein "mode"-Feld wird bewusst NICHT als
|
||||
Modusquelle verwendet (Fixrunde 1: die urspruengliche Fassung hatte den Vorrang
|
||||
umgekehrt).
|
||||
|
||||
Phase/armed_target kommen aus control.json, nur im Migrationsmodus massgeblich - der
|
||||
Rettungsmodus ignoriert control.json ebenso wie server.py das tut. "off" ueberschreibt
|
||||
alles, wenn der Hauptdienst gar nicht laeuft.
|
||||
|
||||
Der Bildsatz ("imageset"/"imageset_ok") kommt in JEDEN Zustand, auch in "off" und in
|
||||
den Rettungsmodus: er ist ein dauerhafter Umschalter, und man soll ihn VOR dem
|
||||
Einschalten sehen - eine Einstellung von vorletzter Woche, die erst beim Blaettern im
|
||||
Menue auffaellt, hat genau den Zweck verfehlt."""
|
||||
conf = _read_conf(conf_path)
|
||||
imageset, imageset_ok = _read_imageset(imageset_path, conf)
|
||||
|
||||
if not _service_active(service_name):
|
||||
return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok}
|
||||
|
||||
mode = _read_mode_file(run_dir)
|
||||
status = _read_json(run_dir / "status.json")
|
||||
state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent"),
|
||||
"imageset": imageset, "imageset_ok": imageset_ok}
|
||||
|
||||
if mode == "recovery":
|
||||
state["ssid"] = conf.get("SSID")
|
||||
else: # migration
|
||||
state["ssid"] = conf.get("MIGRATE_SSID")
|
||||
state["last_seen"] = status.get("last_seen")
|
||||
control = _read_json(run_dir / "control.json")
|
||||
phase = control.get("phase")
|
||||
state["phase"] = phase if phase in ("auto", "listen", "armed") else "auto"
|
||||
state["armed_target"] = control.get("armed_target")
|
||||
|
||||
# Der Zeitschalter ist optional und gilt in beiden Modi gleichermassen - ohne ihn
|
||||
# bleibt das Feld weg und render.blocks() zeichnet keine Zeile dafuer.
|
||||
autooff_minutes = _read_autooff_minutes(run_dir)
|
||||
if autooff_minutes is not None:
|
||||
state["autooff_minutes"] = autooff_minutes
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def read_ui(ui_path: Path = UI_FILE) -> dict:
|
||||
"""Der Bedienzustand des Tasterdienstes, oder {} wenn gerade keine Bedienung laeuft
|
||||
(Datei fehlt, ist unlesbar, halb geschrieben, aus einer aelteren Fassung oder meldet
|
||||
ausdruecklich "idle").
|
||||
|
||||
Jedes Feld wird defensiv gelesen: die Datei kommt aus einem FREMDEN Prozess, der
|
||||
abgestuerzt, aelter oder neuer sein kann - ein Fehler hier darf die Anzeige nicht
|
||||
aufhalten und schon gar nicht eine Migration stoeren."""
|
||||
data = _read_json(ui_path)
|
||||
view = data.get("view")
|
||||
if not data.get("active") or not isinstance(view, str) or view == "idle":
|
||||
return {}
|
||||
return data
|
||||
|
||||
|
||||
def _load_epd_module(revision: str):
|
||||
"""Laedt den passenden Panel-Treiber. Nicht ueber ein Dict aus Modulnamen, damit ein
|
||||
statischer Import-Checker (und ein Mensch, der `grep -n epd2in13` tippt) beide
|
||||
moeglichen Importe findet."""
|
||||
if revision == "V2":
|
||||
import epd2in13_V2 as epd_module
|
||||
elif revision == "V3":
|
||||
import epd2in13_V3 as epd_module
|
||||
else:
|
||||
raise DisplayUnavailable(
|
||||
f"unbekannte EPD_REVISION '{revision}' - erwartet 'V2' oder 'V3'")
|
||||
return epd_module
|
||||
|
||||
|
||||
class Panel:
|
||||
"""Kapselt die V2/V3-Unterschiede der Waveshare-Treiber hinter einer einheitlichen
|
||||
Schnittstelle (Fixrunde 1, C1): epd2in13_V2.init() braucht ein Pflichtargument
|
||||
(FULL_UPDATE/PART_UPDATE), epd2in13_V3.init() braucht keins.
|
||||
|
||||
Verfolgt zusaetzlich selbst, ob das Panel gerade "wach" ist (Register initialisiert,
|
||||
Referenzpuffer fuer Teilaufbauten bekannt) oder zuletzt sleep() gerufen wurde
|
||||
(Fixrunde 2, N1/N3): epd.sleep() schickt das Panel in einen Zustand, aus dem heraus
|
||||
KEINE Revision direkt einen Teilaufbau vertraegt - V2s sleep() ist Deep Sleep Mode 2
|
||||
(RAM/Register verworfen; ein nachfolgendes init(PART_UPDATE) setzt nur einen Teil der
|
||||
Register, die ein Teilaufbau braucht - 0x01/0x11/0x44/0x45/0x4E/0x4F setzt
|
||||
ausschliesslich der FULL_UPDATE-Zweig), V3s sleep() ruft epdconfig.module_exit() und
|
||||
schliesst damit die GPIO-Pins, die epd2in13_V3.displayPartial() als Erstes anfasst
|
||||
(roher AttributeError auf ein geschlossenes Pin-Objekt). Ein Teilaufbau nach dem
|
||||
Schlafen wird deshalb hier, nicht beim Aufrufer, transparent zu einem Vollaufbau
|
||||
aufgewertet - unabhaengig davon, in welcher Reihenfolge draw_full()/draw_partial()/
|
||||
sleep() aufgerufen werden.
|
||||
|
||||
draw_full() (und clear(), Fixrunde 3, F2) verwendet fuer BEIDE Revisionen
|
||||
displayPartBaseImage() statt display()/Clear() (Fixrunde 2, N2): das schreibt beide
|
||||
RAM-Puffer des Controllers (0x24 UND 0x26) und legt damit erst die Referenz an, gegen
|
||||
die ein nachfolgender Teilaufbau diffen kann - display() allein haette nur 0x24
|
||||
beschrieben (und Clear() ebenso - bei V2 ist das 0x26-Schreiben in Clear() sogar
|
||||
auskommentiert) und den ersten Teilaufbau danach gegen einen veralteten/undefinierten
|
||||
Puffer laufen lassen. self._awake wird deshalb nur dort auf True gesetzt, wo
|
||||
tatsaechlich ein gueltiger Referenzpuffer entstanden ist - vorher (Fixrunde 2) setzte
|
||||
clear() es, obwohl es nur epd.Clear() (kein 0x26) rief, und die im Klassen-Docstring
|
||||
behauptete Invariante stimmte nach clear() nicht."""
|
||||
|
||||
def __init__(self, revision: str) -> None:
|
||||
self.revision = revision
|
||||
self._epd_module = _load_epd_module(revision)
|
||||
self.epd = self._epd_module.EPD()
|
||||
# Vor dem ersten clear()/draw_full() ist nichts initialisiert - siehe Klassen-
|
||||
# Docstring, warum draw_partial() das genauso wie ein sleep() davor behandeln
|
||||
# muss (beides: kein gueltiger Referenzpuffer, keine vollstaendige Registerlage).
|
||||
self._awake = False
|
||||
|
||||
def _full_init(self) -> None:
|
||||
if self.revision == "V2":
|
||||
self.epd.init(self.epd.FULL_UPDATE)
|
||||
else:
|
||||
self.epd.init()
|
||||
|
||||
def _blank_buffer(self) -> list[int]:
|
||||
"""Ein reiner Weiss-Puffer in der Groesse, die epd.getbuffer() fuer ein
|
||||
vollformatiges Bild liefern wuerde - dieselbe Formel wie in epd.Clear() selbst
|
||||
(siehe epd2in13_V2.py/epd2in13_V3.py), nur ohne ueber Pillow ein echtes weisses
|
||||
Bild zu erzeugen."""
|
||||
width, height = self.epd.width, self.epd.height
|
||||
linewidth = width // 8 if width % 8 == 0 else width // 8 + 1
|
||||
return [0xFF] * (linewidth * height)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._full_init()
|
||||
# displayPartBaseImage() statt epd.Clear() (Fixrunde 3, F2): dieselbe sichtbare
|
||||
# Wirkung (Panel wird weiss), aber schreibt zusaetzlich den 0x26-Referenzpuffer,
|
||||
# den ein nachfolgender draw_partial() braucht - epd.Clear() allein wuerde
|
||||
# self._awake=True vortaeuschen, ohne die im Klassen-Docstring beschriebene
|
||||
# Invariante wirklich herzustellen.
|
||||
self.epd.displayPartBaseImage(self._blank_buffer())
|
||||
self._awake = True
|
||||
|
||||
def draw_full(self, image) -> None:
|
||||
buf = self.epd.getbuffer(image)
|
||||
self._full_init()
|
||||
self.epd.displayPartBaseImage(buf)
|
||||
self._awake = True
|
||||
|
||||
def draw_partial(self, image) -> bool:
|
||||
"""Zeichnet teilweise - oder, falls das Panel nicht wach ist, vollstaendig.
|
||||
Gibt zurueck, ob intern zu einem Vollaufbau aufgewertet wurde (Fixrunde 3, F4):
|
||||
der Aufrufer muss das wie einen regulaeren Vollaufbau behandeln (z.B. fuer die
|
||||
30-Minuten-Uhr in run_daemon()), sonst kommt der naechste dortige Vollaufbau zu
|
||||
frueh und altert das Panel zusaetzlich, ohne dass ein Teilaufbau ueberhaupt
|
||||
stattgefunden haette."""
|
||||
if not self._awake:
|
||||
# Siehe Klassen-Docstring (Fixrunde 2, N1/N3): ein Teilaufbau direkt nach dem
|
||||
# Schlafen (oder vor dem allerersten Vollaufbau) ist auf keiner Revision eine
|
||||
# gueltige Sequenz - stattdessen einen vollwertigen Vollaufbau ausfuehren.
|
||||
self.draw_full(image)
|
||||
return True
|
||||
buf = self.epd.getbuffer(image)
|
||||
if self.revision == "V2":
|
||||
self.epd.init(self.epd.PART_UPDATE)
|
||||
self.epd.displayPartial(buf)
|
||||
return False
|
||||
|
||||
def sleep(self) -> None:
|
||||
# Idempotent: ein zweiter sleep()-Aufruf, waehrend das Panel schon schlaeft,
|
||||
# wuerde sonst versuchen, ueber bereits von module_exit() geschlossene GPIO-Pins
|
||||
# SPI-Befehle zu schicken (derselbe AttributeError-Mechanismus wie N1, nur
|
||||
# selbst herbeigefuehrt statt durch einen Teilaufbau).
|
||||
if not self._awake:
|
||||
return
|
||||
self.epd.sleep()
|
||||
self._awake = False
|
||||
|
||||
|
||||
class RefreshPlanner:
|
||||
"""Die gesamte Zeitsteuerung der Anzeige - wann voll aufgebaut, wann teilweise, wann
|
||||
geschlafen wird - als reine Zustandsmaschine ohne Schleife, ohne time.sleep() und ohne
|
||||
Hardware.
|
||||
|
||||
Vorher steckte diese Logik direkt in run_daemon()s Endlosschleife und war dadurch
|
||||
ungetestet (Fixrunde 4): drei Korrekturen aus Fixrunde 3 (die stale-Bedingung, die
|
||||
Rueckmeldung einer intern aufgewerteten Teilaktualisierung, die Zeilenumbruchbehandlung
|
||||
in compose()) liessen sich per Mutation aendern, ohne dass ein einziger Test rot wurde.
|
||||
Hier laesst sich stattdessen ein kompletter Tagesverlauf in Millisekunden durchspielen
|
||||
(siehe test_render.py, _run_schedule()).
|
||||
|
||||
Benutzung: pro Runde einmal decide() fragen, die zurueckgegebene Aktion ausfuehren und
|
||||
das Ergebnis per record_draw()/record_sleep() zurueckmelden. decide() selbst aendert
|
||||
nichts - der Aufrufer meldet, was tatsaechlich passiert ist (insbesondere, ob
|
||||
Panel.draw_partial() sich selbst zu einem Vollaufbau aufgewertet hat).
|
||||
|
||||
Aktionen:
|
||||
"full" - kompletter Bildaufbau (Moduswechsel oder faelliger Ghosting-Ausgleich)
|
||||
"partial" - Teilaktualisierung (Inhalt hat sich geaendert)
|
||||
"sleep" - Panel schlafen legen (laenger nichts passiert)
|
||||
"idle" - nichts tun
|
||||
"""
|
||||
|
||||
_NO_MODE = object() # garantiert ungleich jedem echten Modus beim ersten Lauf
|
||||
|
||||
def __init__(self, now: float,
|
||||
full_refresh_interval_s: float | None = None,
|
||||
sleep_after_idle_s: float | None = None) -> None:
|
||||
# Bewusst None statt der Modulkonstanten als Vorgabewert (Fixrunde 5): ein
|
||||
# Default-Argument wird zur Importzeit ausgewertet, ein spaeteres
|
||||
# monkeypatch.setattr(display, "FULL_REFRESH_INTERVAL_S", ...) haette dann keine
|
||||
# Wirkung mehr auf neu gebaute Planer - die alte Schleife las die Konstanten pro
|
||||
# Runde. Die Properties unten lesen sie weiterhin bei jeder Entscheidung.
|
||||
self._full_refresh_interval_s = full_refresh_interval_s
|
||||
self._sleep_after_idle_s = sleep_after_idle_s
|
||||
self.last_lines: list[str] | None = None
|
||||
self.last_mode: object = self._NO_MODE
|
||||
self.last_full = now
|
||||
self.last_draw = now
|
||||
# Zaehlt Teilaufbauten seit dem letzten Vollaufbau. full_refresh_interval_s
|
||||
# existiert nur, um Geisterbilder aus AUFEINANDERFOLGENDEN Teilaufbauten
|
||||
# auszugleichen - ohne mindestens einen Teilaufbau seit dem letzten Vollaufbau gibt
|
||||
# es nichts auszugleichen, und ein Vollaufbau des UNVERAENDERTEN Bildes waere
|
||||
# reine, nutzlose Alterung (Fixrunde 3, F3): ohne diese Absicherung entstand im
|
||||
# eingeschwungenen Ruhezustand ein endloser 10-Minuten-schlafen/10-Minuten-wach-
|
||||
# Zyklus (33% Bias-Anteil) rein aus der 30-Minuten-Uhr, obwohl sich der Inhalt nie
|
||||
# aenderte.
|
||||
#
|
||||
# Der Zaehler wird beim Einschlafen BEWUSST NICHT zurueckgesetzt (Fixrunde 4, N1):
|
||||
# das tat die Fassung aus Fixrunde 3 und liess den faelligen Ghosting-Ausgleich
|
||||
# damit dauerhaft ausfallen - nach einem Buendel Teilaufbauten (z.B. 30 Lampen in
|
||||
# 2,5 Minuten) schlief das Panel nach 10 Minuten mit dem entstandenen Geisterbild
|
||||
# ein und zeigte genau dieses Bild stundenlang weiter, weil stale wegen
|
||||
# partials_since_full == 0 nie mehr feuern konnte. So bleibt der Ausgleich
|
||||
# ausstehend und wird zum 30-Minuten-Termin nachgeholt (das weckt das Panel einmal
|
||||
# kurz auf; danach ist der Zaehler 0 und der eingeschwungene Ruhezustand bleibt
|
||||
# wie in F3 gewuenscht bei 3% Wachanteil). Die Alternative - vor dem Einschlafen
|
||||
# einmal draw_full() ziehen - kostet denselben einen Vollaufbau, aber JEDES Mal,
|
||||
# wenn ein Buendel endet, auch wenn kurz darauf ohnehin neue Inhalte kommen (die
|
||||
# das Panel nach dem Schlafen sowieso ueber draw_partial()s Aufwertung voll
|
||||
# aufbauen). Diese Variante altert das Panel im Betriebsfall also mehr, nicht
|
||||
# weniger, und der Ausgleich hier kommt spaetestens zum ohnehin vorgesehenen
|
||||
# Termin.
|
||||
self.partials_since_full = 0
|
||||
self.asleep = False
|
||||
|
||||
@property
|
||||
def full_refresh_interval_s(self) -> float:
|
||||
return (FULL_REFRESH_INTERVAL_S if self._full_refresh_interval_s is None
|
||||
else self._full_refresh_interval_s)
|
||||
|
||||
@property
|
||||
def sleep_after_idle_s(self) -> float:
|
||||
return (SLEEP_AFTER_IDLE_S if self._sleep_after_idle_s is None
|
||||
else self._sleep_after_idle_s)
|
||||
|
||||
def decide(self, now: float, lines: list[str], mode: object) -> str:
|
||||
"""Was jetzt zu tun ist. Aendert nichts - das Ergebnis wird per record_*()
|
||||
zurueckgemeldet."""
|
||||
mode_changed = mode != self.last_mode
|
||||
stale = (self.partials_since_full > 0
|
||||
and (now - self.last_full) >= self.full_refresh_interval_s)
|
||||
|
||||
if lines != self.last_lines or mode_changed or stale:
|
||||
return "full" if (mode_changed or stale) else "partial"
|
||||
if not self.asleep and (now - self.last_draw) >= self.sleep_after_idle_s:
|
||||
# Nach laengerer Ruhe schlafen legen (Fixrunde 1, I4: die Pixel bleiben -
|
||||
# e-Paper ist bistabil - aber ohne dauerhafte Spannung altert das Panel
|
||||
# deutlich weniger).
|
||||
return "sleep"
|
||||
return "idle"
|
||||
|
||||
def record_draw(self, now: float, lines: list[str], mode: object,
|
||||
was_full: bool) -> None:
|
||||
"""`was_full` ist True fuer einen regulaeren Vollaufbau UND dann, wenn
|
||||
Panel.draw_partial() sich selbst zu einem Vollaufbau aufgewertet hat (Fixrunde 3,
|
||||
F4) - sonst kaeme der naechste "stale"-Vollaufbau zu frueh und altert das Panel
|
||||
zusaetzlich, obwohl gerade erst vollstaendig aufgebaut wurde."""
|
||||
self.last_lines = lines
|
||||
self.last_mode = mode
|
||||
self.last_draw = now
|
||||
self.asleep = False
|
||||
if was_full:
|
||||
self.last_full = now
|
||||
self.partials_since_full = 0
|
||||
else:
|
||||
self.partials_since_full += 1
|
||||
|
||||
def record_sleep(self) -> None:
|
||||
"""Kein `now`-Parameter (Fixrunde 5): das Einschlafen verschiebt weder die
|
||||
30-Minuten-Uhr noch last_draw - es merkt sich nur, dass ein weiteres
|
||||
panel.sleep() nichts mehr zu tun haette."""
|
||||
self.asleep = True
|
||||
|
||||
|
||||
def _test_image(revision: str):
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
width, height = 250, 122
|
||||
image = Image.new("1", (width, height), 255)
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle((0, 0, width - 1, height - 1), outline=0)
|
||||
draw.text((4, 4), "ninlight-display Test", font=ImageFont.load_default(), fill=0)
|
||||
draw.text((4, 20), f"EPD_REVISION={revision}", font=ImageFont.load_default(), fill=0)
|
||||
draw.line((4, 40, width - 4, 40), fill=0)
|
||||
# Vier Kaestchen, abwechselnd gefuellt/leer - hilft, Kontrast/Inversion am
|
||||
# angeschlossenen Panel auf einen Blick zu beurteilen.
|
||||
for i in range(4):
|
||||
x0 = 4 + i * 20
|
||||
draw.rectangle((x0, 50, x0 + 16, 90), outline=0, fill=0 if i % 2 == 0 else 255)
|
||||
return image
|
||||
|
||||
|
||||
def run_test(revision: str) -> None:
|
||||
"""Zeichnet ein Testbild und beendet sich - siehe Modul-Docstring, wie man damit V2
|
||||
von V3 unterscheidet."""
|
||||
panel = Panel(revision)
|
||||
panel.clear()
|
||||
panel.draw_full(_test_image(revision))
|
||||
panel.sleep()
|
||||
log.info("Testbild gezeichnet (EPD_REVISION=%s)", revision)
|
||||
|
||||
|
||||
def run_final(revision: str, kind: str) -> None:
|
||||
"""Zeichnet den Abschlussbildschirm ("poweroff"/"reboot") und legt das Panel schlafen.
|
||||
|
||||
Das ist das Bild, das nach dem Herunterfahren DAUERHAFT stehen bleibt - E-Paper ist
|
||||
bistabil, es haelt sein Bild auch ohne Strom. Bewusst ein VOLLAUFBAU: ein Teilaufbau
|
||||
liesse Reste des vorigen Bildes stehen, und dieses eine Bild ueberdauert alle anderen.
|
||||
|
||||
Aufgerufen wird das ausschliesslich ueber "ninlight-station display final <kind>" aus
|
||||
einer systemd-Unit heraus, und zwar ERST, nachdem der Anzeigedienst gestoppt ist - er
|
||||
haelt sonst die GPIO-/SPI-Leitungen ("lgpio.error: 'GPIO busy'"). Diese Uebergabe
|
||||
macht das CLI (display_final()), nicht diese Funktion."""
|
||||
panel = Panel(revision)
|
||||
panel.clear()
|
||||
panel.draw_full(render.compose({"mode": "final", "final": kind}))
|
||||
panel.sleep()
|
||||
log.info("Abschlussbild gezeichnet (%s, EPD_REVISION=%s)", kind, revision)
|
||||
|
||||
|
||||
def run_clear(revision: str) -> None:
|
||||
panel = Panel(revision)
|
||||
panel.clear()
|
||||
panel.sleep()
|
||||
log.info("Panel geleert (EPD_REVISION=%s)", revision)
|
||||
|
||||
|
||||
def run_daemon(revision: str, clock=time, panel: "Panel | None" = None,
|
||||
read_state=build_state, read_ui=read_ui) -> None:
|
||||
"""Endlosschleife: alle UI_POLL_INTERVAL_S Sekunden den Bedienzustand und alle
|
||||
POLL_INTERVAL_S Sekunden den Stationszustand einlesen und die von RefreshPlanner
|
||||
entschiedene Aktion ausfuehren. Hier steht bewusst KEINE Zeitlogik zur Panelalterung
|
||||
mehr (Fixrunde 4) - die steckt vollstaendig in RefreshPlanner und ist dort ohne
|
||||
Schleife, ohne time.sleep() und ohne Hardware pruefbar.
|
||||
|
||||
Die zwei Takte: der teure Teil (build_state() forkt fuer "systemctl is-active")
|
||||
bleibt beim 5-Sekunden-Takt, der billige (eine kleine Datei lesen) laeuft in
|
||||
UI_POLL_INTERVAL_S, damit eine Bedienung am Taster ueberhaupt benutzbar ist. Ein
|
||||
laufender Bedienzustand ERSETZT das Statusbild (render.blocks() zweigt dafuer ganz
|
||||
vorne ab); der Modus bleibt derselbe, sodass das Bild ueber Teilaufbauten kommt und
|
||||
ein Tastendruck dem Panel keinen Vollaufbau kostet.
|
||||
|
||||
`clock` (braucht monotonic() und sleep()), `panel`, `read_state` und `read_ui` sind
|
||||
einschleusbar, damit die VERDRAHTUNG hier - welche Aktion welche Panel-Methode ruft,
|
||||
dass die Selbst-Aufwertung von draw_partial() zurueckgemeldet wird, dass das
|
||||
Einschlafen zurueckgemeldet wird, und die Reihenfolge decide -> ausfuehren ->
|
||||
zurueckmelden - selbst pruefbar ist und nicht nur eine Kopie davon (Fixrunde 5, N2:
|
||||
alle drei Mutationen an genau diesen Zeilen blieben vorher gruen, weil der Test eine
|
||||
nachgebaute Schleife statt dieser hier trieb). Im Betrieb bleiben die Vorgaben: echte
|
||||
Uhr, echtes Panel, echtes build_state()/read_ui()."""
|
||||
if panel is None:
|
||||
panel = Panel(revision)
|
||||
panel.clear()
|
||||
planner = RefreshPlanner(clock.monotonic())
|
||||
base_state = read_state()
|
||||
last_state_poll = clock.monotonic()
|
||||
|
||||
while True:
|
||||
now = clock.monotonic()
|
||||
if now - last_state_poll >= POLL_INTERVAL_S:
|
||||
base_state = read_state()
|
||||
last_state_poll = now
|
||||
ui = read_ui()
|
||||
if ui:
|
||||
state = dict(base_state)
|
||||
state["ui"] = ui
|
||||
else:
|
||||
state = base_state
|
||||
current_lines = render.lines(state)
|
||||
mode = state.get("mode")
|
||||
|
||||
action = planner.decide(now, current_lines, mode)
|
||||
if action == "full":
|
||||
panel.draw_full(render.compose(state))
|
||||
planner.record_draw(now, current_lines, mode, was_full=True)
|
||||
elif action == "partial":
|
||||
# Panel.draw_partial() wertet sich selbst zu einem Vollaufbau auf, wenn das
|
||||
# Panel nicht wach ist (siehe Panel-Klasse) - das meldet es hier zurueck.
|
||||
upgraded = panel.draw_partial(render.compose(state))
|
||||
planner.record_draw(now, current_lines, mode, was_full=upgraded)
|
||||
elif action == "sleep":
|
||||
panel.sleep()
|
||||
planner.record_sleep()
|
||||
|
||||
clock.sleep(UI_POLL_INTERVAL_S)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
|
||||
parser = argparse.ArgumentParser(description="ninlight E-Paper-Anzeige")
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument("--test", action="store_true", help="Testbild zeichnen und beenden")
|
||||
group.add_argument("--clear", action="store_true", help="Panel leeren und beenden")
|
||||
group.add_argument("--final", choices=sorted(render.FINAL_LABELS),
|
||||
help="Abschlussbild zeichnen und beenden (beim Herunterfahren "
|
||||
"bzw. Neustart; die Texte unterscheiden sich)")
|
||||
args = parser.parse_args()
|
||||
|
||||
revision = os.environ.get("EPD_REVISION", "V3")
|
||||
|
||||
try:
|
||||
if args.test:
|
||||
run_test(revision)
|
||||
elif args.clear:
|
||||
run_clear(revision)
|
||||
elif args.final:
|
||||
run_final(revision, args.final)
|
||||
else:
|
||||
run_daemon(revision)
|
||||
except (DisplayUnavailable, ImportError) as e:
|
||||
# Genau eine Zeile, dann sys.exit(0) - siehe Modul-Docstring. Das deckt sowohl
|
||||
# Hardwareprobleme (DisplayUnavailable: kein Panel/SPI) als auch eine fehlende
|
||||
# Bibliothek ab - allen voran Pillow, das laut Spec auf dem Ziel-Pi absichtlich
|
||||
# erst per "apt install python3-pil" nachkommt (Fixrunde 1, C4): render.compose()
|
||||
# importiert Pillow erst hier, ein fehlendes Paket zeigt sich also erst als
|
||||
# ModuleNotFoundError (eine ImportError-Unterklasse) beim ersten Zeichnen, nicht
|
||||
# schon beim Start. Restart=on-failure (statt always) in der Unit greift hier
|
||||
# nicht, weil 0 kein Fehlschlag ist; ein spaeter angestecktes Panel bzw.
|
||||
# nachinstalliertes Paket braucht also einen manuellen/zeitgesteuerten Neustart
|
||||
# der Unit, nicht eine Neustartschleife.
|
||||
log.info("E-Paper-Anzeige nicht verfuegbar, beende ohne Fehler: %s", e)
|
||||
sys.exit(0)
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,324 @@
|
||||
# *****************************************************************************
|
||||
# * | File : epd2in13_V2.py
|
||||
# * | Author : Waveshare team
|
||||
# * | Function : Electronic paper driver
|
||||
# * | Info :
|
||||
# *----------------
|
||||
# * | This version: V4.0
|
||||
# * | Date : 2019-06-20
|
||||
# # | Info : python demo
|
||||
# -----------------------------------------------------------------------------
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documnetation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS OR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
#
|
||||
|
||||
# --- Herkunftsvermerk (ninlight-firmware) ---------------------------------
|
||||
# Unveraendert uebernommen aus waveshareteam/e-Paper (RaspberryPi_JetsonNano/
|
||||
# python/lib/waveshare_epd/epd2in13_V2.py), Commit 85802dee (2023-02-15).
|
||||
# Einzige Aenderung: 'from . import epdconfig' -> 'import epdconfig', damit das
|
||||
# Modul flach importierbar ist (kein Package). Alles andere - Init-Sequenz, LUTs,
|
||||
# Timings - unveraendert. Details: tools/station/display/HERKUNFT.md
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
import logging
|
||||
import epdconfig
|
||||
|
||||
# Display resolution
|
||||
EPD_WIDTH = 122
|
||||
EPD_HEIGHT = 250
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class EPD:
|
||||
def __init__(self):
|
||||
self.reset_pin = epdconfig.RST_PIN
|
||||
self.dc_pin = epdconfig.DC_PIN
|
||||
self.busy_pin = epdconfig.BUSY_PIN
|
||||
self.cs_pin = epdconfig.CS_PIN
|
||||
self.width = EPD_WIDTH
|
||||
self.height = EPD_HEIGHT
|
||||
|
||||
FULL_UPDATE = 0
|
||||
PART_UPDATE = 1
|
||||
lut_full_update= [
|
||||
0x80,0x60,0x40,0x00,0x00,0x00,0x00, #LUT0: BB: VS 0 ~7
|
||||
0x10,0x60,0x20,0x00,0x00,0x00,0x00, #LUT1: BW: VS 0 ~7
|
||||
0x80,0x60,0x40,0x00,0x00,0x00,0x00, #LUT2: WB: VS 0 ~7
|
||||
0x10,0x60,0x20,0x00,0x00,0x00,0x00, #LUT3: WW: VS 0 ~7
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00, #LUT4: VCOM: VS 0 ~7
|
||||
|
||||
0x03,0x03,0x00,0x00,0x02, # TP0 A~D RP0
|
||||
0x09,0x09,0x00,0x00,0x02, # TP1 A~D RP1
|
||||
0x03,0x03,0x00,0x00,0x02, # TP2 A~D RP2
|
||||
0x00,0x00,0x00,0x00,0x00, # TP3 A~D RP3
|
||||
0x00,0x00,0x00,0x00,0x00, # TP4 A~D RP4
|
||||
0x00,0x00,0x00,0x00,0x00, # TP5 A~D RP5
|
||||
0x00,0x00,0x00,0x00,0x00, # TP6 A~D RP6
|
||||
|
||||
0x15,0x41,0xA8,0x32,0x30,0x0A,
|
||||
]
|
||||
|
||||
lut_partial_update = [ #20 bytes
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00, #LUT0: BB: VS 0 ~7
|
||||
0x80,0x00,0x00,0x00,0x00,0x00,0x00, #LUT1: BW: VS 0 ~7
|
||||
0x40,0x00,0x00,0x00,0x00,0x00,0x00, #LUT2: WB: VS 0 ~7
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00, #LUT3: WW: VS 0 ~7
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00, #LUT4: VCOM: VS 0 ~7
|
||||
|
||||
0x0A,0x00,0x00,0x00,0x00, # TP0 A~D RP0
|
||||
0x00,0x00,0x00,0x00,0x00, # TP1 A~D RP1
|
||||
0x00,0x00,0x00,0x00,0x00, # TP2 A~D RP2
|
||||
0x00,0x00,0x00,0x00,0x00, # TP3 A~D RP3
|
||||
0x00,0x00,0x00,0x00,0x00, # TP4 A~D RP4
|
||||
0x00,0x00,0x00,0x00,0x00, # TP5 A~D RP5
|
||||
0x00,0x00,0x00,0x00,0x00, # TP6 A~D RP6
|
||||
|
||||
0x15,0x41,0xA8,0x32,0x30,0x0A,
|
||||
]
|
||||
|
||||
# Hardware reset
|
||||
def reset(self):
|
||||
epdconfig.digital_write(self.reset_pin, 1)
|
||||
epdconfig.delay_ms(200)
|
||||
epdconfig.digital_write(self.reset_pin, 0)
|
||||
epdconfig.delay_ms(5)
|
||||
epdconfig.digital_write(self.reset_pin, 1)
|
||||
epdconfig.delay_ms(200)
|
||||
|
||||
def send_command(self, command):
|
||||
epdconfig.digital_write(self.dc_pin, 0)
|
||||
epdconfig.digital_write(self.cs_pin, 0)
|
||||
epdconfig.spi_writebyte([command])
|
||||
epdconfig.digital_write(self.cs_pin, 1)
|
||||
|
||||
def send_data(self, data):
|
||||
epdconfig.digital_write(self.dc_pin, 1)
|
||||
epdconfig.digital_write(self.cs_pin, 0)
|
||||
epdconfig.spi_writebyte([data])
|
||||
epdconfig.digital_write(self.cs_pin, 1)
|
||||
|
||||
# send a lot of data
|
||||
def send_data2(self, data):
|
||||
epdconfig.digital_write(self.dc_pin, 1)
|
||||
epdconfig.digital_write(self.cs_pin, 0)
|
||||
epdconfig.spi_writebyte2(data)
|
||||
epdconfig.digital_write(self.cs_pin, 1)
|
||||
|
||||
def ReadBusy(self):
|
||||
while(epdconfig.digital_read(self.busy_pin) == 1): # 0: idle, 1: busy
|
||||
epdconfig.delay_ms(100)
|
||||
|
||||
def TurnOnDisplay(self):
|
||||
self.send_command(0x22)
|
||||
self.send_data(0xC7)
|
||||
self.send_command(0x20)
|
||||
self.ReadBusy()
|
||||
|
||||
def TurnOnDisplayPart(self):
|
||||
self.send_command(0x22)
|
||||
self.send_data(0x0c)
|
||||
self.send_command(0x20)
|
||||
self.ReadBusy()
|
||||
|
||||
def init(self, update):
|
||||
if (epdconfig.module_init() != 0):
|
||||
return -1
|
||||
# EPD hardware init start
|
||||
self.reset()
|
||||
if(update == self.FULL_UPDATE):
|
||||
self.ReadBusy()
|
||||
self.send_command(0x12) # soft reset
|
||||
self.ReadBusy()
|
||||
|
||||
self.send_command(0x74) #set analog block control
|
||||
self.send_data(0x54)
|
||||
self.send_command(0x7E) #set digital block control
|
||||
self.send_data(0x3B)
|
||||
|
||||
self.send_command(0x01) #Driver output control
|
||||
self.send_data(0xF9)
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x00)
|
||||
|
||||
self.send_command(0x11) #data entry mode
|
||||
self.send_data(0x01)
|
||||
|
||||
self.send_command(0x44) #set Ram-X address start/end position
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x0F) #0x0C-->(15+1)*8=128
|
||||
|
||||
self.send_command(0x45) #set Ram-Y address start/end position
|
||||
self.send_data(0xF9) #0xF9-->(249+1)=250
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x00)
|
||||
|
||||
self.send_command(0x3C) #BorderWavefrom
|
||||
self.send_data(0x03)
|
||||
|
||||
self.send_command(0x2C) #VCOM Voltage
|
||||
self.send_data(0x55) #
|
||||
|
||||
self.send_command(0x03)
|
||||
self.send_data(self.lut_full_update[70])
|
||||
|
||||
self.send_command(0x04) #
|
||||
self.send_data(self.lut_full_update[71])
|
||||
self.send_data(self.lut_full_update[72])
|
||||
self.send_data(self.lut_full_update[73])
|
||||
|
||||
self.send_command(0x3A) #Dummy Line
|
||||
self.send_data(self.lut_full_update[74])
|
||||
self.send_command(0x3B) #Gate time
|
||||
self.send_data(self.lut_full_update[75])
|
||||
|
||||
self.send_command(0x32)
|
||||
for count in range(70):
|
||||
self.send_data(self.lut_full_update[count])
|
||||
|
||||
self.send_command(0x4E) # set RAM x address count to 0
|
||||
self.send_data(0x00)
|
||||
self.send_command(0x4F) # set RAM y address count to 0X127
|
||||
self.send_data(0xF9)
|
||||
self.send_data(0x00)
|
||||
self.ReadBusy()
|
||||
else:
|
||||
self.send_command(0x2C) #VCOM Voltage
|
||||
self.send_data(0x26)
|
||||
|
||||
self.ReadBusy()
|
||||
|
||||
self.send_command(0x32)
|
||||
for count in range(70):
|
||||
self.send_data(self.lut_partial_update[count])
|
||||
|
||||
self.send_command(0x37)
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x40)
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x00)
|
||||
|
||||
self.send_command(0x22)
|
||||
self.send_data(0xC0)
|
||||
self.send_command(0x20)
|
||||
self.ReadBusy()
|
||||
|
||||
self.send_command(0x3C) #BorderWavefrom
|
||||
self.send_data(0x01)
|
||||
return 0
|
||||
|
||||
def getbuffer(self, image):
|
||||
if self.width%8 == 0:
|
||||
linewidth = int(self.width/8)
|
||||
else:
|
||||
linewidth = int(self.width/8) + 1
|
||||
|
||||
buf = [0xFF] * (linewidth * self.height)
|
||||
image_monocolor = image.convert('1')
|
||||
imwidth, imheight = image_monocolor.size
|
||||
pixels = image_monocolor.load()
|
||||
|
||||
if(imwidth == self.width and imheight == self.height):
|
||||
logger.debug("Vertical")
|
||||
for y in range(imheight):
|
||||
for x in range(imwidth):
|
||||
if pixels[x, y] == 0:
|
||||
x = imwidth - x
|
||||
buf[int(x / 8) + y * linewidth] &= ~(0x80 >> (x % 8))
|
||||
elif(imwidth == self.height and imheight == self.width):
|
||||
logger.debug("Horizontal")
|
||||
for y in range(imheight):
|
||||
for x in range(imwidth):
|
||||
newx = y
|
||||
newy = self.height - x - 1
|
||||
if pixels[x, y] == 0:
|
||||
newy = imwidth - newy - 1
|
||||
buf[int(newx / 8) + newy*linewidth] &= ~(0x80 >> (y % 8))
|
||||
return buf
|
||||
|
||||
|
||||
def display(self, image):
|
||||
self.send_command(0x24)
|
||||
self.send_data2(image)
|
||||
self.TurnOnDisplay()
|
||||
|
||||
def displayPartial(self, image):
|
||||
if self.width%8 == 0:
|
||||
linewidth = int(self.width/8)
|
||||
else:
|
||||
linewidth = int(self.width/8) + 1
|
||||
|
||||
buf = [0x00] * self.height * linewidth
|
||||
for j in range(0, self.height):
|
||||
for i in range(0, linewidth):
|
||||
buf[i + j * linewidth] = ~image[i + j * linewidth]
|
||||
|
||||
self.send_command(0x24)
|
||||
self.send_data2(image)
|
||||
|
||||
|
||||
self.send_command(0x26)
|
||||
self.send_data2(buf)
|
||||
self.TurnOnDisplayPart()
|
||||
|
||||
def displayPartBaseImage(self, image):
|
||||
self.send_command(0x24)
|
||||
self.send_data2(image)
|
||||
|
||||
self.send_command(0x26)
|
||||
self.send_data2(image)
|
||||
self.TurnOnDisplay()
|
||||
|
||||
def Clear(self, color=0xFF):
|
||||
if self.width%8 == 0:
|
||||
linewidth = int(self.width/8)
|
||||
else:
|
||||
linewidth = int(self.width/8) + 1
|
||||
# logger.debug(linewidth)
|
||||
|
||||
buf = [0x00] * self.height * linewidth
|
||||
for j in range(0, self.height):
|
||||
for i in range(0, linewidth):
|
||||
buf[i + j * linewidth] = color
|
||||
|
||||
self.send_command(0x24)
|
||||
self.send_data2(buf)
|
||||
|
||||
# self.send_command(0x26)
|
||||
# for j in range(0, self.height):
|
||||
# for i in range(0, linewidth):
|
||||
# self.send_data(color)
|
||||
|
||||
self.TurnOnDisplay()
|
||||
|
||||
def sleep(self):
|
||||
# self.send_command(0x22) #POWER OFF
|
||||
# self.send_data(0xC3)
|
||||
# self.send_command(0x20)
|
||||
|
||||
self.send_command(0x10) #enter deep sleep
|
||||
self.send_data(0x03)
|
||||
epdconfig.delay_ms(2000)
|
||||
epdconfig.module_exit()
|
||||
|
||||
### END OF FILE ###
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
# *****************************************************************************
|
||||
# * | File : epd2in13_V3.py
|
||||
# * | Author : Waveshare team
|
||||
# * | Function : Electronic paper driver
|
||||
# * | Info :
|
||||
# *----------------
|
||||
# * | This version: V1.2
|
||||
# * | Date : 2022-08-9
|
||||
# # | Info : python demo
|
||||
# -----------------------------------------------------------------------------
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documnetation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS OR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
#
|
||||
|
||||
# --- Herkunftsvermerk (ninlight-firmware) ---------------------------------
|
||||
# Unveraendert uebernommen aus waveshareteam/e-Paper (RaspberryPi_JetsonNano/
|
||||
# python/lib/waveshare_epd/epd2in13_V3.py), Commit 85802dee (2023-02-15).
|
||||
# Einzige Aenderung: 'from . import epdconfig' -> 'import epdconfig', damit das
|
||||
# Modul flach importierbar ist (kein Package). Alles andere - Init-Sequenz, LUTs,
|
||||
# Timings - unveraendert. Details: tools/station/display/HERKUNFT.md
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
import logging
|
||||
import epdconfig
|
||||
|
||||
# Display resolution
|
||||
EPD_WIDTH = 122
|
||||
EPD_HEIGHT = 250
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class EPD:
|
||||
def __init__(self):
|
||||
self.reset_pin = epdconfig.RST_PIN
|
||||
self.dc_pin = epdconfig.DC_PIN
|
||||
self.busy_pin = epdconfig.BUSY_PIN
|
||||
self.cs_pin = epdconfig.CS_PIN
|
||||
self.width = EPD_WIDTH
|
||||
self.height = EPD_HEIGHT
|
||||
|
||||
lut_partial_update= [
|
||||
0x0,0x40,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x80,0x80,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x40,0x40,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x80,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x14,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x1,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x1,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x22,0x22,0x22,0x22,0x22,0x22,0x0,0x0,0x0,
|
||||
0x22,0x17,0x41,0x00,0x32,0x36,
|
||||
]
|
||||
|
||||
lut_full_update = [
|
||||
0x80,0x4A,0x40,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x40,0x4A,0x80,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x80,0x4A,0x40,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x40,0x4A,0x80,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0xF,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0xF,0x0,0x0,0xF,0x0,0x0,0x2,
|
||||
0xF,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x1,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||
0x22,0x22,0x22,0x22,0x22,0x22,0x0,0x0,0x0,
|
||||
0x22,0x17,0x41,0x0,0x32,0x36,
|
||||
]
|
||||
|
||||
'''
|
||||
function :Hardware reset
|
||||
parameter:
|
||||
'''
|
||||
def reset(self):
|
||||
epdconfig.digital_write(self.reset_pin, 1)
|
||||
epdconfig.delay_ms(20)
|
||||
epdconfig.digital_write(self.reset_pin, 0)
|
||||
epdconfig.delay_ms(2)
|
||||
epdconfig.digital_write(self.reset_pin, 1)
|
||||
epdconfig.delay_ms(20)
|
||||
|
||||
'''
|
||||
function :send command
|
||||
parameter:
|
||||
command : Command register
|
||||
'''
|
||||
def send_command(self, command):
|
||||
epdconfig.digital_write(self.dc_pin, 0)
|
||||
epdconfig.digital_write(self.cs_pin, 0)
|
||||
epdconfig.spi_writebyte([command])
|
||||
epdconfig.digital_write(self.cs_pin, 1)
|
||||
|
||||
'''
|
||||
function :send data
|
||||
parameter:
|
||||
data : Write data
|
||||
'''
|
||||
def send_data(self, data):
|
||||
epdconfig.digital_write(self.dc_pin, 1)
|
||||
epdconfig.digital_write(self.cs_pin, 0)
|
||||
epdconfig.spi_writebyte([data])
|
||||
epdconfig.digital_write(self.cs_pin, 1)
|
||||
|
||||
# send a lot of data
|
||||
def send_data2(self, data):
|
||||
epdconfig.digital_write(self.dc_pin, 1)
|
||||
epdconfig.digital_write(self.cs_pin, 0)
|
||||
epdconfig.spi_writebyte2(data)
|
||||
epdconfig.digital_write(self.cs_pin, 1)
|
||||
|
||||
'''
|
||||
function :Wait until the busy_pin goes LOW
|
||||
parameter:
|
||||
'''
|
||||
def ReadBusy(self):
|
||||
logger.debug("e-Paper busy")
|
||||
while(epdconfig.digital_read(self.busy_pin) == 1): # 0: idle, 1: busy
|
||||
epdconfig.delay_ms(10)
|
||||
logger.debug("e-Paper busy release")
|
||||
|
||||
'''
|
||||
function : Turn On Display
|
||||
parameter:
|
||||
'''
|
||||
def TurnOnDisplay(self):
|
||||
self.send_command(0x22) # Display Update Control
|
||||
self.send_data(0xC7)
|
||||
self.send_command(0x20) # Activate Display Update Sequence
|
||||
self.ReadBusy()
|
||||
|
||||
'''
|
||||
function : Turn On Display Part
|
||||
parameter:
|
||||
'''
|
||||
def TurnOnDisplayPart(self):
|
||||
self.send_command(0x22) # Display Update Control
|
||||
self.send_data(0x0f) # fast:0x0c, quality:0x0f, 0xcf
|
||||
self.send_command(0x20) # Activate Display Update Sequence
|
||||
self.ReadBusy()
|
||||
|
||||
'''
|
||||
function : Set lut
|
||||
parameter:
|
||||
lut : lut data
|
||||
'''
|
||||
def Lut(self, lut):
|
||||
self.send_command(0x32)
|
||||
for i in range(0, 153):
|
||||
self.send_data(lut[i])
|
||||
self.ReadBusy()
|
||||
|
||||
'''
|
||||
function : Send lut data and configuration
|
||||
parameter:
|
||||
lut : lut data
|
||||
'''
|
||||
def SetLut(self, lut):
|
||||
self.Lut(lut)
|
||||
self.send_command(0x3f)
|
||||
self.send_data(lut[153])
|
||||
self.send_command(0x03) # gate voltage
|
||||
self.send_data(lut[154])
|
||||
self.send_command(0x04) # source voltage
|
||||
self.send_data(lut[155]) # VSH
|
||||
self.send_data(lut[156]) # VSH2
|
||||
self.send_data(lut[157]) # VSL
|
||||
self.send_command(0x2c) # VCOM
|
||||
self.send_data(lut[158])
|
||||
|
||||
'''
|
||||
function : Setting the display window
|
||||
parameter:
|
||||
xstart : X-axis starting position
|
||||
ystart : Y-axis starting position
|
||||
xend : End position of X-axis
|
||||
yend : End position of Y-axis
|
||||
'''
|
||||
def SetWindow(self, x_start, y_start, x_end, y_end):
|
||||
self.send_command(0x44) # SET_RAM_X_ADDRESS_START_END_POSITION
|
||||
# x point must be the multiple of 8 or the last 3 bits will be ignored
|
||||
self.send_data((x_start>>3) & 0xFF)
|
||||
self.send_data((x_end>>3) & 0xFF)
|
||||
|
||||
self.send_command(0x45) # SET_RAM_Y_ADDRESS_START_END_POSITION
|
||||
self.send_data(y_start & 0xFF)
|
||||
self.send_data((y_start >> 8) & 0xFF)
|
||||
self.send_data(y_end & 0xFF)
|
||||
self.send_data((y_end >> 8) & 0xFF)
|
||||
|
||||
'''
|
||||
function : Set Cursor
|
||||
parameter:
|
||||
x : X-axis starting position
|
||||
y : Y-axis starting position
|
||||
'''
|
||||
def SetCursor(self, x, y):
|
||||
self.send_command(0x4E) # SET_RAM_X_ADDRESS_COUNTER
|
||||
# x point must be the multiple of 8 or the last 3 bits will be ignored
|
||||
self.send_data(x & 0xFF)
|
||||
|
||||
self.send_command(0x4F) # SET_RAM_Y_ADDRESS_COUNTER
|
||||
self.send_data(y & 0xFF)
|
||||
self.send_data((y >> 8) & 0xFF)
|
||||
|
||||
'''
|
||||
function : Initialize the e-Paper register
|
||||
parameter:
|
||||
'''
|
||||
def init(self):
|
||||
if (epdconfig.module_init() != 0):
|
||||
return -1
|
||||
# EPD hardware init start
|
||||
self.reset()
|
||||
|
||||
self.ReadBusy()
|
||||
self.send_command(0x12) #SWRESET
|
||||
self.ReadBusy()
|
||||
|
||||
self.send_command(0x01) #Driver output control
|
||||
self.send_data(0xf9)
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x00)
|
||||
|
||||
self.send_command(0x11) #data entry mode
|
||||
self.send_data(0x03)
|
||||
|
||||
self.SetWindow(0, 0, self.width-1, self.height-1)
|
||||
self.SetCursor(0, 0)
|
||||
|
||||
self.send_command(0x3c)
|
||||
self.send_data(0x05)
|
||||
|
||||
self.send_command(0x21) # Display update control
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x80)
|
||||
|
||||
self.send_command(0x18)
|
||||
self.send_data(0x80)
|
||||
|
||||
self.ReadBusy()
|
||||
|
||||
self.SetLut(self.lut_full_update)
|
||||
return 0
|
||||
|
||||
'''
|
||||
function : Display images
|
||||
parameter:
|
||||
image : Image data
|
||||
'''
|
||||
def getbuffer(self, image):
|
||||
img = image
|
||||
imwidth, imheight = img.size
|
||||
if(imwidth == self.width and imheight == self.height):
|
||||
img = img.convert('1')
|
||||
elif(imwidth == self.height and imheight == self.width):
|
||||
# image has correct dimensions, but needs to be rotated
|
||||
img = img.rotate(90, expand=True).convert('1')
|
||||
else:
|
||||
logger.warning("Wrong image dimensions: must be " + str(self.width) + "x" + str(self.height))
|
||||
# return a blank buffer
|
||||
return [0x00] * (int(self.width/8) * self.height)
|
||||
|
||||
buf = bytearray(img.tobytes('raw'))
|
||||
return buf
|
||||
|
||||
'''
|
||||
function : Sends the image buffer in RAM to e-Paper and displays
|
||||
parameter:
|
||||
image : Image data
|
||||
'''
|
||||
def display(self, image):
|
||||
if self.width%8 == 0:
|
||||
linewidth = int(self.width/8)
|
||||
else:
|
||||
linewidth = int(self.width/8) + 1
|
||||
|
||||
self.send_command(0x24)
|
||||
for j in range(0, self.height):
|
||||
for i in range(0, linewidth):
|
||||
self.send_data(image[i + j * linewidth])
|
||||
self.TurnOnDisplay()
|
||||
|
||||
'''
|
||||
function : Sends the image buffer in RAM to e-Paper and partial refresh
|
||||
parameter:
|
||||
image : Image data
|
||||
'''
|
||||
def displayPartial(self, image):
|
||||
epdconfig.digital_write(self.reset_pin, 0)
|
||||
epdconfig.delay_ms(1)
|
||||
epdconfig.digital_write(self.reset_pin, 1)
|
||||
|
||||
self.SetLut(self.lut_partial_update)
|
||||
self.send_command(0x37)
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x40)
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x00)
|
||||
self.send_data(0x00)
|
||||
|
||||
self.send_command(0x3C) #BorderWavefrom
|
||||
self.send_data(0x80)
|
||||
|
||||
self.send_command(0x22)
|
||||
self.send_data(0xC0)
|
||||
self.send_command(0x20)
|
||||
self.ReadBusy()
|
||||
|
||||
self.SetWindow(0, 0, self.width - 1, self.height - 1)
|
||||
self.SetCursor(0, 0)
|
||||
|
||||
self.send_command(0x24) # WRITE_RAM
|
||||
# for j in range(0, self.height):
|
||||
# for i in range(0, linewidth):
|
||||
# self.send_data(image[i + j * linewidth])
|
||||
self.send_data2(image)
|
||||
self.TurnOnDisplayPart()
|
||||
|
||||
'''
|
||||
function : Refresh a base image
|
||||
parameter:
|
||||
image : Image data
|
||||
'''
|
||||
def displayPartBaseImage(self, image):
|
||||
self.send_command(0x24)
|
||||
self.send_data2(image)
|
||||
|
||||
self.send_command(0x26)
|
||||
self.send_data2(image)
|
||||
self.TurnOnDisplay()
|
||||
|
||||
'''
|
||||
function : Clear screen
|
||||
parameter:
|
||||
'''
|
||||
def Clear(self, color=0xFF):
|
||||
if self.width%8 == 0:
|
||||
linewidth = int(self.width/8)
|
||||
else:
|
||||
linewidth = int(self.width/8) + 1
|
||||
# logger.debug(linewidth)
|
||||
|
||||
self.send_command(0x24)
|
||||
self.send_data2([color] * int(self.height * linewidth))
|
||||
self.TurnOnDisplay()
|
||||
|
||||
'''
|
||||
function : Enter sleep mode
|
||||
parameter:
|
||||
'''
|
||||
def sleep(self):
|
||||
self.send_command(0x10) #enter deep sleep
|
||||
self.send_data(0x01)
|
||||
|
||||
epdconfig.delay_ms(2000)
|
||||
epdconfig.module_exit()
|
||||
|
||||
### END OF FILE ###
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""GPIO/SPI-Unterbau fuer die vendored Waveshare-Panel-Treiber (epd2in13_V2.py,
|
||||
epd2in13_V3.py), geschrieben gegen gpiozero + spidev statt gegen Waveshares eigenes
|
||||
epdconfig.py, das RPi.GPIO voraussetzt - ein Modul, das auf Debian 13 (trixie) mit
|
||||
Python 3.13 nicht mehr baut/importiert. gpiozero, lgpio (gpiozero's Pin-Factory auf
|
||||
trixie) und spidev sind auf dem Ziel-Pi vorhanden.
|
||||
|
||||
Stellt genau die Schnittstelle bereit, die die beiden Treiber erwarten: die Pin-Konstanten
|
||||
RST_PIN/DC_PIN/CS_PIN/BUSY_PIN sowie digital_write, digital_read, delay_ms, spi_writebyte,
|
||||
spi_writebyte2, module_init und module_exit als Modulfunktionen (kein Klassen-Wrapper -
|
||||
die Treiber greifen direkt auf `epdconfig.<name>` zu).
|
||||
|
||||
CS_PIN wird NICHT als GPIO geschaltet: spidev.SpiDev(0, 0) steuert die Hardware-CS0-Leitung
|
||||
selbst bei jeder Übertragung, ein zusaetzliches Software-Toggle wuerde nur mit der Hardware
|
||||
um die Leitung konkurrieren. digital_write()/digital_read() sind fuer CS_PIN deshalb
|
||||
No-ops - genau das Muster, das Waveshares eigene, neuere epdconfig.py-Fassung fuer
|
||||
Raspberry Pi inzwischen ebenfalls verwendet (dort ist die GPIO-Instanziierung fuer CS_PIN
|
||||
auskommentiert).
|
||||
|
||||
Ein fehlender Import (gpiozero/spidev nicht installiert) oder ein fehlendes
|
||||
/dev/spidev0.0 wirft DisplayUnavailable - display.py faengt das ab, protokolliert eine
|
||||
Zeile und beendet sich sauber (sys.exit(0)), statt dass ein fehlendes Panel eine Migration
|
||||
verhindern koennte (siehe Modul-Docstring von display.py)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
try:
|
||||
import gpiozero
|
||||
except ImportError: # pragma: no cover - auf dem Pi immer vorhanden, hier nur defensiv
|
||||
gpiozero = None
|
||||
|
||||
try:
|
||||
import spidev
|
||||
except ImportError: # pragma: no cover - dito
|
||||
spidev = None
|
||||
|
||||
# lgpio ist die Pin-Factory, die gpiozero auf beiden Pis benutzt (Debian 13 trixie wie
|
||||
# Bookworm). Sie meldet einen belegten Pin nicht als gpiozero-Ausnahme, sondern als
|
||||
# lgpio.error - siehe _hardware_error_types(). Auf der Entwicklungsmaschine fehlt das
|
||||
# Modul; dann bleibt es None und die Fehlerliste kommt ohne es aus.
|
||||
try:
|
||||
import lgpio
|
||||
except ImportError: # pragma: no cover - auf dem Pi immer vorhanden
|
||||
lgpio = None
|
||||
|
||||
|
||||
class DisplayUnavailable(Exception):
|
||||
"""Kein Panel ansprechbar: Bibliothek fehlt, /dev/spidev0.0 fehlt, oder der
|
||||
Hardwarezugriff selbst ist fehlgeschlagen (z.B. Berechtigung, Pin belegt)."""
|
||||
|
||||
|
||||
# Dieselben Pin-Nummern wie in Waveshares eigenem epdconfig.py fuer den Raspberry Pi.
|
||||
RST_PIN = 17
|
||||
DC_PIN = 25
|
||||
CS_PIN = 8
|
||||
BUSY_PIN = 24
|
||||
|
||||
_SPI_BUS = 0
|
||||
_SPI_DEVICE = 0
|
||||
_SPI_HZ = 4_000_000
|
||||
|
||||
_rst_pin = None
|
||||
_dc_pin = None
|
||||
_busy_pin = None
|
||||
_spi = None
|
||||
|
||||
|
||||
def digital_write(pin: int, value: int) -> None:
|
||||
if pin == RST_PIN:
|
||||
_rst_pin.value = bool(value)
|
||||
elif pin == DC_PIN:
|
||||
_dc_pin.value = bool(value)
|
||||
elif pin == CS_PIN:
|
||||
pass # Hardware-CS ueber spidev, siehe Modul-Docstring.
|
||||
# Andere Pins fragen die Treiber nicht ab - kein else-Zweig noetig.
|
||||
|
||||
|
||||
def digital_read(pin: int) -> int:
|
||||
if pin == BUSY_PIN:
|
||||
return int(bool(_busy_pin.value))
|
||||
if pin == RST_PIN:
|
||||
return int(bool(_rst_pin.value))
|
||||
if pin == DC_PIN:
|
||||
return int(bool(_dc_pin.value))
|
||||
return 0
|
||||
|
||||
|
||||
def delay_ms(ms: int) -> None:
|
||||
time.sleep(ms / 1000.0)
|
||||
|
||||
|
||||
def spi_writebyte(data) -> None:
|
||||
_spi.writebytes(data)
|
||||
|
||||
|
||||
def spi_writebyte2(data) -> None:
|
||||
_spi.writebytes2(data)
|
||||
|
||||
|
||||
def _busy_hint(error: BaseException) -> str:
|
||||
"""Zusatz fuer die Fehlermeldung, wenn die Leitungen belegt sind. "GPIO busy" heisst
|
||||
auf dieser Station praktisch immer "der Anzeigedienst haelt die Pins" - und trifft
|
||||
ausgerechnet den Befehl, den der Hilfetext als ersten Handgriff der Abnahme nennt.
|
||||
Die Meldung soll den Bediener also nicht zum Suchen schicken, sondern sagen, was los
|
||||
ist. Leer, wenn es nicht danach aussieht - ein erfundener Hinweis waere schlimmer als
|
||||
keiner."""
|
||||
if "busy" in str(error).lower():
|
||||
return (" - haelt der Anzeigedienst die Leitungen? "
|
||||
"Pruefen mit 'systemctl status ninlight-display'")
|
||||
return ""
|
||||
|
||||
|
||||
def _hardware_error_types() -> tuple:
|
||||
"""Die Ausnahmen, die als "keine/blockierte Hardware" gelten und damit zu
|
||||
DisplayUnavailable werden. Bewusst eine ENUMERATION, kein `except Exception`: ein
|
||||
echter Programmierfehler an anderer Stelle (TypeError, AttributeError, NameError)
|
||||
soll weiterhin als das erkennbar bleiben, was er ist, statt hier als "Panel fehlt"
|
||||
getarnt zu werden (Fixrunde 1, C2, zweiter Teil).
|
||||
|
||||
gpiozero.GPIOZeroError (nicht nur GPIODeviceError, Fixrunde 2, N4): die PinError-
|
||||
Familie (PinInvalidPin, PinUnsupported, ... - z.B. bei falscher/fehlender
|
||||
Pin-Factory-Zuordnung) haengt direkt unter GPIOZeroError, nicht unter
|
||||
GPIODeviceError - per `gpiozero.PinInvalidPin.__mro__` nachgeprueft. BadPinFactory
|
||||
erbt zusaetzlich von ImportError und war dadurch schon abgedeckt.
|
||||
|
||||
lgpio.error (Hardware-Abnahme): laeuft der Anzeigedienst, haelt er die GPIO-Leitungen,
|
||||
und ein gleichzeitiges "ninlight-station display test" bekommt beim Anlegen des ersten
|
||||
Pins ein `lgpio.error: 'GPIO busy'`. Das ist KEINE gpiozero.GPIOZeroError - gpiozero
|
||||
reicht die Ausnahme seiner Pin-Factory unveraendert durch - und fiel deshalb durch
|
||||
diese Liste hindurch: statt der vorgesehenen einen Protokollzeile gab es einen rohen
|
||||
Stapelabzug und rc=1. lgpio.error erbt direkt von Exception, die drei
|
||||
Programmierfehler oben bleiben davon also unberuehrt.
|
||||
|
||||
Wird bei JEDEM Aufruf neu gebaut statt einmal beim Import: nur so wirkt ein
|
||||
monkeypatch.setattr(epdconfig, "lgpio", ...) im Test ueberhaupt (dasselbe Muster wie
|
||||
bei gpiozero/spidev in tools/station/tests/test_render.py)."""
|
||||
types: tuple = (OSError, ImportError)
|
||||
if gpiozero is not None:
|
||||
types = types + (gpiozero.GPIOZeroError,)
|
||||
lgpio_error = getattr(lgpio, "error", None) if lgpio is not None else None
|
||||
if isinstance(lgpio_error, type) and issubclass(lgpio_error, BaseException):
|
||||
types = types + (lgpio_error,)
|
||||
return types
|
||||
|
||||
|
||||
def module_init() -> int:
|
||||
"""Oeffnet GPIO-Pins und SPI-Geraet. Wirft DisplayUnavailable statt einer rohen
|
||||
Bibliotheksausnahme, damit display.py genau eine Ausnahmeklasse abfangen muss.
|
||||
|
||||
Idempotent: ein zweiter Aufruf, waehrend die Pins/das SPI-Geraet noch offen sind (die
|
||||
Panel-Treiber rufen dies bei JEDEM epd.init() erneut auf - Vollaufbau bei Moduswechsel
|
||||
oder alle 30 Minuten loest also ohne diese Absicherung einen zweiten, unnoetigen
|
||||
Oeffnungsversuch aus), kehrt sofort zurueck statt gpiozero.DigitalOutputDevice(17)
|
||||
ein zweites Mal anzulegen. Das wuerde mit GPIOPinInUse scheitern - vom breiten
|
||||
except unten (Fixrunde 1, C2) frueher faelschlich als "Panel fehlt" verschluckt,
|
||||
obwohl es ein Programmierfehler in der Aufrufreihenfolge ist. Nach module_exit()
|
||||
(z.B. durch epd.sleep()) ist _spi wieder None, ein echter Neuaufbau laeuft dann
|
||||
normal."""
|
||||
global _rst_pin, _dc_pin, _busy_pin, _spi
|
||||
|
||||
if _spi is not None:
|
||||
return 0
|
||||
|
||||
if gpiozero is None or spidev is None:
|
||||
raise DisplayUnavailable("gpiozero/spidev nicht installiert")
|
||||
|
||||
spi_path = f"/dev/spidev{_SPI_BUS}.{_SPI_DEVICE}"
|
||||
if not os.path.exists(spi_path):
|
||||
raise DisplayUnavailable(f"{spi_path} fehlt - ist SPI aktiviert (raspi-config)?")
|
||||
|
||||
hardware_errors = _hardware_error_types()
|
||||
|
||||
try:
|
||||
_rst_pin = gpiozero.DigitalOutputDevice(RST_PIN)
|
||||
_dc_pin = gpiozero.DigitalOutputDevice(DC_PIN)
|
||||
_busy_pin = gpiozero.DigitalInputDevice(BUSY_PIN, pull_up=False)
|
||||
|
||||
_spi = spidev.SpiDev()
|
||||
_spi.open(_SPI_BUS, _SPI_DEVICE)
|
||||
_spi.max_speed_hz = _SPI_HZ
|
||||
_spi.mode = 0b00
|
||||
except hardware_errors as e:
|
||||
module_exit()
|
||||
raise DisplayUnavailable(
|
||||
f"GPIO/SPI-Initialisierung fehlgeschlagen: {e}{_busy_hint(e)}") from e
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def module_exit() -> None:
|
||||
"""Gibt GPIO-Pins und SPI-Geraet frei. Verzeiht, wenn module_init() nie (vollstaendig)
|
||||
gelaufen ist - display.py ruft das u.a. im Fehlerpfad von module_init() selbst auf."""
|
||||
global _rst_pin, _dc_pin, _busy_pin, _spi
|
||||
|
||||
if _spi is not None:
|
||||
try:
|
||||
_spi.close()
|
||||
except Exception: # noqa: BLE001 - Aufraeumen darf nicht seinerseits scheitern
|
||||
pass
|
||||
_spi = None
|
||||
|
||||
for attr_name in ("_rst_pin", "_dc_pin", "_busy_pin"):
|
||||
device = globals()[attr_name]
|
||||
if device is not None:
|
||||
try:
|
||||
device.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
globals()[attr_name] = None
|
||||
@@ -0,0 +1,717 @@
|
||||
"""Textzerlegung und Bildaufbau fuer die E-Paper-Anzeige der ninlight-Station.
|
||||
|
||||
blocks()/lines() sind bewusst ohne Pillow und ohne Hardwarezugriff importierbar und testbar (siehe
|
||||
tools/station/tests/test_render.py) - compose() importiert Pillow erst innerhalb der
|
||||
Funktion, damit ein fehlendes PIL (das erst per "apt install python3-pil" auf den Pi
|
||||
kommt, siehe setup_pi.sh) render.py selbst nicht unbenutzbar macht.
|
||||
|
||||
Die Schrift kommt aus FONT_SEARCH_PATHS (DejaVu auf beiden Pis) statt aus
|
||||
ImageFont.load_default(): default ist rund 10 px hoch und auf 250x122 aus zwei Metern
|
||||
nicht lesbar (Befund der Hardware-Abnahme) - siehe _load_font(), warum der
|
||||
naheliegende Weg load_default(size=...) auf Pi 2 (Pillow 9.4.0) ausscheidet.
|
||||
|
||||
Das Panel ist PANEL_WIDTH_PX x PANEL_HEIGHT_PX (250x122), quer verwendet. blocks() liefert
|
||||
UNGEKUERZTE Zeilen - die Breitenkuerzung (mit "..." statt Umbruch, Spec: eine zu lange
|
||||
Zeile darf das Layout nicht sprengen) passiert erst in compose(), per _truncate_to_width()
|
||||
gegen die tatsaechliche, schriftabhaengige Pixelbreite (PIL.ImageFont.load_default() ist
|
||||
seit Pillow 10.1 eine proportionale TrueType-Schrift, keine feste Bitmapschrift - eine
|
||||
Kuerzung nach Zeichenzahl (fruehere Fassung: MAX_COLS) ist dafuer keine gueltige
|
||||
Breitengrenze: "W"*40 misst bei dieser Schrift fast doppelt so breit wie "i"*40, und
|
||||
ausgerechnet grossbuchstabenlastige Werte - Produktnamen aus der Lampenerkennung wie
|
||||
"KLYQA GU10 RGB CW/WW" - waeren trotz weniger Zeichen als die alte Grenze ueber die
|
||||
Panelbreite hinausgelaufen (Fixrunde 2, MAX_COLS-Kleinigkeit war in Fixrunde 1 nicht
|
||||
wirklich behoben). _truncate_to_width() selbst importiert kein Pillow (sie nimmt eine
|
||||
Messfunktion entgegen, die der Aufrufer liefert) und bleibt damit wie lines() ohne Pillow
|
||||
testbar; nur compose() verdrahtet sie mit einer echten PIL-Textmessung.
|
||||
|
||||
Der Zustand, den lines()/compose() bekommen, ist bereits zusammengefuehrt: status.json
|
||||
(vom Server geschrieben) traegt kein "ssid"-Feld - die SSID ist Konfiguration aus
|
||||
/etc/ninlight-station.conf. display.py liest beides und reicht ein gemeinsames dict
|
||||
herein. Jedes Feld wird ueber .get() mit Vorgabewert gelesen: status.json,
|
||||
control.json und die Konfigurationsdatei koennen jederzeit fehlen, halb geschrieben oder
|
||||
aus einer aelteren Fassung sein (frisch aufgesetzter Pi, Schreibfehler mitten im
|
||||
Schreiben, Neustart) - siehe test_missing_fields_do_not_raise."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
PANEL_WIDTH_PX = 250
|
||||
PANEL_HEIGHT_PX = 122
|
||||
TEXT_MARGIN_PX = 2
|
||||
|
||||
# --- Rollen und ihre Groessen ------------------------------------------------------
|
||||
#
|
||||
# Bis zur Hardware-Abnahme zeichnete compose() alle Zeilen in derselben
|
||||
# ImageFont.load_default() (rund 10 px hoch) - auf 250x122 war das Kleingedrucktes und
|
||||
# aus zwei Metern nicht mehr lesbar (Befund Michaels am Geraet). blocks() vergibt
|
||||
# deshalb jeder Zeile eine ROLLE, und compose() setzt jede Rolle anders:
|
||||
#
|
||||
# header - Modus, fett, WEISS AUF SCHWARZEM BALKEN ueber die volle
|
||||
# Panelbreite. Invertiert kostet auf e-Paper nichts (bistabil, kein
|
||||
# Stromverbrauch pro Pixel) und trennt die Kopfzeile auf einen Blick
|
||||
# vom Rest.
|
||||
# header_phase - die Phase (AUTO/LISTEN/FREIGEGEBEN), NUR im Migrationsmodus und
|
||||
# nur, wenn eine Phase bekannt ist. Steht in derselben Kopfzeile wie
|
||||
# header, rechtsbuendig, in kleinerer Schrift (zweite Hardware-Abnahme:
|
||||
# die Phase ging als eigene kleine Zeile unter dem Lampennamen unter).
|
||||
# Rettung/aus kennen keine Phase - dort bleibt die Kopfzeile
|
||||
# unveraendert (nur header, mittig, wie zuvor). NUR das kurze Label -
|
||||
# der Zielname der Freigabe steht als eigene Beiwerkzeile, siehe
|
||||
# ARMED_DETAIL_PREFIX.
|
||||
# hero - die eine wichtigste Information des Zustands (Migration: der
|
||||
# erkannte Produktname; Rettung: die SSID), fett.
|
||||
# detail - Beiwerk (Ziel/Variante, Lampenversion, Zaehler, letzte Auslieferung).
|
||||
# big - Zustaende fast ohne Inhalt ("Station aus", "Status unbekannt"):
|
||||
# gross und mittig, damit man quer durch den Raum sieht, dass nichts
|
||||
# laeuft.
|
||||
# footer - eine Zeile am UNTEREN Panelrand, ueber einen Trennstrich abgesetzt,
|
||||
# unabhaengig vom Zeilenfluss darueber. Genau ein Block benutzt sie: der
|
||||
# Bildsatz-Umschalter (mit oder ohne einkompilierte WLAN-Zugangsdaten).
|
||||
# Er gehoert dorthin, weil er ein DAUERZUSTAND ist und nicht zum
|
||||
# wechselnden Inhalt: er gilt in jedem Modus, auch bei ausgeschalteter
|
||||
# Station, und man soll ihn vor dem Einschalten sehen - nicht erst,
|
||||
# wenn zwanzig Lampen mit den eigenen Zugangsdaten geflasht sind.
|
||||
# Waere er eine gewoehnliche Beiwerkzeile, fiele er im vollsten Zustand
|
||||
# als letzte unten heraus, also ausgerechnet waehrend einer laufenden
|
||||
# Migration.
|
||||
ROLE_HEADER = "header"
|
||||
ROLE_HEADER_PHASE = "header_phase"
|
||||
ROLE_HERO = "hero"
|
||||
ROLE_DETAIL = "detail"
|
||||
ROLE_BIG = "big"
|
||||
ROLE_FOOTER = "footer"
|
||||
|
||||
HEADER_FONT_PX = 20
|
||||
HEADER_PHASE_FONT_PX = 13
|
||||
HERO_FONT_PX = 16
|
||||
DETAIL_FONT_PX = 11
|
||||
BIG_FONT_PX = 26
|
||||
FOOTER_FONT_PX = 11
|
||||
|
||||
# Zeilenhoehen so gewaehlt, dass der vollste Zustand (Migration mit erkannter Lampe:
|
||||
# Kopfbalken + grosse Zeile + fuenf Beiwerkzeilen) auf die 122 px passt, mit sichtbarem
|
||||
# Abstand nach unten uebrig (naechster Kommentar, BOTTOM_MARGIN_PX):
|
||||
# 22 + 2 + 20 + 5*13 = 109. Die Phase (frueher eine sechste Beiwerkzeile: "AUTO"/
|
||||
# "FREIGEGEBEN: ...") steht seit der zweiten Hardware-Abnahme in der Kopfzeile
|
||||
# (ROLE_HEADER_PHASE) statt als eigene Zeile - das ist der Grund, warum nur noch fuenf
|
||||
# statt sechs Beiwerkzeilen uebrig bleiben, nicht eine nachtraeglich erfundene Kuerzung.
|
||||
HEADER_BAR_HEIGHT_PX = 22
|
||||
HERO_LINE_HEIGHT_PX = 20
|
||||
DETAIL_LINE_HEIGHT_PX = 13
|
||||
|
||||
# Hardware-Abnahme, zweiter Befund: auf dem echten Panel frisst ein Rahmen den unteren
|
||||
# Rand, der volleste Zustand (s.o., endet bei 109 px) wirkte dort gedraengt. compose()
|
||||
# bricht Beiwerkzeilen deshalb schon ab, bevor sie in die letzten BOTTOM_MARGIN_PX
|
||||
# Zeilen hineinragen wuerden (siehe die Abbruchbedingung dort) - mit den obigen 109 px
|
||||
# bleibt dabei auch im vollsten Zustand jede Zeile sichtbar, es wird nichts abgeschnitten.
|
||||
BOTTOM_MARGIN_PX = 10
|
||||
|
||||
# Das Band am unteren Rand fuer ROLE_FOOTER (Trennstrich + eine 11-px-Zeile). Es liegt
|
||||
# UEBER BOTTOM_MARGIN_PX, der Rand bleibt also weiterhin tintenfrei; der Zeilenfluss
|
||||
# darueber bricht entsprechend frueher ab. Das kostet im vollsten Zustand (Migration mit
|
||||
# erkannter Lampe) eine Beiwerkzeile - die unterste, also die unwichtigste nach der
|
||||
# Sortierung in blocks(). Der Preis ist gewollt: welcher Bildsatz gilt, ist die einzige
|
||||
# Auskunft auf diesem Panel, die man BRAUCHT, bevor etwas passiert.
|
||||
FOOTER_BAND_HEIGHT_PX = 14
|
||||
# Abstand des Trennstrichs zur Fusszeile.
|
||||
FOOTER_RULE_GAP_PX = 2
|
||||
|
||||
# Mindestabstand zwischen dem Ende des Modus-Texts und dem Anfang der Phase in der
|
||||
# Kopfzeile (beide weiss auf demselben schwarzen Balken) - ohne diesen Puffer koennten
|
||||
# beide Texte bei einer langen Phase (z.B. "FREIGEGEBEN: <langer Zielname>")
|
||||
# ineinanderlaufen.
|
||||
HEADER_PHASE_GAP_PX = 6
|
||||
# Bleibt nach Modus-Text und Puffer weniger als das fuer die Phase uebrig, wird sie ganz
|
||||
# weggelassen statt ein einzelnes "…" hinzuzeichnen - das waere ohnehin keine Auskunft
|
||||
# mehr wert (dritte Option aus der Abnahme: kuerzen, verkleinern oder weglassen; die
|
||||
# ersten beiden greifen fuer die im Betrieb vorkommenden Werte immer).
|
||||
#
|
||||
# Dritte Hardware-Abnahme, Befund M2: der Reviewer hat zu Recht angemerkt, dass dieser
|
||||
# Zweig mit den heutigen Werten nicht feuern KANN - der Modus-Text im Migrationsmodus ist
|
||||
# die Konstante "MIGRATION" (MODE_LABELS), available_px ist damit fest (112 px DejaVu,
|
||||
# 127 px Arial) und nie < 24. Der Zweig bleibt trotzdem, aber er ist jetzt getestet
|
||||
# (test_header_phase_is_dropped_when_too_little_room_is_left): was ihn aufweckt, ist
|
||||
# nicht ein langer Zielname (den gibt es seit Befund I2 im Kopf gar nicht mehr), sondern
|
||||
# ein schmalerer Rest neben dem Modus - eine groessere HEADER_FONT_PX, eine deutlich
|
||||
# breitere Schrift, ein kuenftiger dritter Modus mit laengerem Namen oder ein groesserer
|
||||
# HEADER_PHASE_GAP_PX. Ohne den Zweig waere die Folge kein fehlendes Wort, sondern ein
|
||||
# "…", das sich mit dem Modus ueberlagert; das ist der Grund, warum hier ein
|
||||
# totgeglaubtes Sicherheitsnetz billiger ist als sein Fehlen.
|
||||
HEADER_PHASE_MIN_WIDTH_PX = 24
|
||||
|
||||
# Suchkette fuer eine echte, skalierbare Schrift, in dieser Reihenfolge probiert
|
||||
# (Regular, Bold). ImageFont.load_default(size=...) waere der naheliegende Weg, ist aber
|
||||
# erst ab Pillow 10.1 vorhanden - Pi 2 (Raspberry Pi OS Bookworm) hat Pillow 9.4.0, und
|
||||
# dort wirft load_default(size=...) TypeError ("unexpected keyword argument 'size'"), am
|
||||
# Geraet gegengeprueft. Deshalb fuehrt der Hauptweg ueber ImageFont.truetype() auf eine
|
||||
# Datei im Dateisystem: /usr/share/fonts/truetype/dejavu/ liegt auf BEIDEN Pis
|
||||
# (Debian 13 trixie wie Bookworm) und ist damit der einzige Pfad, auf den es im Betrieb
|
||||
# ankommt. Alles darunter existiert nur, damit compose() auch auf Rechnern ohne den
|
||||
# Debian-Pfad eine skalierbare Schrift findet - insbesondere auf der macOS-
|
||||
# Entwicklungsmaschine, wo tools/station/tests/test_render.py laufen muss.
|
||||
#
|
||||
# Letzter Rueckfall (in _load_font(), nicht in dieser Liste) ist load_default() OHNE
|
||||
# Argument: dann ist die Schrift wieder klein, aber nichts stuerzt ab - die Zusage
|
||||
# "eine fehlende Schrift darf die Anzeige (und damit nie eine Migration) aufhalten"
|
||||
# gilt unveraendert.
|
||||
FONT_SEARCH_PATHS = (
|
||||
# Debian/Raspberry Pi OS - der Betriebsfall auf beiden Pis.
|
||||
("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
|
||||
# Andere Linux-Distributionen legen dieselbe Schrift woanders ab.
|
||||
("/usr/share/fonts/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf"),
|
||||
("/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf"),
|
||||
# DejaVu per Homebrew (macOS), falls vorhanden.
|
||||
("/opt/homebrew/share/fonts/DejaVuSans.ttf",
|
||||
"/opt/homebrew/share/fonts/DejaVuSans-Bold.ttf"),
|
||||
# macOS-Bordmittel - nur fuer die Entwicklungsmaschine, nie fuer den Pi.
|
||||
("/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Arial Bold.ttf"),
|
||||
("/Library/Fonts/Arial.ttf", "/Library/Fonts/Arial Bold.ttf"),
|
||||
)
|
||||
|
||||
MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"}
|
||||
# "armed" wird gesondert behandelt (es zieht zusaetzlich eine Beiwerkzeile mit dem
|
||||
# Zielnamen nach sich), deshalb hier nicht gelistet.
|
||||
PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"}
|
||||
|
||||
# Dritte Hardware-Abnahme, Befund I2: in der Kopfzeile stand frueher
|
||||
# "FREIGEGEBEN: <zielname>" - der Platz neben dem Modus reicht dafuer nicht (mit DejaVu
|
||||
# auf dem Pi bleiben 112 px, der Text misst 182 px), gezeichnet wurde also "FREIGEGEBE…":
|
||||
# ein Wortfragment, das bei JEDEM Ziel gleich aussieht. Ausgerechnet in "armed" muss der
|
||||
# Bediener aber sehen, WELCHES Ziel freigegeben ist, sonst gibt er im Zweifel die falsche
|
||||
# Lampe frei. Deshalb im Kopf nur noch das kurze Label (das auch bei einer deutlich
|
||||
# breiteren Schrift hoechstens am Ende beschnitten wird, ohne dass dabei eine Auskunft
|
||||
# verloren geht - der Zustand "freigegeben" ist der einzige mit diesem Label) und der
|
||||
# Zielname als eigene Beiwerkzeile ueber die volle Panelbreite.
|
||||
ARMED_LABEL = "FREIGEGEBEN"
|
||||
ARMED_DETAIL_PREFIX = "Freigabe: "
|
||||
|
||||
# Der Bildsatz-Umschalter in der Fusszeile. Bewusst als Aussage ueber die LAMPE formuliert
|
||||
# und nicht ueber das Verzeichnis: "migrate-wlan" sagt dem Bediener am Geraet nichts,
|
||||
# "Lampe: sofort ins WLAN" sagt ihm genau das, was er wissen muss, bevor er einschaltet.
|
||||
# Der gefaehrliche Zustand steht in Grossbuchstaben - er ist der, den man uebersehen soll
|
||||
# und darf.
|
||||
# Die Zeile muss UNGEKUERZT passen, und zwar auch mit einer breiteren Schrift als der
|
||||
# hier gerade installierten: am Geraet (DejaVu) kam die alte, laengere Fassung als
|
||||
# "Lampe -> Onboarding-AP (ohne WLA..." an - der Zusatz in der Klammer, also gerade die
|
||||
# Aussage, fiel weg. Auf dem Mac des Entwicklers (Arial, schmaler) passte dieselbe Zeile
|
||||
# mit 246,0 von 246 px haarscharf; deshalb hat es niemand bemerkt. Breitenbudget siehe
|
||||
# test_imageset_footer_fits_the_panel_with_room_for_a_wider_font.
|
||||
IMAGESET_LABELS = {
|
||||
"ohne-wlan": "ohne WLAN-Daten (Onboarding-AP)",
|
||||
"mit-wlan": "MIT WLAN-Daten (eigenes Netz)",
|
||||
}
|
||||
# Fehlt das Verzeichnis des gewaehlten Satzes, ERSETZT die Warnung die Zeile, statt sie
|
||||
# zu verlaengern. Als angehaengtes Suffix (" - SATZ FEHLT", bis 2026-09-15) war sie in
|
||||
# BEIDEN Richtungen und in beiden Schriften ausserhalb der Panelbreite - die einzige
|
||||
# Zeile, die sagt, dass die Station gar nichts ausliefern kann, war damit nie zu sehen.
|
||||
IMAGESET_MISSING_LABELS = {
|
||||
"ohne-wlan": "SATZ FEHLT: ohne WLAN-Daten",
|
||||
"mit-wlan": "SATZ FEHLT: MIT WLAN-Daten",
|
||||
}
|
||||
|
||||
# Der Abschlussbildschirm: was auf dem Panel stehen bleibt, wenn der Pi heruntergefaehrt
|
||||
# oder neu gestartet wird. E-Paper ist bistabil - das zuletzt gezeichnete Bild bleibt fuer
|
||||
# immer stehen, auch stromlos. Ohne diesen Schritt behauptet das Panel nach dem
|
||||
# Ausschalten weiter den Zustand von vorher (bei der Abnahme stand dort die RUECKFRAGE,
|
||||
# und der Pi sah aus, als warte er noch auf eine Eingabe).
|
||||
#
|
||||
# Die beiden Texte unterscheiden sich, und das ist der Punkt: beim Ausschalten DARF man
|
||||
# den Stecker ziehen, beim Neustart NICHT - ein Zero W braucht gut anderthalb Minuten, bis
|
||||
# der Anzeigedienst wieder zeichnet, und an genau diesem Pi hat ein Stromzug in dieser
|
||||
# Wartezeit schon eine SD-Karte gekostet.
|
||||
FINAL_LABELS = {
|
||||
# "Strom kann getrennt werden" war in der Hero-Schrift 262,1 px breit und kam am
|
||||
# Geraet als "Strom kann getrennt wer..." an - siehe die Fusszeile weiter oben, es
|
||||
# ist derselbe Fehler. Alle festen Beschriftungen haengen jetzt an
|
||||
# test_fixed_labels_fit_the_panel_with_room_for_a_wider_font.
|
||||
"poweroff": ("AUSGESCHALTET", "Strom trennen erlaubt", []),
|
||||
"reboot": ("NEUSTART", "Strom NICHT trennen!",
|
||||
["Der Pi kommt in rund 2 min wieder.",
|
||||
"Solange bleibt dieses Bild stehen."]),
|
||||
}
|
||||
|
||||
# Woraus die Station die Signiervariante abgeleitet hat (products.classify_variant):
|
||||
# "-dev" kann in der Produkt-ID stehen, in der Version, oder in beiden. Die Quelle steht
|
||||
# mit auf dem Panel, weil genau hier am 2026-09-15 ein falsch signiertes Image
|
||||
# ausgeliefert wurde: wer vor der Freigabe sieht, dass "dev" nur aus der Version kommt
|
||||
# (oder dass "prod" bedeutet: nirgends ein "-dev"), kann eine Fehlklassifikation abfangen,
|
||||
# bevor die Lampe das Image ablehnt. Kurzformen, weil die Beiwerkzeile schmal ist.
|
||||
VARIANT_SOURCE_LABELS = {"both": "ID+Ver", "product_id": "ID", "version": "Ver"}
|
||||
|
||||
|
||||
def _variant_label(last_seen: dict) -> str:
|
||||
""""dev/Ver", "dev/ID", "dev/ID+Ver", "prod" - die Quelle nur, wo sie etwas aussagt.
|
||||
Bei "prod" gibt es keine ("none"), und eine aeltere Serverfassung ohne das Feld
|
||||
liefert wie bisher nur die Variante."""
|
||||
variant = str(last_seen.get("variant") or "?")
|
||||
label = VARIANT_SOURCE_LABELS.get(last_seen.get("variant_source"))
|
||||
if not label:
|
||||
return variant
|
||||
return variant + "/" + label
|
||||
|
||||
|
||||
def _hhmm(ts: object) -> str:
|
||||
""""2026-09-13T13:04:02Z" -> "13:04". Alles andere (fehlt, falsch geformt, aus einer
|
||||
aelteren Serverfassung ohne Zeitstempel) -> "--:--"."""
|
||||
if isinstance(ts, str) and len(ts) >= 16 and ts[10:11] == "T":
|
||||
return ts[11:16]
|
||||
return "--:--"
|
||||
|
||||
|
||||
def blocks(state: dict) -> list[tuple[str, str]]:
|
||||
"""Zerlegt den zusammengefuehrten Zustand in UNGEKUERZTE Textzeilen mit ihrer ROLLE
|
||||
(siehe Rollen-Kommentar oben, und den Modul-Docstring, warum die Kuerzung hier bewusst
|
||||
nicht passiert). Wirft nie - fehlende/falsch geformte Felder werden ausgelassen statt
|
||||
eine Ausnahme zu werfen.
|
||||
|
||||
Bleibt wie lines() ohne Pillow importierbar und testbar: hier entsteht nur Text, kein
|
||||
Bild - die Rolle ist ein reiner Bezeichner, die zugehoerigen Schriftgroessen kennt
|
||||
ausschliesslich compose()."""
|
||||
state = state if isinstance(state, dict) else {}
|
||||
|
||||
# Laeuft gerade eine Bedienung am Taster, ERSETZT sie das Statusbild komplett - eine
|
||||
# Bedienung, die neben dem Statusbild um Platz kaempft, waere auf 250x122 unlesbar,
|
||||
# und in diesem Moment ist der Bedienzustand die einzige Auskunft, auf die der
|
||||
# Bediener wartet. display.read_ui() liefert {} (und damit kein "ui"), sobald nichts
|
||||
# laeuft - dann geht es unveraendert weiter wie zuvor.
|
||||
ui = state.get("ui")
|
||||
if isinstance(ui, dict) and ui.get("view") not in (None, "", "idle"):
|
||||
return _ui_blocks(ui)
|
||||
|
||||
mode = state.get("mode")
|
||||
out: list[tuple[str, str]] = []
|
||||
|
||||
if mode == "final":
|
||||
# Abschlussbildschirm - KEINE Fusszeile und keine weiteren Zeilen: die Station ist
|
||||
# im Begriff zu verschwinden, und die einzige Auskunft, auf die es jetzt ankommt,
|
||||
# ist "darf ich den Stecker ziehen?".
|
||||
label = FINAL_LABELS.get(state.get("final"))
|
||||
if label is None:
|
||||
# Unbekannte Spielart - lieber ehrlich nichts behaupten als "ausgeschaltet"
|
||||
# zu sagen, wenn gleich wieder gebootet wird.
|
||||
return [("Faehrt herunter", ROLE_BIG)]
|
||||
title, hero, details = label
|
||||
return ([(title, ROLE_HEADER), (hero, ROLE_HERO)]
|
||||
+ [(d, ROLE_DETAIL) for d in details])
|
||||
|
||||
# Die Fusszeile haengt an JEDEM Statusbild - auch an "Station aus" und an "Status
|
||||
# unbekannt", die beide sofort zurueckkehren. Sie wird deshalb hier gebaut und unten
|
||||
# bei jedem Rueckgabepunkt angehaengt.
|
||||
footer = _imageset_blocks(state)
|
||||
|
||||
if mode == "off":
|
||||
# Die Station ist komplett aus (kein status.json vorhanden) - display.py setzt
|
||||
# diesen Modus selbst, server.py kennt ihn nicht. Bewusst keine weiteren Zeilen
|
||||
# (insbesondere keine SSID - die waere aus einer alten Konfigurationsdatei und
|
||||
# gerade nicht aktiv). Genau deshalb ROLE_BIG: die Zeile ist der ganze Inhalt.
|
||||
out.append(("Station aus", ROLE_BIG))
|
||||
return out + footer
|
||||
|
||||
if mode not in ("recovery", "migration"):
|
||||
# Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch
|
||||
# nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu
|
||||
# unterstellen.
|
||||
out.append((f"Status unbekannt ({mode})" if mode else "Status unbekannt", ROLE_BIG))
|
||||
return out + footer
|
||||
|
||||
out.append((MODE_LABELS[mode], ROLE_HEADER))
|
||||
|
||||
ssid = state.get("ssid")
|
||||
|
||||
if mode == "recovery":
|
||||
# Im Rettungsmodus gibt es keine Phase - kein ROLE_HEADER_PHASE-Block, die
|
||||
# Kopfzeile bleibt also nur der Modus (compose() zentriert sie dann weiterhin
|
||||
# wie vor der zweiten Hardware-Abnahme, siehe dort).
|
||||
#
|
||||
# Die SSID ist das, was der Bediener am Geraet braucht (er muss sich mit genau
|
||||
# diesem Netz verbinden) - deshalb gross und ohne "SSID: "-Praefix, das den
|
||||
# Platz nur wegnaehme.
|
||||
if ssid:
|
||||
out.append((str(ssid), ROLE_HERO))
|
||||
else:
|
||||
# Phase kommt aus control.json, nicht aus status.json (das schreibt der Server
|
||||
# nur beim Start und nach einer Auslieferung) - display.py reicht sie hier als
|
||||
# Teil des zusammengefuehrten Zustands herein. Im Rettungsmodus gibt es keine
|
||||
# Phase, deshalb dieser Block nur hier drin.
|
||||
#
|
||||
# Seit der zweiten Hardware-Abnahme steht die Phase NICHT mehr als eigene
|
||||
# Beiwerkzeile unter dem Lampennamen (dort ging sie unter), sondern direkt
|
||||
# NEBEN dem Modus in der Kopfzeile (ROLE_HEADER_PHASE, rechtsbuendig) - deshalb
|
||||
# wird sie hier direkt nach dem ROLE_HEADER-Block angehaengt, nicht erst nach
|
||||
# der grossen Zeile.
|
||||
phase = state.get("phase")
|
||||
armed_target = None
|
||||
if phase == "armed":
|
||||
# Nur das Label in den Kopf, der Zielname kommt weiter unten als eigene
|
||||
# Beiwerkzeile (siehe ARMED_DETAIL_PREFIX) - in den Kopf passt er nicht,
|
||||
# und dort war er bis zur dritten Abnahme faktisch unsichtbar.
|
||||
out.append((ARMED_LABEL, ROLE_HEADER_PHASE))
|
||||
armed_target = str(state.get("armed_target") or "?")
|
||||
elif phase in PHASE_LABELS:
|
||||
out.append((PHASE_LABELS[phase], ROLE_HEADER_PHASE))
|
||||
|
||||
last_seen = state.get("last_seen")
|
||||
if not isinstance(last_seen, dict):
|
||||
last_seen = {}
|
||||
product_name = last_seen.get("product_name")
|
||||
|
||||
# Reihenfolge bewusst anders als frueher (da stand die SSID als zweite Zeile):
|
||||
# gross kommt das, was sich gerade aendert und worauf man wartet - die erkannte
|
||||
# Lampe. Solange keine erkannt ist, ruecke die SSID nach oben, damit die grosse
|
||||
# Zeile nie leer bleibt.
|
||||
if product_name:
|
||||
out.append((str(product_name), ROLE_HERO))
|
||||
elif ssid:
|
||||
out.append((str(ssid), ROLE_HERO))
|
||||
|
||||
if armed_target is not None:
|
||||
# ERSTE Beiwerkzeile, also die, die als letzte der unteren Randgrenze zum
|
||||
# Opfer fiele (compose() bricht von unten ab, blocks() ist nach Wichtigkeit
|
||||
# sortiert): in "armed" ist das freigegebene Ziel die Auskunft, wegen der
|
||||
# der Bediener ueberhaupt aufs Panel sieht.
|
||||
out.append((f"{ARMED_DETAIL_PREFIX}{armed_target}", ROLE_DETAIL))
|
||||
|
||||
if product_name:
|
||||
target = last_seen.get("target")
|
||||
variant = last_seen.get("variant")
|
||||
if target or variant:
|
||||
out.append(("{} ({})".format(target or "?", _variant_label(last_seen)),
|
||||
ROLE_DETAIL))
|
||||
lamp_version = last_seen.get("lamp_version")
|
||||
if lamp_version:
|
||||
out.append((f"FW: {lamp_version}", ROLE_DETAIL))
|
||||
if ssid and armed_target is None:
|
||||
# Nur hier, wo die grosse Zeile schon vergeben ist - sonst stuende die
|
||||
# SSID zweimal auf dem Panel.
|
||||
#
|
||||
# Und nur ohne Freigabezeile: aufs Panel passen neben der grossen Zeile
|
||||
# fuenf Beiwerkzeilen (siehe BOTTOM_MARGIN_PX), die Freigabezeile ist die
|
||||
# sechste. Von den beiden gibt die SSID nach - sie ist die Auskunft, mit
|
||||
# der man das Netz FINDET, und in "armed" hat die Lampe es laengst
|
||||
# gefunden (sie wurde ja erkannt, sonst gaebe es kein product_name);
|
||||
# ohne erkannte Lampe steht die SSID ohnehin als grosse Zeile da.
|
||||
out.append((f"SSID: {ssid}", ROLE_DETAIL))
|
||||
|
||||
# Der optionale Zeitschalter, in BEIDEN Modi und vor den reinen Zaehlerzeilen: ein
|
||||
# Zeitschalter, den man am Panel nicht sieht, ist schlimmer als keiner - man glaubt,
|
||||
# die Station laeuft, und sie ist laengst aus. Fehlt er (der Regelfall, denn er
|
||||
# entsteht nur mit "--for"), fehlt die Zeile ersatzlos.
|
||||
#
|
||||
# Er steht damit VOR "Ausgeliefert"/"zuletzt ausgeliefert": im vollsten Zustand
|
||||
# passen fuenf Beiwerkzeilen aufs Panel (siehe BOTTOM_MARGIN_PX), die sechste faellt
|
||||
# unten heraus. Von diesen beiden Auskuenften ist "wann geht die Station aus"
|
||||
# diejenige, nach der man ueberhaupt aufs Panel sieht - der Zaehler der letzten
|
||||
# Auslieferung steht auch noch in "ninlight-station status".
|
||||
autooff_minutes = state.get("autooff_minutes")
|
||||
if isinstance(autooff_minutes, int) and not isinstance(autooff_minutes, bool):
|
||||
if autooff_minutes > 0:
|
||||
out.append(("Auto-Aus in {} min".format(autooff_minutes), ROLE_DETAIL))
|
||||
else:
|
||||
out.append(("Auto-Aus steht an", ROLE_DETAIL))
|
||||
|
||||
served = state.get("served")
|
||||
out.append((f"Ausgeliefert: {served if isinstance(served, int) else 0}", ROLE_DETAIL))
|
||||
|
||||
recent = state.get("recent")
|
||||
if isinstance(recent, list) and recent:
|
||||
last = recent[-1]
|
||||
if isinstance(last, dict):
|
||||
out.append((f"{_hhmm(last.get('ts'))} {last.get('target') or '?'} "
|
||||
f"{last.get('status') or '?'}", ROLE_DETAIL))
|
||||
|
||||
return out + footer
|
||||
|
||||
|
||||
def _imageset_blocks(state: dict) -> list[tuple[str, str]]:
|
||||
"""Die Fusszeile mit dem Bildsatz-Umschalter, oder [] wenn der Zustand keinen traegt
|
||||
(aeltere display.py-Fassung, von Hand zusammengesetzter Zustand in einem Test).
|
||||
Fehlt das Verzeichnis des GEWAEHLTEN Satzes, sagt die Zeile das - dann liefert
|
||||
server.py naemlich nichts aus, und ein Panel, das trotzdem nur den Namen zeigt,
|
||||
behauptet einen Betrieb, den es nicht gibt."""
|
||||
name = state.get("imageset")
|
||||
label = IMAGESET_LABELS.get(name)
|
||||
if not label:
|
||||
return []
|
||||
if state.get("imageset_ok") is False:
|
||||
label = IMAGESET_MISSING_LABELS[name]
|
||||
return [(label, ROLE_FOOTER)]
|
||||
|
||||
|
||||
def _ui_blocks(ui: dict) -> list[tuple[str, str]]:
|
||||
"""Der Bedienzustand des Tasterdienstes in Zeilen mit Rolle. Bewusst DUMM: welcher
|
||||
Text bei welcher Geste erscheint, entscheidet allein der Tasterdienst (dort ist es
|
||||
ohne Hardware pruefbar) - hier wird er nur noch den vorhandenen Rollen zugeordnet,
|
||||
damit Kopfbalken, Schriftgroessen, Breitenkuerzung und unterer Rand dieselben bleiben
|
||||
wie im Statusbild.
|
||||
|
||||
Jedes Feld wird auf seinen Typ geprueft: die Datei kommt aus einem anderen Prozess
|
||||
(der aelter, neuer oder halb geschrieben sein kann) - genau wie status.json darf sie
|
||||
die Anzeige nie zum Absturz bringen."""
|
||||
out: list[tuple[str, str]] = []
|
||||
title = ui.get("title")
|
||||
if isinstance(title, str) and title:
|
||||
out.append((title, ROLE_HEADER))
|
||||
hero = ui.get("hero")
|
||||
if isinstance(hero, str) and hero:
|
||||
out.append((hero, ROLE_HERO))
|
||||
details = ui.get("details")
|
||||
if isinstance(details, list):
|
||||
for detail in details:
|
||||
if isinstance(detail, str) and detail:
|
||||
out.append((detail, ROLE_DETAIL))
|
||||
if not out:
|
||||
# Es laeuft eine Bedienung, aber die Datei traegt keinen brauchbaren Text
|
||||
# (abgeschnitten, fremde Fassung). Lieber gross sagen, dass bedient wird, als
|
||||
# ein leeres Panel zu zeigen, das nach "haengt" aussieht.
|
||||
out.append(("Bedienung", ROLE_BIG))
|
||||
return out
|
||||
|
||||
|
||||
def lines(state: dict) -> list[str]:
|
||||
"""Nur die Texte aus blocks(), ohne Rollen - unveraenderte Schnittstelle fuer
|
||||
display.run_daemon(), das damit erkennt, ob sich der Inhalt geaendert hat (die Rolle
|
||||
ist dafuer unerheblich, sie haengt nur am Modus). Wie blocks() ohne Pillow benutzbar."""
|
||||
return [text for text, _role in blocks(state)]
|
||||
|
||||
|
||||
def _truncate_to_width(text: str, measure: Callable[[str], float], max_width_px: float) -> str:
|
||||
"""Kuerzt `text` mit "…" am Ende, bis `measure(text)` nicht mehr breiter als
|
||||
`max_width_px` ist - nie umgebrochen, immer von rechts gekuerzt.
|
||||
|
||||
Nimmt eine Messfunktion statt selbst Pillow zu importieren: so bleibt diese Funktion
|
||||
(und damit render.py ausserhalb von compose()) ohne Pillow testbar, z.B. mit
|
||||
`measure=len` fuer einen einfachen Zeichenzahl-Test, waehrend compose() unten eine
|
||||
echte `ImageDraw.textlength()`-Messung durchreicht. Zeichenweise gekuerzt (nicht
|
||||
wortweise): lines() liefert kurze Statuswerte, keinen Fliesstext - ein am Wortende
|
||||
abgeschnittener Produktname ist hier kein Problem, ein zu breiter oder umgebrochener
|
||||
waere eins."""
|
||||
if measure(text) <= max_width_px:
|
||||
return text
|
||||
ellipsis = "…"
|
||||
trimmed = text
|
||||
while trimmed and measure(trimmed + ellipsis) > max_width_px:
|
||||
trimmed = trimmed[:-1]
|
||||
return (trimmed + ellipsis) if trimmed else ellipsis
|
||||
|
||||
|
||||
# Prozess-Cache fuer _load_font(). Dritte Hardware-Abnahme, Befund M4: compose() baute je
|
||||
# Aufruf FUENF Schriften neu (auf der Entwicklungsmaschine gemessen: 25,0 ms je compose(),
|
||||
# davon 21,2 ms Schriftaufrufe - auf einem ARMv6-Kern ohne NEON grob 0,5-1 s, und das auf
|
||||
# demselben Kern, auf dem server.py die Lampen bedient, bei JEDER ausgelieferten Lampe,
|
||||
# weil sich der Zaehler aendert).
|
||||
#
|
||||
# Der Schluessel enthaelt das hereingereichte ImageFont-MODUL und FONT_SEARCH_PATHS, nicht
|
||||
# nur Groesse/Fettung: beide werden in den Tests ersetzt (nachgebautes Modul,
|
||||
# monkeypatch auf eine leere oder andere Suchkette), und ein Cache, der das ignoriert,
|
||||
# wuerde einem Test die Schrift eines anderen unterschieben - genau der Grund, aus dem
|
||||
# Task 7b noch bewusst auf einen Cache verzichtet hatte.
|
||||
_FONT_CACHE: dict = {}
|
||||
|
||||
|
||||
def _load_font(image_font, size_px: int, bold: bool = False):
|
||||
"""Laedt eine skalierbare Schrift der Groesse `size_px` aus FONT_SEARCH_PATHS - der
|
||||
erste Pfad, der sich oeffnen laesst, gewinnt. Das Ergebnis wird prozessweit
|
||||
zwischengespeichert (siehe _FONT_CACHE): dieselbe Schrift in derselben Groesse wird
|
||||
je Prozess genau einmal von der Platte gelesen und geparst.
|
||||
|
||||
`image_font` ist das PIL.ImageFont-MODUL, hereingereicht statt hier importiert: so
|
||||
bleibt render.py (ausserhalb von compose()) ohne Pillow importierbar - dasselbe
|
||||
Muster wie die Messfunktion in _truncate_to_width() - und die Kette laesst sich mit
|
||||
einem nachgebauten Modul pruefen, ohne Schriftdateien anzulegen oder Pillow
|
||||
herunterzustufen.
|
||||
|
||||
Gefangen wird OSError (Datei fehlt/unlesbar/kein gueltiges Schriftformat) UND
|
||||
ImportError: ein ohne FreeType gebautes Pillow laesst truetype() mit genau dieser
|
||||
Ausnahme scheitern - dann darf die Anzeige nicht ausfallen, sondern faellt bis auf
|
||||
load_default() durch.
|
||||
|
||||
load_default() wird BEWUSST OHNE size-Argument gerufen: das gibt es erst ab
|
||||
Pillow 10.1, Pi 2 hat 9.4.0 (siehe FONT_SEARCH_PATHS). Ein `size=`-Aufruf waere dort
|
||||
ein TypeError mitten im Zeichnen - also genau in dem Pfad, der eigentlich der letzte
|
||||
Rettungsanker ist."""
|
||||
# Das Modul selbst (nicht seine id()) im Schluessel: Module und die nachgebauten
|
||||
# Testmodule sind ueber ihre Identitaet hashbar, und solange der Schluessel das
|
||||
# Objekt festhaelt, kann keine recycelte id() zwei verschiedene Module
|
||||
# zusammenwerfen.
|
||||
key = (image_font, size_px, bold, FONT_SEARCH_PATHS)
|
||||
if key in _FONT_CACHE:
|
||||
return _FONT_CACHE[key]
|
||||
|
||||
for regular_path, bold_path in FONT_SEARCH_PATHS:
|
||||
path = bold_path if bold else regular_path
|
||||
try:
|
||||
font = image_font.truetype(path, size_px)
|
||||
break
|
||||
except (OSError, ImportError):
|
||||
continue
|
||||
else:
|
||||
font = image_font.load_default()
|
||||
|
||||
_FONT_CACHE[key] = font
|
||||
return font
|
||||
|
||||
|
||||
def _draw_text(draw, text: str, font, top: int, height: int, fill: int,
|
||||
align: str = "center") -> int:
|
||||
"""Zeichnet `text` senkrecht mittig in das Band [top, top+height) und waagerecht
|
||||
nach `align`: "center" (Panelmitte - bisheriges Verhalten, fuer ROLE_BIG und die
|
||||
Kopfzeile ohne Phase), "left" (linker Rand + TEXT_MARGIN_PX - der Modus, sobald eine
|
||||
Phase daneben steht) oder "right" (rechter Rand - TEXT_MARGIN_PX - die Phase).
|
||||
|
||||
Misst mit textbbox() statt mit der nominellen Schriftgroesse: die tatsaechliche
|
||||
Tinte einer Zeile ist je nach Zeichen (Ober-/Unterlaengen) deutlich kleiner als die
|
||||
Zeilenhoehe, und ein nach Zeilenhoehe zentrierter Text sieht auf einem 122 px hohen
|
||||
Panel sichtbar zu tief aus.
|
||||
|
||||
Gibt die gezeichnete Tintenbreite zurueck - compose() braucht sie fuer den Modus in
|
||||
der Kopfzeile, um daraus auszurechnen, wie viel Platz rechts noch fuer die Phase
|
||||
uebrig ist."""
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
ink_width = bbox[2] - bbox[0]
|
||||
if align == "left":
|
||||
visual_x = TEXT_MARGIN_PX
|
||||
elif align == "right":
|
||||
visual_x = PANEL_WIDTH_PX - TEXT_MARGIN_PX - ink_width
|
||||
else:
|
||||
visual_x = (PANEL_WIDTH_PX - ink_width) // 2
|
||||
y = top + (height - (bbox[3] - bbox[1])) // 2 - bbox[1]
|
||||
draw.text((visual_x - bbox[0], y), text, font=font, fill=fill)
|
||||
return ink_width
|
||||
|
||||
|
||||
def compose(state: dict):
|
||||
"""Baut das Panel-Bild (PANEL_WIDTH_PX x PANEL_HEIGHT_PX, 1-Bit) aus blocks().
|
||||
Importiert Pillow erst hier - siehe Modul-Docstring."""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
image = Image.new("1", (PANEL_WIDTH_PX, PANEL_HEIGHT_PX), 255)
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# Je Rolle FAUL geladen, nicht alle fuenf im Voraus (Befund M4): "Station aus" und
|
||||
# "Status unbekannt" zeichnen genau eine Rolle (ROLE_BIG) - vier der fuenf Schriften
|
||||
# waeren dort reine Ladezeit. Zusammen mit _FONT_CACHE kostet jede Rolle im Betrieb
|
||||
# hoechstens einmal je Prozess etwas, danach nur noch einen Woerterbuchzugriff.
|
||||
role_fonts = {
|
||||
ROLE_HEADER: (HEADER_FONT_PX, True),
|
||||
ROLE_HEADER_PHASE: (HEADER_PHASE_FONT_PX, True),
|
||||
ROLE_HERO: (HERO_FONT_PX, True),
|
||||
ROLE_DETAIL: (DETAIL_FONT_PX, False),
|
||||
ROLE_BIG: (BIG_FONT_PX, True),
|
||||
ROLE_FOOTER: (FOOTER_FONT_PX, True),
|
||||
}
|
||||
loaded: dict = {}
|
||||
|
||||
def font_for(role: str):
|
||||
if role not in loaded:
|
||||
size_px, bold = role_fonts[role]
|
||||
loaded[role] = _load_font(ImageFont, size_px, bold=bold)
|
||||
return loaded[role]
|
||||
|
||||
max_width_px = PANEL_WIDTH_PX - 2 * TEXT_MARGIN_PX
|
||||
|
||||
def fit(raw_text: str, font, width_px: float = max_width_px) -> str:
|
||||
# draw.textlength() wirft ValueError("can't measure length of multiline text"),
|
||||
# sobald eine Zeile ein "\n" enthaelt (mit Pillow 12 nachgeprueft) - waere von
|
||||
# main()s except (DisplayUnavailable, ImportError) nicht gefangen (Fixrunde 3,
|
||||
# F5). blocks() erzeugt selbst kein "\n", aber Werte aus status.json/last_seen
|
||||
# (z.B. product_name) sind letztlich Daten von der Lampe/dem Netzwerk, kein
|
||||
# Wert, den blocks() selbst kontrolliert - ein Zeilenumbruch dort waere ohnehin
|
||||
# nur ein zerstoertes Layout, kein sinnvoller Inhalt.
|
||||
text = raw_text.replace("\n", " ")
|
||||
return _truncate_to_width(
|
||||
text, measure=lambda t: draw.textlength(t, font=font),
|
||||
max_width_px=width_px)
|
||||
|
||||
block_list = blocks(state)
|
||||
# Nur wenn eine Phase folgt, ruckt der Modus in der Kopfzeile nach links (siehe
|
||||
# Rollen-Kommentar oben) - Rettung und "aus" kennen keinen ROLE_HEADER_PHASE-Block
|
||||
# und behalten die zentrierte Kopfzeile von vor der zweiten Hardware-Abnahme.
|
||||
has_header_phase = any(role == ROLE_HEADER_PHASE for _, role in block_list)
|
||||
header_mode_right_edge_px = 0.0
|
||||
|
||||
# Die Fusszeile sitzt an einem FESTEN Platz am unteren Rand, nicht im Zeilenfluss -
|
||||
# der Zeilenfluss darueber muss deshalb frueher abbrechen. Ohne Fusszeile bleibt die
|
||||
# Grenze exakt die alte (BOTTOM_MARGIN_PX), damit sich an allen bisherigen Bildern
|
||||
# nichts verschiebt.
|
||||
footer_texts = [text for text, role in block_list if role == ROLE_FOOTER]
|
||||
block_list = [(text, role) for text, role in block_list if role != ROLE_FOOTER]
|
||||
has_footer = bool(footer_texts)
|
||||
footer_top_px = PANEL_HEIGHT_PX - BOTTOM_MARGIN_PX - FOOTER_BAND_HEIGHT_PX
|
||||
flow_limit_px = footer_top_px if has_footer else PANEL_HEIGHT_PX - BOTTOM_MARGIN_PX
|
||||
# ROLE_BIG bekommt das ganze Panel (bisheriges Verhalten) bzw. alles oberhalb der
|
||||
# Fusszeile - der untere Rand ist dort kein Abbruchkriterium, sondern nur eine
|
||||
# Reserve fuer den Zeilenfluss.
|
||||
big_height_px = footer_top_px if has_footer else PANEL_HEIGHT_PX
|
||||
|
||||
# Die Fusszeile wird VOR dem Zeilenfluss gezeichnet und ist aus block_list bereits
|
||||
# herausgenommen - sonst haette sie der "break" der Abbruchbedingung weiter unten
|
||||
# verschluckt, sobald der Zeilenfluss vorher voll ist (also ausgerechnet im vollsten
|
||||
# Zustand, waehrend einer laufenden Migration). Sie sitzt an festem Platz; ihre
|
||||
# Reihenfolge innerhalb von blocks() spielt fuer das Bild keine Rolle.
|
||||
if has_footer:
|
||||
footer_font = font_for(ROLE_FOOTER)
|
||||
# Trennstrich ueber die volle Breite, darunter die Zeile - so ist auf einen
|
||||
# Blick klar, dass das keine weitere Beiwerkzeile ist, sondern ein Zustand,
|
||||
# der unabhaengig vom Rest gilt.
|
||||
draw.line((0, footer_top_px, PANEL_WIDTH_PX - 1, footer_top_px), fill=0)
|
||||
_draw_text(draw, fit(footer_texts[0], footer_font), footer_font,
|
||||
top=footer_top_px + FOOTER_RULE_GAP_PX,
|
||||
height=FOOTER_BAND_HEIGHT_PX - FOOTER_RULE_GAP_PX,
|
||||
fill=0, align="center")
|
||||
|
||||
y = TEXT_MARGIN_PX
|
||||
for raw_text, role in block_list:
|
||||
font = font_for(role)
|
||||
|
||||
if role == ROLE_BIG:
|
||||
# Einziger Block dieses Zustands (siehe blocks()) - er bekommt das ganze
|
||||
# Panel.
|
||||
text = fit(raw_text, font)
|
||||
# Mittig im Bereich UEBER der Fusszeile (ohne Fusszeile: im ganzen Panel) -
|
||||
# sonst sitzt "Station aus" sichtbar zu tief und beruehrt den Trennstrich.
|
||||
_draw_text(draw, text, font, top=0, height=big_height_px, fill=0,
|
||||
align="center")
|
||||
continue
|
||||
|
||||
if role == ROLE_HEADER:
|
||||
text = fit(raw_text, font)
|
||||
draw.rectangle((0, 0, PANEL_WIDTH_PX - 1, HEADER_BAR_HEIGHT_PX - 1), fill=0)
|
||||
align = "left" if has_header_phase else "center"
|
||||
ink_width = _draw_text(draw, text, font, top=0, height=HEADER_BAR_HEIGHT_PX,
|
||||
fill=255, align=align)
|
||||
if align == "left":
|
||||
header_mode_right_edge_px = TEXT_MARGIN_PX + ink_width
|
||||
y = HEADER_BAR_HEIGHT_PX + TEXT_MARGIN_PX
|
||||
continue
|
||||
|
||||
if role == ROLE_HEADER_PHASE:
|
||||
# Rechtsbuendig NEBEN dem Modus (der links steht, s.o.) im selben Balken -
|
||||
# gemessen ab dem tatsaechlichen rechten Rand des Modus-Texts, nicht ab
|
||||
# einer angenommenen festen Breite: "MIGRATION" und "RETTUNG" sind
|
||||
# unterschiedlich breit, und nur "MIGRATION" hat je eine Phase daneben.
|
||||
available_px = (PANEL_WIDTH_PX - TEXT_MARGIN_PX - header_mode_right_edge_px
|
||||
- HEADER_PHASE_GAP_PX)
|
||||
if available_px >= HEADER_PHASE_MIN_WIDTH_PX:
|
||||
text = fit(raw_text, font, width_px=available_px)
|
||||
_draw_text(draw, text, font, top=0, height=HEADER_BAR_HEIGHT_PX,
|
||||
fill=255, align="right")
|
||||
# sonst: Phase weglassen (dritte Option aus der Abnahme) - bei so wenig
|
||||
# Platz waere selbst ein gekuerztes "…" keine Auskunft mehr wert, und ein
|
||||
# Ueberlappen mit dem Modus ist nicht erlaubt.
|
||||
continue
|
||||
|
||||
text = fit(raw_text, font)
|
||||
line_height = HERO_LINE_HEIGHT_PX if role == ROLE_HERO else DETAIL_LINE_HEIGHT_PX
|
||||
if y + line_height > flow_limit_px:
|
||||
# Passt nicht mehr aufs Panel, ohne in den fuer BOTTOM_MARGIN_PX
|
||||
# reservierten unteren Rand zu ragen - abschneiden statt ueberzeichnen. Die
|
||||
# Reihenfolge in blocks() ist deshalb nach Wichtigkeit sortiert.
|
||||
break
|
||||
draw.text((TEXT_MARGIN_PX, y), text, font=font, fill=0)
|
||||
y += line_height
|
||||
|
||||
return image
|
||||
@@ -0,0 +1,75 @@
|
||||
[Unit]
|
||||
Description=ninlight Taster (Bedienung der Station ueber GPIO23)
|
||||
# Wie beim Anzeigedienst nur After=, NICHT Requires=/BindsTo=/Wants=: ein fehlender oder
|
||||
# abgestuerzter Taster darf server.py niemals mitreissen, und server.py muss starten
|
||||
# koennen, auch wenn dieser Dienst fehlt oder deaktiviert ist. KEIN
|
||||
# Wants=ninlight-station.service - das wuerde die absichtlich deaktivierte Station bei
|
||||
# jedem Boot mit hochziehen (Fund der Hardware-Abnahme, ausfuehrlich in
|
||||
# ninlight-display.service begruendet).
|
||||
After=ninlight-station.service
|
||||
# Gehoeren seit systemd 229 ins [Unit], nicht ins [Service] (dort werden sie mit
|
||||
# "Unknown key name" ignoriert, und die Deckelung unten greift dann nie).
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment=NINLIGHT_RUN_DIR=/run/ninlight-station
|
||||
Environment=NINLIGHT_UI_FILE=/run/ninlight-button/ui.json
|
||||
# "-"-Praefix VOR dem Dateipfad (nicht vor dem Schluesselnamen - so herum ist es ein
|
||||
# ungueltiger Schluessel und systemd verwirft die Zeile ganz): ohne die
|
||||
# Konfigurationsdatei startet der Taster trotzdem.
|
||||
EnvironmentFile=-/etc/ninlight-station.conf
|
||||
# Eigenes RuntimeDirectory, aus zwei Gruenden:
|
||||
#
|
||||
# 1. Hier liegt ui.json. Es darf NICHT in /run/ninlight-station liegen (dem
|
||||
# RuntimeDirectory der Hauptunit): systemd raeumt das beim regulaeren Stop von
|
||||
# ninlight-station auf - also ausgerechnet im Zustand "Station aus", in dem das Menue
|
||||
# ("Rettung starten"/"Migration starten") am dringendsten gebraucht wird. So
|
||||
# verschwindet ui.json genau dann, wenn dieser Dienst endet, und ein
|
||||
# stehengebliebenes Menue kann ihn nicht ueberdauern.
|
||||
# 2. lgpio (gpiozero's bevorzugte Pin-Factory) legt beim Start "$HOME/.lgd-nfy0" an.
|
||||
# Unter systemd ist HOME per Vorgabe LEER und das Arbeitsverzeichnis "/" - der Pfad
|
||||
# wird "//.lgd-nfy0" und scheitert. Am Geraet fuer den Anzeigedienst nachgemessen,
|
||||
# siehe dort; derselbe Stolperstein trifft jeden gpiozero-Dienst.
|
||||
RuntimeDirectory=ninlight-button
|
||||
RuntimeDirectoryMode=0755
|
||||
WorkingDirectory=/run/ninlight-button
|
||||
Environment=HOME=/run/ninlight-button
|
||||
ExecStart=/usr/bin/python3 /opt/ninlight-station/button/button.py
|
||||
|
||||
# button.py faengt einen fehlenden Taster (keine Bibliothek, kein Pin, Pin belegt) als
|
||||
# ButtonUnavailable ab, protokolliert eine Zeile und beendet sich mit sys.exit(0) - das
|
||||
# ist nie ein "Fehlschlag" im systemd-Sinne und loest kein Restart= aus. on-failure
|
||||
# (nicht always) deckelt damit genau das Richtige: echte Abstuerze werden neu versucht,
|
||||
# ein dauerhaft fehlender Taster nicht.
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# BEWUSST KEIN NoNewPrivileges=/ProtectSystem=strict/ProtectHome= hier - anders als beim
|
||||
# Anzeigedienst, und das ist kein Versehen:
|
||||
#
|
||||
# Dieser Dienst ruft /usr/local/bin/ninlight-station auf, und dessen einziger Zweck ist
|
||||
# es, NetworkManager-Profile umzuschalten, Units zu starten/stoppen und nach
|
||||
# /run/ninlight-station zu schreiben - alles ueber "sudo". Kindprozesse erben den
|
||||
# Namensraum und das no_new_privs-Flag dieser Unit: mit ProtectSystem=strict waere
|
||||
# /run/ninlight-station fuer das CLI schreibgeschuetzt (mode/control.json liessen sich
|
||||
# nicht mehr schreiben), und no_new_privs laesst kein setuid-Binary mehr zu (sudo). Eine
|
||||
# Absicherung, die genau die Aktionen unmoeglich macht, wegen derer es den Dienst gibt,
|
||||
# ist keine. Die Angriffsflaeche ist stattdessen eng gehalten: EIN Eingangspin, kein
|
||||
# Netzwerk, keine Eingabe ausser der Taste selbst, und ausgefuehrt wird ausschliesslich
|
||||
# die feste Liste von CLI-Unterbefehlen aus button.py.
|
||||
MemoryMax=64M
|
||||
|
||||
# Laeuft als root (wie Haupt- und Anzeigedienst) fuer GPIO23 und fuer das CLI - kein
|
||||
# SupplementaryGroups=gpio: auf reinem Debian gibt es die Gruppe oft gar nicht, und eine
|
||||
# fehlende Gruppe ist fuer systemd ein harter Startfehler (216/GROUP), ausgerechnet in
|
||||
# einer Unit, die nie stoeren darf.
|
||||
|
||||
# KEIN Zugriff auf SPI und keine Zeichenleitung: der Tasterdienst fasst den Panel-Bus
|
||||
# nicht an (zwei Prozesse darauf ergeben "lgpio.error: 'GPIO busy'"). Er schreibt seinen
|
||||
# Bedienzustand nach /run/ninlight-button/ui.json, der Anzeigedienst liest ihn und
|
||||
# zeichnet.
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,109 @@
|
||||
[Unit]
|
||||
Description=ninlight E-Paper-Anzeige (Status der Migrations-/Rettungsstation)
|
||||
# Nur eine Anzeige fuer den Hauptdienst - After=, NICHT Requires=/BindsTo=/Wants=: ein
|
||||
# fehlendes oder abgestuerztes Panel darf server.py niemals mitreissen, und server.py
|
||||
# darf umgekehrt starten, auch wenn dieser Dienst fehlt oder deaktiviert ist. Siehe
|
||||
# display.py's Modul-Docstring: die Anzeige ist nirgends Voraussetzung fuer die
|
||||
# Migration.
|
||||
#
|
||||
# KEIN Wants=ninlight-station.service (stand hier zuerst, Fund der Hardware-Abnahme auf
|
||||
# Pi 2): Wants= ist keine reine Reihenfolgeangabe, sondern AKTIVIERT die genannte Unit
|
||||
# beim Start dieser Unit, unabhaengig davon, ob sie enabled ist - "ninlight-station" ist
|
||||
# absichtlich disabled (siehe deren Kommentar: die Station ist per Vorgabe aus), wurde
|
||||
# aber trotzdem bei jedem Boot mitgestartet, weil dieser (enabled) Anzeigedienst sie ueber
|
||||
# Wants= mitzog. Ohne Zugangspunkt kann server.py 192.168.8.1 nicht binden, stirbt, wird
|
||||
# von Restart=always dort mehrfach neu versucht und bleibt auf "failed" haengen - das
|
||||
# flutet das Journal bei jedem Boot und laesst "ninlight-station status" faelschlich
|
||||
# einen Fehler ("failed" statt "inactive") vermuten, wo absichtlich nichts laeuft.
|
||||
# After= allein ordnet nur die Startreihenfolge, FALLS beide Units ohnehin gestartet
|
||||
# werden (z.B. wenn jemand die Station von Hand einschaltet) - und genau das war hier
|
||||
# gemeint, nicht "zieh die Station mit hoch". Nicht aus falscher Vorsicht wieder
|
||||
# einbauen: der Nebeneffekt ist genau der oben beschriebene, kein Sicherheitsnetz.
|
||||
After=ninlight-station.service
|
||||
# Gehoeren seit systemd 229 ins [Unit], nicht ins [Service] (dort standen sie zuerst -
|
||||
# Fixrunde 1, I1: systemd ignoriert sie dort mit "Unknown key name", die Vorgabe (5
|
||||
# Starts/10s) greift dann bei RestartSec=5 nie, und genau das sollte die Deckelung unten
|
||||
# eigentlich verhindern). ninlight-station.service (dieselbe Sektion) macht es richtig.
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Reihenfolge wichtig: systemd wendet Environment=/EnvironmentFile= in der Reihenfolge
|
||||
# an, in der sie hier stehen, und eine spaetere Zuweisung ueberschreibt eine gleichnamige
|
||||
# fruehere. EPD_REVISION=V3 hier ist nur der Vorgabewert, falls die Konfigurationsdatei
|
||||
# (unten, per EnvironmentFile=) den Schluessel (noch) nicht enthaelt - z.B. auf einem Pi,
|
||||
# der vor Task 6 aufgesetzt wurde. Steht er dort, gewinnt er.
|
||||
Environment=EPD_REVISION=V3
|
||||
Environment=NINLIGHT_RUN_DIR=/run/ninlight-station
|
||||
Environment=NINLIGHT_STATION_CONF=/etc/ninlight-station.conf
|
||||
# "-"-Praefix VOR dem Dateipfad, nicht vor dem Direktivennamen (Fixrunde 3, F1 - das war
|
||||
# in Fixrunde 2 falsch herum: "-EnvironmentFile=..." ist kein gueltiger Schluesselname,
|
||||
# systemd verwirft die Zeile komplett mit "Unknown key name", und die Konfigurationsdatei
|
||||
# wurde dadurch ueberhaupt nicht mehr gelesen - Environment=EPD_REVISION=V3 oben haette
|
||||
# IMMER gewonnen, unabhaengig davon, was in /etc/ninlight-station.conf steht). Mit dem
|
||||
# Praefix am richtigen Platz gilt: ohne die Konfigurationsdatei startet die Anzeige
|
||||
# trotzdem (mit den Environment=-Vorgaben oben) statt mit einem harten Fehler - "die
|
||||
# Anzeige darf nirgends Voraussetzung sein" gilt auch fuer ihre eigene Konfiguration.
|
||||
EnvironmentFile=-/etc/ninlight-station.conf
|
||||
# RuntimeDirectory=/WorkingDirectory=/Environment=HOME= zusammen beheben einen Fund aus
|
||||
# der Hardware-Abnahme: lgpio (gpiozero's bevorzugte Pin-Factory) legt beim Start eine
|
||||
# Benachrichtigungs-Pipe unter "$HOME/.lgd-nfy0" an. Unter systemd ist HOME per Vorgabe
|
||||
# LEER und das Arbeitsverzeichnis "/" - der Pfad wird "//.lgd-nfy0", und ProtectSystem=
|
||||
# strict (unten) macht "/" schreibgeschuetzt. lgpio scheitert dann mit "Can't set
|
||||
# permissions", gpiozero faellt ueber rpigpio/pigpio bis zur experimentellen NativeFactory
|
||||
# durch, und DIE scheitert ihrerseits mit [Errno 22] Invalid argument - display.py faengt
|
||||
# das korrekt als DisplayUnavailable ab und beendet sich mit 0 (siehe Kommentar zu
|
||||
# Restart=on-failure unten), aber das Panel bleibt schwarz und das Journal sieht aus wie
|
||||
# "kein Panel angeschlossen", obwohl die Hardware in Ordnung ist. Am Geraet nachgemessen
|
||||
# (systemd-run mit denselben zwei Property=, gegen ein Shell-echo von $HOME und pwd).
|
||||
#
|
||||
# RuntimeDirectory=ninlight-display ist ABSICHTLICH ein eigenes Verzeichnis, nicht das
|
||||
# RuntimeDirectory=ninlight-station der Hauptunit: dieser Dienst liest von dort nur
|
||||
# (status.json/control.json ueber NINLIGHT_RUN_DIR, s.o.), er soll dort nichts anlegen
|
||||
# koennen. Schriebe die Anzeige in fremden Laufzeitzustand, waere das eine unnoetige
|
||||
# Kopplung an dessen Lebenszyklus (siehe die Begruendung zu ReadOnlyPaths= weiter unten:
|
||||
# server.py's RuntimeDirectory wird beim Stop/Neustart geleert). systemd legt
|
||||
# /run/ninlight-display beim Start dieser Unit selbst an (Vorgabe 0755, vom User dieses
|
||||
# Dienstes - hier root, s.u. - beschreibbar) und raeumt es beim Stop wieder auf.
|
||||
RuntimeDirectory=ninlight-display
|
||||
WorkingDirectory=/run/ninlight-display
|
||||
Environment=HOME=/run/ninlight-display
|
||||
ExecStart=/usr/bin/python3 /opt/ninlight-station/display/display.py
|
||||
|
||||
# display.py faengt ein fehlendes/abgestuerztes Panel als DisplayUnavailable ab,
|
||||
# protokolliert eine Zeile und beendet sich mit sys.exit(0) - das ist also nie ein
|
||||
# "Fehlschlag" im systemd-Sinne und loest kein Restart= aus. Restart=on-failure (nicht
|
||||
# always) greift nur fuer echte Abstuerze (z.B. ein Python-Traceback aus einem
|
||||
# Programmierfehler); ohne den Deckel wuerde ein dauerhaft fehlendes Panel sonst NICHT
|
||||
# staendig neu versucht (gut so), waehrend ein echter Bug endlos neu starten wuerde und
|
||||
# so das Journal fluten koennte.
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
# KEIN ReadOnlyPaths=/run/ninlight-station hier (stand hier zuerst, Fixrunde 1, C3):
|
||||
# server.py's Unit traegt RuntimeDirectory=ninlight-station, und "ninlight-station off"
|
||||
# stoppt den Hauptdienst regulaer - dabei raeumt systemd dessen RuntimeDirectory auf,
|
||||
# sofern nicht gerade ein Neustart lief (RuntimeDirectoryPreserve=restart deckt nur den
|
||||
# Neustart-Fall ab, nicht den regulaeren Stop). Ein nicht mit "-" prefigierter Pfad in
|
||||
# ReadOnlyPaths=/ReadWritePaths=/BindPaths=, der beim Start fehlt, laesst systemd die
|
||||
# Namensraum-Einrichtung mit 226/NAMESPACE abbrechen - ausgerechnet dann, wenn die
|
||||
# Anzeige "Station aus" zeigen soll, waere sie selbst nicht mehr startbar gewesen. Die
|
||||
# Zeile war obendrein wirkungslos: ProtectSystem=strict macht "/" (und damit "/run",
|
||||
# soweit nicht per ReadWritePaths= explizit freigegeben) bereits schreibgeschuetzt.
|
||||
RestrictAddressFamilies=AF_UNIX AF_NETLINK
|
||||
MemoryMax=64M
|
||||
|
||||
# Laeuft als root (wie der Hauptdienst) fuer /dev/spidev0.0 und die GPIO-Zeichenleitung -
|
||||
# kein SupplementaryGroups=spi gpio hier (stand hier zuerst, Fixrunde 1, I2): auf reinem
|
||||
# Debian (im Unterschied zu Raspberry Pi OS) gibt es diese Gruppen oft gar nicht, und
|
||||
# eine fehlende Gruppe ist fuer systemd ein harter Startfehler (216/GROUP) - ausgerechnet
|
||||
# in der Unit, die nie stoeren soll, wenn etwas an der Anzeige fehlt. Als root sind die
|
||||
# Gruppen fuer den Geraetezugriff ohnehin nicht noetig.
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,42 @@
|
||||
[Unit]
|
||||
Description=ninlight-Station: "Ausgeschaltet"-Bild aufs E-Paper
|
||||
Documentation=file:/opt/ninlight-station/README.md
|
||||
# E-Paper ist bistabil: das zuletzt gezeichnete Bild bleibt fuer immer stehen, auch
|
||||
# stromlos. Faehrt der Pi herunter, wird der Anzeigedienst mit abgeraeumt - ohne diese
|
||||
# Unit behauptet das Panel danach dauerhaft den Zustand von vorher. Bei der Abnahme stand
|
||||
# dort die Rueckfrage des Tastermenues, und der ausgeschaltete Pi sah aus, als warte er
|
||||
# noch auf eine Eingabe.
|
||||
#
|
||||
# Sie haengt am SYSTEM, nicht am Tasterdienst: so greift sie bei jedem Herunterfahren -
|
||||
# ueber das Tastermenue (systemctl poweroff), ueber "poweroff" per SSH, ueber jeden
|
||||
# anderen Weg nach poweroff.target. Am Taster allein haetten wir die Anzeige nur auf
|
||||
# einem von drei Wegen.
|
||||
#
|
||||
# Die Gegenstueck-Unit ninlight-reboot.service macht dasselbe fuer den Neustart, mit
|
||||
# ANDEREM Text: beim Neustart darf man den Stecker gerade nicht ziehen.
|
||||
DefaultDependencies=no
|
||||
# Erst zeichnen, wenn der Anzeigedienst die GPIO-/SPI-Leitungen losgelassen hat: zwei
|
||||
# Prozesse darauf enden in "lgpio.error: 'GPIO busy'" (in diesem Projekt schon zweimal
|
||||
# passiert). "After=" auf eine Unit, die gerade gestoppt wird, ordnet deren Stop VOR den
|
||||
# Start dieser hier - genau die gesuchte Reihenfolge. Das CLI wartet zusaetzlich, bis
|
||||
# "systemctl is-active ninlight-display" nicht mehr anschlaegt (display_final()).
|
||||
After=ninlight-display.service ninlight-button.service
|
||||
# ... und noch BEVOR das System die Leitungen verliert: nach umount.target gibt es weder
|
||||
# /opt/ninlight-station noch die Schriftdateien, aus denen das Bild entsteht.
|
||||
Before=umount.target shutdown.target final.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# Das Bild entsteht ueber das vorhandene CLI, nicht durch nachgebaute Logik: nur dort
|
||||
# stehen EPD_REVISION aus /etc/ninlight-station.conf, die Uebergabe der GPIO-Leitungen
|
||||
# und die Zusage "ein Fehler an der Anzeige haelt das Herunterfahren nie auf" (das CLI
|
||||
# beendet sich hier immer mit 0).
|
||||
ExecStart=/usr/local/bin/ninlight-station display final poweroff
|
||||
# Ein Bildaufbau auf diesem Panel dauert Sekunden, nicht Minuten; dazu die Wartezeit auf
|
||||
# die Freigabe der Leitungen. Der Deckel ist da, damit ein haengendes Panel das
|
||||
# Herunterfahren nicht dauerhaft aufhaelt - systemds Vorgabe von 90 s waere hier eine
|
||||
# sehr lange Zeit vor einem dunklen Geraet.
|
||||
TimeoutStartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=poweroff.target
|
||||
@@ -0,0 +1,30 @@
|
||||
[Unit]
|
||||
Description=ninlight-Station: "Neustart"-Bild aufs E-Paper
|
||||
Documentation=file:/opt/ninlight-station/README.md
|
||||
# Das Gegenstueck zu ninlight-poweroff.service, mit einem ANDEREN Text - und das ist hier
|
||||
# kein Schoenheitsfehler, sondern der Zweck der zweiten Unit:
|
||||
#
|
||||
# Herunterfahren -> "AUSGESCHALTET / Strom kann getrennt werden"
|
||||
# Neustart -> "NEUSTART / Strom NICHT trennen!"
|
||||
#
|
||||
# Ein Raspberry Pi Zero W braucht gut anderthalb Minuten, bis der Anzeigedienst wieder
|
||||
# zeichnet. Stuende dort waehrenddessen "Strom kann getrennt werden", waere das eine
|
||||
# Aufforderung zu genau dem Griff, der an diesem Pi schon eine SD-Karte gekostet hat.
|
||||
#
|
||||
# Zwei Units statt einer mit Fallunterscheidung: systemd sagt einem ExecStop/ExecStart
|
||||
# nicht, wohin die Reise geht - man muesste "systemctl list-jobs" nach "reboot.target"
|
||||
# durchsuchen. Ueber WantedBy= ist es dagegen der Abschalt-Zieleinheit selbst entnommen
|
||||
# und damit nicht zu verwechseln.
|
||||
DefaultDependencies=no
|
||||
# Reihenfolge und Begruendung wie in ninlight-poweroff.service (GPIO-Uebergabe, und noch
|
||||
# vor umount.target zeichnen).
|
||||
After=ninlight-display.service ninlight-button.service
|
||||
Before=umount.target shutdown.target final.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/ninlight-station display final reboot
|
||||
TimeoutStartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=reboot.target
|
||||
+1110
-51
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ StartLimitBurst=5
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/ninlight-station.conf
|
||||
ExecStart=/usr/bin/python3 /opt/ninlight-station/server.py --image-dir ${IMAGE_DIR}
|
||||
ExecStart=/usr/bin/python3 /opt/ninlight-station/server.py --image-dir ${IMAGE_DIR} --mode-file /run/ninlight-station/mode --imageset-file /var/lib/ninlight-station/imageset
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
@@ -32,6 +32,18 @@ ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RuntimeDirectory=ninlight-station
|
||||
# Ohne dies leert systemd /run/ninlight-station bei JEDEM Stop und Neustart, nicht
|
||||
# nur beim allerersten Start - und ninlight-station (das CLI) schreibt mode/
|
||||
# control.json immer VOR dem "systemctl restart", mit dem migrate/recovery on den
|
||||
# Dienst (neu) starten. Ohne RuntimeDirectoryPreserve=restart wischt genau dieser
|
||||
# Neustart die beiden gerade geschriebenen Dateien wieder weg, bevor der neue
|
||||
# Serverprozess sie lesen kann - der Dienst faellt dann still auf den
|
||||
# Recovery-Default zurueck (server.py resolve_mode()/read_control()), und ein
|
||||
# ausdrueckliches "migrate on --listen" wird zu "Rettungsmodus" bzw. "auto"
|
||||
# (Fixrunde 1, C1). "restart" (nicht "yes") reicht: das Verzeichnis muss nur einen
|
||||
# Neustart ueberleben, nicht dauerhaft bestehen bleiben, wenn der Dienst regulaer
|
||||
# gestoppt wird.
|
||||
RuntimeDirectoryPreserve=restart
|
||||
ReadWritePaths=/run/ninlight-station
|
||||
RestrictAddressFamilies=AF_INET AF_UNIX
|
||||
MemoryMax=128M
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Produkt-IDs der alten Klyqa-Firmware und ihre Zuordnung zu ninlight-Zielen.
|
||||
|
||||
Vollstaendig uebernommen aus qcl_product_info.c der alten Firmware
|
||||
(~/workspace/qcx-w600-firmware).
|
||||
|
||||
Die Variante (dev/prod) entscheidet ueber den Signierschluessel: Lampen mit
|
||||
Entwicklungs-Bootloader akzeptieren nur den Entwicklungs-, Produktionslampen nur den
|
||||
Produktionsschluessel. Sie ist aber KEINE Eigenschaft der Produkt-ID, sondern eine
|
||||
Eigenschaft der ANFRAGE - das ist der Befund vom 2026-09-15 an echter Hardware:
|
||||
|
||||
/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1.1.1.2357-dev_ota_light.img
|
||||
|
||||
Dieselbe Entwicklungs-G95, aber das "-dev" steht nur in der VERSION, nicht in der
|
||||
Produkt-ID. Die Station hielt sie damit fuer eine Produktionslampe, lieferte das
|
||||
produktionssignierte Image aus, und die Lampe lehnte es ab. Die Machbarkeitsprobe im
|
||||
September hatte eine andere Entwicklungs-G95 mit "-dev" an BEIDEN Stellen gesehen, eine
|
||||
Produktionslampe an keiner - alle drei Formen kommen also vor.
|
||||
|
||||
Regel seither: "-dev" an einer der beiden Stellen (Produkt-ID oder Version) bedeutet
|
||||
Entwicklungsgeraet. PRODUCTS bildet nur noch auf Geraetetyp und Anzeigename ab, beide
|
||||
Schreibweisen der Produkt-ID fuehren auf denselben Eintrag; die Variante ermittelt
|
||||
parse_request() aus beiden Teilen und meldet in .variant_source mit, WORAUS sie stammt
|
||||
("both", "product_id", "version", "none"), damit eine Fehlklassifikation vor der Freigabe
|
||||
am Panel und in status.json sichtbar ist statt erst an der Lampe.
|
||||
"""
|
||||
import re
|
||||
from collections import namedtuple
|
||||
|
||||
Product = namedtuple("Product", "product_id target name")
|
||||
ParsedRequest = namedtuple(
|
||||
"ParsedRequest", "product_id lamp_version product variant variant_source")
|
||||
|
||||
# (Produkt-ID ohne "@", Ziel, Anzeigename) - die "-dev"-Schreibweise wird darunter
|
||||
# als zweiter Schluessel auf denselben Eintrag erzeugt.
|
||||
_BASE = [
|
||||
("klyqa.lighting.rgb-cw-ww.e27", "e27_rgb", "Klyqa E27 RGB CW/WW"),
|
||||
("klyqa.lighting.rgb-cw-ww.e14", "e14_rgb", "Klyqa E14 RGB CW/WW"),
|
||||
("klyqa.lighting.rgb-cw-ww.gu10", "gu10_rgb", "Klyqa GU10 RGB CW/WW"),
|
||||
("klyqa.lighting.cw-ww.e27", "e27_cw", "Klyqa E27 CW/WW"),
|
||||
("klyqa.lighting.cw-ww.e14", "e14_cw", "Klyqa E14 CW/WW"),
|
||||
("klyqa.lighting.cw-ww.gu10", "gu10_cw", "Klyqa GU10 CW/WW"),
|
||||
("klyqa.lighting.cw-ww.g95", "g95_cw", "Klyqa G95 Vintage CW/WW"),
|
||||
("qcx.montana.black.ledshield.rgb", "ledshield_rgb", "Montana Black RGB LED Shield"),
|
||||
]
|
||||
|
||||
_UNSAFE = re.compile(r"[^0-9A-Za-z+\-_.]")
|
||||
# Die Produkt-ID darf kein "_" enthalten - daran trennen wir Version und ID.
|
||||
_PATH = re.compile(r"^/qcx_firmware_([^/_]+)_([^/_]+)_ota_light\.img$")
|
||||
# "-dev" in der Version, aber nicht als Anfang eines laengeren Wortes ("-devel", "-dev2"):
|
||||
# erlaubt sind danach nur Ende oder ein Trennzeichen wie "." (z.B. "V1.1-dev.3").
|
||||
_DEV_IN_VERSION = re.compile(r"-dev(?![0-9A-Za-z])")
|
||||
|
||||
|
||||
def websafe(product_id):
|
||||
"""Ersetzt jedes Zeichen ausserhalb [0-9A-Za-z+-_.] durch '-', wie BuildOTAURL()
|
||||
in qc_ota.c der alten Firmware. Praktisch betrifft das nur das fuehrende '@'."""
|
||||
return _UNSAFE.sub("-", product_id)
|
||||
|
||||
|
||||
def _build():
|
||||
t = {}
|
||||
for pid, target, name in _BASE:
|
||||
base_key = websafe("@" + pid)
|
||||
product = Product(base_key, target, name)
|
||||
# Beide Schreibweisen zeigen auf DENSELBEN Eintrag - "-dev" an der Produkt-ID
|
||||
# sagt seit dem Hardwarebefund nichts mehr ueber das Ziel, nur noch (mit) etwas
|
||||
# ueber die Variante, und die ermittelt parse_request().
|
||||
t[base_key] = product
|
||||
t[websafe("@" + pid + "-dev")] = product
|
||||
# Das EVK hat nur eine Produktions-ID. Im Dev-Build meldet es sich laut
|
||||
# qcl_product_info.c als "@klyqa.lighting.rgb-cw-ww.e27-dev" - die EVK-Zeile ist dort
|
||||
# auskommentiert und durch die E27 ersetzt. Ein dev-signiertes EVK ist damit von einer
|
||||
# dev-signierten E27 RGB NICHT unterscheidbar und bekommt deren Image. Das EVK wird
|
||||
# ohnehin seriell geflasht; die Zeile steht nur der Vollstaendigkeit halber hier.
|
||||
# Meldet es eine "-dev"-Version, gilt auch hier die Regel: dann wird evk_dev.img
|
||||
# gesucht (und mangels Datei sauber mit 404 abgelehnt), nicht das falsch signierte.
|
||||
key = websafe("@klyqa.lighting.w600.evk1.2")
|
||||
t[key] = Product(key, "evk", "W600 EVK v1.2")
|
||||
return t
|
||||
|
||||
|
||||
PRODUCTS = _build()
|
||||
|
||||
|
||||
def classify_variant(product_id, lamp_version):
|
||||
"""(variant, variant_source) aus Produkt-ID UND Version.
|
||||
|
||||
"-dev" an einer der beiden Stellen bedeutet Entwicklungsgeraet; variant_source sagt,
|
||||
an welcher es stand: "both", "product_id", "version" oder - bei prod - "none"."""
|
||||
in_id = product_id.endswith("-dev")
|
||||
in_version = bool(_DEV_IN_VERSION.search(lamp_version))
|
||||
if in_id and in_version:
|
||||
return "dev", "both"
|
||||
if in_id:
|
||||
return "dev", "product_id"
|
||||
if in_version:
|
||||
return "dev", "version"
|
||||
return "prod", "none"
|
||||
|
||||
|
||||
def parse_request(path):
|
||||
"""Zerlegt einen angefragten Pfad. Gibt None zurueck, wenn er nicht dem Muster der
|
||||
alten Firmware entspricht, sonst ParsedRequest - dessen .product ist None, wenn die
|
||||
Produkt-ID unbekannt ist. Unbekannt heisst 404, nicht raten. .variant/.variant_source
|
||||
stehen auch bei unbekannter Produkt-ID, sie haengen nicht an der Tabelle."""
|
||||
m = _PATH.match(path)
|
||||
if not m:
|
||||
return None
|
||||
pid, version = m.group(1), m.group(2)
|
||||
if pid != websafe(pid) or "/" in pid or ".." in pid.split("."):
|
||||
return None
|
||||
variant, source = classify_variant(pid, version)
|
||||
return ParsedRequest(pid, version, PRODUCTS.get(pid), variant, source)
|
||||
+472
-38
@@ -1,9 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ninlight recovery station.
|
||||
"""ninlight recovery/migration 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.
|
||||
Serves firmware images to lamps that look for a rescue station while booting (mode
|
||||
"recovery": ninlight_<target>_ota.img) or to old Klyqa lamps looking for their original
|
||||
update server (mode "migration": qcx_firmware_<product-id>_<version>_ota_light.img).
|
||||
Deliberately minimal: only files matching one of these two exact patterns ever leave the
|
||||
machine, and only the pattern for the active mode is served.
|
||||
|
||||
Standard library only - the Pi gets no extra packages.
|
||||
"""
|
||||
@@ -24,7 +26,15 @@ 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")
|
||||
# products.py lives next to this file. sys.path[0] already covers that when server.py is
|
||||
# run the normal way (as a script or via `python3 server.py ...`), but a test that loads
|
||||
# this module through importlib.util.spec_from_file_location (see test_server.py's
|
||||
# test_log_error_is_not_silenced_by_log_message) does not get that for free.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import products
|
||||
|
||||
TARGETS = ("evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw",
|
||||
"ledshield_rgb")
|
||||
PATH_RE = re.compile(r"^/ninlight_(" + "|".join(TARGETS) + r")_ota\.img$")
|
||||
TRAILER_SIZE = 512
|
||||
TRAILER_MAGIC = b"NINL"
|
||||
@@ -53,24 +63,266 @@ def trailer_version(path: Path) -> str:
|
||||
return tail[VERSION_OFF:VERSION_OFF + VERSION_LEN].rstrip(b"\x00").decode("ascii", "replace")
|
||||
|
||||
|
||||
# --- Bildsatz: mit oder ohne einkompilierte WLAN-Zugangsdaten -----------------------
|
||||
#
|
||||
# Die WLAN-Zugangsdaten stecken zur BAUZEIT in der Firmware (config.local.cmake). Es gibt
|
||||
# deshalb zwei Abzuege je Ziel, und beide liegen auf dem Pi:
|
||||
#
|
||||
# "ohne-wlan" -> images/migrate-nocreds/ aus tools/build_nocreds.sh: die migrierte
|
||||
# Lampe startet im eigenen Onboarding-Zugangspunkt (ninlight-<mac4>)
|
||||
# und wartet, bis ihr jemand ein WLAN nennt.
|
||||
# "mit-wlan" -> images/migrate-wlan/ aus build/: die Lampe haengt sofort im WLAN
|
||||
# des Bedieners.
|
||||
#
|
||||
# Welcher ausgeliefert wird, entscheidet ein UMSCHALTER (ninlight-station bildsatz bzw.
|
||||
# der Menueeintrag am Taster), nicht eine Abfrage je Migration: sein Zustand bleibt, bis
|
||||
# ihn jemand aendert, und er steht dauerhaft auf dem Panel.
|
||||
#
|
||||
# Vorgabe ist "ohne-wlan", und zwar in JEDEM Zweifelsfall (Datei fehlt, unlesbar,
|
||||
# unbekannter Inhalt). Begruendung: im Automatikmodus wird jede alte Klyqa-Lampe in
|
||||
# Funkreichweite migriert, auch fremde. Ein Abzug OHNE Zugangsdaten ist dann ein
|
||||
# aergerlicher, aber harmloser Fehlgriff - die Lampe steht im Onboarding-AP und wartet.
|
||||
# Ein Abzug MIT Zugangsdaten traegt das WLAN-Passwort des Bedieners unwiderruflich in
|
||||
# ein fremdes Geraet. Der teurere Fehler darf nie der sein, der ohne Zutun passiert.
|
||||
IMAGE_SET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"}
|
||||
DEFAULT_IMAGE_SET = "ohne-wlan"
|
||||
|
||||
|
||||
def read_image_set(path: Path | None) -> str:
|
||||
"""Der eingestellte Bildsatz, oder DEFAULT_IMAGE_SET, wenn die Datei fehlt, unlesbar
|
||||
ist oder etwas Unbekanntes enthaelt. Wirft nie - eine kaputte Umschalterdatei darf
|
||||
den Server nicht abschiessen, und sie darf vor allem nicht auf den teureren der
|
||||
beiden Saetze zeigen (siehe IMAGE_SET_DIRS)."""
|
||||
if path is None:
|
||||
return DEFAULT_IMAGE_SET
|
||||
try:
|
||||
value = path.read_text().strip()
|
||||
except OSError:
|
||||
return DEFAULT_IMAGE_SET
|
||||
return value if value in IMAGE_SET_DIRS else DEFAULT_IMAGE_SET
|
||||
|
||||
|
||||
class Station:
|
||||
def __init__(self, image_dir: Path, state_path: Path, stall_after: int | None) -> None:
|
||||
def __init__(self, image_dir: Path, state_path: Path, stall_after: int | None,
|
||||
mode: str = "recovery", control_path: Path | None = None,
|
||||
imageset_path: Path | None = None) -> None:
|
||||
self.image_dir = image_dir
|
||||
# Bei JEDER Anfrage neu gelesen, nicht einmal beim Start: der Umschalter soll
|
||||
# wirken, ohne den Dienst neu zu starten - ein Neustart mitten in einer
|
||||
# laufenden Auslieferung waere genau das, was die Station nicht tun darf.
|
||||
self.imageset_path = imageset_path
|
||||
self.state_path = state_path
|
||||
self.stall_after = stall_after
|
||||
self.mode = mode
|
||||
self.control_path = control_path or Path("/run/ninlight-station/control.json")
|
||||
self.since = now_iso()
|
||||
self.served = 0
|
||||
self.recent: list[dict] = []
|
||||
# Zuletzt erkanntes Modell im Migrationsmodus, auch wenn (noch) nichts ausgeliefert
|
||||
# wurde - das ist es, was die Anzeige gross darstellt (Spec 5.2).
|
||||
self.last_seen: dict | None = None
|
||||
self._manifest_mtime: float | None = None
|
||||
# Zu welchem Bildsatz der Cache gehoert - siehe _load_manifest().
|
||||
self._manifest_path: Path | None = None
|
||||
# Der geparste Inhalt von manifest.json (oder {}, wenn sie fehlt/kaputt/falsch
|
||||
# geformt ist) statt nur einer einzelnen Version - seit Fixrunde 2 (N2) traegt
|
||||
# jeder Eintrag seine eigene Version, image_version() muss also den ganzen
|
||||
# Eintragssatz vorhalten, nicht nur einen String.
|
||||
self._manifest_cache: dict = {}
|
||||
# Wird True, wenn write_control() zuletzt fehlgeschlagen ist (z.B. volles oder
|
||||
# schreibgeschuetztes /run) - claim_delivery() liest das, um "armed" nicht
|
||||
# dauerhaft scharf zu lassen, nur weil das Verbrauchen der Freigabe nicht auf
|
||||
# Platte geschrieben werden konnte (Fixrunde 1, I3). control_error landet in
|
||||
# status.json, damit ein Bedienfehler nicht nur auf stdout verschwindet.
|
||||
self._control_write_broken = False
|
||||
self.control_error: str | None = None
|
||||
# Cumulative bytes already delivered to each client while --stall-after is active,
|
||||
# keyed by client IP. A lamp whose download stalls re-opens a fresh connection and
|
||||
# resumes with Range right where it left off, so a per-request counter would hand
|
||||
# every reconnect a brand-new --stall-after allowance and the stall would never
|
||||
# actually stick (see reserve_stall_bytes below).
|
||||
self.stall_sent: dict[str, int] = {}
|
||||
# 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.
|
||||
# can be downloading at once. Every mutation of served/recent/stall_sent 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 image_set(self) -> str:
|
||||
"""Der gerade eingestellte Bildsatz ("ohne-wlan"/"mit-wlan")."""
|
||||
return read_image_set(self.imageset_path)
|
||||
|
||||
def migrate_dir(self) -> Path:
|
||||
"""Das Verzeichnis des EINGESTELLTEN Bildsatzes. Es kann fehlen (der Satz wurde
|
||||
nie ausgeliefert) - dann wird NICHTS ausgeliefert, siehe Handler: auf den anderen
|
||||
Satz auszuweichen waere genau der Fehlgriff, gegen den der Umschalter da ist."""
|
||||
return self.image_dir / IMAGE_SET_DIRS[self.image_set()]
|
||||
|
||||
def migrate_image_path(self, target: str, variant: str) -> Path:
|
||||
return self.migrate_dir() / f"{target}_{variant}.img"
|
||||
|
||||
def migrate_set_present(self) -> bool:
|
||||
"""Liegt der eingestellte Bildsatz ueberhaupt auf dem Pi? "Vorhanden" heisst: das
|
||||
Verzeichnis gibt es UND es enthaelt mindestens ein Image. Ein leeres Verzeichnis
|
||||
zaehlt bewusst als nicht vorhanden - es entsteht genauso leicht (angelegt, aber nie
|
||||
bestueckt) und unterscheidet sich fuer den Bediener in nichts; das Panel und das
|
||||
CLI beurteilen es nach derselben Regel, sonst behaupten drei Stellen dreierlei."""
|
||||
try:
|
||||
return any(p.name.endswith(".img") for p in self.migrate_dir().iterdir())
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def _load_manifest(self) -> dict:
|
||||
"""Geparster Inhalt von manifest.json IM EINGESTELLTEN Bildsatz, neu eingelesen
|
||||
bei jeder Aenderung der Datei (mtime-Vergleich). Fehlt sie, ist sie
|
||||
unlesbar/kaputtes JSON, oder ist ihr Wurzelelement kein Objekt, ist das Ergebnis
|
||||
{} - dieselbe Formpruefung wie beim Merge in deploy.sh (Fixrunde 2, N1), damit
|
||||
eine falsch geformte, aber syntaktisch gueltige Datei nicht mit einem
|
||||
AttributeError den Server abschiesst."""
|
||||
path = self.migrate_dir() / "manifest.json"
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
self._manifest_mtime = None
|
||||
self._manifest_path = path
|
||||
self._manifest_cache = {}
|
||||
return self._manifest_cache
|
||||
# Der Pfad gehoert mit in den Vergleich: nach einem Umschalten zeigt er auf eine
|
||||
# ANDERE Datei, die zufaellig dieselbe mtime tragen kann (beide entstehen im
|
||||
# selben Deploy, oft in derselben Sekunde). Ohne ihn behielte der Cache die
|
||||
# Versionen des vorigen Satzes - und die stuenden dann in status.json und am
|
||||
# Panel neben einem Image, das gar nicht von dort kommt.
|
||||
if mtime != self._manifest_mtime or path != getattr(self, "_manifest_path", None):
|
||||
self._manifest_path = path
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except (OSError, ValueError):
|
||||
data = {}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
self._manifest_cache = data
|
||||
self._manifest_mtime = mtime
|
||||
return self._manifest_cache
|
||||
|
||||
def image_version(self, target: str, variant: str) -> str:
|
||||
"""Firmware-Version fuer genau das Migrationsimage, das gerade ausgeliefert
|
||||
wird - die Migrationsimages selbst tragen keinen ninlight-Trailer, aus dem der
|
||||
Server sie lesen koennte (Spec Abschnitt 4). Bevorzugt die Version aus dem
|
||||
passenden Eintrag in manifest.json; nur wenn der Eintrag keine eigene traegt
|
||||
(Bestandskompatibilitaet mit einem manifestweiten Feld), faellt es auf das
|
||||
Feld auf oberster Ebene zurueck. Ein einzelnes manifestweites Feld allein
|
||||
waere seit dem Zusammenfuehren mehrerer Deploys (deploy.sh, Fixrunde 1 I1)
|
||||
schlicht nicht mehr wahr: uebernommene Alt-Eintraege koennen eine andere
|
||||
Version tragen als die gerade neu signierten (Fixrunde 2, N2)."""
|
||||
data = self._load_manifest()
|
||||
images = data.get("images")
|
||||
if isinstance(images, list):
|
||||
for entry in images:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("target") == target and entry.get("variant") == variant:
|
||||
version = entry.get("ninlight_version")
|
||||
if isinstance(version, str) and version:
|
||||
return version
|
||||
break
|
||||
top = data.get("ninlight_version")
|
||||
return top if isinstance(top, str) and top else "unbekannt"
|
||||
|
||||
def read_control(self):
|
||||
"""Phase und Freigabe. Fehlt die Datei oder ist sie unlesbar/kaputt, gilt "auto" -
|
||||
eine fehlende Steuerdatei darf eine Migration nie blockieren, sondern hoechstens
|
||||
bequemer machen als beabsichtigt. Bei jeder Anfrage frisch gelesen, damit
|
||||
"ninlight-station arm" ohne Neustart des Dienstes wirkt."""
|
||||
try:
|
||||
d = json.loads(self.control_path.read_text())
|
||||
except (OSError, ValueError):
|
||||
return "auto", None
|
||||
phase = d.get("phase")
|
||||
if phase not in ("auto", "listen", "armed"):
|
||||
return "auto", None
|
||||
return phase, d.get("armed_target")
|
||||
|
||||
def write_control(self, phase: str, armed_target: str | None) -> None:
|
||||
with self.lock:
|
||||
try:
|
||||
self.control_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=self.control_path.parent, prefix=".control-")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump({"phase": phase, "armed_target": armed_target}, f)
|
||||
# Dasselbe Muster wie write_state() unten, aus demselben Grund: der
|
||||
# Dienst laeuft als root, mkstemp() legt 0600 an, und ohne das
|
||||
# explizite chmod() koennte das CLI die Steuerdatei nicht lesen.
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, self.control_path)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
except OSError as e:
|
||||
self._control_write_broken = True
|
||||
self.control_error = f"cannot write {self.control_path}: {e}"
|
||||
print(f"warning: {self.control_error}", flush=True)
|
||||
else:
|
||||
self._control_write_broken = False
|
||||
self.control_error = None
|
||||
|
||||
def claim_delivery(self, target: str) -> tuple[str, str | None, bool]:
|
||||
"""Entscheidet unter dem Lock, ob eine Anfrage fuer `target` gerade ausgeliefert
|
||||
werden darf, und verbraucht - falls die Freigabe dafuer verantwortlich ist - diese
|
||||
sofort per Compare-and-Set (lesen und auf "listen" setzen, ohne das Lock
|
||||
dazwischen loszulassen). Ohne das koennten zwei gleichzeitige Anfragen (zwei
|
||||
Lampen, oder Erst- und Range-Resume-Verbindung derselben Lampe) beide phase ==
|
||||
"armed" sehen und beide ausgeliefert bekommen (Fixrunde 1, C1) - genau das Szenario,
|
||||
fuer das die Freigabephase laut Spec 3.3 da ist.
|
||||
|
||||
Liefert (phase, armed_target) wie sie VOR dem Verbrauch galten, plus ob die
|
||||
aktuelle Anfrage ausliefern darf. Bricht die Auslieferung anschliessend ab
|
||||
(status == "aborted") oder findet der Aufrufer kein Image, muss er bei
|
||||
phase == "armed" restore_armed(target) aufrufen, damit die Freigabe fuer den
|
||||
naechsten Bootversuch stehen bleibt.
|
||||
|
||||
Schlaegt das Schreiben von "listen" fehl (I3), bekommt nur die eine Anfrage, die
|
||||
den Fehler zuerst ausloest, noch ihre Auslieferung - jede weitere schlaegt hier
|
||||
erneut fehl (_control_write_broken bleibt True), bis ein write_control()-Versuch
|
||||
wieder klappt. Damit bleibt "armed" nicht dauerhaft scharf, nur weil /run gerade
|
||||
nicht beschreibbar ist, heilt sich aber von selbst, sobald es das wieder ist."""
|
||||
with self.lock:
|
||||
phase, armed_target = self.read_control()
|
||||
if phase == "listen" or (phase == "armed" and armed_target != target):
|
||||
return phase, armed_target, False
|
||||
if phase == "armed":
|
||||
was_broken = self._control_write_broken
|
||||
self.write_control("listen", None)
|
||||
if self._control_write_broken:
|
||||
return phase, armed_target, not was_broken
|
||||
return phase, armed_target, True
|
||||
|
||||
def restore_armed(self, target: str) -> None:
|
||||
"""Setzt eine per claim_delivery() verbrauchte Freigabe zurueck, wenn die
|
||||
Auslieferung danach doch nicht (vollstaendig) stattgefunden hat - ein Abbruch darf
|
||||
die Freigabe nicht verbrauchen, sonst muesste am Geraet erneut von Hand gearmt
|
||||
werden, obwohl die Lampe es beim naechsten Bootversuch selbst nochmal versuchen
|
||||
wuerde (Spec 3.3, Brief-Schritt 3.6)."""
|
||||
self.write_control("armed", target)
|
||||
|
||||
def reserve_stall_bytes(self, ip: str, n: int) -> int:
|
||||
"""How many of the next `n` bytes this client may still receive under --stall-after.
|
||||
|
||||
Tracks bytes cumulatively per client IP, across requests, so a stalled connection
|
||||
that the lamp abandons and resumes via Range does not get a fresh budget just
|
||||
because it is a new TCP connection. Only called when stall_after is set.
|
||||
"""
|
||||
with self.lock:
|
||||
sent_so_far = self.stall_sent.get(ip, 0)
|
||||
allowed = max(0, min(n, self.stall_after - sent_so_far))
|
||||
self.stall_sent[ip] = sent_so_far + allowed
|
||||
return allowed
|
||||
|
||||
def record(self, ip: str, target: str, version: str, nbytes: int, status: str) -> None:
|
||||
with self.lock:
|
||||
if status == "ok":
|
||||
@@ -80,9 +332,41 @@ class Station:
|
||||
del self.recent[:-RECENT_MAX]
|
||||
self.write_state()
|
||||
|
||||
def record_migration(self, ip: str, target: str | None, variant: str | None,
|
||||
product_id: str, lamp_version: str, product_name: str | None,
|
||||
version: str, nbytes: int, status: str,
|
||||
variant_source: str | None = None) -> None:
|
||||
"""Protokolliert eine Migrationsanfrage. Anders als record() oben wird hier auch
|
||||
dann ein Eintrag angelegt (Status "listen"/"unknown"/"notfound"), wenn nichts
|
||||
ausgeliefert wurde - das ist der ganze Zweck der Horchphase. last_seen wird nur bei
|
||||
einer erkannten Produkt-ID gesetzt (target is not None); eine unbekannte Produkt-ID
|
||||
hat kein Modell, das die Anzeige zeigen koennte."""
|
||||
with self.lock:
|
||||
if status == "ok":
|
||||
self.served += 1
|
||||
entry = {"ts": now_iso(), "ip": ip, "target": target, "variant": variant,
|
||||
"variant_source": variant_source,
|
||||
"product_id": product_id, "lamp_version": lamp_version,
|
||||
"version": version, "bytes": nbytes, "status": status}
|
||||
self.recent.append(entry)
|
||||
del self.recent[:-RECENT_MAX]
|
||||
if target is not None:
|
||||
self.last_seen = {"product_id": product_id, "target": target,
|
||||
"variant": variant, "variant_source": variant_source,
|
||||
"product_name": product_name,
|
||||
"lamp_version": lamp_version, "ip": ip, "ts": entry["ts"]}
|
||||
self.write_state()
|
||||
|
||||
def write_state(self) -> None:
|
||||
with self.lock:
|
||||
payload = {"since": self.since, "served": self.served, "recent": list(self.recent)}
|
||||
payload = {"since": self.since, "mode": self.mode, "served": self.served,
|
||||
"recent": list(self.recent)}
|
||||
if self.mode == "migration":
|
||||
phase, armed_target = self.read_control()
|
||||
payload["phase"] = phase
|
||||
payload["armed_target"] = armed_target
|
||||
payload["last_seen"] = self.last_seen
|
||||
payload["control_error"] = self.control_error
|
||||
try:
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-")
|
||||
@@ -119,12 +403,26 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt: str, *args) -> None: # noqa: D102 - quieter default log
|
||||
pass
|
||||
|
||||
def log_error(self, fmt: str, *args) -> None:
|
||||
# BaseHTTPRequestHandler.log_error() calls log_message() by default, which we
|
||||
# silence above in favour of our own log() calls - but that also silences it for
|
||||
# requests that never reach do_GET/do_POST (a malformed request line, a client
|
||||
# timeout mid-request). The station is the only diagnostic tool available during a
|
||||
# rescue, so those cases must still produce a line instead of nothing.
|
||||
try:
|
||||
client = self.client_address[0]
|
||||
except (AttributeError, IndexError):
|
||||
client = "?"
|
||||
print(f"{now_iso()} {client} ERROR {fmt % args}", flush=True)
|
||||
|
||||
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:
|
||||
started = time.monotonic()
|
||||
self.reject(405, "method not allowed")
|
||||
self.log(405, 0, started)
|
||||
|
||||
do_PUT = do_DELETE = do_HEAD = do_POST
|
||||
|
||||
@@ -139,6 +437,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_GET(self) -> None:
|
||||
started = time.monotonic()
|
||||
if self.station.mode == "migration":
|
||||
self._do_get_migration(started)
|
||||
else:
|
||||
self._do_get_recovery(started)
|
||||
|
||||
def _do_get_recovery(self, started: float) -> None:
|
||||
m = PATH_RE.match(self.path)
|
||||
if not m:
|
||||
self.reject(404, "not found")
|
||||
@@ -154,6 +458,90 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.log(404, 0, started)
|
||||
return
|
||||
|
||||
version = trailer_version(path)
|
||||
sent, status = self._serve_file(path, started)
|
||||
# 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.station.record(self.client_address[0], target, version, sent, status)
|
||||
|
||||
def _do_get_migration(self, started: float) -> None:
|
||||
parsed = products.parse_request(self.path)
|
||||
if parsed is None:
|
||||
# Ein fremder Pfad, kein qcx_firmware_*-Muster - kein Protokolleintrag in
|
||||
# status.json, das waere nur Rauschen (Brief Schritt 3.4).
|
||||
self.reject(404, "not found")
|
||||
self.log(404, 0, started)
|
||||
return
|
||||
pid, lamp_version, product, variant, variant_source = parsed
|
||||
ip = self.client_address[0]
|
||||
if product is None:
|
||||
self.station.record_migration(ip, None, None, pid, lamp_version, None, "?", 0,
|
||||
"unknown")
|
||||
self.reject(404, "unknown product")
|
||||
self.log(404, 0, started)
|
||||
return
|
||||
|
||||
# variant/variant_source kommen aus parse_request(), nicht mehr aus der
|
||||
# Produkttabelle: das "-dev" kann in der Produkt-ID stehen, in der Version, oder in
|
||||
# beiden (Hardwarebefund 2026-09-15, siehe products.py). Das entscheidet, welches
|
||||
# der beiden unterschiedlich signierten Images ausgeliefert wird.
|
||||
target, name = product.target, product.name
|
||||
version = self.station.image_version(target, variant)
|
||||
# claim_delivery() liest die Phase UND verbraucht eine armed-Freigabe atomar unter
|
||||
# station.lock (Fixrunde 1, C1) - zwischen dem Lesen und dem Verbrauchen darf keine
|
||||
# zweite gleichzeitige Anfrage hindurchschluepfen, siehe die Methode selbst.
|
||||
phase, armed_target, allowed = self.station.claim_delivery(target)
|
||||
|
||||
if not allowed:
|
||||
# last_seen wird trotzdem gesetzt - das ist der ganze Zweck der Horchphase:
|
||||
# die Anzeige soll zeigen, welches Modell da ist, auch wenn nichts rausgeht.
|
||||
self.station.record_migration(ip, target, variant, pid, lamp_version, name,
|
||||
version, 0, "listen", variant_source)
|
||||
self.reject(404, "listening only")
|
||||
self.log(404, 0, started)
|
||||
return
|
||||
|
||||
path = self.station.migrate_image_path(target, variant)
|
||||
if not self.station.migrate_set_present():
|
||||
# Der EINGESTELLTE Bildsatz liegt gar nicht auf dem Pi. Hier wird bewusst
|
||||
# nichts ausgeliefert und auch nicht auf den anderen Satz ausgewichen: der
|
||||
# Umschalter existiert genau dafuer, dass niemand versehentlich Lampen mit
|
||||
# den eigenen WLAN-Zugangsdaten flasht. Ein eigener Status, damit der
|
||||
# Bediener am Panel den Unterschied zu "dieses Modell fehlt" sieht.
|
||||
if phase == "armed":
|
||||
self.station.restore_armed(target)
|
||||
self.station.record_migration(ip, target, variant, pid, lamp_version, name,
|
||||
version, 0, "noimageset", variant_source)
|
||||
self.reject(404, "selected image set not available")
|
||||
self.log(404, 0, started)
|
||||
return
|
||||
if not path.is_file():
|
||||
if phase == "armed":
|
||||
# claim_delivery() hat die Freigabe schon verbraucht, bevor wir wussten,
|
||||
# dass es gar kein Image gibt - das darf nicht auf Kosten der Freigabe gehen.
|
||||
self.station.restore_armed(target)
|
||||
self.station.record_migration(ip, target, variant, pid, lamp_version, name,
|
||||
version, 0, "notfound", variant_source)
|
||||
self.reject(404, "no image for this device")
|
||||
self.log(404, 0, started)
|
||||
return
|
||||
|
||||
sent, status = self._serve_file(path, started)
|
||||
if phase == "armed" and status == "aborted":
|
||||
# Bricht der Transfer ab, bleibt die Freigabe stehen, damit die Lampe es beim
|
||||
# naechsten Boot noch einmal versuchen kann (Spec 3.3). claim_delivery() hat sie
|
||||
# bereits auf "listen" gesetzt, bevor _serve_file() ueberhaupt anfing zu senden
|
||||
# (das ist der Punkt von C1) - bei einem Abbruch wird das hier zurueckgenommen.
|
||||
self.station.restore_armed(target)
|
||||
self.station.record_migration(ip, target, variant, pid, lamp_version, name, version,
|
||||
sent, status, variant_source)
|
||||
|
||||
def _serve_file(self, path: Path, started: float) -> tuple[int, str]:
|
||||
"""Sendet `path` mit Range-Unterstuetzung; liefert (gesendete Bytes, Status).
|
||||
Gemeinsam fuer Rettungs- und Migrationsroute - Uebertragungsmechanik (Range,
|
||||
--stall-after, Abbruchbehandlung) ist identisch, nur was mit dem Ergebnis
|
||||
geschieht, unterscheidet sich."""
|
||||
size = path.stat().st_size
|
||||
start = 0
|
||||
rng = self.headers.get("Range", "")
|
||||
@@ -162,7 +550,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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))
|
||||
@@ -175,51 +562,88 @@ class Handler(BaseHTTPRequestHandler):
|
||||
sent = 0
|
||||
status = "ok"
|
||||
remaining = size - start
|
||||
client_ip = self.client_address[0]
|
||||
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
|
||||
read_size = min(CHUNK, remaining - sent)
|
||||
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))
|
||||
# Reserve against this client's cumulative budget, not just this
|
||||
# request's byte count - see Station.reserve_stall_bytes.
|
||||
read_size = self.station.reserve_stall_bytes(client_ip, read_size)
|
||||
if read_size == 0:
|
||||
# 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")
|
||||
chunk = f.read(read_size)
|
||||
if not chunk:
|
||||
# Die Datei ist kuerzer als das eingangs per stat() ermittelte
|
||||
# Content-Length (z.B. von deploy.sh waehrend einer laufenden
|
||||
# Auslieferung durch eine kleinere ersetzt) - sent < remaining an
|
||||
# dieser Stelle immer, denn die Schleifenbedingung oben haette sonst
|
||||
# schon abgebrochen. Das ist ein abgebrochener, kein erfolgreicher
|
||||
# Transfer (Fixrunde 1, I2): ohne dies wuerde eine im Migrationsmodus
|
||||
# laufende "armed"-Freigabe faelschlich als verbraucht gelten, obwohl
|
||||
# die Lampe nie das vollstaendige Image bekommen hat.
|
||||
status = "aborted"
|
||||
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)
|
||||
return sent, status
|
||||
|
||||
|
||||
def resolve_mode(explicit_mode: str | None, mode_file: Path | None) -> str:
|
||||
"""Bestimmt den Betriebsmodus. Vorrang: ein ausdrueckliches --mode schlaegt
|
||||
--mode-file, --mode-file schlaegt die Vorgabe "recovery". Eine fehlende oder
|
||||
unlesbare/unerwartete Modusdatei bedeutet ebenfalls "recovery" - Task 4 baut die
|
||||
systemd-Unit darauf auf, dass der Modus ohne Neustart-Argumentaenderung aus einer
|
||||
Datei kommt (/run/ninlight-station/mode), und ein fehlerhafter Zustand dieser Datei
|
||||
darf den Dienst nicht zum Absturz bringen oder unbeabsichtigt scharf schalten."""
|
||||
if explicit_mode is not None:
|
||||
return explicit_mode
|
||||
if mode_file is not None:
|
||||
try:
|
||||
text = mode_file.read_text().strip()
|
||||
except OSError:
|
||||
return "recovery"
|
||||
if text in ("recovery", "migration"):
|
||||
return text
|
||||
return "recovery"
|
||||
return "recovery"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="ninlight recovery station HTTP server")
|
||||
p = argparse.ArgumentParser(description="ninlight recovery/migration 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("--mode", choices=("recovery", "migration"), default=None,
|
||||
help="ausdruecklicher Modus - schlaegt --mode-file (Vorgabe: siehe dort)")
|
||||
p.add_argument("--mode-file", type=Path, default=None,
|
||||
help="Datei mit 'recovery' oder 'migration'; wird ohne --mode verwendet, "
|
||||
"Vorgabe bei fehlender/kaputter Datei ist 'recovery'")
|
||||
p.add_argument("--control", type=Path, default=Path("/run/ninlight-station/control.json"),
|
||||
help="Steuerdatei fuer Phase/Freigabe im Migrationsmodus")
|
||||
p.add_argument("--imageset-file", type=Path, default=None,
|
||||
help="Datei mit 'ohne-wlan' oder 'mit-wlan' (Umschalter des "
|
||||
"Migrations-Bildsatzes); Vorgabe bei fehlender/kaputter Datei "
|
||||
"ist 'ohne-wlan'")
|
||||
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)
|
||||
@@ -228,13 +652,23 @@ def main(argv: list[str] | None = None) -> int:
|
||||
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)
|
||||
mode = resolve_mode(a.mode, a.mode_file)
|
||||
station = Station(a.image_dir, a.state, a.stall_after, mode=mode, control_path=a.control,
|
||||
imageset_path=a.imageset_file)
|
||||
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): "
|
||||
print(f"{now_iso()} listening on {a.bind}:{a.port}, mode={mode}, {len(images)} image(s): "
|
||||
f"{', '.join(images) or 'none'}", flush=True)
|
||||
if mode == "migration":
|
||||
# Nur im Migrationsmodus: der Bildsatz betrifft ausschliesslich migrate/-Images.
|
||||
# Im Journal, weil "warum kam das Image mit meinem WLAN?" sonst nirgends
|
||||
# nachschlagbar waere - das Panel zeigt nur den JETZIGEN Zustand.
|
||||
selected = station.image_set()
|
||||
present = "vorhanden" if station.migrate_set_present() else "FEHLT"
|
||||
print(f"{now_iso()} image set: {selected} ({station.migrate_dir()}, {present})",
|
||||
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:
|
||||
|
||||
+543
-68
@@ -1,47 +1,432 @@
|
||||
#!/usr/bin/env bash
|
||||
# Richtet einen Raspberry Pi als ninlight-Recovery-Station ein. Idempotent:
|
||||
# mehrfaches Ausfuehren aendert nichts und schadet nicht.
|
||||
# Richtet einen Raspberry Pi als ninlight-Station ein. Idempotent: mehrfaches
|
||||
# Ausfuehren aendert nichts und schadet nicht - auch nicht auf einem Pi, auf dem
|
||||
# schon eine aeltere Version dieses Skripts gelaufen ist (siehe "Konfiguration"
|
||||
# unten: fehlende Schluessel werden ergaenzt, vorhandene bleiben unangetastet).
|
||||
#
|
||||
# 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.
|
||||
# Ueberschreibbar (wie NINLIGHT_STATION_ROOT unten und 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}
|
||||
# Wo die per scp uebertragenen Dateien auf dem Zielsystem zwischenlanden, bevor
|
||||
# sie an ihren endgueltigen Platz installiert werden. Ueberschreibbar wie die
|
||||
# anderen NINLIGHT_*-Pfade: auf dem Pi bleibt es bei /tmp, ein Test kann hier in
|
||||
# eine Sandbox statt in das echte /tmp des Entwicklungsrechners umleiten
|
||||
# (Fixrunde 2, N4) - sowohl fuer die lokale scp-Zielangabe unten als auch fuer
|
||||
# die REMOTE-Bloecke, die dieselbe Variable (mit demselben Default) referenzieren.
|
||||
STAGE_DIR=${NINLIGHT_STAGE_DIR:-/tmp}
|
||||
|
||||
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"'
|
||||
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||
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"
|
||||
REMOTE
|
||||
|
||||
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"
|
||||
"$SCP_BIN" -q "$HERE/station.conf.example" "$HOST:$STAGE_DIR/ninlight-station.conf.example"
|
||||
# CONF ueberschreibbar wie ROOT_DIR/BIN_PATH/UNIT_PATH unten: auf dem Pi bleibt es
|
||||
# beim Default, ein Test kann hier gegen eine Sandbox-Datei statt gegen echte
|
||||
# Systempfade laufen.
|
||||
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||
set -e
|
||||
CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf}
|
||||
TEMPLATE=${NINLIGHT_STAGE_DIR:-/tmp}/ninlight-station.conf.example
|
||||
if [ -f "$CONF" ]; then
|
||||
# Eine bestehende Konfiguration bleibt bei ihren Werten - nur Schluessel, die
|
||||
# eine neuere Version des CLI zusaetzlich verlangt (z.B. die Migrations- und
|
||||
# Anzeige-Schluessel aus Task 4), werden aus der Vorlage ergaenzt. Ohne das
|
||||
# wuerde das Ausrollen eines neuen ninlight-station auf einen bereits
|
||||
# eingerichteten Pi jeden Unterbefehl - "off" eingeschlossen - mit "Schluessel
|
||||
# ... fehlt" blockieren, und ein bereits laufender Zugangspunkt bliebe an, bis
|
||||
# jemand die 0600-Datei von Hand ergaenzt (Fixrunde 1, I4).
|
||||
ADDED=""
|
||||
for KEY in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION \
|
||||
MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do
|
||||
# Nicht nur "^KEY=": eine vorhandene Zuweisung mit fuehrendem Leerraum oder
|
||||
# einem "export" davor (beides gueltiges ". datei"-Shell) wuerde sonst als
|
||||
# fehlend gelten, die Vorlagenzeile wird angehaengt, und da das CLI die
|
||||
# Datei per ". $CONF" sourct, gewinnt die LETZTE Zuweisung - ein eigenes
|
||||
# Passwort wuerde so still durch den oeffentlichen Vorlagenwert ersetzt
|
||||
# (Fixrunde 2, N3: genau das "vorhandene Werte nicht ueberschreiben" aus I4,
|
||||
# nur eine Formatierungsvariante weiter).
|
||||
if ! sudo grep -Eq "^[[:space:]]*(export[[:space:]]+)?${KEY}=" "$CONF"; then
|
||||
LINE=$(grep -E "^${KEY}=" "$TEMPLATE" || true)
|
||||
if [ -n "$LINE" ]; then
|
||||
printf '%s\n' "$LINE" | sudo tee -a "$CONF" >/dev/null
|
||||
ADDED="$ADDED $KEY"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ -n "$ADDED" ]; then
|
||||
echo " $CONF: fehlende Schluessel ergaenzt:$ADDED (vorhandene Werte unveraendert)"
|
||||
else
|
||||
sudo install -m 0600 /tmp/ninlight-station.conf.example /etc/ninlight-station.conf
|
||||
echo " /etc/ninlight-station.conf aus der Vorlage angelegt"
|
||||
echo " $CONF existiert bereits, alle Schluessel vorhanden"
|
||||
fi
|
||||
rm -f /tmp/ninlight-station.conf.example'
|
||||
else
|
||||
sudo install -m 0600 "$TEMPLATE" "$CONF"
|
||||
echo " $CONF aus der Vorlage angelegt"
|
||||
fi
|
||||
rm -f "$TEMPLATE"
|
||||
REMOTE
|
||||
|
||||
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 "== Server, CLI und Units installieren =="
|
||||
# Reststand der Ablage wegraeumen, BEVOR das scp laeuft (Task 6b F1, I1). "scp -r quelle
|
||||
# ziel" kopiert das Quellverzeichnis IN ein bereits vorhandenes Ziel hinein - aus
|
||||
# einem liegengebliebenen "…/ninlight-display/" wird dann "…/ninlight-display/display/",
|
||||
# die Installationsschleife unten ueberspringt Verzeichnisse und findet gar nichts mehr.
|
||||
# Ergebnis waere ein Lauf, der Erfolg meldet, den Anzeigedienst aktiviert und ein leeres
|
||||
# $ROOT_DIR/display hinterlaesst: 203/EXEC im 5-Sekunden-Takt, Panel schwarz. Genau so
|
||||
# ein Reststand entsteht, wenn ein frueherer Lauf nach dem scp abbricht (sudo-Timeout,
|
||||
# Platte voll, Strg-C) - die Aufraeumzeile unten laeuft dann nicht mehr. Idempotent
|
||||
# heisst, dass auch dieser Fall glattgeht (siehe Kopf dieser Datei).
|
||||
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||
set -e
|
||||
STAGE_DIR=${NINLIGHT_STAGE_DIR:-/tmp}
|
||||
rm -rf "$STAGE_DIR/ninlight-display" "$STAGE_DIR/ninlight-button"
|
||||
REMOTE
|
||||
"$SCP_BIN" -q "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" \
|
||||
"$HERE/ninlight-station.service" "$HERE/ninlight-display.service" "$HERE/ninlight-button.service" \
|
||||
"$HERE/ninlight-poweroff.service" "$HERE/ninlight-reboot.service" \
|
||||
"$HOST:$STAGE_DIR/"
|
||||
# Das Anzeigeverzeichnis als Ganzes: render.py, display.py, epdconfig.py, die beiden
|
||||
# uebernommenen Panel-Treiber und HERKUNFT.md (der Herkunftsnachweis gehoert mit auf den
|
||||
# Pi). "scp -r" statt rsync, weil setup_pi.sh - anders als deploy.sh - ausdruecklich nur
|
||||
# ssh/scp voraussetzt; __pycache__ wird auf der Gegenseite weggeraeumt (unten).
|
||||
"$SCP_BIN" -q -r "$HERE/display" "$HOST:$STAGE_DIR/ninlight-display"
|
||||
# Das Tasterverzeichnis genauso: als Ganzes, mit demselben "vorher aufraeumen"-Schutz
|
||||
# gegen ein "scp -r" auf ein bereits vorhandenes Ziel (siehe Kommentar oben zu
|
||||
# ninlight-display).
|
||||
"$SCP_BIN" -q -r "$HERE/button" "$HOST:$STAGE_DIR/ninlight-button"
|
||||
# Pfade als Variablen mit Default (wie in deploy.sh): so kann ein Test die
|
||||
# Installation gegen ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen
|
||||
# lassen.
|
||||
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||
set -e
|
||||
STAGE_DIR=${NINLIGHT_STAGE_DIR:-/tmp}
|
||||
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}
|
||||
DISPLAY_UNIT_PATH=${NINLIGHT_DISPLAY_UNIT:-/etc/systemd/system/ninlight-display.service}
|
||||
BUTTON_UNIT_PATH=${NINLIGHT_BUTTON_UNIT:-/etc/systemd/system/ninlight-button.service}
|
||||
POWEROFF_UNIT_PATH=${NINLIGHT_POWEROFF_UNIT:-/etc/systemd/system/ninlight-poweroff.service}
|
||||
REBOOT_UNIT_PATH=${NINLIGHT_REBOOT_UNIT:-/etc/systemd/system/ninlight-reboot.service}
|
||||
|
||||
echo "== NetworkManager-Profil =="
|
||||
# Die Ablage wird in JEDEM Fall abgeraeumt, auch wenn dieser Block vorzeitig aussteigt
|
||||
# (Task 6b F2, N1). Vorher stand das "rm -rf" nur am Blockende und wurde beim Abbruch
|
||||
# uebersprungen - die uebertragenen Dateien blieben unter /tmp liegen und waren beim
|
||||
# naechsten Lauf genau der Reststand, gegen den der Block eigentlich absichert.
|
||||
cleanup_stage() {
|
||||
rm -rf "$STAGE_DIR/server.py" "$STAGE_DIR/products.py" "$STAGE_DIR/ninlight-station" \
|
||||
"$STAGE_DIR/ninlight-station.service" "$STAGE_DIR/ninlight-display.service" \
|
||||
"$STAGE_DIR/ninlight-display" "$STAGE_DIR/ninlight-button.service" \
|
||||
"$STAGE_DIR/ninlight-button" "$STAGE_DIR/ninlight-poweroff.service" \
|
||||
"$STAGE_DIR/ninlight-reboot.service"
|
||||
}
|
||||
trap cleanup_stage EXIT
|
||||
|
||||
rm -rf "$STAGE_DIR/ninlight-display/__pycache__" "$STAGE_DIR/ninlight-button/__pycache__"
|
||||
# Harte Vorbedingung, und zwar VOR dem ersten "sudo install" (Task 6b F2, N1): ohne
|
||||
# display.py in der Ablage hat das scp nicht geliefert, was es sollte - abgebrochen,
|
||||
# falsch verschachtelt oder gar nicht gelaufen. Die Installationsschleife weiter unten
|
||||
# wuerde das stillschweigend als "nichts zu tun" verbuchen, der Lauf meldete Erfolg und
|
||||
# der aktivierte Anzeigedienst liefe gegen eine nicht existierende Datei.
|
||||
#
|
||||
# Die Pruefung stand zuerst NACH den vier "sudo install" darunter - die Meldung "Nichts
|
||||
# installiert" war damit schlicht unwahr: server.py, products.py, das CLI und die
|
||||
# Stations-Unit lagen dann bereits auf dem Pi. Ausgerechnet an dem Werkbank-Moment, fuer
|
||||
# den diese Pruefung gedacht ist, log das Skript den Bediener an. Jetzt stimmt der Satz.
|
||||
if [ ! -f "$STAGE_DIR/ninlight-display/display.py" ]; then
|
||||
echo "Fehler: $STAGE_DIR/ninlight-display/display.py fehlt - die Uebertragung des Anzeigeverzeichnisses ist fehlgeschlagen." >&2
|
||||
echo " Nichts installiert: $ROOT_DIR, $BIN_PATH und alle drei Units sind unveraendert. Ablage aufgeraeumt, Lauf einfach wiederholen." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Dieselbe harte Vorbedingung wie bei display.py, aus demselben Grund: ohne button.py in
|
||||
# der Ablage hat das scp nicht geliefert, was es sollte, und die Installationsschleife
|
||||
# unten wuerde das stillschweigend als "nichts zu tun" verbuchen.
|
||||
if [ ! -f "$STAGE_DIR/ninlight-button/button.py" ]; then
|
||||
echo "Fehler: $STAGE_DIR/ninlight-button/button.py fehlt - die Uebertragung des Tasterverzeichnisses ist fehlgeschlagen." >&2
|
||||
echo " Nichts installiert: $ROOT_DIR, $BIN_PATH und alle drei Units sind unveraendert. Ablage aufgeraeumt, Lauf einfach wiederholen." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo install -d -m 0755 "$ROOT_DIR" "$ROOT_DIR/images" "$ROOT_DIR/display" "$ROOT_DIR/button"
|
||||
# Dauerhafter Zustand, der einen Neustart ueberleben muss - bisher nur der Umschalter des
|
||||
# Migrations-Bildsatzes (/var/lib/ninlight-station/imageset). BEWUSST NICHT in /run: der
|
||||
# Umschalter ist ein Zustand, den der Bediener setzt und der bis zur naechsten Aenderung
|
||||
# gilt; ein tmpfs wuerde ihn bei jedem Neustart stillschweigend zuruecksetzen.
|
||||
#
|
||||
# 0755, weil der Anzeigedienst die Datei lesen muss (er zeigt den Zustand dauerhaft auf
|
||||
# dem Panel). Die Datei SELBST wird hier nicht angelegt: fehlt sie, gilt in CLI, Server
|
||||
# und Anzeige derselbe sichere Vorgabewert "ohne-wlan" - eine hier vorgeschriebene Datei
|
||||
# waere eine zweite Quelle fuer denselben Wert.
|
||||
#
|
||||
# Die beiden Bildsatzverzeichnisse werden hier ebenfalls NICHT angelegt: ein leeres
|
||||
# Verzeichnis ist fuer den Bediener nicht von einem bestueckten zu unterscheiden, und
|
||||
# genau das soll das Panel melden koennen ("SATZ FEHLT"). Sie entstehen in deploy.sh,
|
||||
# sobald wirklich Images hineinkommen.
|
||||
STATE_DIR=${NINLIGHT_STATION_STATE_DIR:-/var/lib/ninlight-station}
|
||||
sudo install -d -m 0755 "$STATE_DIR"
|
||||
sudo install -m 0755 "$STAGE_DIR/server.py" "$ROOT_DIR/server.py"
|
||||
sudo install -m 0644 "$STAGE_DIR/products.py" "$ROOT_DIR/products.py"
|
||||
sudo install -m 0755 "$STAGE_DIR/ninlight-station" "$BIN_PATH"
|
||||
sudo install -m 0644 "$STAGE_DIR/ninlight-station.service" "$UNIT_PATH"
|
||||
# Anzeige: display.py ist ausfuehrbar (die Unit ruft es zwar ueber /usr/bin/python3 auf,
|
||||
# "ninlight-station display test" aber auch von Hand), der Rest ist importierter Code.
|
||||
for f in "$STAGE_DIR"/ninlight-display/*; do
|
||||
[ -f "$f" ] || continue
|
||||
case "$(basename "$f")" in
|
||||
display.py) sudo install -m 0755 "$f" "$ROOT_DIR/display/$(basename "$f")" ;;
|
||||
*) sudo install -m 0644 "$f" "$ROOT_DIR/display/$(basename "$f")" ;;
|
||||
esac
|
||||
done
|
||||
# Was im Repo geloescht wurde, muss auch hier verschwinden - dasselbe Argument wie in
|
||||
# deploy.sh (dort per "rsync --delete"): display/ ist reiner Code aus dem Repo, es gibt
|
||||
# keinen Bestand, der einen Lauf ueberleben muesste, und ein zurueckgebliebenes altes
|
||||
# Modul wuerde weiter mit importiert. rsync steht hier nicht zur Verfuegung (setup_pi.sh
|
||||
# setzt ausdruecklich nur ssh/scp voraus), also von Hand.
|
||||
for f in "$ROOT_DIR"/display/*; do
|
||||
[ -e "$f" ] || continue
|
||||
if [ ! -e "$STAGE_DIR/ninlight-display/$(basename "$f")" ]; then
|
||||
sudo rm -rf "$f"
|
||||
echo " veraltete Datei entfernt: display/$(basename "$f")"
|
||||
fi
|
||||
done
|
||||
sudo install -m 0644 "$STAGE_DIR/ninlight-display.service" "$DISPLAY_UNIT_PATH"
|
||||
# Taster: dieselbe Behandlung wie die Anzeige - button.py ist ausfuehrbar (die Unit ruft
|
||||
# es zwar ueber /usr/bin/python3 auf, von Hand pruefen laesst es sich trotzdem), der Rest
|
||||
# (aktuell nichts weiter) waere importierter Code.
|
||||
for f in "$STAGE_DIR"/ninlight-button/*; do
|
||||
[ -f "$f" ] || continue
|
||||
case "$(basename "$f")" in
|
||||
button.py) sudo install -m 0755 "$f" "$ROOT_DIR/button/$(basename "$f")" ;;
|
||||
*) sudo install -m 0644 "$f" "$ROOT_DIR/button/$(basename "$f")" ;;
|
||||
esac
|
||||
done
|
||||
# Dasselbe Argument wie bei display/: button/ ist reiner Code aus dem Repo.
|
||||
for f in "$ROOT_DIR"/button/*; do
|
||||
[ -e "$f" ] || continue
|
||||
if [ ! -e "$STAGE_DIR/ninlight-button/$(basename "$f")" ]; then
|
||||
sudo rm -rf "$f"
|
||||
echo " veraltete Datei entfernt: button/$(basename "$f")"
|
||||
fi
|
||||
done
|
||||
sudo install -m 0644 "$STAGE_DIR/ninlight-button.service" "$BUTTON_UNIT_PATH"
|
||||
# Abschlussbild beim Herunterfahren und beim Neustart (zwei Units, zwei Texte).
|
||||
sudo install -m 0644 "$STAGE_DIR/ninlight-poweroff.service" "$POWEROFF_UNIT_PATH"
|
||||
sudo install -m 0644 "$STAGE_DIR/ninlight-reboot.service" "$REBOOT_UNIT_PATH"
|
||||
# Das Aufraeumen der Ablage macht der EXIT-Trap oben - auf beiden Wegen aus diesem Block.
|
||||
sudo systemctl daemon-reload
|
||||
echo " installiert"
|
||||
REMOTE
|
||||
|
||||
echo "== Pakete =="
|
||||
# Pillow kommt auf dem Pi bewusst aus dem Distributionspaket (python3-pil), nicht per
|
||||
# pip: tools/station/ laeuft dort unter dem System-Python 3.13, und Debian 13 laesst ein
|
||||
# "pip install" ins System-Python gar nicht mehr zu (PEP 668). Ohne Pillow zeichnet die
|
||||
# Anzeige nicht - display.py faengt den ImportError ab und beendet sich still mit 0
|
||||
# (Absicht, damit ein fehlendes Panel nie eine Migration verhindert). Genau deshalb muss
|
||||
# das Paket ueberhaupt hier kommen: sonst faellt es nirgends auf.
|
||||
#
|
||||
# Aber NACH der Installation und nur als Warnung (Task 6b F1, I4): eine Migrationsstation
|
||||
# steht typischerweise nicht am Netz, und mit veralteten Paketlisten oder ohne Internet
|
||||
# scheitert apt. Unter "set -e" vor der Installation haette das den Lauf abgebrochen,
|
||||
# bevor server.py, das CLI, beide Units und beide NetworkManager-Profile installiert sind
|
||||
# - man haette dann GAR KEINE Station statt einer Station ohne Panel. Das waere die
|
||||
# einzige harte Stelle in einer sonst durchgehend weichen Kette gewesen (display.py
|
||||
# beendet sich mit 0, deploy.sh warnt nur, enable/restart unten warnen nur).
|
||||
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||
set -e
|
||||
if python3 -c 'import PIL' 2>/dev/null; then
|
||||
echo " python3-pil ist bereits vorhanden"
|
||||
else
|
||||
# "update" zuerst: auf einem laenger nicht gepflegten Pi zeigen die Paketlisten auf
|
||||
# Versionen, die es im Archiv nicht mehr gibt, und "install" scheitert mit 404.
|
||||
#
|
||||
# Aber "|| true", und "install" laeuft UNABHAENGIG davon (Task 6b F2, N2): "apt-get
|
||||
# update" liefert schon dann einen Fehler, wenn eine einzige Quelle nicht erreichbar
|
||||
# ist - eine veraltete Drittanbieter-Liste, teilweises Netz. Mit "update && install"
|
||||
# bekaeme ein Pi, auf dem "install" aus dem Paketcache oder den erreichbaren Quellen
|
||||
# muehelos durchgelaufen waere, still kein Pillow. Das Auffrischen ist eine
|
||||
# Verbesserung der Chancen, keine Voraussetzung.
|
||||
sudo apt-get update >/dev/null 2>&1 || true
|
||||
if sudo apt-get install -y python3-pil; then
|
||||
echo " python3-pil installiert"
|
||||
else
|
||||
echo " Warnung: python3-pil liess sich nicht installieren (kein Netz? veraltete Paketlisten?)." >&2
|
||||
echo " Die Station ist vollstaendig eingerichtet, das E-Paper bleibt aber leer," >&2
|
||||
echo " bis 'sudo apt install python3-pil' durchlaeuft und der Anzeigedienst neu startet." >&2
|
||||
fi
|
||||
fi
|
||||
REMOTE
|
||||
|
||||
echo "== SPI aktivieren =="
|
||||
# Auf einem frischen Raspberry Pi OS ist SPI ausgeschaltet: dtparam=spi=on steht
|
||||
# auskommentiert in der Boot-Konfiguration, /dev/spidev0.0 fehlt, und der Anzeigedienst
|
||||
# meldet "E-Paper-Anzeige nicht verfuegbar" - das sieht nach fehlender Hardware aus,
|
||||
# ist aber nur ein abgeschalteter Bus. Ohne diesen Schritt haette jede frische Station
|
||||
# ein schwarzes Panel, bis jemand von Hand nachhilft (wie hier gestern geschehen).
|
||||
#
|
||||
# Die Aenderung wirkt erst nach einem Neustart der Boot-Konfiguration - das Skript
|
||||
# startet den Pi NICHT selbst neu (ein unerwarteter Reboot mitten in einer Fernsitzung
|
||||
# waere ueberraschender als ein Hinweis am Ende), meldet ueber den Exit-Code des
|
||||
# Remote-Blocks aber, ob einer noetig war (10 = geaendert, 0 = schon an, 20 = keine
|
||||
# Boot-Konfiguration gefunden). $SPI_REBOOT_NEEDED steuert unten, ob der Hinweis in den
|
||||
# naechsten Schritten erscheint - nur dann, wenn wirklich etwas geaendert wurde.
|
||||
SPI_REBOOT_NEEDED=0
|
||||
set +e
|
||||
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||
set -e
|
||||
# Ueberschreibbar wie die anderen NINLIGHT_*-Pfade oben: auf dem Pi bleibt es bei den
|
||||
# echten Pfaden, ein Test kann hier gegen Sandbox-Dateien statt gegen echte
|
||||
# Systempfade laufen.
|
||||
SPIDEV=${NINLIGHT_SPIDEV_PATH:-/dev/spidev0.0}
|
||||
CONFIG_NEW=${NINLIGHT_BOOT_CONFIG_NEW:-/boot/firmware/config.txt}
|
||||
CONFIG_OLD=${NINLIGHT_BOOT_CONFIG_OLD:-/boot/config.txt}
|
||||
|
||||
# Existiert das Geraeteknoten schon, ist SPI aktiv - unabhaengig davon, was in der
|
||||
# Boot-Konfiguration steht (es kann auch per Overlay oder einem anderen Mechanismus
|
||||
# kommen). Dann kein Eingriff und kein Neustart-Hinweis.
|
||||
if [ -e "$SPIDEV" ]; then
|
||||
echo " SPI ist bereits aktiv ($SPIDEV vorhanden)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Aktuelle Raspberry-Pi-OS-Images nutzen /boot/firmware/config.txt, aeltere
|
||||
# /boot/config.txt - beide werden geprueft, die neuere zuerst. Gibt es keinen von
|
||||
# beiden, ist das kein Grund abzubrechen: ein anderes Boot-Verfahren (oder gar kein
|
||||
# Raspberry Pi OS) ist moeglich, und die Anzeige ist nirgends Voraussetzung fuer eine
|
||||
# funktionierende Station (dasselbe Prinzip wie beim apt-Schritt und beim
|
||||
# Anzeigedienst oben).
|
||||
CONFIG=""
|
||||
if sudo test -f "$CONFIG_NEW"; then
|
||||
CONFIG="$CONFIG_NEW"
|
||||
elif sudo test -f "$CONFIG_OLD"; then
|
||||
CONFIG="$CONFIG_OLD"
|
||||
fi
|
||||
|
||||
if [ -z "$CONFIG" ]; then
|
||||
echo "Warnung: weder $CONFIG_NEW noch $CONFIG_OLD gefunden - vermutlich kein" >&2
|
||||
echo " Raspberry Pi OS oder ein anderes Boot-Verfahren. SPI konnte nicht" >&2
|
||||
echo " geprueft werden - bleibt das E-Paper schwarz, ist das der" >&2
|
||||
echo " wahrscheinlichste Grund." >&2
|
||||
exit 20
|
||||
fi
|
||||
|
||||
# Eine aktive (nicht auskommentierte) "dtparam=spi=on"-Zeile heisst: schon an - dann
|
||||
# nichts tun und keinen Neustart verlangen, den es nicht braucht.
|
||||
if sudo grep -Eq '^[[:space:]]*dtparam=spi=on[[:space:]]*$' "$CONFIG"; then
|
||||
echo " SPI ist in $CONFIG bereits eingeschaltet"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BACKUP="${CONFIG}.bak-ninlight"
|
||||
if sudo test -f "$BACKUP"; then
|
||||
# Nicht ueberschreiben: sonst waere beim zweiten Lauf (z.B. weil zwischendurch von
|
||||
# Hand wieder auf "off" gestellt wurde) der urspruengliche Originalzustand weg.
|
||||
echo " Sicherung $BACKUP existiert bereits - wird nicht ueberschrieben"
|
||||
else
|
||||
sudo cp -p "$CONFIG" "$BACKUP"
|
||||
echo " Sicherung angelegt: $BACKUP"
|
||||
fi
|
||||
|
||||
# Drei Faelle bleiben: die Zeile fehlt ganz, sie steht auskommentiert da, oder sie
|
||||
# steht aktiv auf "off" (z.B. von Hand deaktiviert). In allen dreien wird auf
|
||||
# "dtparam=spi=on" normalisiert - eine vorhandene Zeile wird ERSETZT statt eine
|
||||
# zweite anzuhaengen, sonst stuenden am Ende zwei widerspruechliche
|
||||
# dtparam=spi-Zeilen in der Datei und es waere Zufall, welche NetworkManager beim
|
||||
# naechsten Boot zuletzt liest.
|
||||
#
|
||||
# Kein "sed -i": dieser Block laeuft zwar auf dem Pi (GNU) unter "REMOTE", aber der
|
||||
# Stub-Test fuehrt ihn woertlich auf dem Entwicklungsrechner (macOS/BSD) aus - "sed
|
||||
# -i" braucht dort ein Suffix-Argument, GNU-sed keins, und die falsche Reihenfolge
|
||||
# war in diesem Projekt schon zweimal ein Fehler. Deshalb sed nur als Filter
|
||||
# (Eingabe->Ausgabe), das Ergebnis geht ueber eine Temp-Datei und "sudo install" an
|
||||
# den Zielpfad - das funktioniert auf beiden Plattformen identisch.
|
||||
if sudo grep -Eq '^[[:space:]]*#?[[:space:]]*dtparam=spi=(on|off)[[:space:]]*$' "$CONFIG"; then
|
||||
TMP_CONFIG=$(mktemp "${TMPDIR:-/tmp}/ninlight-spi-config.XXXXXX")
|
||||
sudo sed -E 's/^[[:space:]]*#?[[:space:]]*dtparam=spi=(on|off)[[:space:]]*$/dtparam=spi=on/' "$CONFIG" >"$TMP_CONFIG"
|
||||
sudo install -m 0644 -o root -g root "$TMP_CONFIG" "$CONFIG"
|
||||
rm -f "$TMP_CONFIG"
|
||||
echo " $CONFIG: dtparam=spi=on gesetzt (vorhandene Zeile aktiviert)"
|
||||
else
|
||||
printf '%s\n' 'dtparam=spi=on' | sudo tee -a "$CONFIG" >/dev/null
|
||||
echo " $CONFIG: dtparam=spi=on ergaenzt"
|
||||
fi
|
||||
|
||||
# Signalisiert dem Aufrufer, dass ein Neustart noetig ist (siehe oben).
|
||||
exit 10
|
||||
REMOTE
|
||||
SPI_RC=$?
|
||||
set -e
|
||||
case "$SPI_RC" in
|
||||
0) ;;
|
||||
10) SPI_REBOOT_NEEDED=1 ;;
|
||||
20) ;;
|
||||
*) echo "Warnung: SPI-Aktivierung ergab einen unerwarteten Fehler (rc=$SPI_RC) - /boot/firmware/config.txt bzw. /boot/config.txt von Hand pruefen." >&2 ;;
|
||||
esac
|
||||
|
||||
echo "== Anzeigedienst aktivieren =="
|
||||
# Die Anzeige wird aktiviert (startet also beim Booten mit) UND jetzt gestartet - anders
|
||||
# als die Station selbst, die absichtlich aus bleibt: die Anzeige sendet nichts, sie
|
||||
# zeigt nur an, und "Station aus" ist genau das, was nach dem Einrichten auf dem Panel
|
||||
# stehen soll. Erst hier, nach dem Paketschritt: so startet der Dienst bereits mit
|
||||
# vorhandenem Pillow, statt sich beim ersten Zeichnen still mit 0 zu beenden und bis zum
|
||||
# naechsten manuellen Neustart nichts anzuzeigen.
|
||||
#
|
||||
# Fehlt Panel oder Pillow, beendet sich der Dienst still mit 0 (siehe display.py) -
|
||||
# deshalb nur Warnungen: ein fehlendes Panel darf setup_pi.sh nicht abbrechen.
|
||||
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||
set -e
|
||||
sudo systemctl enable ninlight-display.service >/dev/null 2>&1 || \
|
||||
echo " Warnung: 'systemctl enable ninlight-display' fehlgeschlagen - die Anzeige startet nach einem Neustart des Pi nicht von allein mit" >&2
|
||||
sudo systemctl restart ninlight-display.service >/dev/null 2>&1 || \
|
||||
echo " Warnung: 'systemctl restart ninlight-display' fehlgeschlagen - journalctl -u ninlight-display" >&2
|
||||
echo " Anzeigedienst aktiviert"
|
||||
REMOTE
|
||||
|
||||
echo "== Tasterdienst aktivieren =="
|
||||
# Dieselbe weiche Behandlung wie die Anzeige, aus demselben Grund: der Taster ist
|
||||
# nirgends Voraussetzung. Fehlt gpiozero, fehlt der Taster oder ist GPIO23 belegt,
|
||||
# beendet sich button.py still mit 0 (siehe dessen Modul-Docstring) - ein
|
||||
# fehlgeschlagenes enable/start darf setup_pi.sh trotzdem nicht abbrechen, denn eine
|
||||
# Station ohne Taster muss vollstaendig benutzbar bleiben.
|
||||
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||
set -e
|
||||
sudo systemctl enable ninlight-button.service >/dev/null 2>&1 || \
|
||||
echo " Warnung: 'systemctl enable ninlight-button' fehlgeschlagen - der Taster startet nach einem Neustart des Pi nicht von allein mit" >&2
|
||||
sudo systemctl restart ninlight-button.service >/dev/null 2>&1 || \
|
||||
echo " Warnung: 'systemctl restart ninlight-button' fehlgeschlagen - journalctl -u ninlight-button" >&2
|
||||
echo " Tasterdienst aktiviert"
|
||||
REMOTE
|
||||
|
||||
echo "== Abschlussbild aktivieren =="
|
||||
# Zwei Units, zwei Texte: beim Herunterfahren "Strom kann getrennt werden", beim Neustart
|
||||
# "Strom NICHT trennen!". Ohne sie bliebe auf dem E-Paper fuer immer das letzte Bild
|
||||
# stehen - bei der Abnahme war das die Rueckfrage des Tastermenues.
|
||||
#
|
||||
# Dieselbe weiche Behandlung wie Anzeige und Taster: ein fehlgeschlagenes "enable" ist
|
||||
# eine Warnung, kein Abbruch. Eine Station, deren Panel nach dem Ausschalten das falsche
|
||||
# Bild zeigt, ist aergerlich; eine Station, die sich gar nicht einrichten laesst, ist
|
||||
# unbrauchbar. "enable" allein, kein "start": diese Units tun beim Systemstart NICHTS,
|
||||
# sie gehoeren zur Abschalt-Transaktion.
|
||||
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||
set -e
|
||||
for unit in ninlight-poweroff.service ninlight-reboot.service; do
|
||||
sudo systemctl enable "$unit" >/dev/null 2>&1 || \
|
||||
echo " Warnung: 'systemctl enable $unit' fehlgeschlagen - das E-Paper behaelt nach dem Herunterfahren sein letztes Bild" >&2
|
||||
done
|
||||
echo " Abschlussbild aktiviert (Herunterfahren und Neustart)"
|
||||
REMOTE
|
||||
|
||||
echo "== NetworkManager-Profile =="
|
||||
# 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
|
||||
@@ -56,53 +441,143 @@ echo "== NetworkManager-Profil =="
|
||||
# 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"
|
||||
CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf}
|
||||
NM_DIR=${NINLIGHT_NM_DIR:-/etc/NetworkManager/system-connections}
|
||||
eval "$(sudo cat "$CONF")"
|
||||
|
||||
# Die Keyfiles werden erst in einer Temp-Datei aufgebaut und dann per "install"
|
||||
# an ihren Platz gelegt - dazwischen liegt das WLAN-Passwort in einer Datei, die
|
||||
# noch nicht 0600 root:root ist. Ein Abbruch (set -e) genau dort darf sie nicht
|
||||
# liegen lassen. Ein EXIT-Trap in write_profile() selbst waere dafuer untauglich:
|
||||
# die Funktion laeuft zweimal, der zweite trap ueberschriebe den ersten, und
|
||||
# "$tmp" ist "local" - beim Auswerten des Traps am Skriptende gaebe es die
|
||||
# Variable gar nicht mehr. Deshalb ein gemeinsames Verzeichnis mit genau einem
|
||||
# Trap darum herum (0700, mktemp -d legt es so an).
|
||||
#
|
||||
# Zur Reichweite des Traps (Task 6b F2, N3 - die Begruendung hier war vorher falsch):
|
||||
# der EXIT-Trap allein WUERDE bei einem Strg-C schon reichen, bash fuehrt ihn auch beim
|
||||
# Abbruch durch ein nicht abgefangenes SIGINT aus (Exit 130). Der Grund fuer die
|
||||
# Signalhandler ist ein anderer: sie sollen den Exit-Code ehrlich halten. Ein Handler
|
||||
# fuer INT/TERM, der nicht selbst beendet, laesst bash naemlich NACH dem Handler
|
||||
# weiterlaufen - ein Strg-C mitten im Schreiben der Profile koennte dann in "fertig,
|
||||
# Exit 0" muenden, obwohl der Lauf abgebrochen wurde. Deshalb raeumt jeder Handler auf
|
||||
# und beendet danach ausdruecklich mit dem ueblichen Signal-Code (128+n).
|
||||
#
|
||||
# Mit ausdruecklicher Vorlage unter ${TMPDIR:-/tmp}, nicht als blankes "mktemp -d":
|
||||
# BSD-mktemp (macOS, wo diese Suite auch laeuft) beachtet TMPDIR ohne Vorlage gar nicht
|
||||
# und legt unabhaengig davon unter /var/folders/... an, GNU-mktemp (der Pi) dagegen
|
||||
# schon. Ohne Vorlage landet das Verzeichnis also je nach Plattform woanders - ein Test,
|
||||
# der nachsieht, ob wirklich nichts liegengeblieben ist, sucht dann auf macOS an der
|
||||
# falschen Stelle und ist gruen, ohne etwas geprueft zu haben.
|
||||
PROFILE_TMPDIR=$(mktemp -d "${TMPDIR:-/tmp}/ninlight-profile.XXXXXX")
|
||||
trap 'rm -rf "$PROFILE_TMPDIR"' EXIT
|
||||
trap 'rm -rf "$PROFILE_TMPDIR"; exit 130' INT
|
||||
trap 'rm -rf "$PROFILE_TMPDIR"; exit 143' TERM
|
||||
trap 'rm -rf "$PROFILE_TMPDIR"; exit 129' HUP
|
||||
|
||||
# $1 = Profilname, $2 = SSID, $3 = PSK, $4 = "update" (bestehendes Profil neu schreiben,
|
||||
# UUID behalten) oder "keep" (bestehendes Profil unangetastet lassen).
|
||||
#
|
||||
# Beide Profile bekommen dieselbe Adresse 192.168.8.1/24 auf wlan0 - genau deshalb
|
||||
# schliessen sie sich gegenseitig aus, und genau deshalb prueft ninlight-station's
|
||||
# ap_switch() nach jedem Umschalten nach, dass wirklich nur eines aktiv ist.
|
||||
write_profile() {
|
||||
local id=$1 ssid=$2 psk=$3 mode=$4
|
||||
# Ein leerer Profilname wuerde eine Datei ".nmconnection" anlegen, eine leere SSID
|
||||
# oder ein leeres PSK ein Profil, das NetworkManager beim Hochfahren stumm ablehnt -
|
||||
# in beiden Faellen faellt es erst auf, wenn jemand mit einer toten Lampe vor der
|
||||
# Station steht. Der Konfigurationsschritt oben ergaenzt fehlende Schluessel aus der
|
||||
# Vorlage; leer sein koennen sie trotzdem (von Hand geleert).
|
||||
if [ -z "$id" ] || [ -z "$ssid" ] || [ -z "$psk" ]; then
|
||||
echo "Fehler: Profilname, SSID oder PSK ist leer - $CONF pruefen (Profil '$id')" >&2
|
||||
return 1
|
||||
fi
|
||||
local conn_file="$NM_DIR/${id}.nmconnection"
|
||||
local uuid action tmp
|
||||
if sudo test -f "$conn_file"; then
|
||||
if [ "$mode" = "keep" ]; then
|
||||
# Der Migrations-AP ist auf manchen Pis von Hand angelegt worden (SSID/PSK sind
|
||||
# von der alten Klyqa-Firmware fest vorgegeben, es gibt also nichts zu variieren).
|
||||
# Ein bestehendes Profil wird deshalb nicht ueberschrieben - es koennte
|
||||
# handjustierte Felder (Kanal, Band, Regulierungsdomaene) tragen, die dieses
|
||||
# Skript gar nicht kennt.
|
||||
echo " Profil $id existiert bereits - unveraendert gelassen"
|
||||
return 0
|
||||
fi
|
||||
uuid=$(sudo sed -n 's/^uuid=//p' "$conn_file" | head -n1)
|
||||
action="aktualisiert"
|
||||
else
|
||||
# /proc/sys/kernel/random/uuid gibt es nur unter Linux (dem Pi) - der Fallback
|
||||
# auf uuidgen ist es, was einen Testlauf gegen den lokalen Stub (macOS) ueberhaupt
|
||||
# ermoeglicht, aendert aber nichts am Verhalten auf dem Pi selbst.
|
||||
uuid=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen | tr '[:upper:]' '[:lower:]')
|
||||
action="angelegt"
|
||||
fi
|
||||
tmp=$(mktemp "$PROFILE_TMPDIR/profile.XXXXXX")
|
||||
{
|
||||
printf '%s\n' '[connection]'
|
||||
printf 'id=%s\n' "$id"
|
||||
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 $id $action"
|
||||
}
|
||||
|
||||
write_profile "$AP_CONNECTION" "$SSID" "$PSK" update
|
||||
# Ohne dieses zweite Profil scheitert "ninlight-station migrate on" auf einem frisch
|
||||
# eingerichteten Pi an need_profile "$MIGRATE_AP_CONNECTION".
|
||||
write_profile "$MIGRATE_AP_CONNECTION" "$MIGRATE_SSID" "$MIGRATE_PSK" keep
|
||||
REMOTE
|
||||
|
||||
cat <<'EOF'
|
||||
# Der Neustart-Hinweis steht an eigener, auffaelliger Stelle VOR den "Naechsten
|
||||
# Schritten" - nicht dazwischengemischt - und nur, wenn oben wirklich etwas an der
|
||||
# Boot-Konfiguration geaendert wurde (SPI_REBOOT_NEEDED). Ein Hinweis bei jedem Lauf,
|
||||
# auch wenn SPI laengst an ist, wuerde nach ein paar Durchlaeufen ignoriert.
|
||||
SPI_NOTE=""
|
||||
if [ "$SPI_REBOOT_NEEDED" -eq 1 ]; then
|
||||
SPI_NOTE='
|
||||
WICHTIG: SPI wurde soeben erst eingeschaltet (Aenderung an der Boot-Konfiguration) -
|
||||
das wirkt erst nach einem Neustart. Bis dahin bleibt das E-Paper schwarz,
|
||||
auch wenn sonst alles richtig eingerichtet ist.
|
||||
ssh <pi-host> sudo reboot
|
||||
'
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Fertig. Die Station ist absichtlich noch AUS.
|
||||
|
||||
$SPI_NOTE
|
||||
Naechste Schritte:
|
||||
ssh <pi-host> ninlight-station display test
|
||||
Testbild aufs E-Paper - bleibt es leer oder
|
||||
verzerrt, steht EPD_REVISION in
|
||||
/etc/ninlight-station.conf auf der falschen
|
||||
Panel-Revision (V2 statt V3 oder umgekehrt)
|
||||
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)
|
||||
|
||||
Der Taster auf GPIO23 ist mit eingerichtet: kurzer Druck oeffnet das Menue am Geraet,
|
||||
3 s gehalten brechen eine laufende Migration/Rettung ab.
|
||||
EOF
|
||||
|
||||
@@ -24,3 +24,15 @@ HOME_CONNECTION='preconfigured'
|
||||
|
||||
# NetworkManager-Profil der Station selbst.
|
||||
AP_CONNECTION='ninlight-recovery'
|
||||
|
||||
# Von der alten Klyqa-Firmware fest vorgegeben (qcx-w600-firmware) - nicht
|
||||
# aenderbar, ohne dass alte Lampen die Migrationsstation nicht mehr finden.
|
||||
MIGRATE_SSID='qconnex-firmware'
|
||||
MIGRATE_PSK='qconnex-firmware'
|
||||
|
||||
# NetworkManager-Profil der Migrations-Station.
|
||||
MIGRATE_AP_CONNECTION='qconnex-firmware'
|
||||
|
||||
# Panel-Revision der E-Paper-Anzeige (V2 oder V3) - am Geraet nicht auslesbar,
|
||||
# siehe README.md.
|
||||
EPD_REVISION='V3'
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
"""Tests fuer den Tasterdienst (tools/station/button/button.py).
|
||||
|
||||
Die Bedienlogik ist reine Zustandsfuehrung und wird hier OHNE Hardware geprueft - aber
|
||||
bewusst NICHT, indem der Ablauf im Test nachgebaut wird: getrieben wird die ECHTE
|
||||
Hauptschleife button.run_daemon() mit einer Fake-Uhr, einem nach Zeitplan gedrueckten
|
||||
Taster-Doppel, dem ECHTEN CliActions (nur dessen subprocess.run ist ersetzt) und einem
|
||||
protokollierenden Schreiber.
|
||||
|
||||
Der Grund steht in display.py/test_render.py: bei der Zeitsteuerung der Anzeige trieben
|
||||
die Tests eine KOPIE der Verdrahtung zwischen Zustandsmaschine und Ausfuehrung, und man
|
||||
konnte die eigentliche Aktion durch eine falsche ersetzen, ohne dass ein Test rot wurde.
|
||||
Deshalb laeuft hier alles ueber dieselbe Schleife, die auch auf dem Pi laeuft."""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "button"))
|
||||
|
||||
import button # noqa: E402
|
||||
|
||||
|
||||
# --- Zustaende, von denen die Menueeintraege abhaengen -------------------------------
|
||||
|
||||
STATE_OFF = {"running": False, "mode": "recovery", "target": None,
|
||||
"imageset": "ohne-wlan"}
|
||||
STATE_RECOVERY = {"running": True, "mode": "recovery", "target": None,
|
||||
"imageset": "ohne-wlan"}
|
||||
STATE_MIGRATION = {"running": True, "mode": "migration", "target": "gu10_rgb",
|
||||
"imageset": "ohne-wlan"}
|
||||
STATE_MIGRATION_BLIND = {"running": True, "mode": "migration", "target": None,
|
||||
"imageset": "ohne-wlan"}
|
||||
|
||||
|
||||
# --- Fake-Uhr, Taster-Doppel, Protokoll ---------------------------------------------
|
||||
|
||||
|
||||
class _StopDaemon(Exception):
|
||||
"""Bricht run_daemon()s Endlosschleife ab, sobald die simulierte Zeit abgelaufen ist."""
|
||||
|
||||
|
||||
class _FakeClock:
|
||||
def __init__(self, until_s: float) -> None:
|
||||
self.t = 0.0
|
||||
self.until_s = until_s
|
||||
|
||||
def monotonic(self) -> float:
|
||||
return self.t
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self.t += seconds
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
self.t += seconds
|
||||
if self.t > self.until_s:
|
||||
raise _StopDaemon
|
||||
|
||||
|
||||
class _Recorder:
|
||||
"""Gemeinsame Zeitleiste von Schreiber und Ausfuehrung - nur so faellt auf, wenn die
|
||||
Reihenfolge in run_daemon() kippt (z.B. das "Bitte warten" erst NACH dem
|
||||
sekundenlangen CLI-Aufruf geschrieben wuerde)."""
|
||||
|
||||
def __init__(self, clock: _FakeClock) -> None:
|
||||
self.clock = clock
|
||||
self.events: list[tuple[float, str, object]] = []
|
||||
|
||||
def note(self, kind: str, payload: object) -> None:
|
||||
self.events.append((self.clock.monotonic(), kind, payload))
|
||||
|
||||
def views(self) -> list[tuple[float, dict]]:
|
||||
return [(t, p) for t, k, p in self.events if k == "view"]
|
||||
|
||||
def runs(self) -> list[tuple[float, list]]:
|
||||
return [(t, p) for t, k, p in self.events if k == "run"]
|
||||
|
||||
def argvs(self) -> list[list]:
|
||||
return [p for _t, p in self.runs()]
|
||||
|
||||
def kinds(self) -> list[str]:
|
||||
return [v.get("view") for _t, v in self.views()]
|
||||
|
||||
def first_view(self, name: str):
|
||||
for t, v in self.views():
|
||||
if v.get("view") == name:
|
||||
return t, v
|
||||
return None
|
||||
|
||||
|
||||
class _ScriptedButton:
|
||||
"""Taster-Doppel: gedrueckt genau waehrend der angegebenen Zeitfenster. `is_pressed`
|
||||
ist wie bei gpiozero ein PEGEL (die Entprellung macht dort bounce_time), keine
|
||||
Flanke - genau das, was run_daemon() abfragt."""
|
||||
|
||||
def __init__(self, clock: _FakeClock, presses) -> None:
|
||||
self.clock = clock
|
||||
self.presses = list(presses)
|
||||
|
||||
@property
|
||||
def is_pressed(self) -> bool:
|
||||
t = self.clock.monotonic()
|
||||
return any(start <= t < end for start, end in self.presses)
|
||||
|
||||
|
||||
class _RecordingWriter:
|
||||
"""Schreiber-Doppel: protokolliert jeden tatsaechlich geschriebenen Bedienzustand.
|
||||
Dedupliziert wie der echte UiWriter, damit die Zeitleiste die ANZEIGEWECHSEL zeigt
|
||||
und nicht 50 identische Zeilen je Sekunde."""
|
||||
|
||||
def __init__(self, rec: _Recorder) -> None:
|
||||
self.rec = rec
|
||||
self.path = Path("/dev/null")
|
||||
self._last = None
|
||||
|
||||
def write(self, view: dict) -> bool:
|
||||
if view == self._last:
|
||||
return False
|
||||
self._last = dict(view)
|
||||
self.rec.note("view", dict(view))
|
||||
return True
|
||||
|
||||
|
||||
def _fake_runner(clock: _FakeClock, rec: _Recorder, duration_s: float = 0.0, rc: int = 0):
|
||||
"""Ersetzt subprocess.run in CliActions - der Rest von CliActions (und damit die
|
||||
ZUSAMMENSETZUNG der Kommandozeile, auf die es sicherheitsrelevant ankommt) ist echt."""
|
||||
|
||||
def run(argv, check=False, timeout=None):
|
||||
rec.note("run", list(argv))
|
||||
# Ein CLI-Aufruf dauert Sekunden (Profilumschaltung) - die simulierte Uhr muss
|
||||
# das mitmachen, sonst pruefen die Tests eine Schleife, die es so nicht gibt.
|
||||
clock.advance(duration_s)
|
||||
return types.SimpleNamespace(returncode=rc)
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def _drive(presses, duration_s: float, state=STATE_OFF, action_duration_s: float = 0.0,
|
||||
rc: int = 0) -> _Recorder:
|
||||
"""Laesst das echte button.run_daemon() `duration_s` simulierte Sekunden laufen."""
|
||||
clock = _FakeClock(duration_s)
|
||||
rec = _Recorder(clock)
|
||||
actions = button.CliActions(cli="/usr/local/bin/ninlight-station",
|
||||
runner=_fake_runner(clock, rec, action_duration_s, rc))
|
||||
try:
|
||||
button.run_daemon(button=_ScriptedButton(clock, presses),
|
||||
writer=_RecordingWriter(rec), actions=actions, clock=clock,
|
||||
read_state=lambda: dict(state))
|
||||
except _StopDaemon:
|
||||
pass
|
||||
return rec
|
||||
|
||||
|
||||
def _short(at: float):
|
||||
"""Ein kurzer Druck (deutlich unter SHORT_PRESS_MAX_S, deutlich ueber DEBOUNCE_S)."""
|
||||
return (at, at + 0.2)
|
||||
|
||||
|
||||
# --- Menueaufbau --------------------------------------------------------------------
|
||||
|
||||
|
||||
def _labels(state):
|
||||
return [a.label for a in button.menu_actions(state)]
|
||||
|
||||
|
||||
def test_menu_off_offers_starting_and_shutdown():
|
||||
assert _labels(STATE_OFF) == ["Rettung starten", "Migration: horchen",
|
||||
"Migration: AUTOMATIK", "Umschalten: mit WLAN",
|
||||
"Herunterfahren"]
|
||||
|
||||
|
||||
def test_the_listening_migration_comes_before_the_automatic_one():
|
||||
"""Der horchende Modus ist der erste und naheliegende Eintrag - wer nur blaettert und
|
||||
irgendwo loslaesst, soll nicht im Automatikmodus landen."""
|
||||
labels = _labels(STATE_OFF)
|
||||
assert labels.index("Migration: horchen") < labels.index("Migration: AUTOMATIK")
|
||||
|
||||
|
||||
def test_menu_in_recovery_offers_stopping_refreshing_and_shutdown():
|
||||
assert _labels(STATE_RECOVERY) == ["Station aus", "Umschalten: mit WLAN",
|
||||
"Anzeige auffrischen", "Herunterfahren"]
|
||||
|
||||
|
||||
def test_menu_in_migration_offers_the_detected_target():
|
||||
assert _labels(STATE_MIGRATION) == ["Freigabe: gu10_rgb", "Station aus",
|
||||
"Umschalten: mit WLAN", "Anzeige auffrischen",
|
||||
"Herunterfahren"]
|
||||
|
||||
|
||||
def test_menu_in_migration_without_a_detected_target_offers_no_release():
|
||||
"""Ohne last_seen gibt es nichts freizugeben - der Eintrag faellt ganz weg, statt
|
||||
eine Freigabe ins Leere anzubieten (die das CLI ohnehin abweisen wuerde)."""
|
||||
assert "Freigabe: " not in " ".join(_labels(STATE_MIGRATION_BLIND))
|
||||
assert _labels(STATE_MIGRATION_BLIND) == ["Station aus", "Umschalten: mit WLAN",
|
||||
"Anzeige auffrischen", "Herunterfahren"]
|
||||
|
||||
|
||||
def test_only_the_refresh_entry_skips_the_confirmation():
|
||||
"""Zusage: JEDE veraendernde Auswahl bekommt eine Rueckfrage. "Anzeige auffrischen"
|
||||
veraendert nichts und braucht keine."""
|
||||
for state in (STATE_OFF, STATE_RECOVERY, STATE_MIGRATION):
|
||||
for action in button.menu_actions(state):
|
||||
assert action.confirm is (action.label != "Anzeige auffrischen"), action.label
|
||||
|
||||
|
||||
def test_actions_run_through_the_station_cli():
|
||||
"""Die Aktionen gehen ueber das vorhandene CLI, nicht durch nachgebaute Logik - und
|
||||
das Herunterfahren als einzige an systemd selbst."""
|
||||
actions = button.CliActions(cli="/usr/local/bin/ninlight-station")
|
||||
assert actions.argv_for(button.menu_actions(STATE_OFF)[0]) == [
|
||||
"/usr/local/bin/ninlight-station", "recovery", "on"]
|
||||
assert actions.argv_for(button.action_shutdown()) == ["systemctl", "poweroff"]
|
||||
|
||||
|
||||
# --- Bedienung, getrieben durch die echte Hauptschleife ------------------------------
|
||||
|
||||
|
||||
def test_a_short_press_opens_the_menu():
|
||||
rec = _drive([_short(1.0)], 4.0)
|
||||
assert rec.argvs() == []
|
||||
t, view = rec.first_view("menu")
|
||||
assert view["hero"] == "> Rettung starten"
|
||||
assert view["active"] is True
|
||||
# Spuerbar, aber bedienbar: das Menue steht binnen eines Schleifentakts nach dem
|
||||
# Loslassen (die Verzoegerung am Panel kommt aus dessen eigenem Takt, siehe
|
||||
# display.UI_POLL_INTERVAL_S).
|
||||
assert 1.2 <= t <= 1.3
|
||||
|
||||
|
||||
def test_a_short_press_steps_to_the_next_entry():
|
||||
rec = _drive([_short(1.0), _short(2.0)], 4.0)
|
||||
assert rec.argvs() == []
|
||||
assert rec.views()[-1][1]["hero"] == "> Migration: horchen"
|
||||
|
||||
|
||||
def test_a_short_press_wraps_around_the_last_entry():
|
||||
rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0), _short(5.0),
|
||||
_short(6.0)], 8.0)
|
||||
assert rec.views()[-1][1]["hero"] == "> Rettung starten"
|
||||
|
||||
|
||||
def test_holding_one_and_a_half_seconds_selects_the_entry():
|
||||
rec = _drive([_short(1.0), (2.0, 4.0)], 6.0)
|
||||
t, view = rec.first_view("confirm")
|
||||
assert view["hero"] == "Rettung starten"
|
||||
assert 3.5 <= t <= 3.6
|
||||
# Die Auswahl allein fuehrt noch nichts aus - dafuer ist die Rueckfrage da.
|
||||
assert rec.argvs() == []
|
||||
|
||||
|
||||
def test_holding_in_the_confirmation_executes_the_entry():
|
||||
rec = _drive([_short(1.0), (2.0, 4.0), (5.0, 7.0)], 9.0)
|
||||
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "recovery", "on"]]
|
||||
assert 6.5 <= rec.runs()[0][0] <= 6.6
|
||||
|
||||
|
||||
def test_a_short_press_in_the_confirmation_cancels_and_returns_to_the_menu():
|
||||
rec = _drive([_short(1.0), (2.0, 4.0), _short(5.0)], 8.0)
|
||||
assert rec.argvs() == []
|
||||
assert rec.views()[-1][1]["view"] == "menu"
|
||||
|
||||
|
||||
def test_the_refresh_entry_runs_without_a_confirmation():
|
||||
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 8.0,
|
||||
state=STATE_RECOVERY)
|
||||
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "display", "test"]]
|
||||
assert "confirm" not in rec.kinds()
|
||||
|
||||
|
||||
def test_the_listening_entry_calls_the_cli_with_listen():
|
||||
"""Der zweite Eintrag ("Migration: horchen") ruft das CLI MIT --listen: der
|
||||
Zugangspunkt steht, es geht aber nichts raus, bis jemand eine erkannte Lampe
|
||||
freigibt."""
|
||||
rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0), (6.0, 8.0)], 10.0)
|
||||
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "migrate", "on",
|
||||
"--listen"]]
|
||||
|
||||
|
||||
def test_the_automatic_entry_calls_the_cli_without_listen():
|
||||
"""Der dritte Eintrag ("Migration: AUTOMATIK") ruft dasselbe CLI OHNE --listen - das
|
||||
ist der ganze Unterschied zwischen den beiden Eintraegen, und er entscheidet, ob
|
||||
fremde Lampen ungefragt migriert werden."""
|
||||
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0), (7.0, 9.0)], 11.0)
|
||||
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "migrate", "on"]]
|
||||
assert "--listen" not in rec.argvs()[0]
|
||||
|
||||
|
||||
def test_the_automatic_entry_asks_a_louder_question_than_the_others():
|
||||
"""Zusage: die Rueckfrage des Automatikmodus benennt die Folge, statt nur "Wirklich?"
|
||||
zu fragen - und sie unterscheidet sich sichtbar von der der uebrigen Eintraege."""
|
||||
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 8.0)
|
||||
_t, view = rec.first_view("confirm")
|
||||
assert view["title"] == "ACHTUNG"
|
||||
assert view["hero"] == "Migration: AUTOMATIK"
|
||||
text = " ".join(view["details"]).lower()
|
||||
assert "jede" in text and "fremde" in text
|
||||
# Die Bedienhinweise bleiben trotzdem stehen, und zwar zuletzt.
|
||||
assert view["details"][-2:] == ["halten (1,5 s): ja", "kurz: nein"]
|
||||
|
||||
# Gegenprobe am horchenden Eintrag: uebliche Rueckfrage, keine Warnung.
|
||||
rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0)], 7.0)
|
||||
_t, plain = rec.first_view("confirm")
|
||||
assert plain["title"] == "RUECKFRAGE"
|
||||
assert plain["details"] == ["halten (1,5 s): ja", "kurz: nein"]
|
||||
|
||||
|
||||
def test_the_cancel_fires_at_three_seconds_while_the_button_is_still_held():
|
||||
"""Der Abbruch feuert BEI 3 s, nicht erst beim Loslassen - der Bediener soll die
|
||||
Wirkung am Panel sehen, waehrend er noch haelt."""
|
||||
rec = _drive([(1.0, 8.0)], 10.0)
|
||||
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "off"]]
|
||||
t = rec.runs()[0][0]
|
||||
assert 4.0 <= t <= 4.1
|
||||
# ... und danach laeuft am Panel der Countdown der zweiten Stufe.
|
||||
later = [v for vt, v in rec.views() if vt > t]
|
||||
assert any("Herunterfahren in" in v.get("hero", "") for v in later)
|
||||
|
||||
|
||||
def test_releasing_between_three_and_ten_seconds_does_not_shut_down():
|
||||
rec = _drive([(1.0, 7.0)], 12.0)
|
||||
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "off"]]
|
||||
assert ["systemctl", "poweroff"] not in rec.argvs()
|
||||
|
||||
|
||||
def test_holding_ten_seconds_shuts_down():
|
||||
rec = _drive([(1.0, 12.0)], 14.0)
|
||||
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "off"],
|
||||
["systemctl", "poweroff"]]
|
||||
assert 11.0 <= rec.runs()[1][0] <= 11.1
|
||||
|
||||
|
||||
def test_the_shutdown_countdown_is_visible_before_it_happens():
|
||||
"""Das Herunterfahren ist nur ueber eine SICHTBARE Zustandsaenderung hinweg
|
||||
erreichbar - der Countdown muss also vorher auf dem Panel gestanden haben."""
|
||||
rec = _drive([(1.0, 12.0)], 14.0)
|
||||
heroes = [v.get("hero", "") for t, v in rec.views() if t < 11.0]
|
||||
assert "Herunterfahren in 6 s" in heroes
|
||||
assert "Herunterfahren in 1 s" in heroes
|
||||
|
||||
|
||||
def test_a_blocking_action_does_not_break_the_hold_gesture():
|
||||
"""Der Abbruch-Aufruf blockiert die Schleife sekundenlang (Profilumschaltung). Danach
|
||||
wird derselbe PEGEL gegen dieselbe Uhr gemessen - die zweite Stufe muss trotzdem
|
||||
erreichbar bleiben."""
|
||||
rec = _drive([(1.0, 12.0)], 14.0, action_duration_s=2.0)
|
||||
assert rec.argvs()[-1] == ["systemctl", "poweroff"]
|
||||
|
||||
|
||||
def test_the_waiting_screen_is_written_before_the_slow_call():
|
||||
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 9.0,
|
||||
state=STATE_RECOVERY, action_duration_s=4.0)
|
||||
kinds = [k for _t, k, _p in rec.events]
|
||||
run_at = kinds.index("run")
|
||||
assert rec.events[run_at - 1][2].get("view") == "busy"
|
||||
|
||||
|
||||
def test_the_menu_closes_after_twenty_seconds_without_input():
|
||||
rec = _drive([_short(1.0)], 26.0)
|
||||
assert rec.kinds()[-1] == "idle"
|
||||
closed_at = [t for t, v in rec.views() if v.get("view") == "idle" and t > 2.0][0]
|
||||
assert 21.2 <= closed_at <= 21.4
|
||||
|
||||
|
||||
def test_the_confirmation_expires_after_ten_seconds_as_a_cancel():
|
||||
rec = _drive([_short(1.0), (2.0, 4.0)], 18.0)
|
||||
assert rec.argvs() == []
|
||||
assert rec.kinds()[-1] == "idle"
|
||||
expired_at = [t for t, v in rec.views() if v.get("view") == "idle" and t > 4.0][0]
|
||||
assert 13.5 <= expired_at <= 13.7
|
||||
|
||||
|
||||
def test_an_expired_confirmation_leaves_nothing_pending():
|
||||
"""Der Abbruch muss die vorgemerkte Aktion auch wirklich vergessen - eine Aktion, die
|
||||
nach dem Verfall noch in der Maschine liegt, ist genau das, was eine Rueckfrage
|
||||
verhindern soll."""
|
||||
ui = button.ButtonUI(0.0, read_state=lambda: dict(STATE_OFF))
|
||||
ui.update(1.0, True)
|
||||
ui.update(1.2, False) # kurzer Druck: Menue auf
|
||||
ui.update(2.0, True)
|
||||
ui.update(3.6, True) # 1,6 s gehalten: Eintrag gewaehlt -> Rueckfrage
|
||||
ui.update(3.8, False)
|
||||
assert ui.screen == "confirm" and ui.pending is not None
|
||||
|
||||
ui.update(3.6 + button.CONFIRM_TIMEOUT_S + 0.1, False)
|
||||
|
||||
assert ui.screen == "idle"
|
||||
assert ui.pending is None
|
||||
|
||||
|
||||
def test_a_failed_action_says_so_on_the_panel():
|
||||
rec = _drive([_short(1.0), (2.0, 4.0), (5.0, 7.0)], 9.0, rc=1)
|
||||
assert any(v.get("view") == "result" and v.get("title") == "FEHLER"
|
||||
for _t, v in rec.views())
|
||||
|
||||
|
||||
def test_nothing_happens_without_any_press():
|
||||
rec = _drive([], 30.0)
|
||||
assert rec.argvs() == []
|
||||
assert rec.kinds() == ["idle"]
|
||||
|
||||
|
||||
# --- Bedienzustandsdatei ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_ui_file_is_written_atomically_with_0644(tmp_path):
|
||||
"""Dasselbe Muster und dieselben Rechte wie status.json/control.json - ohne 0644
|
||||
kann der Anzeigedienst die Datei nicht lesen, ohne das Umbenennen sieht er sie halb
|
||||
geschrieben."""
|
||||
path = tmp_path / "run" / "ui.json"
|
||||
writer = button.UiWriter(path)
|
||||
view = {"view": "menu", "title": "MENUE", "hero": "> Station aus", "details": [],
|
||||
"active": True}
|
||||
# Unter einer strengen umask geschrieben: die Rechte muessen ausdruecklich gesetzt
|
||||
# werden, nicht zufaellig aus der umask des Dienstes fallen - sonst laege die Zusage
|
||||
# "der Anzeigedienst kann die Datei lesen" an einer Umgebungsvariablen des Systems.
|
||||
old_umask = os.umask(0o077)
|
||||
try:
|
||||
assert writer.write(view) is True
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
assert json.loads(path.read_text()) == view
|
||||
assert oct(os.stat(path).st_mode & 0o777) == "0o644"
|
||||
# Keine Temp-Datei zurueckgelassen (sie wuerde sonst mit jedem Neustart ein weiteres
|
||||
# Bruchstueck im RuntimeDirectory hinterlassen).
|
||||
assert [p.name for p in path.parent.iterdir()] == ["ui.json"]
|
||||
|
||||
|
||||
def test_the_ui_file_is_only_written_on_change(tmp_path):
|
||||
writer = button.UiWriter(tmp_path / "ui.json")
|
||||
view = {"view": "idle", "active": False}
|
||||
assert writer.write(view) is True
|
||||
assert writer.write(dict(view)) is False
|
||||
|
||||
|
||||
def test_an_unwritable_ui_file_is_survived_and_logged_once(tmp_path, caplog):
|
||||
"""Ein Fehler am Taster darf niemals eine laufende Migration stoeren - und eine Zeile
|
||||
je Schleifentakt wuerde das Journal fluten."""
|
||||
blocker = tmp_path / "blocker"
|
||||
blocker.write_text("kein Verzeichnis")
|
||||
writer = button.UiWriter(blocker / "ui.json")
|
||||
with caplog.at_level("WARNING", logger="ninlight-button"):
|
||||
assert writer.write({"view": "menu", "active": True}) is False
|
||||
assert writer.write({"view": "confirm", "active": True}) is False
|
||||
assert len(caplog.records) == 1
|
||||
|
||||
|
||||
# --- Der Taster ist nirgends Voraussetzung ------------------------------------------
|
||||
|
||||
|
||||
def test_missing_gpiozero_logs_exactly_one_line_and_exits_zero(monkeypatch, caplog):
|
||||
"""Dieselbe Zusage wie bei der Anzeige (dort seit fuenf Fixrunden): kein Taster ->
|
||||
GENAU EINE Protokollzeile und sys.exit(0), kein Fehler - sonst startet systemd
|
||||
(Restart=on-failure) den Dienst endlos neu und flutet das Journal."""
|
||||
monkeypatch.setitem(sys.modules, "gpiozero", None)
|
||||
with caplog.at_level("INFO", logger="ninlight-button"):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
button.main()
|
||||
assert excinfo.value.code == 0
|
||||
assert len(caplog.records) == 1
|
||||
assert "gpiozero" in caplog.records[0].getMessage()
|
||||
|
||||
|
||||
def test_a_busy_pin_is_treated_like_a_missing_button(monkeypatch, caplog):
|
||||
""""GPIO busy" kommt je nach Pin-Factory als gpiozero-Fehler, als roher lgpio.error
|
||||
oder als OSError - alles davon heisst "kein Taster", nicht "abstuerzen"."""
|
||||
class _Busy(Exception):
|
||||
pass
|
||||
|
||||
fake = types.SimpleNamespace(
|
||||
Button=lambda *a, **k: (_ for _ in ()).throw(_Busy("GPIO busy")))
|
||||
monkeypatch.setitem(sys.modules, "gpiozero", fake)
|
||||
with caplog.at_level("INFO", logger="ninlight-button"):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
button.main()
|
||||
assert excinfo.value.code == 0
|
||||
assert len(caplog.records) == 1
|
||||
|
||||
|
||||
def test_a_failing_cli_call_never_escapes(monkeypatch):
|
||||
"""subprocess-Fehler (Binary fehlt, Timeout) duerfen den Dienst nicht beenden."""
|
||||
def boom(argv, check=False, timeout=None):
|
||||
raise OSError("kein CLI da")
|
||||
|
||||
actions = button.CliActions(runner=boom)
|
||||
assert actions.run(button.action_cancel()) is False
|
||||
|
||||
def slow(argv, check=False, timeout=None):
|
||||
raise subprocess.TimeoutExpired(argv, timeout)
|
||||
|
||||
assert button.CliActions(runner=slow).run(button.action_cancel()) is False
|
||||
|
||||
|
||||
def test_an_unreadable_station_state_keeps_the_menu_closed(caplog):
|
||||
"""Menueeintraege haengen am Stationszustand. Ist der nicht zu ermitteln, bleibt das
|
||||
Menue zu, statt einen falschen Zustand zu behaupten - und der Dienst laeuft weiter."""
|
||||
ui = button.ButtonUI(0.0, read_state=lambda: (_ for _ in ()).throw(RuntimeError("weg")))
|
||||
with caplog.at_level("WARNING", logger="ninlight-button"):
|
||||
ui.update(0.0, True)
|
||||
assert ui.update(0.2, False) is None
|
||||
assert ui.view(0.3) == {"view": "idle", "active": False}
|
||||
|
||||
|
||||
# --- Bildsatz-Umschalter im Menue ----------------------------------------------------
|
||||
|
||||
|
||||
def test_the_image_set_entry_names_where_it_switches_to():
|
||||
"""Waehrend das Menue offen ist, ersetzt es das Statusbild samt Fusszeile - der
|
||||
Eintrag muss deshalb das ZIEL nennen, nicht den jetzigen Zustand."""
|
||||
assert "Umschalten: mit WLAN" in _labels(STATE_OFF)
|
||||
ohne = dict(STATE_OFF, imageset="mit-wlan")
|
||||
assert "Umschalten: ohne WLAN" in _labels(ohne)
|
||||
assert "Umschalten: mit WLAN" not in _labels(ohne)
|
||||
|
||||
|
||||
def test_the_image_set_entry_switches_through_the_cli():
|
||||
actions = button.CliActions(cli="/usr/local/bin/ninlight-station")
|
||||
assert actions.argv_for(button.action_imageset("ohne-wlan")) == [
|
||||
"/usr/local/bin/ninlight-station", "bildsatz", "mit-wlan"]
|
||||
assert actions.argv_for(button.action_imageset("mit-wlan")) == [
|
||||
"/usr/local/bin/ninlight-station", "bildsatz", "ohne-wlan"]
|
||||
|
||||
|
||||
def test_switching_to_wifi_credentials_asks_louder_than_switching_back():
|
||||
loud = button.action_imageset("ohne-wlan")
|
||||
back = button.action_imageset("mit-wlan")
|
||||
assert loud.confirm and back.confirm
|
||||
assert loud.confirm_title == "ACHTUNG"
|
||||
assert back.confirm_title == "RUECKFRAGE"
|
||||
assert "WLAN-Passwort" in " ".join(loud.confirm_details)
|
||||
assert back.confirm_details is None
|
||||
|
||||
|
||||
def test_the_image_set_entry_really_runs_the_switch(tmp_path):
|
||||
"""Durch die echte Hauptschleife, nicht nur ueber menu_actions(): vierter Eintrag im
|
||||
ausgeschalteten Zustand, Auswahl halten, Rueckfrage halten."""
|
||||
rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0),
|
||||
(5.0, 7.0), (8.0, 10.0)], 12.0)
|
||||
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "bildsatz", "mit-wlan"]]
|
||||
|
||||
|
||||
def test_station_state_reads_the_same_switch_file_as_the_cli(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(button, "_service_active", lambda service_name: False)
|
||||
switch = tmp_path / "imageset"
|
||||
state = lambda: button.station_state(run_dir=tmp_path, service_name="x",
|
||||
imageset_path=switch)
|
||||
assert state()["imageset"] == "ohne-wlan" # Datei fehlt -> sichere Vorgabe
|
||||
switch.write_text("mit-wlan\n")
|
||||
assert state()["imageset"] == "mit-wlan"
|
||||
switch.write_text("quatsch")
|
||||
assert state()["imageset"] == "ohne-wlan"
|
||||
Executable
+1493
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import products # noqa: E402
|
||||
|
||||
|
||||
def test_table_is_complete():
|
||||
"""Acht Geraete in beiden Schreibweisen der Produkt-ID, plus das EVK, das nur eine
|
||||
Produktions-ID hat."""
|
||||
assert len(products.PRODUCTS) == 17
|
||||
targets = {p.target for p in products.PRODUCTS.values()}
|
||||
assert targets == {"evk", "e27_rgb", "e14_rgb", "gu10_rgb",
|
||||
"e27_cw", "e14_cw", "gu10_cw", "g95_cw", "ledshield_rgb"}
|
||||
|
||||
|
||||
def test_table_carries_no_variant():
|
||||
"""Die Variante ist eine Eigenschaft der Anfrage, nicht der Produkt-ID - sie darf gar
|
||||
nicht mehr aus der Tabelle zu holen sein (Hardwarebefund 2026-09-15)."""
|
||||
assert not hasattr(products.PRODUCTS["-klyqa.lighting.cw-ww.g95"], "variant")
|
||||
assert "variant" not in products.Product._fields
|
||||
|
||||
|
||||
def test_both_spellings_map_to_the_same_device():
|
||||
with_dev = products.PRODUCTS["-klyqa.lighting.cw-ww.g95-dev"]
|
||||
without = products.PRODUCTS["-klyqa.lighting.cw-ww.g95"]
|
||||
assert with_dev.target == without.target == "g95_cw"
|
||||
assert with_dev.name == without.name == "Klyqa G95 Vintage CW/WW"
|
||||
|
||||
|
||||
def test_production_id_maps_to_device_and_name():
|
||||
p = products.PRODUCTS["-klyqa.lighting.rgb-cw-ww.gu10"]
|
||||
assert p.target == "gu10_rgb"
|
||||
assert p.name == "Klyqa GU10 RGB CW/WW"
|
||||
|
||||
|
||||
def test_parses_real_request_from_g95_dev_in_both_places():
|
||||
"""Am 2026-09-13 an einer echten Entwicklungs-G95 protokolliert: "-dev" in Produkt-ID
|
||||
UND Version."""
|
||||
r = products.parse_request(
|
||||
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1.1.1.1800-dev_ota_light.img")
|
||||
assert r is not None
|
||||
assert r.product_id == "-klyqa.lighting.cw-ww.g95-dev"
|
||||
assert r.lamp_version == "V1.1.1.1800-dev"
|
||||
assert r.product.target == "g95_cw"
|
||||
assert (r.variant, r.variant_source) == ("dev", "both")
|
||||
|
||||
|
||||
def test_parses_real_request_from_g95_dev_only_in_version():
|
||||
"""Am 2026-09-15 an einer echten Entwicklungs-G95 protokolliert - die Anfrage, an der
|
||||
die Station bisher scheiterte: Produkt-ID OHNE "-dev", Version MIT. Sie muss trotzdem
|
||||
als Entwicklungsgeraet gelten, sonst geht das produktionssignierte Image raus."""
|
||||
r = products.parse_request(
|
||||
"/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1.1.1.2357-dev_ota_light.img")
|
||||
assert r is not None
|
||||
assert r.product_id == "-klyqa.lighting.cw-ww.g95"
|
||||
assert r.lamp_version == "V1.1.1.2357-dev"
|
||||
assert r.product.target == "g95_cw"
|
||||
assert (r.variant, r.variant_source) == ("dev", "version")
|
||||
|
||||
|
||||
def test_parses_real_request_from_gu10_without_any_dev():
|
||||
"""Die erfolgreich migrierte Produktionslampe: nirgends ein "-dev"."""
|
||||
r = products.parse_request(
|
||||
"/qcx_firmware_-klyqa.lighting.rgb-cw-ww.gu10_V1.1.0.1138_ota_light.img")
|
||||
assert r.product.target == "gu10_rgb"
|
||||
assert (r.variant, r.variant_source) == ("prod", "none")
|
||||
assert r.lamp_version == "V1.1.0.1138"
|
||||
|
||||
|
||||
def test_dev_only_in_product_id_is_dev():
|
||||
"""Die vierte denkbare Kombination - so noch nicht am Geraet gesehen, aber die
|
||||
Machbarkeitsprobe zeigt, dass die Produkt-ID das "-dev" tragen kann, und nichts
|
||||
garantiert, dass die Version es dann auch tut."""
|
||||
r = products.parse_request(
|
||||
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1.1.1.1800_ota_light.img")
|
||||
assert r.product.target == "g95_cw"
|
||||
assert (r.variant, r.variant_source) == ("dev", "product_id")
|
||||
|
||||
|
||||
def test_dev_must_be_a_whole_word_in_the_version():
|
||||
""""-devel"/"-dev2" sind kein Entwicklungskennzeichen - sonst wuerde eine
|
||||
Produktionslampe mit so einer Version das dev-signierte Image bekommen."""
|
||||
for version in ("V1.1.1.2357-devel", "V1.1.1.2357-dev2"):
|
||||
r = products.parse_request(
|
||||
"/qcx_firmware_-klyqa.lighting.cw-ww.g95_" + version + "_ota_light.img")
|
||||
assert (r.variant, r.variant_source) == ("prod", "none"), version
|
||||
# ... ein "-dev" vor einem Trennzeichen dagegen schon.
|
||||
r = products.parse_request(
|
||||
"/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1.1-dev.3_ota_light.img")
|
||||
assert (r.variant, r.variant_source) == ("dev", "version")
|
||||
|
||||
|
||||
def test_variant_is_classified_even_for_unknown_product_ids():
|
||||
r = products.parse_request(
|
||||
"/qcx_firmware_-acme.lightbulb.xxl_V9.9-dev_ota_light.img")
|
||||
assert r.product is None
|
||||
assert (r.variant, r.variant_source) == ("dev", "version")
|
||||
|
||||
|
||||
def test_unknown_product_id_parses_but_has_no_product():
|
||||
r = products.parse_request(
|
||||
"/qcx_firmware_-acme.lightbulb.xxl_V9.9_ota_light.img")
|
||||
assert r is not None
|
||||
assert r.product is None
|
||||
assert r.product_id == "-acme.lightbulb.xxl"
|
||||
|
||||
|
||||
def test_non_matching_paths():
|
||||
for p in ("/", "/ninlight_evk_ota.img", "/qcx_firmware_.img",
|
||||
"/qcx_firmware_-klyqa.lighting.cw-ww.g95_ota_light.img",
|
||||
"/x/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1_ota_light.img"):
|
||||
assert products.parse_request(p) is None, p
|
||||
|
||||
|
||||
def test_path_traversal_is_not_parseable():
|
||||
assert products.parse_request(
|
||||
"/qcx_firmware_../../etc/passwd_V1_ota_light.img") is None
|
||||
|
||||
|
||||
def test_websafe_transform():
|
||||
assert products.websafe("@klyqa.lighting.cw-ww.g95") == "-klyqa.lighting.cw-ww.g95"
|
||||
assert products.websafe("a b/c") == "a-b-c"
|
||||
assert products.websafe("keep+all-_.09Az") == "keep+all-_.09Az"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
@@ -92,6 +93,44 @@ class Station:
|
||||
self.proc.terminate()
|
||||
self.proc.wait(timeout=5)
|
||||
|
||||
def wait_for_log_line(self, contains, timeout=3.0):
|
||||
"""Reads the server's merged stdout/stderr (see __init__) until a line containing
|
||||
`contains` shows up, skipping unrelated lines (e.g. the startup banner) - used to
|
||||
check that a request actually produced a log line (F3: every request must,
|
||||
including rejected ones and errors the base handler catches before routing).
|
||||
|
||||
Reads the raw fd directly with os.read() instead of self.proc.stdout.readline():
|
||||
readline() goes through TextIOWrapper's internal BufferedReader, whose underlying
|
||||
read() can slurp everything currently sitting in the pipe (banner *and* the log
|
||||
line we're waiting for) into its own buffer on the very first call, while only
|
||||
handing back the first line. select() then reports the fd as not-readable on the
|
||||
next iteration - the data isn't gone, it is just buffered somewhere select() can't
|
||||
see - and a fixed-size test timeout would flake on exactly the line it's checking
|
||||
for.
|
||||
"""
|
||||
fd = self.proc.stdout.fileno()
|
||||
buf = getattr(self, "_log_buf", "")
|
||||
deadline = time.time() + timeout
|
||||
while True:
|
||||
while "\n" in buf:
|
||||
line, buf = buf.split("\n", 1)
|
||||
self._log_buf = buf
|
||||
if contains in line:
|
||||
return line + "\n"
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
self._log_buf = buf
|
||||
return None
|
||||
r, _, _ = select.select([fd], [], [], remaining)
|
||||
if not r:
|
||||
self._log_buf = buf
|
||||
return None
|
||||
chunk = os.read(fd, 65536)
|
||||
if not chunk:
|
||||
self._log_buf = buf
|
||||
return None
|
||||
buf += chunk.decode("utf-8", "replace")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def station(tmp_path):
|
||||
@@ -139,6 +178,43 @@ def test_other_methods_are_405(station):
|
||||
assert c.getresponse().status == 405
|
||||
|
||||
|
||||
def test_rejected_method_produces_a_log_line(station):
|
||||
"""F3: do_POST (and PUT/DELETE/HEAD) used to call reject() without logging anything -
|
||||
the station is the only diagnostic tool during a rescue, and the spec requires a line
|
||||
per request, rejections included."""
|
||||
c = station.conn()
|
||||
c.request("POST", "/ninlight_evk_ota.img", body=b"")
|
||||
assert c.getresponse().status == 405
|
||||
line = station.wait_for_log_line(" 405 ")
|
||||
assert line is not None, "kein Log-Eintrag fuer die abgelehnte POST-Anfrage"
|
||||
assert "/ninlight_evk_ota.img" in line
|
||||
|
||||
|
||||
def test_log_error_is_not_silenced_by_log_message(capsys):
|
||||
"""F3: BaseHTTPRequestHandler.log_error() calls log_message() by default (used e.g. for
|
||||
a malformed request line or a client timeout, cases that never reach do_GET/do_POST).
|
||||
server.py silences log_message() in favour of its own per-request log() calls - which
|
||||
used to silence log_error() right along with it, since it wasn't overridden separately.
|
||||
This exercises Handler.log_error() directly rather than over a raw socket: this
|
||||
sandbox's network layer intercepts/rewrites malformed non-HTTP payloads before they
|
||||
reach the server (observed via a spoofed 'Server: aiohttp' response), which makes a
|
||||
genuine malformed-request-line test unreliable here."""
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location("ninlight_station_server", SERVER)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
class _Stub:
|
||||
client_address = ("203.0.113.5", 54321)
|
||||
|
||||
module.Handler.log_error(_Stub(), "code %d, message %s", 400, "Bad request version")
|
||||
out = capsys.readouterr().out
|
||||
assert "ERROR" in out
|
||||
assert "203.0.113.5" in out
|
||||
assert "code 400, message Bad request version" in out
|
||||
|
||||
|
||||
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()
|
||||
@@ -278,6 +354,62 @@ def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path):
|
||||
s.stop()
|
||||
|
||||
|
||||
def test_stall_after_budget_survives_a_resumed_request(tmp_path):
|
||||
"""--stall-after must stop a client for good, not just for one request.
|
||||
|
||||
On real hardware, a lamp whose download stalls hits its own receive timeout, gives up
|
||||
on the connection, and opens a new one with a Range header picking up right where it
|
||||
left off - exactly what test_stall_after_stops_sending_but_keeps_the_connection above
|
||||
exercises for a single request. A per-request byte counter would hand that resumed
|
||||
request a brand-new --stall-after allowance, so the "stall" would only ever delay
|
||||
delivery instead of actually stopping it - which is what was observed on the EVK: the
|
||||
server stalled at 65536 bytes each time, but the lamp reconnected five times and walked
|
||||
away with the complete image. This test reproduces that reconnect and checks that the
|
||||
second, resumed request gets no further bytes at all.
|
||||
"""
|
||||
s = Station(tmp_path, "--stall-after", "1000")
|
||||
try:
|
||||
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
|
||||
|
||||
# First request: burns through the whole --stall-after budget, then the server
|
||||
# holds the connection open and sends nothing further.
|
||||
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
|
||||
assert len(got) < 20000 # far less than the full image, same bound as above
|
||||
sock.close() # the lamp's watchdog gives up and reconnects, like on real hardware
|
||||
|
||||
# Second request, resuming with Range exactly like a real lamp would. This
|
||||
# client's cumulative budget is already spent, so it must get zero more bytes of
|
||||
# body - not a fresh --stall-after allowance just because it's a new connection.
|
||||
sock2 = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
||||
sock2.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\nRange: bytes=1000-\r\n\r\n")
|
||||
sock2.settimeout(2)
|
||||
header = b""
|
||||
while b"\r\n\r\n" not in header:
|
||||
header += sock2.recv(4096)
|
||||
assert b" 206 " in header.split(b"\r\n", 1)[0]
|
||||
body_after_header = header.split(b"\r\n\r\n", 1)[1]
|
||||
assert body_after_header == b"", "resumed request must not receive any body bytes"
|
||||
try:
|
||||
more = sock2.recv(1)
|
||||
except socket.timeout:
|
||||
more = None
|
||||
assert more in (None, b""), "server sent data after the client's stall budget was spent"
|
||||
sock2.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.
|
||||
|
||||
@@ -321,3 +453,649 @@ def test_concurrent_downloads_are_all_recorded(station):
|
||||
assert entry["status"] == "ok"
|
||||
assert entry["target"] == "evk"
|
||||
assert entry["bytes"] == expected_len
|
||||
|
||||
|
||||
# --- Migrationsroute (Task 3) -------------------------------------------------------
|
||||
#
|
||||
# Eigener Testklient statt der Station-Klasse oben: die migration_server/server-Fixtures
|
||||
# brauchen get()/status()/set_control()/read_control()/delete_control()/write_raw_control(),
|
||||
# waehrend die Station-Klasse und die "station"-Fixture oben unveraendert fuer die
|
||||
# bestehenden Rettungsmodus-Tests weiterlaufen sollen.
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, status, headers, body):
|
||||
self.status = status
|
||||
self.headers = headers
|
||||
self.body = body
|
||||
|
||||
|
||||
class MigrationTestServer:
|
||||
"""Startet server.py mit --mode/--control und bietet die vom Brief geforderten
|
||||
Hilfsmethoden.
|
||||
|
||||
status()/read_control() folgen demselben Muster wie Station.read_state() oben
|
||||
(Zeilen 68-90): sie pollen bis zu einigen Sekunden auf ein optionales Praedikat, statt
|
||||
sich auf eine feste Pause nach get() zu verlassen. Grund ist derselbe wie dort -
|
||||
server.py schreibt status.json/control.json fuer eine Anfrage erst, nachdem der
|
||||
Antwortkoerper vollstaendig gesendet ist, aber http.client's read() kann zurueckkehren,
|
||||
sobald Content-Length Bytes angekommen sind, was diesem Schreiben knapp zuvorkommen
|
||||
kann. Jeder Aufrufer kennt sein eigenes Praedikat (das ohnehin schon als Assertion
|
||||
dasteht), daher kein pauschales Warten hier."""
|
||||
|
||||
def __init__(self, tmp_path, mode, *extra_args):
|
||||
self.image_dir = tmp_path / "images"
|
||||
self.image_dir.mkdir()
|
||||
self.state_path = tmp_path / "status.json"
|
||||
# Eigenes Unterverzeichnis fuer die Steuerdatei, getrennt von status.json: ein Test
|
||||
# fuer I3 (Fixrunde 1) macht genau dieses Verzeichnis schreibgeschuetzt, um einen
|
||||
# fehlschlagenden write_control() zu erzwingen, und darf dabei nicht auch das
|
||||
# Schreiben von status.json lahmlegen, ueber das derselbe Test den Fehler beobachtet.
|
||||
control_dir = tmp_path / "control"
|
||||
control_dir.mkdir()
|
||||
self.control_path = control_dir / "control.json"
|
||||
# Der Umschalter des Bildsatzes. Die Datei existiert zunaechst NICHT - das ist
|
||||
# der Auslieferungszustand und muss "ohne-wlan" (migrate-nocreds/) bedeuten.
|
||||
self.imageset_path = tmp_path / "imageset"
|
||||
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_path), "--mode", mode,
|
||||
"--control", str(self.control_path),
|
||||
"--imageset-file", str(self.imageset_path), *extra_args],
|
||||
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 get(self, path, headers=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
|
||||
try:
|
||||
conn.request("GET", path, headers=headers or {})
|
||||
r = conn.getresponse()
|
||||
body = r.read()
|
||||
return _Response(r.status, dict(r.getheaders()), body)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _poll_json(self, path, predicate=None, timeout=5.0):
|
||||
"""Wie Station.read_state() oben: liest `path` wiederholt (bis zu `timeout`
|
||||
Sekunden), bis er sich parsen laesst und - falls angegeben - `predicate` erfuellt.
|
||||
Ohne predicate reicht ein einziges erfolgreiches Parsen, genau wie dort."""
|
||||
last = None
|
||||
last_err = None
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
last = json.loads(path.read_text())
|
||||
if predicate is None or predicate(last):
|
||||
return last
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
last_err = e
|
||||
time.sleep(0.02)
|
||||
if last is None:
|
||||
raise AssertionError(f"could not read {path}: {last_err}")
|
||||
raise AssertionError(f"{path} never satisfied predicate, last seen: {last!r}")
|
||||
|
||||
def status(self, predicate=None):
|
||||
return self._poll_json(self.state_path, predicate)
|
||||
|
||||
def read_control(self, predicate=None):
|
||||
return self._poll_json(self.control_path, predicate)
|
||||
|
||||
def set_control(self, phase, armed_target=None):
|
||||
tmp = self.control_path.with_name(self.control_path.name + ".tmp")
|
||||
tmp.write_text(json.dumps({"phase": phase, "armed_target": armed_target}))
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, self.control_path)
|
||||
|
||||
def delete_control(self):
|
||||
try:
|
||||
self.control_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def write_raw_control(self, text):
|
||||
self.control_path.write_text(text)
|
||||
|
||||
def set_image_set(self, value):
|
||||
"""Stellt den Umschalter so um, wie es das CLI tut: eine Datei mit dem Namen des
|
||||
Bildsatzes. `None` loescht sie wieder (= Auslieferungszustand)."""
|
||||
if value is None:
|
||||
try:
|
||||
self.imageset_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return
|
||||
self.imageset_path.write_text(value)
|
||||
|
||||
def stop(self):
|
||||
self.proc.terminate()
|
||||
self.proc.wait(timeout=5)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(tmp_path):
|
||||
srv = MigrationTestServer(tmp_path, "recovery")
|
||||
yield srv, srv.image_dir
|
||||
srv.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def migration_server(tmp_path):
|
||||
srv = MigrationTestServer(tmp_path, "migration")
|
||||
yield srv, srv.image_dir
|
||||
srv.stop()
|
||||
|
||||
|
||||
def test_migration_auto_serves_known_product(migration_server):
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG" * 1000)
|
||||
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1.1.1.1800-dev_ota_light.img")
|
||||
assert r.status == 200
|
||||
assert r.body == b"IMG" * 1000
|
||||
st = srv.status(lambda s: s.get("last_seen") is not None)
|
||||
assert st["last_seen"]["target"] == "g95_cw"
|
||||
assert st["last_seen"]["variant"] == "dev"
|
||||
|
||||
|
||||
def test_migration_dev_only_in_version_serves_the_dev_image(migration_server):
|
||||
"""Der Hardwarebefund vom 2026-09-15: Produkt-ID OHNE "-dev", Version MIT. Frueher
|
||||
ging hier das produktionssignierte Image raus (200, und die Lampe lehnte es ab) -
|
||||
jetzt muss das dev-signierte gehen, und status.json muss die Quelle mitfuehren."""
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"DEV" * 1000)
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_prod.img").write_bytes(b"PROD" * 1000)
|
||||
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1.1.1.2357-dev_ota_light.img")
|
||||
assert r.status == 200
|
||||
assert r.body == b"DEV" * 1000
|
||||
st = srv.status(lambda s: s.get("last_seen") is not None)
|
||||
assert st["last_seen"]["target"] == "g95_cw"
|
||||
assert st["last_seen"]["variant"] == "dev"
|
||||
assert st["last_seen"]["variant_source"] == "version"
|
||||
assert st["recent"][-1]["variant"] == "dev"
|
||||
assert st["recent"][-1]["variant_source"] == "version"
|
||||
|
||||
|
||||
def test_migration_production_lamp_still_gets_the_prod_image(migration_server):
|
||||
"""Die erfolgreich migrierte GU10: nirgends ein "-dev", also prod - und die Quelle
|
||||
"none", damit am Panel/in status.json steht, worauf die Einstufung beruht."""
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate-nocreds" / "gu10_rgb_dev.img").write_bytes(b"DEV" * 1000)
|
||||
(imgdir / "migrate-nocreds" / "gu10_rgb_prod.img").write_bytes(b"PROD" * 1000)
|
||||
r = srv.get("/qcx_firmware_-klyqa.lighting.rgb-cw-ww.gu10_V1.1.0.1138_ota_light.img")
|
||||
assert r.status == 200
|
||||
assert r.body == b"PROD" * 1000
|
||||
st = srv.status(lambda s: s.get("last_seen") is not None)
|
||||
assert st["last_seen"]["variant"] == "prod"
|
||||
assert st["last_seen"]["variant_source"] == "none"
|
||||
|
||||
|
||||
def test_migration_listen_records_but_serves_nothing(migration_server):
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||
srv.set_control(phase="listen")
|
||||
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img")
|
||||
assert r.status == 404
|
||||
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||
assert st["last_seen"]["target"] == "g95_cw" # erkannt
|
||||
assert st["served"] == 0 # aber nichts ausgeliefert
|
||||
assert st["recent"][-1]["status"] == "listen"
|
||||
|
||||
|
||||
def test_arm_serves_exactly_once_then_falls_back_to_listen(migration_server):
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||
srv.set_control(phase="armed", armed_target="g95_cw")
|
||||
p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
|
||||
assert srv.get(p).status == 200
|
||||
assert srv.read_control(lambda c: c.get("phase") == "listen")["phase"] == "listen"
|
||||
assert srv.get(p).status == 404
|
||||
|
||||
|
||||
def test_armed_for_other_target_does_not_serve(migration_server):
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||
srv.set_control(phase="armed", armed_target="gu10_rgb")
|
||||
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img")
|
||||
assert r.status == 404
|
||||
assert srv.read_control()["phase"] == "armed" # Freigabe bleibt stehen
|
||||
|
||||
|
||||
# --- Eine Freigabe wird NUR durch eine erfolgreiche Auslieferung verbraucht ----------
|
||||
#
|
||||
# Die Zusage (Spec 3.3): claim_delivery() nimmt die Freigabe schon unter der Sperre
|
||||
# weg, BEVOR ueberhaupt gesendet wird (das ist der Punkt von C1 aus Fixrunde 1). Geht
|
||||
# danach nichts raus, muss restore_armed() sie zurueckgeben - sonst steht der Bediener
|
||||
# vor einer Lampe, die beim naechsten Boot nichts mehr bekommt, und muss raten, warum.
|
||||
#
|
||||
# Es gibt drei solche Stellen in _do_get_migration(): fehlender Bildsatz, fehlendes
|
||||
# Einzelimage und abgebrochener Transfer. Nur die erste (die juengste) war getestet -
|
||||
# das Abschlussreview hat die beiden anderen mit je einem "if False:" ausgehebelt, ohne
|
||||
# dass ein einziger der 225 Tests rot wurde. Die beiden folgenden Tests schliessen das.
|
||||
|
||||
|
||||
def test_a_missing_image_does_not_burn_the_release(migration_server):
|
||||
"""Der Bildsatz ist da, nur DIESES Modell fehlt darin (Status "notfound"). Die
|
||||
Freigabe darf daran nicht verfallen: sie war fuer genau dieses Ziel gedacht, und ein
|
||||
fehlendes Image ist ein Deploy-Versaeumnis, kein Auslieferungsvorgang."""
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
# Der Satz existiert (ein anderes Modell liegt darin), e14_cw fehlt darin.
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||
srv.set_control(phase="armed", armed_target="e14_cw")
|
||||
|
||||
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.e14_V1_ota_light.img")
|
||||
|
||||
assert r.status == 404
|
||||
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||
assert st["recent"][-1]["status"] == "notfound"
|
||||
assert st["served"] == 0
|
||||
control = srv.read_control()
|
||||
assert control["phase"] == "armed", \
|
||||
"die Freigabe wurde verbraucht, obwohl es gar kein Image auszuliefern gab"
|
||||
assert control["armed_target"] == "e14_cw", "das freigegebene Ziel ging verloren"
|
||||
|
||||
|
||||
def test_an_aborted_transfer_does_not_burn_the_release(tmp_path):
|
||||
"""Bricht die Uebertragung ab (Lampe verliert Strom oder Funk mitten im Download),
|
||||
muss die Freigabe stehen bleiben, damit die Lampe es beim naechsten Boot erneut
|
||||
versuchen kann - sonst muss jemand vor Ort erneut freigeben, und genau diese
|
||||
Nachsicht ist der Grund, warum restore_armed() ueberhaupt existiert.
|
||||
|
||||
Nachgestellt wie test_aborted_transfer_is_recorded oben: --stall-after laesst den
|
||||
Server nach n Byte verstummen, der Test schliesst die Verbindung danach, und
|
||||
_serve_file() meldet "aborted"."""
|
||||
srv = MigrationTestServer(tmp_path, "migration", "--stall-after", "256")
|
||||
try:
|
||||
imgdir = srv.image_dir
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"y" * 100000)
|
||||
srv.set_control(phase="armed", armed_target="g95_cw")
|
||||
|
||||
sock = socket.create_connection(("127.0.0.1", srv.port), timeout=5)
|
||||
try:
|
||||
sock.sendall(("GET " + G95_DEV + " HTTP/1.1\r\nHost: x\r\n\r\n").encode())
|
||||
sock.recv(4096)
|
||||
time.sleep(0.3)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
st = srv.status(lambda s: s["recent"] and s["recent"][-1]["status"] == "aborted")
|
||||
assert st["recent"][-1]["bytes"] < 100000
|
||||
assert st["served"] == 0
|
||||
control = srv.read_control(lambda c: True)
|
||||
assert control["phase"] == "armed", \
|
||||
"die Freigabe wurde von einem ABGEBROCHENEN Transfer verbraucht"
|
||||
assert control["armed_target"] == "g95_cw", "das freigegebene Ziel ging verloren"
|
||||
finally:
|
||||
srv.stop()
|
||||
|
||||
|
||||
def test_unknown_product_id_is_404_and_logged(migration_server):
|
||||
srv, _ = migration_server
|
||||
r = srv.get("/qcx_firmware_-acme.bulb_V1_ota_light.img")
|
||||
assert r.status == 404
|
||||
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||
assert st["recent"][-1]["status"] == "unknown"
|
||||
|
||||
|
||||
def test_missing_migrate_image_is_404(migration_server):
|
||||
srv, imgdir = migration_server
|
||||
# Der Bildsatz ist da (er enthaelt ein anderes Modell), nur dieses eine Modell fehlt
|
||||
# darin - ein anderer Zustand als ein fehlender Bildsatz (siehe
|
||||
# test_a_selected_but_missing_image_set_delivers_nothing).
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.e14_V1_ota_light.img")
|
||||
assert r.status == 404
|
||||
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||
assert st["recent"][-1]["status"] == "notfound"
|
||||
|
||||
|
||||
def test_recovery_path_is_404_in_migration_mode(migration_server):
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "ninlight_evk_ota.img").write_bytes(b"x" * 600)
|
||||
assert srv.get("/ninlight_evk_ota.img").status == 404
|
||||
|
||||
|
||||
def test_migration_path_is_404_in_recovery_mode(server):
|
||||
srv, _ = server
|
||||
assert srv.get(
|
||||
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 404
|
||||
|
||||
|
||||
def test_missing_control_file_means_auto(migration_server):
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||
srv.delete_control()
|
||||
assert srv.get(
|
||||
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 200
|
||||
|
||||
|
||||
def test_corrupt_control_file_means_auto(migration_server):
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||
srv.write_raw_control("{das ist kein json")
|
||||
assert srv.get(
|
||||
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 200
|
||||
|
||||
|
||||
def test_range_resume_on_migration_image(migration_server):
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(bytes(range(256)) * 40)
|
||||
p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
|
||||
r = srv.get(p, headers={"Range": "bytes=1000-"})
|
||||
assert r.status == 206
|
||||
assert r.headers["Content-Range"] == "bytes 1000-10239/10240"
|
||||
assert r.body == (bytes(range(256)) * 40)[1000:]
|
||||
|
||||
|
||||
def test_version_comes_from_manifest(migration_server):
|
||||
"""Faellt auf das manifestweite Feld zurueck, wenn der Eintrag selbst keine eigene
|
||||
Version traegt - Bestandskompatibilitaet mit einem Manifest aus der Zeit vor
|
||||
Fixrunde 2 (N2)."""
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||
(imgdir / "migrate-nocreds" / "manifest.json").write_text(json.dumps({
|
||||
"ninlight_version": "0.3.9",
|
||||
"images": [{"target": "g95_cw", "variant": "dev", "file": "g95_cw_dev.img"}]}))
|
||||
srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img")
|
||||
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||
assert st["recent"][-1]["version"] == "0.3.9"
|
||||
|
||||
|
||||
def test_version_prefers_the_delivered_entry_over_the_manifest_wide_field(migration_server):
|
||||
"""Fixrunde 2, N2: nach einem Merge in deploy.sh (Voll-Deploy mit nur einem der
|
||||
beiden Signierschluessel) koennen Eintraege im selben Manifest unterschiedliche
|
||||
Firmware-Versionen tragen - z.B. ein neu signiertes g95_cw_dev.img auf 1.2.0, aber
|
||||
ein unveraendert liegen gebliebenes aelteres g95_cw_prod.img auf 1.0.0. Der Server
|
||||
darf dann nicht das manifestweite Feld fuer beide protokollieren, sondern muss die
|
||||
Version DES AUSGELIEFERTEN Eintrags nehmen."""
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG-DEV")
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_prod.img").write_bytes(b"IMG-PROD")
|
||||
(imgdir / "migrate-nocreds" / "manifest.json").write_text(json.dumps({
|
||||
# Manifestweites Feld absichtlich falsch/veraltet, damit ein Zurueckfallen
|
||||
# darauf im Test auffallen wuerde.
|
||||
"ninlight_version": "1.2.0",
|
||||
"images": [
|
||||
{"target": "g95_cw", "variant": "dev", "file": "g95_cw_dev.img",
|
||||
"ninlight_version": "1.2.0"},
|
||||
{"target": "g95_cw", "variant": "prod", "file": "g95_cw_prod.img",
|
||||
"ninlight_version": "1.0.0"},
|
||||
]}))
|
||||
srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1_ota_light.img")
|
||||
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||
assert st["recent"][-1]["version"] == "1.0.0"
|
||||
|
||||
|
||||
def test_control_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
|
||||
"""Dieselbe 0600-Falle wie bei status.json (siehe
|
||||
test_state_file_is_world_readable_even_under_a_restrictive_umask oben), aber fuer
|
||||
control.json: das CLI aus Task 4 muss die Datei lesen UND schreiben koennen."""
|
||||
old_umask = os.umask(0o077)
|
||||
try:
|
||||
srv = MigrationTestServer(tmp_path, "migration")
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
try:
|
||||
(srv.image_dir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
(srv.image_dir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||
srv.set_control(phase="armed", armed_target="g95_cw")
|
||||
|
||||
def world_readable():
|
||||
mode = stat.S_IMODE(srv.control_path.stat().st_mode)
|
||||
return bool(mode & stat.S_IROTH)
|
||||
|
||||
# set_control() oben schreibt direkt aus dem Test, nicht ueber den Server - das
|
||||
# will hier nicht geprueft werden. Erst die Anfrage unten bringt den Server selbst
|
||||
# dazu, control.json zu schreiben (armed -> listen), also denselben Codepfad
|
||||
# (Station.write_control()) wie write_state() fuer status.json.
|
||||
assert srv.get(
|
||||
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 200
|
||||
srv.read_control(lambda _: world_readable())
|
||||
assert world_readable(), "control.json ist nach dem Schreiben durch den Server " \
|
||||
"nicht welt-lesbar"
|
||||
finally:
|
||||
srv.stop()
|
||||
|
||||
|
||||
def test_armed_serves_exactly_once_under_concurrency(migration_server):
|
||||
"""C1 (Fixrunde 1): read_control()/Phasenpruefung/Auslieferung/write_control("listen")
|
||||
liefen ohne gemeinsame Sperre ab - zwei gleichzeitige Anfragen (zwei Lampen, oder Erst-
|
||||
und Range-Resume-Verbindung derselben Lampe) konnten beide phase == "armed" sehen und
|
||||
beide die volle Auslieferung bekommen. station.claim_delivery() muss das Lesen und das
|
||||
Verbrauchen der Freigabe atomar unter einer Sperre machen, damit von n gleichzeitigen
|
||||
Anfragen gegen dieselbe Freigabe hoechstens eine durchkommt."""
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG" * 20000)
|
||||
srv.set_control(phase="armed", armed_target="g95_cw")
|
||||
p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
|
||||
|
||||
n_workers = 20
|
||||
results = []
|
||||
results_lock = threading.Lock()
|
||||
|
||||
def worker():
|
||||
r = srv.get(p)
|
||||
with results_lock:
|
||||
results.append(r.status)
|
||||
|
||||
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 len(results) == n_workers, "nicht alle Worker sind fertig geworden"
|
||||
assert results.count(200) == 1, f"erwartet genau eine 200, bekommen: {results}"
|
||||
assert results.count(404) == n_workers - 1
|
||||
assert srv.read_control(lambda c: c.get("phase") == "listen")["phase"] == "listen"
|
||||
|
||||
|
||||
def test_control_write_failure_blocks_further_armed_deliveries(migration_server):
|
||||
"""I3 (Fixrunde 1): schlaegt das Schreiben von control.json fehl (z.B. volles oder
|
||||
schreibgeschuetztes /run), darf die Freigabe nicht dauerhaft auf "armed" stehen bleiben
|
||||
- sonst wuerde jede weitere Lampe des Ziels ebenfalls bedient, genau die Luecke aus C1,
|
||||
nur diesmal durch einen Schreibfehler statt durch Nebenlaeufigkeit ausgeloest. Die eine
|
||||
Anfrage, die den Fehler ausloest, bekommt noch ihre Auslieferung (wie eine normale
|
||||
Freigabe); jede weitere schlaegt fehl, bis das Schreiben wieder klappt. Der Fehler muss
|
||||
ausserdem in status.json sichtbar sein, sonst wundert sich niemand, warum "arm" nicht
|
||||
mehr wirkt."""
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||
srv.set_control(phase="armed", armed_target="g95_cw")
|
||||
p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
|
||||
|
||||
control_dir = srv.control_path.parent
|
||||
old_mode = control_dir.stat().st_mode
|
||||
os.chmod(control_dir, 0o500) # r-x fuer den Besitzer, kein Schreiben mehr moeglich
|
||||
try:
|
||||
r1 = srv.get(p)
|
||||
assert r1.status == 200, "die eine vorgesehene Auslieferung muss noch rausgehen"
|
||||
st = srv.status(lambda s: s.get("control_error"))
|
||||
assert st["control_error"], "Schreibfehler muss in status.json sichtbar sein"
|
||||
|
||||
r2 = srv.get(p)
|
||||
assert r2.status == 404, \
|
||||
"ein Schreibfehler darf die Freigabe nicht dauerhaft offen lassen"
|
||||
finally:
|
||||
os.chmod(control_dir, old_mode)
|
||||
|
||||
|
||||
def _load_server_module():
|
||||
"""Laedt server.py wie test_log_error_is_not_silenced_by_log_message oben - als
|
||||
eigenstaendiges Modulobjekt, ohne es in sys.modules einzutragen, damit mehrfache
|
||||
Aufrufe (einer je resolve_mode-Test) sich nicht in die Quere kommen."""
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location("ninlight_station_server_rm", SERVER)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_resolve_mode_explicit_mode_wins_over_file(tmp_path):
|
||||
"""I5 (Fixrunde 1): --mode schlaegt --mode-file."""
|
||||
module = _load_server_module()
|
||||
mode_file = tmp_path / "mode"
|
||||
mode_file.write_text("migration")
|
||||
assert module.resolve_mode("recovery", mode_file) == "recovery"
|
||||
|
||||
|
||||
def test_resolve_mode_reads_the_mode_file(tmp_path):
|
||||
module = _load_server_module()
|
||||
mode_file = tmp_path / "mode"
|
||||
mode_file.write_text("migration")
|
||||
assert module.resolve_mode(None, mode_file) == "migration"
|
||||
|
||||
|
||||
def test_resolve_mode_missing_file_means_recovery(tmp_path):
|
||||
module = _load_server_module()
|
||||
mode_file = tmp_path / "mode" # bewusst nicht angelegt
|
||||
assert module.resolve_mode(None, mode_file) == "recovery"
|
||||
|
||||
|
||||
def test_resolve_mode_garbage_file_means_recovery(tmp_path):
|
||||
module = _load_server_module()
|
||||
mode_file = tmp_path / "mode"
|
||||
mode_file.write_text("garbage\n")
|
||||
assert module.resolve_mode(None, mode_file) == "recovery"
|
||||
|
||||
|
||||
def test_resolve_mode_default_without_mode_file_is_recovery():
|
||||
module = _load_server_module()
|
||||
assert module.resolve_mode(None, None) == "recovery"
|
||||
|
||||
|
||||
# --- Umschalter: mit oder ohne einkompilierte WLAN-Zugangsdaten ---------------------
|
||||
#
|
||||
# Zwei Bildsaetze liegen auf dem Pi (images/migrate-nocreds und images/migrate-wlan);
|
||||
# welcher ausgeliefert wird, entscheidet allein der Umschalter. Geprueft wird deshalb
|
||||
# immer an der TATSAECHLICH AUSGELIEFERTEN DATEI, nicht an einem Pfad oder einem
|
||||
# Statusfeld - der Fehlgriff, gegen den der Umschalter da ist, ist ein falscher Inhalt
|
||||
# in einer Lampe.
|
||||
|
||||
def _both_sets(imgdir):
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"OHNE" * 100)
|
||||
(imgdir / "migrate-wlan").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate-wlan" / "g95_cw_dev.img").write_bytes(b"MITW" * 100)
|
||||
|
||||
|
||||
G95_DEV = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
|
||||
|
||||
|
||||
def test_the_default_image_set_is_the_one_without_wifi_credentials(migration_server):
|
||||
"""Ohne Umschalterdatei (Auslieferungszustand) geht der Abzug OHNE Zugangsdaten
|
||||
raus. Die sichere Vorgabe ist die, bei der ein Fehlgriff niemandem das eigene
|
||||
WLAN-Passwort in ein fremdes Geraet schreibt."""
|
||||
srv, imgdir = migration_server
|
||||
_both_sets(imgdir)
|
||||
r = srv.get(G95_DEV)
|
||||
assert r.status == 200
|
||||
assert r.body == b"OHNE" * 100
|
||||
|
||||
|
||||
def test_the_switch_decides_which_file_is_actually_delivered(migration_server):
|
||||
srv, imgdir = migration_server
|
||||
_both_sets(imgdir)
|
||||
srv.set_image_set("mit-wlan")
|
||||
assert srv.get(G95_DEV).body == b"MITW" * 100
|
||||
# ... und wieder zurueck, ohne Dienstneustart: der Umschalter wirkt sofort.
|
||||
srv.set_image_set("ohne-wlan")
|
||||
assert srv.get(G95_DEV).body == b"OHNE" * 100
|
||||
|
||||
|
||||
def test_a_broken_switch_file_falls_back_to_the_safe_set(migration_server):
|
||||
"""Unbekannter Inhalt ist kein Grund, den teureren Satz zu nehmen."""
|
||||
srv, imgdir = migration_server
|
||||
_both_sets(imgdir)
|
||||
srv.set_image_set("voellig-anderes")
|
||||
assert srv.get(G95_DEV).body == b"OHNE" * 100
|
||||
|
||||
|
||||
def test_a_selected_but_missing_image_set_delivers_nothing(migration_server):
|
||||
"""Der gewaehlte Satz liegt nicht auf dem Pi: es darf NICHTS rausgehen - insbesondere
|
||||
nicht die Datei des anderen Satzes, die daneben liegt und genauso heisst."""
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"OHNE" * 100)
|
||||
srv.set_image_set("mit-wlan") # migrate-wlan/ existiert nicht
|
||||
|
||||
r = srv.get(G95_DEV)
|
||||
assert r.status == 404
|
||||
assert b"OHNE" not in r.body
|
||||
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||
assert st["served"] == 0
|
||||
# Eigener Status, damit am Panel/in status.json "Satz fehlt" von "dieses Modell
|
||||
# fehlt" unterscheidbar bleibt.
|
||||
assert st["recent"][-1]["status"] == "noimageset"
|
||||
|
||||
|
||||
def test_a_missing_image_set_does_not_burn_the_release(migration_server):
|
||||
"""Wie beim fehlenden Einzelimage: eine Freigabe darf nicht daran verfallen, dass es
|
||||
gar nichts auszuliefern gab."""
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||
srv.set_image_set("mit-wlan")
|
||||
srv.set_control(phase="armed", armed_target="g95_cw")
|
||||
assert srv.get(G95_DEV).status == 404
|
||||
assert srv.read_control()["phase"] == "armed"
|
||||
|
||||
|
||||
def test_the_version_comes_from_the_manifest_of_the_selected_set(migration_server):
|
||||
"""Nach dem Umschalten darf nicht die Version des vorigen Satzes stehenbleiben - die
|
||||
beiden manifest.json entstehen im selben Deploy und koennen dieselbe mtime tragen."""
|
||||
srv, imgdir = migration_server
|
||||
_both_sets(imgdir)
|
||||
for name, version in (("migrate-nocreds", "0.4.0-onb"), ("migrate-wlan", "0.4.0-wlan")):
|
||||
(imgdir / name / "manifest.json").write_text(json.dumps({
|
||||
"images": [{"target": "g95_cw", "variant": "dev", "file": "g95_cw_dev.img",
|
||||
"ninlight_version": version}]}))
|
||||
# Beiden Manifesten dieselbe mtime geben - genau der Fall, den ein reiner
|
||||
# mtime-Vergleich nicht bemerkt haette.
|
||||
stamp = 1600000000
|
||||
for name in ("migrate-nocreds", "migrate-wlan"):
|
||||
os.utime(imgdir / name / "manifest.json", (stamp, stamp))
|
||||
|
||||
assert srv.get(G95_DEV).status == 200
|
||||
assert srv.status(lambda s: s["recent"])["recent"][-1]["version"] == "0.4.0-onb"
|
||||
|
||||
srv.set_image_set("mit-wlan")
|
||||
assert srv.get(G95_DEV).status == 200
|
||||
assert srv.status(
|
||||
lambda s: len(s["recent"]) >= 2
|
||||
)["recent"][-1]["version"] == "0.4.0-wlan"
|
||||
|
||||
|
||||
def test_read_image_set_defaults_without_a_file(tmp_path):
|
||||
module = _load_server_module()
|
||||
assert module.read_image_set(None) == "ohne-wlan"
|
||||
assert module.read_image_set(tmp_path / "fehlt") == "ohne-wlan"
|
||||
p = tmp_path / "imageset"
|
||||
p.write_text("mit-wlan\n")
|
||||
assert module.read_image_set(p) == "mit-wlan"
|
||||
|
||||
Executable
+1142
File diff suppressed because it is too large
Load Diff
@@ -5,3 +5,41 @@ ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
cmake -S "$ROOT/test" -B "$ROOT/build/host" -G Ninja >/dev/null
|
||||
cmake --build "$ROOT/build/host"
|
||||
ctest --test-dir "$ROOT/build/host" --output-on-failure "$@"
|
||||
|
||||
if python3 -c 'import pytest' 2>/dev/null; then
|
||||
# tools/mkimage als GANZES Verzeichnis, nicht nur tools/mkimage/tests: die 19 Tests in
|
||||
# tools/mkimage/test_nl_mkimage.py liegen direkt daneben und liefen sonst in keiner
|
||||
# Suite mit - darunter test_stays_python38_compatible, die Wache fuer die bindende
|
||||
# 3.8-Randbedingung des Docker-Build-Containers.
|
||||
#
|
||||
# Diese Wache braucht das Paket "vermin" und schlaegt ohne es HART fehl (kein Skip):
|
||||
# eine Wache, die sich beim Fehlen ihres Werkzeugs stillschweigend abschaltet, haette
|
||||
# den Build-Bruch von damals genauso durchgelassen wie gar kein Test. Fehlt vermin,
|
||||
# nachinstallieren:
|
||||
# pip install vermin (oder: pip3 install --user vermin)
|
||||
python3 -m pytest "$ROOT/tools/mkimage" "$ROOT/tools/station/tests" -q
|
||||
else
|
||||
echo "Hinweis: pytest fehlt - Python-Tests uebersprungen" >&2
|
||||
echo " Installation: pip install pytest vermin (vermin braucht" >&2
|
||||
echo " tools/mkimage/test_nl_mkimage.py::test_stays_python38_compatible)" >&2
|
||||
fi
|
||||
|
||||
# Alle Shell-Suiten - test_deploy.sh fehlte hier und lief damit nur bei Handaufruf
|
||||
# (Task 6b F1, I3): dieselbe Klasse von Luecke wie die aus keiner Suite gefallene
|
||||
# test_nl_mkimage.py, nur eine Ebene hoeher. Mit ihr liefen 95 Zusagen nirgends mit,
|
||||
# darunter die komplette Deploy-Haelfte der Anzeige-Auslieferung.
|
||||
#
|
||||
# Als ausdrueckliche Liste, nicht als lose Folge von Aufrufen (Task 6b F2): so gibt es
|
||||
# EINE Stelle, die vollstaendig sein muss, und
|
||||
# tools/mkimage/tests/test_suite_coverage.py kann sie gegen das Dateisystem pruefen,
|
||||
# statt im Skripttext nach Dateinamen zu suchen - eine Textsuche haelt schon ein
|
||||
# nachgestellter Kommentar zum Narren.
|
||||
SHELL_SUITES=(
|
||||
"$ROOT/tools/station/tests/test_cli.sh"
|
||||
"$ROOT/tools/station/tests/test_deploy.sh"
|
||||
"$ROOT/tools/station/tests/test_setup_pi.sh"
|
||||
"$ROOT/tools/tests/test_release.sh"
|
||||
)
|
||||
for suite in "${SHELL_SUITES[@]}"; do
|
||||
"$suite"
|
||||
done
|
||||
|
||||
Executable
+496
@@ -0,0 +1,496 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stub-Tests fuer tools/release.sh (und tools/build_nocreds.sh) - ohne Docker, ohne
|
||||
# echten Firmwarebau ausfuehrbar.
|
||||
#
|
||||
# Gestubbt wird nur die UNTERSTE Ebene: NINLIGHT_DOCKER zeigt auf ein Skript, das sich
|
||||
# wie der Bau im Container verhaelt (relative Pfade ab Repo-Wurzel, legt _gz.img und
|
||||
# _ota.img ab). Alles darueber - das Beiseitelegen von config.local.cmake, das
|
||||
# Signieren, README/SHA256SUMS, das Packen - laeuft echt. Wichtig dabei: der Stub
|
||||
# verhaelt sich wie ein Compiler und SCHREIBT die Werte aus config.local.cmake in die
|
||||
# Images, wenn die Datei zum Bauzeitpunkt noch da ist. Nur so pruefen die Faelle 5/6
|
||||
# wirklich das Skript und nicht den Stub: vergisst release.sh/build_nocreds.sh das
|
||||
# Beiseitelegen, landen die Zugangsdaten tatsaechlich im Abzug.
|
||||
#
|
||||
# Aufruf: tools/tests/test_release.sh
|
||||
set -euo pipefail
|
||||
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
TOOLS_DIR=$(cd "$HERE/.." && pwd)
|
||||
REPO=$(cd "$TOOLS_DIR/.." && pwd)
|
||||
|
||||
# Sicherheitsnetz (wie in tools/station/tests/test_deploy.sh): NINLIGHT_SIGN_DIR zeigt
|
||||
# auf diesem Rechner auf ECHTE Signierschluessel. Sie darf nicht aus der Umgebung in
|
||||
# diese Suite durchsickern - jedes Szenario setzt sie hart, Testschluessel kommen ueber
|
||||
# die separate Variable TEST_SIGN_DIR.
|
||||
unset NINLIGHT_SIGN_DIR
|
||||
unset TEST_SIGN_DIR
|
||||
unset NINLIGHT_NOCREDS_BUILD_REL
|
||||
unset NINLIGHT_DOCKER
|
||||
|
||||
# GNU vor BSD: auf dem Pi/Debian gibt es sha256sum, auf dem Mac nur shasum.
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
SHA_CHECK=(sha256sum -c)
|
||||
else
|
||||
SHA_CHECK=(shasum -a 256 -c)
|
||||
fi
|
||||
|
||||
TMPROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPROOT"' EXIT
|
||||
|
||||
FAILED=0
|
||||
pass() { echo "ok - $1"; }
|
||||
fail() { echo "FEHLER - $1" >&2; FAILED=1; }
|
||||
|
||||
TARGETS=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
|
||||
|
||||
# Wegwerf-Zugangsdaten - niemals die echten aus dem Repo.
|
||||
SECRET_SSID="TestWLAN-4b7f-geheim"
|
||||
SECRET_PASS="testpasswort-4b7f"
|
||||
SECRET_TOKEN="0badc0de4b7f0badc0de4b7f"
|
||||
|
||||
# --- Sandbox-Repo: release.sh, build_nocreds.sh, nl_sign.py, bruno/, config.local.cmake ---
|
||||
setup_sandbox() {
|
||||
local sandbox=$1
|
||||
rm -rf "$sandbox"
|
||||
mkdir -p "$sandbox/tools/mkimage" "$sandbox/bruno" "$sandbox/dist"
|
||||
cp "$TOOLS_DIR/release.sh" "$sandbox/tools/release.sh"
|
||||
cp "$TOOLS_DIR/build_nocreds.sh" "$sandbox/tools/build_nocreds.sh"
|
||||
chmod +x "$sandbox/tools/release.sh" "$sandbox/tools/build_nocreds.sh"
|
||||
# Das ECHTE Signierwerkzeug, nur die Schluessel sind Wegwerf-Testschluessel.
|
||||
cp "$TOOLS_DIR/mkimage/nl_sign.py" "$sandbox/tools/mkimage/nl_sign.py"
|
||||
# Die ECHTE Bruno-Collection: die Zusage ist, dass sie im Archiv liegt - mit einer
|
||||
# erfundenen Attrappe wuerde der Test nur sich selbst pruefen.
|
||||
cp "$REPO/bruno/ninlight.yml" "$sandbox/bruno/ninlight.yml"
|
||||
cat >"$sandbox/config.local.cmake" <<EOF
|
||||
set(NINLIGHT_WIFI_SSID "$SECRET_SSID")
|
||||
set(NINLIGHT_WIFI_PASSWORD "$SECRET_PASS")
|
||||
set(NINLIGHT_TOKEN "$SECRET_TOKEN")
|
||||
EOF
|
||||
}
|
||||
|
||||
# --- Docker-Stub: verhaelt sich wie der Bau im Container ---
|
||||
# Steuerbar ueber:
|
||||
# STUB_FAIL_TARGET bricht beim Bau dieses Ziels mit Exit 1 ab
|
||||
# STUB_TERM_TARGET schickt beim Bau dieses Ziels SIGTERM an den Aufrufer
|
||||
# (build_nocreds.sh) - erzwungener Abbruch mitten im Lauf
|
||||
# STUB_LEAK=1 schreibt die Zugangsdaten IMMER ins Image, auch wenn
|
||||
# config.local.cmake beiseite liegt (prueft den doppelten Boden)
|
||||
setup_stub_docker() {
|
||||
local stub=$1 sandbox=$2
|
||||
cat >"$stub" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
# Erwartet "cmake -S . -B <rel>/<ziel> ..." bzw. "cmake --build <rel>/<ziel>".
|
||||
set -euo pipefail
|
||||
cd "$NINLIGHT_STUB_ROOT"
|
||||
mode=""
|
||||
outdir=""
|
||||
args=("$@")
|
||||
for ((i = 0; i < ${#args[@]}; i++)); do
|
||||
case "${args[$i]}" in
|
||||
--build) mode=build; outdir=${args[$((i + 1))]} ;;
|
||||
-B) mode=${mode:-configure}; outdir=${args[$((i + 1))]} ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$outdir" ] || { echo "Stub: kein Build-Verzeichnis in '$*'" >&2; exit 1; }
|
||||
target=$(basename "$outdir")
|
||||
mkdir -p "$outdir"
|
||||
[ "$mode" = "build" ] || exit 0
|
||||
|
||||
if [ "${STUB_TERM_TARGET:-}" = "$target" ]; then
|
||||
kill -TERM "$PPID"
|
||||
sleep 5
|
||||
exit 1
|
||||
fi
|
||||
if [ "${STUB_FAIL_TARGET:-}" = "$target" ]; then
|
||||
echo "Stub: Bau von $target fehlgeschlagen" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# "Compiler": die Zugangsdaten aus config.local.cmake landen im Abzug - genau wie beim
|
||||
# echten Bau, wo CMakeLists.txt sie per set() einkompiliert.
|
||||
creds=""
|
||||
if [ -f config.local.cmake ]; then
|
||||
creds=$(sed -n 's/.*set(NINLIGHT_[A-Z_]* "\(.*\)").*/\1/p' config.local.cmake | tr '\n' ' ')
|
||||
fi
|
||||
if [ "${STUB_LEAK:-}" = "1" ]; then
|
||||
creds="$STUB_LEAK_VALUE"
|
||||
fi
|
||||
CREDS="$creds" TARGET="$target" OUTDIR="$outdir" python3 - <<'PY'
|
||||
import os
|
||||
|
||||
target, outdir, creds = os.environ["TARGET"], os.environ["OUTDIR"], os.environ["CREDS"]
|
||||
body = bytearray(("firmware-%s-" % target).encode() * 200)
|
||||
body = body[:4096] + b"\0" * (4096 - len(body[:4096]))
|
||||
blob = creds.encode()
|
||||
if blob:
|
||||
body[100:100 + len(blob)] = blob
|
||||
gz = bytes(body)
|
||||
with open(os.path.join(outdir, "ninlight_%s_gz.img" % target), "wb") as fh:
|
||||
fh.write(gz) # roh, ohne NINL-Trailer - das signiert nl_sign.py
|
||||
trailer = bytearray(b"\xff" * 512)
|
||||
trailer[0:4] = b"NINL"
|
||||
with open(os.path.join(outdir, "ninlight_%s_ota.img" % target), "wb") as fh:
|
||||
fh.write(gz + bytes(trailer))
|
||||
PY
|
||||
STUB
|
||||
chmod +x "$stub"
|
||||
}
|
||||
|
||||
# Wegwerf-Testschluessel ueber nl_sign.generate_test_key() - nie echte Schluessel.
|
||||
make_test_keys() {
|
||||
local keydir=$1 with_prod=$2 with_dev=$3
|
||||
mkdir -p "$keydir/QCX_Device_CA"
|
||||
PYTHONPATH="$TOOLS_DIR/mkimage" python3 - "$keydir" "$with_prod" "$with_dev" <<'PY'
|
||||
import sys
|
||||
import nl_sign
|
||||
|
||||
keydir, with_prod, with_dev = sys.argv[1], sys.argv[2] == "1", sys.argv[3] == "1"
|
||||
if with_prod:
|
||||
k = nl_sign.generate_test_key()
|
||||
with open(keydir + "/QCX_Device_CA/qcx_firmware_signing_key.pem", "wb") as fh:
|
||||
fh.write(k.private_pem)
|
||||
with open(keydir + "/prod_pub.der", "wb") as fh:
|
||||
fh.write(k.public_der)
|
||||
if with_dev:
|
||||
k = nl_sign.generate_test_key()
|
||||
with open(keydir + "/fw_sign.pem", "wb") as fh:
|
||||
fh.write(k.private_pem)
|
||||
with open(keydir + "/dev_pub.der", "wb") as fh:
|
||||
fh.write(k.public_der)
|
||||
PY
|
||||
}
|
||||
|
||||
# Ruft release.sh in der Sandbox auf. NINLIGHT_SIGN_DIR wird hier HART gesetzt, nie aus
|
||||
# der geerbten Umgebung uebernommen (siehe Sicherheitsnetz oben).
|
||||
run_release() {
|
||||
local sandbox=$1 version=$2
|
||||
local sign_dir="${TEST_SIGN_DIR:-$sandbox/kein-schluesselverzeichnis}"
|
||||
env NINLIGHT_SIGN_DIR="$sign_dir" \
|
||||
NINLIGHT_DOCKER="$sandbox/stub-docker" \
|
||||
NINLIGHT_STUB_ROOT="$sandbox" \
|
||||
STUB_FAIL_TARGET="${STUB_FAIL_TARGET:-}" \
|
||||
STUB_TERM_TARGET="${STUB_TERM_TARGET:-}" \
|
||||
STUB_LEAK="${STUB_LEAK:-}" \
|
||||
STUB_LEAK_VALUE="$SECRET_SSID" \
|
||||
"$sandbox/tools/release.sh" "$version"
|
||||
}
|
||||
|
||||
# Sucht die Zugangsdaten BINAER in jeder Datei unterhalb eines Verzeichnisses.
|
||||
grep_secrets() {
|
||||
local dir=$1
|
||||
SSID="$SECRET_SSID" PASS="$SECRET_PASS" TOKEN="$SECRET_TOKEN" python3 - "$dir" <<'PY'
|
||||
import os
|
||||
import sys
|
||||
|
||||
root = sys.argv[1]
|
||||
needles = {k: os.environ[k].encode() for k in ("SSID", "PASS", "TOKEN")}
|
||||
for dirpath, _dirs, files in os.walk(root):
|
||||
for fn in sorted(files):
|
||||
p = os.path.join(dirpath, fn)
|
||||
if os.path.islink(p):
|
||||
continue
|
||||
with open(p, "rb") as fh:
|
||||
data = fh.read()
|
||||
for name, needle in needles.items():
|
||||
if needle in data:
|
||||
print("%s in %s" % (name, os.path.relpath(p, root)))
|
||||
PY
|
||||
}
|
||||
|
||||
sha_of() { python3 -c "
|
||||
import hashlib,sys
|
||||
print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$1"; }
|
||||
|
||||
echo "=== Szenario 1: Erfolgslauf mit beiden Schluesseln ==="
|
||||
SANDBOX="$TMPROOT/s1"
|
||||
setup_sandbox "$SANDBOX"
|
||||
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
|
||||
KEYS_FULL="$TMPROOT/keys-full"
|
||||
make_test_keys "$KEYS_FULL" 1 1
|
||||
CONF_BEFORE=$(sha_of "$SANDBOX/config.local.cmake")
|
||||
set +e
|
||||
OUT=$(TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC: $OUT"; fi
|
||||
TGZ="$SANDBOX/dist/ninlight-0.9.9.tar.gz"
|
||||
if [ -f "$TGZ" ]; then pass "Archiv entstanden"; else fail "kein Archiv unter $TGZ"; fi
|
||||
|
||||
EXTRACT="$TMPROOT/x1"
|
||||
mkdir -p "$EXTRACT"
|
||||
tar xzf "$TGZ" -C "$EXTRACT"
|
||||
A="$EXTRACT/ninlight-0.9.9"
|
||||
if [ -d "$A" ]; then pass "Archiv entpackt nach ninlight-0.9.9/"; else fail "Verzeichnis ninlight-0.9.9/ fehlt"; fi
|
||||
|
||||
echo "--- Fall 1/2/3: Verzeichnisse und Dateizahlen ---"
|
||||
for d in ota migrate flash bruno; do
|
||||
if [ -d "$A/$d" ]; then pass "$d/ vorhanden"; else fail "$d/ fehlt"; fi
|
||||
done
|
||||
for f in SHA256SUMS README.md onboard.sh bruno/ninlight.yml; do
|
||||
if [ -f "$A/$f" ]; then pass "$f vorhanden"; else fail "$f fehlt"; fi
|
||||
done
|
||||
if [ -x "$A/onboard.sh" ]; then pass "onboard.sh ist ausfuehrbar"; else fail "onboard.sh ist nicht ausfuehrbar"; fi
|
||||
N_OTA=$(find "$A/ota" -type f | wc -l | tr -d ' ')
|
||||
N_FLASH=$(find "$A/flash" -type f | wc -l | tr -d ' ')
|
||||
N_MIG=$(find "$A/migrate" -type f | wc -l | tr -d ' ')
|
||||
[ "$N_OTA" = "${#TARGETS[@]}" ] && pass "ota/ enthaelt ${#TARGETS[@]} Dateien" || fail "ota/ enthaelt $N_OTA statt ${#TARGETS[@]}"
|
||||
[ "$N_FLASH" = "${#TARGETS[@]}" ] && pass "flash/ enthaelt ${#TARGETS[@]} Dateien" || fail "flash/ enthaelt $N_FLASH statt ${#TARGETS[@]}"
|
||||
[ "$N_MIG" = "$(( 2 * ${#TARGETS[@]} ))" ] && pass "migrate/ enthaelt $(( 2 * ${#TARGETS[@]} )) Dateien" || fail "migrate/ enthaelt $N_MIG statt $(( 2 * ${#TARGETS[@]} ))"
|
||||
MISS=""
|
||||
for t in "${TARGETS[@]}"; do
|
||||
[ -f "$A/ota/ninlight_${t}_ota.img" ] || MISS="$MISS ota/$t"
|
||||
[ -f "$A/flash/ninlight_${t}_gz.img" ] || MISS="$MISS flash/$t"
|
||||
[ -f "$A/migrate/${t}_prod.img" ] || MISS="$MISS migrate/${t}_prod"
|
||||
[ -f "$A/migrate/${t}_dev.img" ] || MISS="$MISS migrate/${t}_dev"
|
||||
done
|
||||
[ -z "$MISS" ] && pass "je Ziel genau die erwarteten Dateinamen" || fail "fehlende Dateien:$MISS"
|
||||
|
||||
echo "--- Migrationsimages sind echt signiert (und zwar das _gz.img) ---"
|
||||
if python3 "$TOOLS_DIR/mkimage/nl_sign.py" verify --pub "$KEYS_FULL/prod_pub.der" \
|
||||
--in "$A/migrate/evk_prod.img" >/dev/null 2>&1; then
|
||||
pass "evk_prod.img traegt eine gueltige Produktionssignatur"
|
||||
else
|
||||
fail "evk_prod.img besteht die Signaturpruefung nicht"
|
||||
fi
|
||||
if python3 "$TOOLS_DIR/mkimage/nl_sign.py" verify --pub "$KEYS_FULL/dev_pub.der" \
|
||||
--in "$A/migrate/g95_cw_dev.img" >/dev/null 2>&1; then
|
||||
pass "g95_cw_dev.img traegt eine gueltige Entwicklungssignatur"
|
||||
else
|
||||
fail "g95_cw_dev.img besteht die Signaturpruefung nicht"
|
||||
fi
|
||||
# Die Signatur belegt denselben 512-Byte-Block wie der Trailer: signiert werden muss das
|
||||
# rohe _gz.img. Ein versehentlich signiertes _ota.img waere 512 Byte laenger.
|
||||
GZ_SIZE=$(python3 -c "import os,sys;print(os.path.getsize(sys.argv[1]))" "$A/flash/ninlight_evk_gz.img")
|
||||
MIG_SIZE=$(python3 -c "import os,sys;print(os.path.getsize(sys.argv[1]))" "$A/migrate/evk_prod.img")
|
||||
if [ "$MIG_SIZE" -eq $((GZ_SIZE + 512)) ]; then
|
||||
pass "Migrationsimage = _gz.img + 512 Byte Signatur (nicht das _ota.img signiert)"
|
||||
else
|
||||
fail "Migrationsimage hat $MIG_SIZE Byte, erwartet $((GZ_SIZE + 512))"
|
||||
fi
|
||||
|
||||
echo "--- Fall 4: SHA256SUMS deckt jede Datei ab und stimmt ---"
|
||||
LISTED=$(awk '{print $2}' "$A/SHA256SUMS" | sed 's|^\*||' | sort)
|
||||
PRESENT=$( (cd "$A" && find . -type f ! -name SHA256SUMS | sed 's|^\./||' | sort) )
|
||||
if [ "$LISTED" = "$PRESENT" ]; then
|
||||
pass "SHA256SUMS listet genau die Dateien des Archivs ($(echo "$PRESENT" | wc -l | tr -d ' '))"
|
||||
else
|
||||
fail "SHA256SUMS und Archivinhalt weichen ab:
|
||||
$(diff <(echo "$LISTED") <(echo "$PRESENT") || true)"
|
||||
fi
|
||||
if (cd "$A" && "${SHA_CHECK[@]}" SHA256SUMS >/dev/null 2>&1); then
|
||||
pass "Pruefsummen stimmen"
|
||||
else
|
||||
fail "Pruefsummenpruefung fehlgeschlagen"
|
||||
fi
|
||||
|
||||
echo "--- Fall 5: keine Zugangsdaten im Archiv (binaer gesucht) ---"
|
||||
HITS=$(grep_secrets "$A")
|
||||
if [ -z "$HITS" ]; then
|
||||
pass "weder SSID noch Passwort noch Token in irgendeiner Datei des Archivs"
|
||||
else
|
||||
fail "Zugangsdaten im Archiv gefunden:
|
||||
$HITS"
|
||||
fi
|
||||
# Auch im gepackten Archiv selbst (tar speichert Inhalte ungefiltert, gzip ist keine
|
||||
# Verschleierung - aber eine unkomprimierbare Kopie wuerde hier auffallen).
|
||||
if python3 -c "
|
||||
import gzip,sys
|
||||
data = gzip.open(sys.argv[1],'rb').read()
|
||||
sys.exit(0 if not any(s.encode() in data for s in sys.argv[2:]) else 1)" \
|
||||
"$TGZ" "$SECRET_SSID" "$SECRET_PASS" "$SECRET_TOKEN"; then
|
||||
pass "auch im entpackten tar-Strom keine Zugangsdaten"
|
||||
else
|
||||
fail "Zugangsdaten im tar-Strom des Archivs"
|
||||
fi
|
||||
# Der Stub verhaelt sich nur dann wie ein Compiler, wenn er die Werte ueberhaupt
|
||||
# einbauen KANN - sonst waere Fall 5 wertlos. Gegenprobe: mit vorhandener
|
||||
# config.local.cmake landen sie im Abzug.
|
||||
mkdir -p "$TMPROOT/probe"
|
||||
env NINLIGHT_STUB_ROOT="$SANDBOX" "$SANDBOX/stub-docker" cmake --build "$TMPROOT/probe/evk" >/dev/null
|
||||
if grep_secrets "$TMPROOT/probe" | grep -q SSID; then
|
||||
pass "Gegenprobe: der Stub baut die Zugangsdaten ein, solange config.local.cmake da ist"
|
||||
else
|
||||
fail "Gegenprobe fehlgeschlagen - der Stub baut nie Zugangsdaten ein, Fall 5 waere wertlos"
|
||||
fi
|
||||
|
||||
echo "--- Fall 6a: config.local.cmake nach dem Erfolgslauf unveraendert ---"
|
||||
if [ -f "$SANDBOX/config.local.cmake" ] && [ "$(sha_of "$SANDBOX/config.local.cmake")" = "$CONF_BEFORE" ]; then
|
||||
pass "config.local.cmake liegt unveraendert an seinem Platz"
|
||||
else
|
||||
fail "config.local.cmake fehlt oder wurde veraendert"
|
||||
fi
|
||||
|
||||
echo "--- README nennt die Vertrauensgrenze und die Verzeichnisse ---"
|
||||
for phrase in "kein kryptografisches Signaturverfahren" "ohne TLS" "onboard.sh" "migrate/" "flash/"; do
|
||||
if grep -qF "$phrase" "$A/README.md"; then pass "README nennt '$phrase'"; else fail "README nennt '$phrase' nicht"; fi
|
||||
done
|
||||
|
||||
echo "=== Szenario 2: Bau bricht mittendrin ab (Fall 6) ==="
|
||||
SANDBOX="$TMPROOT/s2"
|
||||
setup_sandbox "$SANDBOX"
|
||||
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
|
||||
CONF_BEFORE=$(sha_of "$SANDBOX/config.local.cmake")
|
||||
set +e
|
||||
OUT=$(STUB_FAIL_TARGET=e27_cw TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Exit-Code war 0"; fi
|
||||
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein halbes Archiv zurueckgelassen"; else fail "Archiv trotz Abbruch entstanden"; fi
|
||||
if [ -f "$SANDBOX/config.local.cmake" ] && [ "$(sha_of "$SANDBOX/config.local.cmake")" = "$CONF_BEFORE" ]; then
|
||||
pass "config.local.cmake nach dem Abbruch unveraendert an seinem Platz"
|
||||
else
|
||||
fail "config.local.cmake nach dem Abbruch verschwunden oder veraendert"
|
||||
fi
|
||||
|
||||
echo "=== Szenario 3: Abbruch per Signal (SIGTERM mitten im Bau) ==="
|
||||
SANDBOX="$TMPROOT/s3"
|
||||
setup_sandbox "$SANDBOX"
|
||||
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
|
||||
CONF_BEFORE=$(sha_of "$SANDBOX/config.local.cmake")
|
||||
set +e
|
||||
OUT=$(STUB_TERM_TARGET=gu10_rgb TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Exit-Code war 0"; fi
|
||||
if [ -f "$SANDBOX/config.local.cmake" ] && [ "$(sha_of "$SANDBOX/config.local.cmake")" = "$CONF_BEFORE" ]; then
|
||||
pass "config.local.cmake nach dem Signalabbruch unveraendert an seinem Platz"
|
||||
else
|
||||
fail "config.local.cmake nach dem Signalabbruch verschwunden oder veraendert"
|
||||
fi
|
||||
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv nach dem Signalabbruch"; else fail "Archiv trotz Signalabbruch entstanden"; fi
|
||||
|
||||
echo "=== Szenario 4: ohne Schluesselverzeichnis (Fall 3) ==="
|
||||
SANDBOX="$TMPROOT/s4"
|
||||
setup_sandbox "$SANDBOX"
|
||||
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
|
||||
set +e
|
||||
OUT=$(run_release "$SANDBOX" 0.9.9 2>&1) # kein TEST_SIGN_DIR -> Verzeichnis gibt es nicht
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0 - das Archiv entsteht trotzdem"; else fail "Exit-Code $RC: $OUT"; fi
|
||||
if echo "$OUT" | grep -q "Schluesselverzeichnis"; then pass "deutliche Meldung zum fehlenden Schluesselverzeichnis"; else fail "keine Meldung zum fehlenden Schluesselverzeichnis"; fi
|
||||
EXTRACT="$TMPROOT/x4"; mkdir -p "$EXTRACT"
|
||||
tar xzf "$SANDBOX/dist/ninlight-0.9.9.tar.gz" -C "$EXTRACT"
|
||||
A4="$EXTRACT/ninlight-0.9.9"
|
||||
if [ ! -d "$A4/migrate" ]; then pass "migrate/ liegt nicht vor"; else fail "migrate/ trotzdem vorhanden"; fi
|
||||
if [ -d "$A4/ota" ] && [ "$(find "$A4/ota" -type f | wc -l | tr -d ' ')" = "${#TARGETS[@]}" ]; then
|
||||
pass "ota/ trotzdem vollstaendig"
|
||||
else
|
||||
fail "ota/ unvollstaendig"
|
||||
fi
|
||||
if grep -q "migrate/\` fehlt in diesem Archiv" "$A4/README.md"; then
|
||||
pass "README.md sagt, warum migrate/ fehlt"
|
||||
else
|
||||
fail "README.md erklaert das fehlende migrate/ nicht"
|
||||
fi
|
||||
if (cd "$A4" && "${SHA_CHECK[@]}" SHA256SUMS >/dev/null 2>&1); then
|
||||
pass "SHA256SUMS stimmt auch ohne migrate/"
|
||||
else
|
||||
fail "SHA256SUMS stimmt ohne migrate/ nicht"
|
||||
fi
|
||||
|
||||
echo "=== Szenario 5: zweiter Lauf mit derselben Version bricht ab (Fall 7) ==="
|
||||
BEFORE=$(sha_of "$SANDBOX/dist/ninlight-0.9.9.tar.gz")
|
||||
set +e
|
||||
OUT=$(run_release "$SANDBOX" 0.9.9 2>&1)
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "zweiter Lauf war erfolgreich"; fi
|
||||
if echo "$OUT" | grep -q "existiert bereits"; then pass "Meldung 'existiert bereits'"; else fail "keine Meldung zum vorhandenen Archiv: $OUT"; fi
|
||||
if [ "$(sha_of "$SANDBOX/dist/ninlight-0.9.9.tar.gz")" = "$BEFORE" ]; then
|
||||
pass "vorhandenes Archiv unveraendert"
|
||||
else
|
||||
fail "vorhandenes Archiv wurde ueberschrieben"
|
||||
fi
|
||||
# Und eine andere Version laeuft weiterhin durch.
|
||||
set +e
|
||||
OUT=$(run_release "$SANDBOX" 0.9.10 2>&1)
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "$RC" -eq 0 ] && [ -f "$SANDBOX/dist/ninlight-0.9.10.tar.gz" ]; then
|
||||
pass "andere Version laeuft normal durch"
|
||||
else
|
||||
fail "andere Version schlaegt fehl: $OUT"
|
||||
fi
|
||||
|
||||
echo "=== Szenario 6: doppelter Boden - Zugangsdaten im Abzug brechen den Lauf ab ==="
|
||||
SANDBOX="$TMPROOT/s6"
|
||||
setup_sandbox "$SANDBOX"
|
||||
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
|
||||
set +e
|
||||
OUT=$(STUB_LEAK=1 TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Lauf war trotz Zugangsdaten im Abzug erfolgreich"; fi
|
||||
if echo "$OUT" | grep -q "Zugangsdaten aus config.local.cmake im Archiv gefunden"; then
|
||||
pass "Meldung benennt den Fund"
|
||||
else
|
||||
fail "keine Meldung zum Fund: $OUT"
|
||||
fi
|
||||
if echo "$OUT" | grep -qF "$SECRET_SSID"; then
|
||||
fail "die Meldung druckt den geheimen Wert selbst"
|
||||
else
|
||||
pass "die Meldung druckt den Wert nicht, nur den Namen"
|
||||
fi
|
||||
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv gepackt"; else fail "Archiv trotz Fund gepackt"; fi
|
||||
|
||||
echo "=== Szenario 7: onboard.sh spricht die richtige Schnittstelle an ==="
|
||||
ONB="$TMPROOT/x1/ninlight-0.9.9/onboard.sh"
|
||||
STUBBIN="$TMPROOT/onb-bin"
|
||||
mkdir -p "$STUBBIN"
|
||||
CURLLOG="$TMPROOT/curl.log"
|
||||
cat >"$STUBBIN/curl" <<'CURL'
|
||||
#!/usr/bin/env bash
|
||||
{ for a in "$@"; do printf '%s\n' "$a"; done; echo "--"; } >>"$CURL_LOG"
|
||||
echo '{}'
|
||||
CURL
|
||||
chmod +x "$STUBBIN/curl"
|
||||
: >"$CURLLOG"
|
||||
CURL_LOG="$CURLLOG" PATH="$STUBBIN:$PATH" NINLIGHT_ONBOARD_WAIT=0 "$ONB" "MeinWLAN" "geheim123" >/dev/null
|
||||
if grep -q "^http://192.168.4.1:3333/api/v1/system/settings$" "$CURLLOG"; then
|
||||
pass "PUT geht an system/settings im Onboarding-AP"
|
||||
else
|
||||
fail "falsche URL: $(cat "$CURLLOG")"
|
||||
fi
|
||||
if grep -q '^PUT$' "$CURLLOG"; then pass "Methode PUT"; else fail "keine PUT-Methode"; fi
|
||||
if grep -qF '{"type": "wifi", "ssid": "MeinWLAN", "password": "geheim123"}' "$CURLLOG"; then
|
||||
pass "Rumpf entspricht der Bruno-Anfrage 'Set WiFi'"
|
||||
else
|
||||
fail "falscher Rumpf: $(cat "$CURLLOG")"
|
||||
fi
|
||||
if grep -q "^http://192.168.4.1:3333/api/v1/system/info$" "$CURLLOG"; then
|
||||
pass "danach wird system/info abgefragt"
|
||||
else
|
||||
fail "system/info wird nicht abgefragt"
|
||||
fi
|
||||
if grep -q "^Authorization:" "$CURLLOG"; then
|
||||
fail "ohne --token wird trotzdem ein Authorization-Header geschickt"
|
||||
else
|
||||
pass "ohne --token kein Authorization-Header (offener Onboarding-AP)"
|
||||
fi
|
||||
: >"$CURLLOG"
|
||||
CURL_LOG="$CURLLOG" PATH="$STUBBIN:$PATH" NINLIGHT_ONBOARD_WAIT=0 "$ONB" --token abc12345 "MeinWLAN" "geheim123" >/dev/null
|
||||
if grep -q "^Authorization: abc12345$" "$CURLLOG"; then
|
||||
pass "mit --token wird der Header geschickt (Fallback-AP)"
|
||||
else
|
||||
fail "--token kommt nicht als Authorization-Header an"
|
||||
fi
|
||||
HELP=$("$ONB" --help)
|
||||
if echo "$HELP" | grep -qi "von hand" && echo "$HELP" | grep -q "ninlight-<mac4>"; then
|
||||
pass "Hilfetext nennt die manuellen Schritte und den AP-Namen"
|
||||
else
|
||||
fail "Hilfetext erklaert die manuellen Schritte nicht: $HELP"
|
||||
fi
|
||||
set +e
|
||||
"$ONB" >/dev/null 2>&1
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "$RC" -ne 0 ]; then pass "ohne SSID bricht onboard.sh ab"; else fail "ohne SSID lief onboard.sh durch"; fi
|
||||
|
||||
echo
|
||||
if [ "$FAILED" -eq 0 ]; then
|
||||
echo "alle Zusagen erfuellt"
|
||||
else
|
||||
echo "es gab Fehler" >&2
|
||||
fi
|
||||
exit "$FAILED"
|
||||
Reference in New Issue
Block a user