fix(station): V2/V3-Panelkapselung, wiedereintrittsfaehiges module_init, Unit-Fixes
Fixrunde 1 zu Task 6a: epd2in13_V2.init() braucht ein Pflichtargument und dessen displayPartial() setzt selbst keine LUT (neue Panel-Klasse in display.py kapselt das statt es zu verstreuen); epdconfig.module_init() war nicht wiedereintrittsfaehig und ein zu breites except tarnte GPIOPinInUse als "Panel fehlt" (jetzt idempotent, gezielter except); ninlight-display.service hatte ReadOnlyPaths=/run/ninlight-station (verhindert den Start, wenn die Station regulaer aus ist), StartLimit* in der falschen Sektion und SupplementaryGroups=spi gpio (harter Fehler auf reinem Debian ohne diese Gruppen); ein fehlendes Pillow fuehrte zu ModuleNotFoundError statt zum vorgesehenen stillen Exit 0. Dazu: Panel schlaeft nach jedem Zeichnen (Alterung), Modus-Vorrang liegt jetzt bei RUN_DIR/mode statt bei status.json, export-Praefix in der Konfigurationsdatei wird erkannt, MAX_COLS auf das mit Pillow nachgemessene Mass korrigiert, und build_state() hat jetzt eigene Tests.
This commit is contained in:
@@ -19,11 +19,11 @@ wiederholen. Zwischen den beiden Versuchen kurz die Stromversorgung des Panels t
|
|||||||
(--clear reicht nicht immer, falsche Revision kann das Panel in einem Zwischenzustand
|
(--clear reicht nicht immer, falsche Revision kann das Panel in einem Zwischenzustand
|
||||||
lassen).
|
lassen).
|
||||||
|
|
||||||
Die Anzeige ist nirgends Voraussetzung fuer die Migration: fehlt das Panel, SPI oder die
|
Die Anzeige ist nirgends Voraussetzung fuer die Migration: fehlt das Panel, SPI oder eine
|
||||||
gpiozero/spidev-Bibliothek, faengt main() das als DisplayUnavailable ab, protokolliert
|
Bibliothek (gpiozero/spidev/Pillow), faengt main() das als DisplayUnavailable bzw.
|
||||||
GENAU EINE Zeile und beendet sich mit sys.exit(0) - nicht mit einem Fehler, sonst wuerde
|
ImportError ab, protokolliert GENAU EINE Zeile und beendet sich mit sys.exit(0) - nicht mit
|
||||||
systemd (Restart=on-failure) den Dienst endlos neu starten und das Journal fluten. Ein
|
einem Fehler, sonst wuerde systemd (Restart=on-failure) den Dienst endlos neu starten und
|
||||||
Display-Fehler darf niemals eine Migration verhindern.
|
das Journal fluten. Ein Display-Fehler darf niemals eine Migration verhindern.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -73,8 +73,11 @@ def _read_json(path: Path) -> dict:
|
|||||||
|
|
||||||
def _read_conf(path: Path) -> dict[str, str]:
|
def _read_conf(path: Path) -> dict[str, str]:
|
||||||
"""Minimaler Parser fuer /etc/ninlight-station.conf: KEY='VALUE'-Zeilen, wie
|
"""Minimaler Parser fuer /etc/ninlight-station.conf: KEY='VALUE'-Zeilen, wie
|
||||||
setup_pi.sh sie schreibt. Kein echtes Shell-Sourcing (das CLI braucht das fuer $-
|
setup_pi.sh sie schreibt, optional mit einem "export "-Praefix (setup_pi.sh prueft
|
||||||
und `-Escaping in Passwoertern, siehe dessen Kommentar - display.py liest hier nur
|
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)."""
|
SSID/MIGRATE_SSID, reine Bezeichner ohne Sonderzeichen)."""
|
||||||
values: dict[str, str] = {}
|
values: dict[str, str] = {}
|
||||||
try:
|
try:
|
||||||
@@ -85,6 +88,8 @@ def _read_conf(path: Path) -> dict[str, str]:
|
|||||||
line = raw_line.strip()
|
line = raw_line.strip()
|
||||||
if not line or line.startswith("#") or "=" not in line:
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
continue
|
continue
|
||||||
|
if line.startswith("export ") or line.startswith("export\t"):
|
||||||
|
line = line[len("export"):].lstrip()
|
||||||
key, _, value = line.partition("=")
|
key, _, value = line.partition("=")
|
||||||
key = key.strip()
|
key = key.strip()
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
@@ -106,12 +111,19 @@ def _read_mode_file(run_dir: Path) -> str:
|
|||||||
return value if value in ("recovery", "migration") else "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:
|
def _service_active(service_name: str) -> bool:
|
||||||
"""Laeuft der Hauptdienst (server.py) gerade? "ninlight-station off" stoppt ihn und
|
"""Laeuft der Hauptdienst (server.py) gerade? "ninlight-station off" stoppt ihn und
|
||||||
faehrt den Zugangspunkt herunter, laesst status.json dabei aber unangetastet liegen -
|
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
|
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
|
Modus vor dem Abschalten zeigen. Deshalb ist die Dienstaktivitaet selbst (nicht das
|
||||||
Vorhandensein von status.json) die Quelle fuer den Anzeigemodus "off"."""
|
Vorhandensein von status.json) die Quelle fuer den Anzeigemodus "off"."""
|
||||||
|
global _service_active_fallback_logged
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["systemctl", "is-active", "--quiet", service_name],
|
["systemctl", "is-active", "--quiet", service_name],
|
||||||
@@ -120,26 +132,33 @@ def _service_active(service_name: str) -> bool:
|
|||||||
except (OSError, subprocess.SubprocessError):
|
except (OSError, subprocess.SubprocessError):
|
||||||
# systemctl selbst nicht erreichbar (z.B. Handlauf ohne systemd) - dann anhand
|
# systemctl selbst nicht erreichbar (z.B. Handlauf ohne systemd) - dann anhand
|
||||||
# der Statusdatei entscheiden statt "off" zu behaupten, ohne es zu wissen.
|
# 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
|
return True
|
||||||
|
|
||||||
|
|
||||||
def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
|
def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
|
||||||
service_name: str = SERVICE_NAME) -> dict:
|
service_name: str = SERVICE_NAME) -> dict:
|
||||||
"""Baut den zusammengefuehrten Zustand, den render.lines()/compose() erwarten -
|
"""Baut den zusammengefuehrten Zustand, den render.lines()/compose() erwarten.
|
||||||
status.json (served/recent/last_seen), control.json (phase/armed_target, nur im
|
|
||||||
Migrationsmodus massgeblich), die SSID aus der Konfigurationsdatei und "off", wenn
|
Modus kommt vorrangig aus RUN_DIR/mode (Vorgabe "recovery", wie
|
||||||
der Hauptdienst gar nicht laeuft."""
|
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."""
|
||||||
if not _service_active(service_name):
|
if not _service_active(service_name):
|
||||||
return {"mode": "off"}
|
return {"mode": "off"}
|
||||||
|
|
||||||
|
mode = _read_mode_file(run_dir)
|
||||||
status = _read_json(run_dir / "status.json")
|
status = _read_json(run_dir / "status.json")
|
||||||
mode = status.get("mode")
|
|
||||||
if mode not in ("recovery", "migration"):
|
|
||||||
# status.json fehlt/ist kaputt/stammt (noch) nicht vom laufenden Prozess -
|
|
||||||
# RUN_DIR/mode ist das, was "ninlight-station on"/"migrate on" zuletzt
|
|
||||||
# geschrieben hat und ist unabhaengig von status.json verfuegbar.
|
|
||||||
mode = _read_mode_file(run_dir)
|
|
||||||
|
|
||||||
conf = _read_conf(conf_path)
|
conf = _read_conf(conf_path)
|
||||||
state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent")}
|
state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent")}
|
||||||
|
|
||||||
@@ -148,13 +167,6 @@ def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
|
|||||||
else: # migration
|
else: # migration
|
||||||
state["ssid"] = conf.get("MIGRATE_SSID")
|
state["ssid"] = conf.get("MIGRATE_SSID")
|
||||||
state["last_seen"] = status.get("last_seen")
|
state["last_seen"] = status.get("last_seen")
|
||||||
# Die Phase kommt bewusst aus control.json, nicht aus status.json: server.py
|
|
||||||
# schreibt status.json nur beim Start und nach einer Auslieferung, waehrend
|
|
||||||
# "ninlight-station arm"/"listen" nur die Steuerdatei anfasst (Spec/CLI
|
|
||||||
# status_cmd folgt demselben Muster). Und bewusst nur HIER gelesen - im
|
|
||||||
# Rettungsmodus ignoriert server.py control.json ebenfalls, ein
|
|
||||||
# liegengebliebener Inhalt aus einer frueheren Migration darf dort nicht
|
|
||||||
# faelschlich "Phase: FREIGEGEBEN" zeigen.
|
|
||||||
control = _read_json(run_dir / "control.json")
|
control = _read_json(run_dir / "control.json")
|
||||||
phase = control.get("phase")
|
phase = control.get("phase")
|
||||||
state["phase"] = phase if phase in ("auto", "listen", "armed") else "auto"
|
state["phase"] = phase if phase in ("auto", "listen", "armed") else "auto"
|
||||||
@@ -177,28 +189,55 @@ def _load_epd_module(revision: str):
|
|||||||
return epd_module
|
return epd_module
|
||||||
|
|
||||||
|
|
||||||
def _draw(epd, image, full: bool) -> None:
|
class Panel:
|
||||||
buf = epd.getbuffer(image)
|
"""Kapselt die V2/V3-Unterschiede der Waveshare-Treiber hinter einer einheitlichen
|
||||||
if full:
|
Schnittstelle (Fixrunde 1, C1): epd2in13_V2.init() braucht ein Pflichtargument
|
||||||
# epd.init() laedt die volle LUT neu - noetig, weil displayPartial() unten immer
|
(FULL_UPDATE/PART_UPDATE) und dessen displayPartial() setzt selbst keine LUT - vorher
|
||||||
# die Teil-LUT setzt und die naechste display()-Auslieferung sonst mit der
|
muessen init(PART_UPDATE) und (fuer den allerersten Teilaufbau nach einem Vollaufbau)
|
||||||
# falschen Wellenform liefe.
|
displayPartBaseImage() gelaufen sein, sonst zeichnet das Panel mit der falschen
|
||||||
epd.init()
|
Wellenform oder gegen einen veralteten Referenzpuffer. epd2in13_V3.init() braucht
|
||||||
epd.display(buf)
|
dagegen kein Argument und dessen displayPartial() setzt seine Teil-LUT selbst bei
|
||||||
else:
|
jedem Aufruf - dort reicht ein einfaches init() vor jedem Vollaufbau."""
|
||||||
epd.displayPartial(buf)
|
|
||||||
|
def __init__(self, revision: str) -> None:
|
||||||
|
self.revision = revision
|
||||||
|
self._epd_module = _load_epd_module(revision)
|
||||||
|
self.epd = self._epd_module.EPD()
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
if self.revision == "V2":
|
||||||
|
self.epd.init(self.epd.FULL_UPDATE)
|
||||||
|
else:
|
||||||
|
self.epd.init()
|
||||||
|
self.epd.Clear(0xFF)
|
||||||
|
|
||||||
|
def draw_full(self, image) -> None:
|
||||||
|
buf = self.epd.getbuffer(image)
|
||||||
|
if self.revision == "V2":
|
||||||
|
self.epd.init(self.epd.FULL_UPDATE)
|
||||||
|
# displayPartBaseImage() zeichnet UND schreibt gleichzeitig beide RAM-Puffer
|
||||||
|
# des Controllers - genau das, was ein nachfolgender Teilaufbau als
|
||||||
|
# Referenzpuffer braucht. display() allein wuerde nur einen Puffer setzen und
|
||||||
|
# den ersten Teilaufbau danach gegen einen veralteten Referenzpuffer diffen
|
||||||
|
# lassen (Fixrunde 1, C1).
|
||||||
|
self.epd.displayPartBaseImage(buf)
|
||||||
|
else:
|
||||||
|
self.epd.init()
|
||||||
|
self.epd.display(buf)
|
||||||
|
|
||||||
|
def draw_partial(self, image) -> None:
|
||||||
|
buf = self.epd.getbuffer(image)
|
||||||
|
if self.revision == "V2":
|
||||||
|
self.epd.init(self.epd.PART_UPDATE)
|
||||||
|
self.epd.displayPartial(buf)
|
||||||
|
|
||||||
|
def sleep(self) -> None:
|
||||||
|
self.epd.sleep()
|
||||||
|
|
||||||
|
|
||||||
def run_test(revision: str) -> None:
|
def _test_image(revision: str):
|
||||||
"""Zeichnet ein Testbild und beendet sich - siehe Modul-Docstring, wie man damit V2
|
|
||||||
von V3 unterscheidet."""
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
epd_module = _load_epd_module(revision)
|
|
||||||
epd = epd_module.EPD()
|
|
||||||
epd.init()
|
|
||||||
epd.Clear(0xFF)
|
|
||||||
|
|
||||||
width, height = 250, 122
|
width, height = 250, 122
|
||||||
image = Image.new("1", (width, height), 255)
|
image = Image.new("1", (width, height), 255)
|
||||||
draw = ImageDraw.Draw(image)
|
draw = ImageDraw.Draw(image)
|
||||||
@@ -211,25 +250,29 @@ def run_test(revision: str) -> None:
|
|||||||
for i in range(4):
|
for i in range(4):
|
||||||
x0 = 4 + i * 20
|
x0 = 4 + i * 20
|
||||||
draw.rectangle((x0, 50, x0 + 16, 90), outline=0, fill=0 if i % 2 == 0 else 255)
|
draw.rectangle((x0, 50, x0 + 16, 90), outline=0, fill=0 if i % 2 == 0 else 255)
|
||||||
|
return image
|
||||||
|
|
||||||
epd.display(epd.getbuffer(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)
|
log.info("Testbild gezeichnet (EPD_REVISION=%s)", revision)
|
||||||
|
|
||||||
|
|
||||||
def run_clear(revision: str) -> None:
|
def run_clear(revision: str) -> None:
|
||||||
epd_module = _load_epd_module(revision)
|
panel = Panel(revision)
|
||||||
epd = epd_module.EPD()
|
panel.clear()
|
||||||
epd.init()
|
panel.sleep()
|
||||||
epd.Clear(0xFF)
|
|
||||||
epd.sleep()
|
|
||||||
log.info("Panel geleert (EPD_REVISION=%s)", revision)
|
log.info("Panel geleert (EPD_REVISION=%s)", revision)
|
||||||
|
|
||||||
|
|
||||||
def run_daemon(revision: str) -> None:
|
def run_daemon(revision: str) -> None:
|
||||||
epd_module = _load_epd_module(revision)
|
panel = Panel(revision)
|
||||||
epd = epd_module.EPD()
|
panel.clear()
|
||||||
epd.init()
|
|
||||||
epd.Clear(0xFF)
|
|
||||||
|
|
||||||
last_lines: list[str] | None = None
|
last_lines: list[str] | None = None
|
||||||
last_mode: object = object() # garantiert ungleich jedem echten Modus beim ersten Lauf
|
last_mode: object = object() # garantiert ungleich jedem echten Modus beim ersten Lauf
|
||||||
@@ -244,9 +287,18 @@ def run_daemon(revision: str) -> None:
|
|||||||
|
|
||||||
if current_lines != last_lines or need_full:
|
if current_lines != last_lines or need_full:
|
||||||
image = render.compose(state)
|
image = render.compose(state)
|
||||||
_draw(epd, image, full=need_full)
|
|
||||||
if need_full:
|
if need_full:
|
||||||
|
panel.draw_full(image)
|
||||||
last_full = now
|
last_full = now
|
||||||
|
else:
|
||||||
|
panel.draw_partial(image)
|
||||||
|
# Panel nach jedem Bildaufbau schlafen legen: die Pixel bleiben (e-Paper ist
|
||||||
|
# bistabil), aber ohne dauerhafte Spannung altert das Panel deutlich weniger -
|
||||||
|
# Waveshare warnt fuer diese Serie ausdruecklich davor, es dauerhaft unter
|
||||||
|
# Bias zu lassen (Fixrunde 1, I4). epd.sleep() gibt auch GPIO/SPI frei
|
||||||
|
# (epdconfig.module_exit()); der naechste Aufbau ruft ohnehin wieder init()
|
||||||
|
# und damit module_init() auf, das das sauber neu aufsetzt.
|
||||||
|
panel.sleep()
|
||||||
last_lines = current_lines
|
last_lines = current_lines
|
||||||
last_mode = mode
|
last_mode = mode
|
||||||
|
|
||||||
@@ -271,11 +323,17 @@ def main() -> None:
|
|||||||
run_clear(revision)
|
run_clear(revision)
|
||||||
else:
|
else:
|
||||||
run_daemon(revision)
|
run_daemon(revision)
|
||||||
except DisplayUnavailable as e:
|
except (DisplayUnavailable, ImportError) as e:
|
||||||
# Genau eine Zeile, dann sys.exit(0) - siehe Modul-Docstring. Restart=on-failure
|
# Genau eine Zeile, dann sys.exit(0) - siehe Modul-Docstring. Das deckt sowohl
|
||||||
# (statt always) in der Unit greift hier nicht, weil 0 kein Fehlschlag ist; ein
|
# Hardwareprobleme (DisplayUnavailable: kein Panel/SPI) als auch eine fehlende
|
||||||
# spaeter angestecktes Panel braucht also einen manuellen/zeitgesteuerten
|
# Bibliothek ab - allen voran Pillow, das laut Spec auf dem Ziel-Pi absichtlich
|
||||||
# Neustart der Unit, nicht eine Neustartschleife.
|
# 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)
|
log.info("E-Paper-Anzeige nicht verfuegbar, beende ohne Fehler: %s", e)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|||||||
@@ -90,10 +90,23 @@ def spi_writebyte2(data) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def module_init() -> int:
|
def module_init() -> int:
|
||||||
"""Oeffnet GPIO-Pins und SPI-Geraet. Wirft DisplayUnavailable statt irgendeiner
|
"""Oeffnet GPIO-Pins und SPI-Geraet. Wirft DisplayUnavailable statt einer rohen
|
||||||
Bibliotheksausnahme, damit display.py genau eine Ausnahmeklasse abfangen muss."""
|
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
|
global _rst_pin, _dc_pin, _busy_pin, _spi
|
||||||
|
|
||||||
|
if _spi is not None:
|
||||||
|
return 0
|
||||||
|
|
||||||
if gpiozero is None or spidev is None:
|
if gpiozero is None or spidev is None:
|
||||||
raise DisplayUnavailable("gpiozero/spidev nicht installiert")
|
raise DisplayUnavailable("gpiozero/spidev nicht installiert")
|
||||||
|
|
||||||
@@ -101,6 +114,14 @@ def module_init() -> int:
|
|||||||
if not os.path.exists(spi_path):
|
if not os.path.exists(spi_path):
|
||||||
raise DisplayUnavailable(f"{spi_path} fehlt - ist SPI aktiviert (raspi-config)?")
|
raise DisplayUnavailable(f"{spi_path} fehlt - ist SPI aktiviert (raspi-config)?")
|
||||||
|
|
||||||
|
# Nur Fehler, die tatsaechlich von einer fehlenden/blockierten Hardware kommen, werden
|
||||||
|
# zu DisplayUnavailable - alles andere (ein echter Programmierfehler an anderer
|
||||||
|
# Stelle) soll weiterhin als das erkennbar bleiben, was es ist, statt hier als
|
||||||
|
# "Panel fehlt" getarnt zu werden (Fixrunde 1, C2, zweiter Teil).
|
||||||
|
hardware_errors: tuple[type[BaseException], ...] = (OSError, ImportError)
|
||||||
|
if gpiozero is not None:
|
||||||
|
hardware_errors = hardware_errors + (gpiozero.GPIODeviceError,)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_rst_pin = gpiozero.DigitalOutputDevice(RST_PIN)
|
_rst_pin = gpiozero.DigitalOutputDevice(RST_PIN)
|
||||||
_dc_pin = gpiozero.DigitalOutputDevice(DC_PIN)
|
_dc_pin = gpiozero.DigitalOutputDevice(DC_PIN)
|
||||||
@@ -110,7 +131,7 @@ def module_init() -> int:
|
|||||||
_spi.open(_SPI_BUS, _SPI_DEVICE)
|
_spi.open(_SPI_BUS, _SPI_DEVICE)
|
||||||
_spi.max_speed_hz = _SPI_HZ
|
_spi.max_speed_hz = _SPI_HZ
|
||||||
_spi.mode = 0b00
|
_spi.mode = 0b00
|
||||||
except Exception as e: # noqa: BLE001 - alles hier wird zu DisplayUnavailable
|
except hardware_errors as e:
|
||||||
module_exit()
|
module_exit()
|
||||||
raise DisplayUnavailable(f"GPIO/SPI-Initialisierung fehlgeschlagen: {e}") from e
|
raise DisplayUnavailable(f"GPIO/SPI-Initialisierung fehlgeschlagen: {e}") from e
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ Funktion, damit ein fehlendes PIL (das erst per "apt install python3-pil" auf de
|
|||||||
kommt, siehe setup_pi.sh) render.py selbst nicht unbenutzbar macht.
|
kommt, siehe setup_pi.sh) render.py selbst nicht unbenutzbar macht.
|
||||||
|
|
||||||
Das Panel ist 250x122 Pixel, quer verwendet. MAX_COLS begrenzt jede Textzeile so, dass sie
|
Das Panel ist 250x122 Pixel, quer verwendet. MAX_COLS begrenzt jede Textzeile so, dass sie
|
||||||
bei der in compose() verwendeten Schriftgroesse nicht umgebrochen werden muss - ueberlange
|
bei der in compose() verwendeten Schriftgroesse (PIL.ImageFont.load_default(), Textursprung
|
||||||
Werte (z.B. ein Produktname aus der Lampenerkennung) werden mit "..." gekuerzt, nie
|
x=2) nicht umgebrochen werden muss - ueberlange Werte (z.B. ein Produktname aus der
|
||||||
umgebrochen (Spec: eine zu lange Zeile darf das Layout nicht sprengen).
|
Lampenerkennung) werden mit "..." gekuerzt, nie umgebrochen (Spec: eine zu lange Zeile darf
|
||||||
|
das Layout nicht sprengen). Der Wert 40 ist mit genau dieser Schrift nachgemessen
|
||||||
|
(draw.textbbox(): 40 Zeichen reichen bis x=243, 42 bis x=255 - beim 250 px breiten Panel
|
||||||
|
waere das "..." selbst abgeschnitten gewesen, siehe Fixrunde 1, Kleinigkeit).
|
||||||
|
|
||||||
Der Zustand, den lines()/compose() bekommen, ist bereits zusammengefuehrt: status.json
|
Der Zustand, den lines()/compose() bekommen, ist bereits zusammengefuehrt: status.json
|
||||||
(vom Server geschrieben) traegt kein "ssid"-Feld - die SSID ist Konfiguration aus
|
(vom Server geschrieben) traegt kein "ssid"-Feld - die SSID ist Konfiguration aus
|
||||||
@@ -19,7 +22,7 @@ aus einer aelteren Fassung sein (frisch aufgesetzter Pi, Schreibfehler mitten im
|
|||||||
Schreiben, Neustart) - siehe test_missing_fields_do_not_raise."""
|
Schreiben, Neustart) - siehe test_missing_fields_do_not_raise."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
MAX_COLS = 42
|
MAX_COLS = 40
|
||||||
|
|
||||||
MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"}
|
MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"}
|
||||||
# "armed" wird gesondert behandelt (braucht armed_target im Text), deshalb hier nicht
|
# "armed" wird gesondert behandelt (braucht armed_target im Text), deshalb hier nicht
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ Description=ninlight E-Paper-Anzeige (Status der Migrations-/Rettungsstation)
|
|||||||
# Migration.
|
# Migration.
|
||||||
After=ninlight-station.service
|
After=ninlight-station.service
|
||||||
Wants=ninlight-station.service
|
Wants=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]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
@@ -30,20 +36,30 @@ ExecStart=/usr/bin/python3 /opt/ninlight-station/display/display.py
|
|||||||
# so das Journal fluten koennte.
|
# so das Journal fluten koennte.
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
StartLimitIntervalSec=60
|
|
||||||
StartLimitBurst=5
|
|
||||||
|
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=yes
|
ProtectHome=yes
|
||||||
PrivateTmp=yes
|
PrivateTmp=yes
|
||||||
ReadOnlyPaths=/run/ninlight-station
|
# 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
|
RestrictAddressFamilies=AF_UNIX AF_NETLINK
|
||||||
MemoryMax=64M
|
MemoryMax=64M
|
||||||
|
|
||||||
# Fuer /dev/spidev0.0 und die GPIO-Zeichenleitung (per lgpio/gpiozero) noetig - laeuft
|
# Laeuft als root (wie der Hauptdienst) fuer /dev/spidev0.0 und die GPIO-Zeichenleitung -
|
||||||
# deshalb nicht mit DynamicUser=, sondern als root wie der Hauptdienst.
|
# kein SupplementaryGroups=spi gpio hier (stand hier zuerst, Fixrunde 1, I2): auf reinem
|
||||||
SupplementaryGroups=spi gpio
|
# 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]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "display"))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "display"))
|
||||||
|
|
||||||
|
import display # noqa: E402
|
||||||
import render # noqa: E402
|
import render # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@@ -54,3 +56,74 @@ def test_armed_phase_is_visible():
|
|||||||
lines = render.lines({"mode": "migration", "phase": "armed", "armed_target": "g95_cw",
|
lines = render.lines({"mode": "migration", "phase": "armed", "armed_target": "g95_cw",
|
||||||
"served": 0, "recent": [], "ssid": "qconnex-firmware"})
|
"served": 0, "recent": [], "ssid": "qconnex-firmware"})
|
||||||
assert "FREIGEGEBEN" in "\n".join(lines)
|
assert "FREIGEGEBEN" in "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# --- display.build_state() (Fixrunde 1, I3) ---------------------------------------
|
||||||
|
#
|
||||||
|
# Dieselbe Unterscheidung, die im CLI (tools/station/ninlight-station, status_cmd) zweimal
|
||||||
|
# nachgebessert werden musste: Phase/armed_target kommen aus control.json und sind nur im
|
||||||
|
# Migrationsmodus gueltig; ein control.json-Rest aus einer frueheren Migration darf im
|
||||||
|
# Rettungsmodus nicht als "FREIGEGEBEN" durchschlagen.
|
||||||
|
|
||||||
|
def test_control_json_is_ignored_outside_migration_mode(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
||||||
|
(tmp_path / "mode").write_text("recovery")
|
||||||
|
(tmp_path / "control.json").write_text(json.dumps({"phase": "armed", "armed_target": "g95_cw"}))
|
||||||
|
conf_path = tmp_path / "station.conf"
|
||||||
|
conf_path.write_text("SSID='ninlight-recovery'\n")
|
||||||
|
|
||||||
|
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x")
|
||||||
|
|
||||||
|
assert state["mode"] == "recovery"
|
||||||
|
assert "phase" not in state
|
||||||
|
assert "armed_target" not in state
|
||||||
|
assert "FREIGEGEBEN" not in "\n".join(render.lines(state))
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_state_off_when_service_not_active(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(display, "_service_active", lambda service_name: False)
|
||||||
|
# Selbst ein noch so frisches/gueltiges status.json darf "off" nicht verhindern -
|
||||||
|
# "ninlight-station off" laesst status.json bewusst unangetastet liegen.
|
||||||
|
(tmp_path / "mode").write_text("migration")
|
||||||
|
(tmp_path / "status.json").write_text(json.dumps({"mode": "migration", "served": 5}))
|
||||||
|
|
||||||
|
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
|
||||||
|
service_name="x")
|
||||||
|
|
||||||
|
assert state == {"mode": "off"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_state_mode_comes_from_mode_file_not_status_json(tmp_path, monkeypatch):
|
||||||
|
"""Der Modus-Vorrang liegt bei RUN_DIR/mode, nicht bei status.json's "mode"-Feld -
|
||||||
|
status.json kann veraltet sein (Fixrunde 1, Kleinigkeit: der Vorrang war zuerst
|
||||||
|
umgekehrt)."""
|
||||||
|
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
||||||
|
(tmp_path / "mode").write_text("recovery")
|
||||||
|
(tmp_path / "status.json").write_text(json.dumps({"mode": "migration", "served": 1}))
|
||||||
|
conf_path = tmp_path / "station.conf"
|
||||||
|
conf_path.write_text("SSID='ninlight-recovery'\n")
|
||||||
|
|
||||||
|
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x")
|
||||||
|
|
||||||
|
assert state["mode"] == "recovery"
|
||||||
|
assert state["served"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_state_falls_back_to_recovery_without_mode_file(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
||||||
|
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
|
||||||
|
service_name="x")
|
||||||
|
assert state["mode"] == "recovery"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_conf_accepts_export_prefix(tmp_path):
|
||||||
|
"""setup_pi.sh prueft beim Nachtragen fehlender Schluessel ausdruecklich auf ein
|
||||||
|
optionales "export "-Praefix (^(export[[:space:]]+)?KEY=) - eine bereits vorhandene
|
||||||
|
Konfigurationsdatei mit "export SSID=..." muss also genauso gelesen werden koennen."""
|
||||||
|
conf_path = tmp_path / "station.conf"
|
||||||
|
conf_path.write_text("export SSID='ninlight-recovery'\nexport MIGRATE_SSID='qconnex-firmware'\n")
|
||||||
|
|
||||||
|
values = display._read_conf(conf_path)
|
||||||
|
|
||||||
|
assert values["SSID"] == "ninlight-recovery"
|
||||||
|
assert values["MIGRATE_SSID"] == "qconnex-firmware"
|
||||||
|
|||||||
Reference in New Issue
Block a user