feat(station): E-Paper-Anzeige fuer Modus, Lampe und Auslieferungen
render.lines() zerlegt den zusammengefuehrten Zustand (status.json + control.json + SSID aus der Konfigurationsdatei) in Textzeilen, ohne Pillow und ohne Hardware zu brauchen - render.compose() importiert Pillow erst innerhalb der Funktion. epdconfig.py ist eine eigene GPIO/SPI-Schicht auf gpiozero/spidev, weil RPi.GPIO auf Debian 13 mit Python 3.13 nicht mehr traegt; epd2in13_V2.py/epd2in13_V3.py sind unveraendert aus dem Waveshare-Repository uebernommen (siehe HERKUNFT.md), nur der epdconfig-Import zeigt auf unser Modul. display.py laeuft als eigener systemd-Dienst (Wants=, Restart=on-failure), fehlendes Panel/SPI/Bibliothek beendet ihn sauber mit sys.exit(0) statt einer Neustartschleife - eine Migration darf davon nie abhaengen. Verdrahtung in setup_pi.sh/deploy.sh/ninlight-station folgt in einem eigenen Durchgang.
This commit is contained in:
@@ -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,286 @@
|
||||
#!/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 die
|
||||
gpiozero/spidev-Bibliothek, faengt main() das als DisplayUnavailable 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")
|
||||
|
||||
POLL_INTERVAL_S = 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
|
||||
|
||||
|
||||
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. 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
|
||||
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_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"
|
||||
|
||||
|
||||
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"."""
|
||||
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.
|
||||
return True
|
||||
|
||||
|
||||
def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
|
||||
service_name: str = SERVICE_NAME) -> dict:
|
||||
"""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
|
||||
der Hauptdienst gar nicht laeuft."""
|
||||
if not _service_active(service_name):
|
||||
return {"mode": "off"}
|
||||
|
||||
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)
|
||||
state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent")}
|
||||
|
||||
if mode == "recovery":
|
||||
state["ssid"] = conf.get("SSID")
|
||||
else: # migration
|
||||
state["ssid"] = conf.get("MIGRATE_SSID")
|
||||
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")
|
||||
phase = control.get("phase")
|
||||
state["phase"] = phase if phase in ("auto", "listen", "armed") else "auto"
|
||||
state["armed_target"] = control.get("armed_target")
|
||||
|
||||
return state
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _draw(epd, image, full: bool) -> None:
|
||||
buf = epd.getbuffer(image)
|
||||
if full:
|
||||
# epd.init() laedt die volle LUT neu - noetig, weil displayPartial() unten immer
|
||||
# die Teil-LUT setzt und die naechste display()-Auslieferung sonst mit der
|
||||
# falschen Wellenform liefe.
|
||||
epd.init()
|
||||
epd.display(buf)
|
||||
else:
|
||||
epd.displayPartial(buf)
|
||||
|
||||
|
||||
def run_test(revision: str) -> None:
|
||||
"""Zeichnet ein Testbild und beendet sich - siehe Modul-Docstring, wie man damit V2
|
||||
von V3 unterscheidet."""
|
||||
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
|
||||
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)
|
||||
|
||||
epd.display(epd.getbuffer(image))
|
||||
log.info("Testbild gezeichnet (EPD_REVISION=%s)", revision)
|
||||
|
||||
|
||||
def run_clear(revision: str) -> None:
|
||||
epd_module = _load_epd_module(revision)
|
||||
epd = epd_module.EPD()
|
||||
epd.init()
|
||||
epd.Clear(0xFF)
|
||||
epd.sleep()
|
||||
log.info("Panel geleert (EPD_REVISION=%s)", revision)
|
||||
|
||||
|
||||
def run_daemon(revision: str) -> None:
|
||||
epd_module = _load_epd_module(revision)
|
||||
epd = epd_module.EPD()
|
||||
epd.init()
|
||||
epd.Clear(0xFF)
|
||||
|
||||
last_lines: list[str] | None = None
|
||||
last_mode: object = object() # garantiert ungleich jedem echten Modus beim ersten Lauf
|
||||
last_full = time.monotonic()
|
||||
|
||||
while True:
|
||||
state = build_state()
|
||||
current_lines = render.lines(state)
|
||||
mode = state.get("mode")
|
||||
now = time.monotonic()
|
||||
need_full = (mode != last_mode) or (now - last_full >= FULL_REFRESH_INTERVAL_S)
|
||||
|
||||
if current_lines != last_lines or need_full:
|
||||
image = render.compose(state)
|
||||
_draw(epd, image, full=need_full)
|
||||
if need_full:
|
||||
last_full = now
|
||||
last_lines = current_lines
|
||||
last_mode = mode
|
||||
|
||||
time.sleep(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")
|
||||
args = parser.parse_args()
|
||||
|
||||
revision = os.environ.get("EPD_REVISION", "V3")
|
||||
|
||||
try:
|
||||
if args.test:
|
||||
run_test(revision)
|
||||
elif args.clear:
|
||||
run_clear(revision)
|
||||
else:
|
||||
run_daemon(revision)
|
||||
except DisplayUnavailable as e:
|
||||
# Genau eine Zeile, dann sys.exit(0) - siehe Modul-Docstring. Restart=on-failure
|
||||
# (statt always) in der Unit greift hier nicht, weil 0 kein Fehlschlag ist; ein
|
||||
# spaeter angestecktes Panel 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,139 @@
|
||||
"""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
|
||||
|
||||
|
||||
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 module_init() -> int:
|
||||
"""Oeffnet GPIO-Pins und SPI-Geraet. Wirft DisplayUnavailable statt irgendeiner
|
||||
Bibliotheksausnahme, damit display.py genau eine Ausnahmeklasse abfangen muss."""
|
||||
global _rst_pin, _dc_pin, _busy_pin, _spi
|
||||
|
||||
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)?")
|
||||
|
||||
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 Exception as e: # noqa: BLE001 - alles hier wird zu DisplayUnavailable
|
||||
module_exit()
|
||||
raise DisplayUnavailable(f"GPIO/SPI-Initialisierung fehlgeschlagen: {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,133 @@
|
||||
"""Textzerlegung und Bildaufbau fuer die E-Paper-Anzeige der ninlight-Station.
|
||||
|
||||
lines() ist 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.
|
||||
|
||||
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
|
||||
Werte (z.B. ein Produktname aus der Lampenerkennung) werden mit "..." gekuerzt, nie
|
||||
umgebrochen (Spec: eine zu lange Zeile darf das Layout nicht sprengen).
|
||||
|
||||
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
|
||||
|
||||
MAX_COLS = 42
|
||||
|
||||
MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"}
|
||||
# "armed" wird gesondert behandelt (braucht armed_target im Text), deshalb hier nicht
|
||||
# gelistet.
|
||||
PHASE_LABELS = {"auto": "AUTO", "listen": "HORCHT"}
|
||||
|
||||
|
||||
def _cut(text: object) -> str:
|
||||
"""Kuerzt auf MAX_COLS Zeichen, mit "..." am Ende statt Umbruch."""
|
||||
text = str(text)
|
||||
if len(text) <= MAX_COLS:
|
||||
return text
|
||||
if MAX_COLS <= 1:
|
||||
return text[:MAX_COLS]
|
||||
return text[: MAX_COLS - 1] + "…"
|
||||
|
||||
|
||||
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 lines(state: dict) -> list[str]:
|
||||
"""Zerlegt den zusammengefuehrten Zustand in Textzeilen fuers Panel. Wirft nie -
|
||||
fehlende/falsch geformte Felder werden ausgelassen statt eine Ausnahme zu werfen."""
|
||||
state = state if isinstance(state, dict) else {}
|
||||
mode = state.get("mode")
|
||||
out: list[str] = []
|
||||
|
||||
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).
|
||||
out.append("Station aus")
|
||||
return out
|
||||
|
||||
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(_cut(f"Status unbekannt ({mode})") if mode else "Status unbekannt")
|
||||
return out
|
||||
|
||||
out.append(MODE_LABELS[mode])
|
||||
|
||||
ssid = state.get("ssid")
|
||||
if ssid:
|
||||
out.append(_cut(f"SSID: {ssid}"))
|
||||
|
||||
if mode == "migration":
|
||||
# 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.
|
||||
phase = state.get("phase")
|
||||
if phase == "armed":
|
||||
target = state.get("armed_target") or "?"
|
||||
out.append(_cut(f"FREIGEGEBEN: {target}"))
|
||||
elif phase in PHASE_LABELS:
|
||||
out.append(PHASE_LABELS[phase])
|
||||
|
||||
last_seen = state.get("last_seen")
|
||||
if not isinstance(last_seen, dict):
|
||||
last_seen = {}
|
||||
product_name = last_seen.get("product_name")
|
||||
if product_name:
|
||||
out.append(_cut(product_name))
|
||||
target = last_seen.get("target")
|
||||
variant = last_seen.get("variant")
|
||||
if target or variant:
|
||||
out.append(_cut(f"{target or '?'} ({variant or '?'})"))
|
||||
lamp_version = last_seen.get("lamp_version")
|
||||
if lamp_version:
|
||||
out.append(_cut(f"FW: {lamp_version}"))
|
||||
|
||||
served = state.get("served")
|
||||
out.append(_cut(f"Ausgeliefert: {served if isinstance(served, int) else 0}"))
|
||||
|
||||
recent = state.get("recent")
|
||||
if isinstance(recent, list) and recent:
|
||||
last = recent[-1]
|
||||
if isinstance(last, dict):
|
||||
out.append(_cut(f"{_hhmm(last.get('ts'))} {last.get('target') or '?'} "
|
||||
f"{last.get('status') or '?'}"))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def compose(state: dict):
|
||||
"""Baut das Panel-Bild (250x122, 1-Bit) aus lines(). Importiert Pillow erst hier -
|
||||
siehe Modul-Docstring."""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
width, height = 250, 122
|
||||
image = Image.new("1", (width, height), 255)
|
||||
draw = ImageDraw.Draw(image)
|
||||
font = ImageFont.load_default()
|
||||
|
||||
line_height = 14
|
||||
y = 2
|
||||
for line in lines(state):
|
||||
if y > height - line_height:
|
||||
break
|
||||
draw.text((2, y), line, font=font, fill=0)
|
||||
y += line_height
|
||||
|
||||
return image
|
||||
Reference in New Issue
Block a user