Compare commits

...

10 Commits

Author SHA1 Message Date
admin 0876d8e827 Montierung voruebergehend auf den Mock stellen
Die echte GM4000 steht in 192.168.1.115, der ESP32 im Heimnetz 192.168.178.x --
vom Geraet aus also nicht erreichbar (der Mac kommt nur per VPN dran). Fuer den
Testbetrieb zeigt MOUNT_HOST deshalb auf den Mock auf dem Mac:

    python3 mount_mock.py --lan --move --low

Die echte Adresse steht als auskommentierte Zeile daneben, zum Zurueckstellen
werden die beiden Zeilen getauscht.

Laeuft der Mock nicht, zeigt die Anzeige "KEIN LINK / TELESKOP" -- das ist
richtiges Verhalten, aber im Betrieb der erwartete Anblick, sobald der Mac aus ist.

Am Geraet bestaetigt: Autostart laeuft autonom ohne USB-Verbindung und kommt auch
nach Aus- und Einschalten der Stromversorgung von allein wieder hoch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 19:52:25 +02:00
admin 142a621066 Dauerbetrieb: Autostart (main.py) und Watchdog
MicroPython fuehrt main.py nach jedem Reset selbst aus -- nach Stromausfall oder
Watchdog-Neustart laeuft die Anzeige damit ohne Notebook wieder an. Bisher gab es
die Datei bewusst nicht, weil am Aufbau gearbeitet wurde.

**Das Abbruchfenster ist kein Komfort, sondern die Notbremse.** Ohne es waere ein
Geraet, dessen Programm beim Start haengt, nur noch durch Neuflashen erreichbar:
Die Endlosschleife belegt die REPL sofort nach dem Reset. main.py wartet deshalb
erst AUTOSTART_DELAY (3 s), in denen Ctrl-C zurueck an die REPL fuehrt -- dann
laeuft auch kein Watchdog.

Watchdog (WDT_TIMEOUT = 120 s): startet das Geraet neu, wenn die Poll-Schleife
stehenbleibt. Gefuettert wird zu Beginn *jedes* Durchlaufs, auch nach einem
Fehler -- "KEIN LINK" ist kein Stillstand, die Schleife laeuft ja weiter. Der
Wert muss ueber allem liegen, was regulaer blockieren darf (WLAN 20 s,
Montierung 3 s, dazu ein MQTT-Versuch); knapper waere er eine Neustart-Schleife.

Ein gestarteter Watchdog laesst sich nicht mehr abschalten. Deshalb legt ihn nur
main.py an -- im Handbetrieb ueber die REPL startete das Geraet sonst neu,
waehrend man nachdenkt.

Weiteres:
- Nach einem Watchdog-Neustart meldet main.py das beim Start
  (machine.reset_cause()), sonst saehe er aus wie ein Stromausfall.
- Eine Ausnahme faengt main.py ab und startet nach RESTART_DELAY (10 s) neu,
  statt in die REPL zu fallen und die Anzeige einzufrieren. Die Pause verhindert
  eine Neustart-Schleife im Sekundentakt.
- Autostart faehrt mit Helligkeitsregelung (with_ldr=True, Nutzerentscheidung).

Am Geraet bestaetigt: Autostart nach Reset laeuft durch (Watchdog aktiv, WLAN,
MQTT), ein absichtlich herbeigefuehrter Stillstand loest den Watchdog aus und das
Geraet kommt von allein wieder hoch -- mit der Meldung "Vorheriger Lauf wurde vom
Watchdog beendet". Ctrl-C im Startfenster gibt die REPL frei.

Beim Geraetetest aufgefallen: make_watchdog wurde ohne log aufgerufen, im
Startprotokoll stand also nichts vom Watchdog. Behoben und durch einen Test
festgehalten. 178 Tests gruen (neu: test_main.py).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:41:33 +02:00
admin 433c53aabd RA in Zehntelminuten mit Beschriftung: "RA 18h36.9m" / "DE +38°47'"
Die GM4000 liefert im Auslieferzustand keine Sekunden, sondern Zehntelminuten.
Bisher wurde daraus eine Sekundenanzeige gerechnet -- die sprang in
6-Sekunden-Schritten, weil die Rohdaten sie gar nicht hergeben. Die Zehntelform
ist ehrlicher und kuerzer, und der gewonnene Platz traegt die Beschriftung:

    RA 18h36.9m
    DE +38°47'

Bei Hochpraezision entfaellt die Beschriftung, dort braucht die Deklination mit
+38°47'01" alle Zellen.

Der Mock konnte das gar nicht nachstellen -- er sendete immer das
Hochpraezisionsformat. Neu: "mount_mock.py --low" bildet den Auslieferzustand ab
(RA als HH:MM.T, DEC ohne Bogensekunden). Vorgabe bleibt hoch, damit die
bestehenden Tests mit ihren festen Erwartungen unveraendert durchlaufen.

Zur Darstellung, alles am Geraet ausprobiert:

- **Schmale Zeichen** (config.CELL_NARROW): Dezimalpunkt und der Abstand
  zwischen Beschriftung und Wert bekommen 3 statt 6 px -- in der Sperrschrift
  standen sie sonst sehr luftig. Fuer die Leerzeichen am Zeilenende gilt das
  nicht, die sollen den Rest der Zeile ueberschreiben.
- Damit entscheidet die **Pixelbreite** ueber das Abschneiden, nicht mehr die
  Zeichenzahl: "RA 18h36.9m" sind 11 Zeichen und trotzdem nur 60 px.
- **Linksbuendig ab LINE_X = 0**, der Schalter zum Zentrieren ist entfallen.
  Zentriert standen die Zeilen um wenige Pixel gegeneinander versetzt, weil die
  RA-Zeile mit ihrem schmalen Punkt kuerzer ist als die DEC-Zeile.

Ein Zwischenschritt, der wieder verworfen wurde und als Warnung im Code steht:
Ein Doppelpunkt als Trenner ("RA:18h36.9m") sah in Zeichensatz 0 zu wuchtig aus;
der aus Zeichensatz 1 ist schlanker, steht aber mitten in der obersten Zeile --
und jeder Zeichensatzwechsel zerstoert die Home-Zelle. Das erste Zeichen danach
noch einmal zu schreiben machte es schlimmer: Das nachgezogene Zeichen bemalt
GLYPH_WIDTH Pixel und frisst die linke Spalte seines rechten Nachbarn. Deshalb
steht in der obersten Zeile jetzt bewusst kein Sonderzeichen.

161 Tests gruen, am Geraet bestaetigt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:23:07 +02:00
admin dae7694a07 Nur geaenderte Zellen schreiben -- gegen das Flackern im Sekundentakt
Seit der Einzelpositionierung blitzten bei jeder Aktualisierung willkuerliche
Pixel auf, in beiden Zeilen. Ursache ist die Telegrammlaenge: Eine volle Zeile
kostet mit einzeln gesetzten Zeichen rund 100 Bytes statt 20, bei 19200 Baud
und 8E1 (11 Bit je Byte) dauerte ein kompletter Bildwechsel damit rund 300 ms.
Die Anzeige stellt schon waehrend des Empfangs dar, also war der Aufbau zu
sehen -- im Sekundentakt ein Drittel der Zeit.

Von einer Sekunde zur naechsten aendert sich aber fast nichts: "18h36m56s" ->
"18h36m57s" ist eine einzige Ziffer, die Deklination steht meist still.
show_lines vergleicht deshalb gegen den zuletzt dargestellten Inhalt und
schreibt nur die betroffenen Zellen; eine unveraenderte Zeile erzeugt gar kein
Telegramm mehr. Am Geraet gemessen: 306 ms -> 66 ms je Bildwechsel.

Dabei am Geraet aufgefallen und mitbehoben: Dem "s" hinter den Sekunden fehlten
die linken Pixel, dem "m" hinter den Minuten ebenso -- beides Zeichen direkt
rechts von einer Stelle, die sich gerade geaendert hatte. Die Anzeige malt je
Zeichen 7 px breit (daher auch der 7-px-Auto-Vorschub), gesetzt wird aber auf
6-px-Raster: Ein neu geschriebenes Zeichen loescht die erste Pixelspalte seines
rechten Nachbarn. Beim vollstaendigen Neuaufbau fiel das nie auf, weil der
Nachbar gleich danach ohnehin neu gemalt wurde. _zelle_faellig zieht ihn jetzt
mit.

Weitere Details:
- clear() verwirft den gemerkten Inhalt, sonst haelt der Vergleich Zellen
  faelschlich fuer vorhanden und die Anzeige bliebe teilweise leer.
- Schlaegt ein Telegramm fehl, wird der gemerkte Inhalt der Zeile verworfen --
  was angekommen ist, ist dann ungewiss.
- Der Zeichensatz wird nur noch bei der ersten Ausgabe einer Zeile gesetzt. Das
  spart nicht nur Bytes: Jeder Wechsel beschaedigt die Home-Zelle. Findet doch
  einer statt (neues Gradzeichen), wird die oberste Zeile vollstaendig
  nachgezogen.

Zu beiden Befunden gibt es Tests mit Gegenprobe (mit der alten Fassung schlagen
sie fehl). 155 Tests gruen, am Geraet bestaetigt: ruhig, keine fehlenden Pixel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 17:18:11 +02:00
admin 41f5eb1821 Per MQTT gesetzte Helligkeit sofort an die Anzeige senden
Am echten Broker aufgefallen: "hell_prozent = 100 uebernommen" stand im Log, die
Anzeige blieb aber auf 50 %. Der Wert landete korrekt in settings.json und der
Dimmer las ihn per reload() auch nach -- nur ging kein Helligkeitstelegramm raus.

Grund: Ein Telegramm wird nur beim *Wechsel* der Stufe gesendet (so war es schon
im alten MSP430-Programm gedacht, und fuer die LDR-Regelung ist das richtig).
Aendert man die Helligkeit von aussen, waehrend die Stufe dieselbe bleibt, gibt es
keinen Wechsel -- die Aenderung waere erst beim naechsten zufaelligen
Stufenwechsel sichtbar geworden.

run_esp32.main setzt nach dem reload() jetzt die Helligkeit der aktuellen Stufe
neu. Ohne Dimmer (kein LDR) gilt weiterhin direkt hell_prozent.

test_mqtt.TestHelligkeitWirdSofortSichtbar haelt das fest. Gegenprobe gemacht:
mit der alten Fassung schlaegt der Test fehl (keine Helligkeit gesendet), mit der
neuen geht genau ein Telegramm mit dem gesetzten Wert raus.

Am Geraet bestaetigt, Broker "nuccy": Verbindung steht, Status (ra/dec/ldr/
helligkeit/link/online) kommt retained an, Einstellungen werden uebernommen,
ungueltige Werte (500, Text) abgelehnt ohne settings.json anzufassen, und die
Anzeige reagiert jetzt sichtbar auf 100 % -> 15 % -> 50 %. 145 Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 16:59:09 +02:00
admin bf39b75ac1 MQTT: Einstellungen empfangen, Zustand veroeffentlichen
Topics (Praefix aus mqtt_config.py, Vorgabe "grossanzeige"):

    <PREFIX>/set/hell_prozent      60      eingehend, schreibt settings.json
    <PREFIX>/set/dunkel_prozent     5
    <PREFIX>/set/schwelle        1800
    <PREFIX>/set/hysterese        150

    <PREFIX>/status/ra        18h36m56s    ausgehend, retained
    <PREFIX>/status/dec      +38°47'01"
    <PREFIX>/status/ldr            2453
    <PREFIX>/status/helligkeit       50
    <PREFIX>/status/link              1
    <PREFIX>/status/online            1    mit Last Will auf 0

Eine empfangene Einstellung wird sofort wirksam: dieselbe Pruefung wie sonst
(settings.update), dann laedt der laufende BrightnessController sie per reload()
nach -- kein Neustart noetig.

Leitgedanke des Moduls: **MQTT darf die Anzeige nie aufhalten.** Die Anzeige ist
der Zweck des Geraets, MQTT ist Beiwerk. Deshalb faengt mqtt.py jeden Fehler
selbst ab und meldet ihn nur:

- Broker nicht erreichbar -> Versuch scheitert, Schleife laeuft weiter.
  Wiederholung mit wachsendem Abstand (5..120 s), sonst kostet ein dauerhaft
  toter Broker in jedem Schleifendurchlauf Zeit.
- Verbindungsabriss -> beim naechsten Senden/Empfangen bemerkt, Verbindung wird
  verworfen und spaeter neu aufgebaut. Danach geht der gesamte Status erneut
  raus, damit der Broker nicht auf veralteten Werten sitzenbleibt.
- Unsinniger Wert von aussen -> verworfen, settings.json bleibt unberuehrt.
- Geraet faellt aus -> Last Will meldet online=0. Ohne das bliebe online=1
  stehen, obwohl niemand mehr da ist.

Der Empfang blockiert nicht (check_msg). Statuswerte gehen nur bei Aenderung
raus -- die Koordinaten aendern sich staendig, LDR und Helligkeit kaum.

mqtt_config.py ist optional und gitignored (Vorlage mqtt_config_example.py);
fehlt sie, laeuft alles wie bisher ohne MQTT. deploy.sh weist nur darauf hin.

test_mqtt.py haengt einen Fake-Broker ein und prueft auch die Faelle, die man
mit einem echten Server schwer herbeifuehrt: Abriss beim Senden, Muell im Topic,
Broker der nicht antwortet. 143 Tests gruen.

Am Geraet bestaetigt ist bisher der Fall OHNE Broker: mqtt.py importiert,
connect_from_config liefert None, ein unerreichbarer Broker wirft keine
Ausnahme. Der Test gegen einen echten Broker steht noch aus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 16:48:41 +02:00
admin 0a893d0684 Helligkeit: zwei Werte und eine Schwelle, zur Laufzeit aenderbar
Die vier abgestuften Helligkeiten des alten MSP430-Programms (5/15/50/100 %)
entfallen. Fuer die Sternwarte genuegt der Wechsel Tag/Nacht: unterhalb der
Schwelle laeuft die Anzeige mit dunkel_prozent, oberhalb mit hell_prozent.

Die Werte stehen nicht mehr im Code, sondern in settings.json auf dem Geraet --
Vorbereitung fuer die spaetere MQTT-Anbindung:

    settings.load()                        # mit Vorgaben aufgefuellt
    settings.update({"hell_prozent": 60})  # aendern und speichern

Vorgaben: dunkel 5 %, hell 50 %, Schwelle 1800, Hysterese 150. Die Schwelle liegt
mit Abstand zwischen den gemessenen Lagen (abgedeckt 0, Raumlicht 2453).

Beibehalten, weil die Messung vom 27.07. sie verlangt: der gleitende Mittelwert
und eine Hysterese um die Schwelle. Der ADC rauscht mit +/-32 (nach Mittelung,
1 Sigma) -- ohne Hysterese wuerde die Anzeige am Umschaltpunkt springen.

settings.py prueft jeden Wert beim Laden UND beim Schreiben auf Typ und Bereich.
Das ist der eigentliche Zweck: Mit MQTT kommen die Werte von aussen, und ein
Helligkeitswert von 500 % oder eine Schwelle von -1 wuerde die Anzeige sonst
unbrauchbar machen. Eine fehlende oder beschaedigte Datei faellt auf die Vorgaben
zurueck, ein einzelner kaputter Wert verwirft nur diesen -- ein Geraet in der
Sternwarte soll sich nicht an seiner Konfigurationsdatei aufhaengen.

settings.json wird von deploy.sh bewusst NICHT mitkopiert und steht in
.gitignore: Es ist Laufzeitzustand, kein Quellcode. Sonst waeren die
eingestellten Werte nach jedem Deployen weg.

Am Geraet bestaetigt: schreiben, wiederlesen, Controller uebernimmt die Werte,
beide Helligkeiten auf der Anzeige sichtbar. 119 Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 16:34:47 +02:00
admin e8a5b5d5e3 LDR-Schwellen am realen Aufbau gemessen statt vom MSP430 uebernommen
Gemessen am XIAO ESP32-C3 mit dem LDR an GPIO2, je 200 Werte, Median
(Angaben nach raw_to_ldr, also in Zaehlrichtung "gross = hell"):

    abgedeckt      roh 4095 (ADC-Anschlag)  ->    0
    Raumlicht      roh 1642                 -> 2453
    angeleuchtet   roh  543                 -> 3552

Zwei Fehler in den alten Werten, die erst die Messung zeigt:

- Stufe 3 (100 %) war unerreichbar. Sie begann bei 3650, selbst eine Taschenlampe
  direkt auf dem Sensor kam nur auf 3552. Die Anzeige haette nie voll aufgedreht.
- Die Hysterese von 30 am untersten Uebergang lag unter dem Rauschen. Der Rohwert
  streut bei Raumlicht um +/-270, nach dem gleitenden Mittel bleiben rund +/-32
  (1 Sigma) -- die unterste Stufe haette geflattert.

Neu: Mitten 1200/2400/3200, Hysterese einheitlich 150. Die Aufteilung haelt den
5-%-Bereich bis 1200 (Nutzerwunsch "nachts laenger dunkel", schont die
Dunkeladaption der Beobachter). Die gestaffelten Hysteresen des alten Programms
entfallen -- die gemessene Streuung ist an allen Grenzen dieselbe.

Bekannte Grenzlage, in test_brightness festgehalten: Raumlicht (2453) liegt nur
53 Zaehler ueber HELL_HIGH und damit innerhalb der Hysterese, faellt also auf
Stufe 1 statt 2. Ob das so gewollt ist, entscheidet sich in der Sternwarte.

Am Geraet bestaetigt: Regelung laeuft stabil ohne Flattern, Abdecken schaltet
herunter, Freigeben wieder hoch, Helligkeitstelegramm wird quittiert. Die
Schwellen sind aber am Schreibtisch gemessen -- with_ldr bleibt deshalb auf
False, bis sie vor Ort bestaetigt sind. 101 Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 16:21:08 +02:00
admin 4f7375add3 10 Zeichen pro Zeile: Zellenbreite am Geraet gemessen, Zeilen zentriert
Die RS232-Strecke ESP32 -> MAX3232 -> Anzeige ist am Geraet bestaetigt; damit
war "probe.py pitch" moeglich und hat die offene Zeichenbreiten-Frage geklaert.

Das Ergebnis korrigiert eine Annahme: Bisher stand CHARS_PER_LINE = 9, abgezaehlt
am Zeilenumbruch. Gemessen wurde damit aber der Auto-Vorschub der Sperrschrift
(7 px), nicht die Zeichenbreite. Die Zeichenmatrix ist 6 px breit -- zehn Ziffern,
einzeln auf x = i*6 gesetzt, stehen sauber getrennt nebeneinander.

- display._emit_line positioniert jedes Zeichen selbst, statt den Auto-Vorschub
  laufen zu lassen. CHARS_PER_LINE = 10, CELL_WIDTH = 6.
- Die Deklination passt dadurch in voller Form: +38°47'01" statt +38°47'01.
- show_lines sendet ein Telegramm je Zeile. Mit Einzelpositionierung kaemen beide
  zusammen auf 224 der erlaubten 230 Bytes -- zu wenig Reserve fuer den
  Dauerbetrieb. Die Reihenfolge bleibt unten vor oben (Home-Zelle).
- Zeilen werden zentriert (CENTER_LINES, LINE_X). Der Zeichenblock bleibt dabei
  gleich breit und an derselben Stelle, damit keine Reste stehen bleiben.
- probe.py kommt mit aufs Geraet: Die Anzeige haengt jetzt am ESP32, nur von dort
  laesst sich die Geometrie noch ausmessen.

Die Tests pruefen nicht mehr auf zusammenhaengenden Text im Telegramm -- den gibt
es nicht mehr -- sondern rechnen ueber die Cursor-Sequenzen zurueck, was wirklich
auf welcher Zelle landet. 97 Tests gruen, Kette am Geraet im Sekundentakt bestaetigt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:56:15 +02:00
admin 42fc7f52d4 Portierung auf den XIAO ESP32-C3 (MicroPython)
Die Kette laeuft auf dem Geraet gegen den Mock auf dem Mac. Getestet mit
MicroPython 1.28.0 (ESP32_GENERIC_C3): alle Module importieren, Telegrammbau
und Koordinaten-Formatierung sind byte-identisch zum Mac (inkl. Gradzeichen),
Poll-Schleife 22 Abfragen in 20 s ohne Aussetzer, Ausfall und Reconnect
verhalten sich wie vorgesehen. Offen bleibt allein die RS232-Strecke zur
Anzeige -- der Steckbrettaufbau mit MAX3232 fehlt noch.

Gemeinsamer Kern statt zweier Programme:
- displayloop.py: die Poll-Schleife, herausgeloest aus run_display. Mac und
  ESP32 fahren jetzt denselben Ablauf; drumherum liegt nur die Kommandozeile
  (run_display.py) bzw. das WLAN-Setup (run_esp32.py).
- ticks.py: Timeouts fuer beide Plattformen. time.ticks_ms() laeuft nach ~12
  Tagen ueber; das bisherige "jetzt + Timeout" haette eine Warteschleife
  danach tagelang haengen lassen. Jetzt ueber ticks_diff.
- wifi.py, run_esp32.py, wifi_config.py (gitignored, Vorlage dabei), deploy.sh.

Pinbelegung aus dem KiCad-Schaltplan uebernommen, nicht geraten: UART1 mit
TX=GPIO21 (Pad 7/D6) und RX=GPIO20 (Pad 8/D7) ueber den MAX3232, LDR an
GPIO2 (Pad 1/A0). UART0 bleibt die REPL.

LDR-Kennlinie ist auf der Platine umgekehrt: der Teiler lautet
3V3-[2k]-A0-[LDR]-GND, die Spannung faellt also mit steigender Helligkeit --
andersherum als beim MSP430, auf den die Schwellen zugeschnitten sind. Wird
im Code gespiegelt (config.LDR_INVERTED, brightness.raw_to_ldr). Die
Schwellen selbst muessen am realen Aufbau noch nachgemessen werden.

Am Geraet gefundene Fehler, die den Attrappen-Tests entgehen mussten, weil
CPython sie nicht hat:
- MicroPython kennt kein str.ljust (auch kein rjust/center/zfill).
  display._emit_line fuellt von Hand auf.
- NOLINK_LINES hatte 10 Zeichen bei 9 Zeichen Zeilenbreite und erschien als
  "MONTIERUN". Jetzt "KEIN LINK"/"TELESKOP".
Dagegen: TestMicropythonVertraeglichkeit prueft die deployten Dateien statisch
auf solche Methoden und auf modulweites argparse/threading und haelt die
Dateiliste mit deploy.sh synchron; test_display.py prueft die Breite fester
Anzeigetexte.

Weitere Portabilitaet: mount_client nutzt getaddrinfo und eine Sendeschleife
statt sendall (MicroPython laesst dessen Verhalten mit Timeout offen);
argparse in brightness wandert in die Funktion; migra._digits und die
Hex-Ausgabe verlassen sich nicht mehr auf %0*d bzw. bytes.hex(). Beides kann
1.28 zwar doch -- am Geraet nachgeprueft, die Kommentare sagen es jetzt richtig.

Widerspruch in der Dokumentation bereinigt: die Zeichenbreite stand an vier
Stellen als 9 oder 10. Gemessen sind 9; die Erklaerung duerfte sein, dass die
Sperrschrift 7 px pro Zelle braucht (9*7 = 63 von 64 px) und nicht 6, wie es
eine 5x7-Matrix mit 1 px Luecke waere. Nachgeprueft ist das nicht -- dafuer
gibt es jetzt "probe.py pitch", das den Auto-Vorschub gegen feste
Pixelabstaende stellt, statt am Umbruch abzuzaehlen.

mount_mock.py --lan bindet auf 0.0.0.0 und nennt die einzutragende LAN-IP,
damit der ESP32 den Mock erreicht.

92 Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 18:51:45 +02:00
30 changed files with 4447 additions and 350 deletions
+12 -1
View File
@@ -10,4 +10,15 @@ venv/
.DS_Store
#Hardware-Sachern nicht ist Git
Hardware/
Hardware/
# WLAN-Zugangsdaten des ESP32 (Vorlage: wifi_config_example.py)
wifi_config.py
# MQTT-Zugangsdaten (Vorlage: mqtt_config_example.py)
mqtt_config.py
# Laufzeit-Einstellungen (Helligkeiten, Schwelle -- spaeter per MQTT verstellt).
# Gehoert dem Geraet, nicht dem Repo; die Vorgaben stehen in settings.DEFAULTS.
settings.json
test_settings_tmp.json
+498 -69
View File
@@ -3,10 +3,10 @@
Ansteuerung einer microSYST **migra SC/MC Seriell** LED-Großanzeige mit den
Koordinaten (Rektaszension / Deklination) einer 10Micron GM4000 HPS II.
Endziel ist ein XIAO ESP32-C3, der die Montierung per WLAN abfragt und die
Koordinaten über UART/RS232 auf die Anzeige schreibt. **Phase 1** (dieser Stand)
ist das Testprogramm auf dem Mac: Koordinaten von Hand übergeben, Ausgabe über
einen USB-RS232-Wandler.
Zielgerät ist ein XIAO ESP32-C3, der die Montierung per WLAN abfragt und die
Koordinaten über UART/RS232 auf die Anzeige schreibt. Entwickelt und getestet
wird auf dem Mac (Koordinaten von Hand bzw. von der Montierung, Ausgabe über
einen USB-RS232-Wandler); dieselben Module laufen auf dem ESP32.
## Benutzung
@@ -23,32 +23,59 @@ python3 run_display.py --host 10.0.0.42 # andere IP
python3 run_display.py --mock --move # ohne Teleskop: lokaler Mock, RA läuft
python3 run_display.py --mock --move --ldr 2000 # zusätzlich mit Helligkeitsregelung
python3 mount_mock.py --move # Mock separat starten
python3 mount_mock.py --move --low # ... im Auslieferformat der GM4000
# (RA als HH:MM.T, DEC ohne Sekunden)
# Helligkeit aus einem LDR-Wert setzen:
python3 brightness.py --ldr 1500 # LDR-Rohwert -> Stufe/Prozent -> senden
python3 brightness.py --ldr 1500 # LDR-Rohwert -> Helligkeit -> senden
python3 -m unittest test_migra test_lx200 test_brightness # alle Tests
python3 -m unittest discover -p 'test_*.py' # alle Tests
python3 probe.py ruler # Geometrie der Anzeige ausmessen
```
Einzige Abhängigkeit auf dem Mac ist `pyserial`.
Auf dem ESP32-C3 (siehe Abschnitt [ESP32-C3](#esp32-c3)):
```bash
cp wifi_config_example.py wifi_config.py # WLAN-Zugangsdaten eintragen
./deploy.sh # Dateien auf den ESP32 kopieren
mpremote repl # dann: import run_esp32
# run_esp32.main()
```
Einzige Abhängigkeit auf dem Mac ist `pyserial`, fürs Übertragen zusätzlich
`mpremote`. Auf dem ESP32 laufen die Module ohne jede Fremdbibliothek.
## Aufbau
| Datei | Zweck |
|---|---|
| `migra.py` | Protokoll: Telegrammrahmen, Prüfsumme, ESC-Sequenzen. Ohne Abhängigkeiten, **läuft unverändert unter MicroPython**. |
| `transport.py` | UART: `pyserial` (Mac), `machine.UART` (ESP32-C3), `FakeTransport` (Trockenlauf). |
| `display.py` | Treiber: Telegramm senden, Antwort auswerten. |
| `coords.py` | Koordinaten einlesen und auf die Zeilenbreite formatieren. |
| `config.py` | Port, Adresse, Geometrie, Helligkeit — alles an einer Stelle. |
| `show_coords.py` | Das Testprogramm (Koordinaten von Hand). |
| `brightness.py` | Helligkeitsregelung: LDR-Wert → Stufe/Prozent (portabel + CLI). |
| `run_display.py` | Hauptschleife Phase 2: Montierung pollen, anzeigen, Helligkeit regeln. |
| `mount_client.py` / `mount_mock.py` | TCP-Client zur GM4000 bzw. Simulator (LX200). |
| `lx200.py` | LX200-Kommandorahmen. |
| `probe.py` | Testmuster zum Ausmessen von Zeilenbreite und Zeilenposition. |
| `test_migra.py` / `test_lx200.py` / `test_brightness.py` | Tests. |
Alles, was auf beiden Plattformen läuft, ist bewusst abhängigkeitsfrei
gehalten. Was sich unterscheidet, steckt in `transport.py` (UART) und in den
beiden Startprogrammen.
| Datei | Zweck | ESP32 |
|---|---|---|
| `migra.py` | Protokoll: Telegrammrahmen, Prüfsumme, ESC-Sequenzen. | ✓ |
| `transport.py` | UART: `pyserial` (Mac), `machine.UART` (ESP32-C3), `FakeTransport` (Trockenlauf). | ✓ |
| `ticks.py` | Timeouts, die unter CPython und MicroPython gleich funktionieren. | ✓ |
| `display.py` | Treiber: Telegramm senden, Antwort auswerten. | ✓ |
| `coords.py` | Koordinaten einlesen und auf die Zeilenbreite formatieren. | ✓ |
| `config.py` | Port/Pins, Adresse, Geometrie, WLAN — fest, ändert sich nur beim Deployen. | ✓ |
| `settings.py` | Zur Laufzeit änderbare Werte (Helligkeiten, Schwelle) in `settings.json`. | ✓ |
| `displayloop.py` | Die Poll-Schleife: abfragen, formatieren, anzeigen, Fehler abfangen. | ✓ |
| `brightness.py` | Helligkeit: LDR-Wert → dunkel/hell, Schwelle aus `settings.json`. | ✓ |
| `mount_client.py` | TCP-Client zur GM4000 (LX200). | ✓ |
| `lx200.py` | LX200-Kommandorahmen. | ✓ |
| `wifi.py` | WLAN-Anbindung des ESP32 (verbinden, Verbindung halten). | ✓ |
| `mqtt.py` | MQTT: Einstellungen empfangen, Zustand veröffentlichen. | ✓ |
| `run_esp32.py` | Startprogramm auf dem ESP32: WLAN, Anzeige, Schleife. | ✓ |
| `main.py` | Autostart nach jedem Reset, mit Watchdog und Abbruchfenster. | ✓ |
| `wifi_config.py` | WLAN-Zugangsdaten, **nicht im Git** (Vorlage `wifi_config_example.py`). | ✓ |
| `mqtt_config.py` | MQTT-Zugangsdaten, **nicht im Git** (Vorlage `mqtt_config_example.py`). | ✓ |
| `run_display.py` | Startprogramm auf dem Mac: dieselbe Schleife mit Kommandozeile. | |
| `show_coords.py` | Testprogramm (Koordinaten von Hand). | |
| `mount_mock.py` | Simulator der Montierung (LX200) fürs Testen ohne Teleskop. | |
| `probe.py` | Testmuster zum Ausmessen von Zeilenbreite und Zeilenposition. | |
| `deploy.sh` | Kopiert die ESP32-Dateien per `mpremote` auf das Gerät. | |
| `test_*.py` | Tests (migra, lx200, brightness, settings, display, esp32). | |
## Protokoll (Kurzfassung)
@@ -91,14 +118,189 @@ Folgt auf eine ESC-Sequenz ein Online-Text, muss **`0x1F` als Trennzeichen**
dazwischen. Data-Unit max. 230 Zeichen. `migra.DataUnit` setzt das Trennzeichen
selbst.
## ESP32-C3
### Verdrahtung
Die Pins stammen aus dem Schaltplan (`Hardware/KiCad/Grossanzeige`) und stehen
in `config.py`. Am XIAO ESP32-C3 ist UART1 frei zuordenbar; UART0 bleibt für die
REPL-Konsole über USB reserviert.
| XIAO | GPIO | Netz | weiter zu |
|---|---|---|---|
| Pad 7 (D6) | 21 | TX | MAX3232 `T2IN``T2OUT` → J5.3 → Anzeige RxD (Sub-D Pin 2) |
| Pad 8 (D7) | 20 | RX | MAX3232 `R2OUT``R2IN` ← J5.2 ← Anzeige TxD (Sub-D Pin 3) |
| Pad 1 (D0/A0) | 2 | LDR | Spannungsteiler J2, siehe unten |
| Pad 12 | — | 3V3 | MAX3232 `VCC` |
| Pad 13 | — | GND | gemeinsame Masse |
Die Anzeige will RS232-Pegel, der ESP32-C3 kann nur TTL — der MAX3232 dazwischen
ist zwingend.
### Einrichten
MicroPython für den ESP32-C3 flashen (einmalig; Firmware von
[micropython.org/download/ESP32_GENERIC_C3](https://micropython.org/download/ESP32_GENERIC_C3/)):
```bash
pip install esptool mpremote
esptool.py --chip esp32c3 --port /dev/cu.usbmodem1101 erase_flash
esptool.py --chip esp32c3 --port /dev/cu.usbmodem1101 --baud 460800 \
write_flash -z 0x0 ESP32_GENERIC_C3-*.bin
```
Dann die Zugangsdaten anlegen und alles übertragen:
```bash
cp wifi_config_example.py wifi_config.py # SSID und Passwort eintragen
./deploy.sh # oder ./deploy.sh /dev/cu.usbmodem1101
```
`wifi_config.py` steht in `.gitignore` — das WLAN-Passwort gehört nicht ins
Repository.
### Starten
```bash
mpremote repl
>>> import run_esp32
>>> run_esp32.main() # ohne Helligkeitsregelung
>>> run_esp32.main(with_ldr=True) # mit LDR über den ADC
>>> run_esp32.main(with_mqtt=False) # ohne MQTT (sonst an, sofern konfiguriert)
```
### Dauerbetrieb: Autostart und Watchdog
`main.py` startet die Anzeige nach jedem Reset von selbst — nach Stromausfall
oder Watchdog-Neustart läuft sie also ohne Notebook wieder an.
**Das Abbruchfenster ist die Notbremse.** Ohne es wäre ein Gerät, dessen Programm
beim Start hängt, nur noch durch Neuflashen erreichbar: Die Endlosschleife belegt
die REPL sofort nach dem Reset. Deshalb wartet `main.py` erst
`config.AUTOSTART_DELAY` Sekunden:
```
Grossanzeige startet in 3 s -- Ctrl-C bricht ab.
Watchdog: aktiv, 120 s
WLAN: verbinde mit Mizar ...
```
In diesen drei Sekunden führt Ctrl-C zurück an die REPL (`Autostart abgebrochen,
REPL ist frei`) — dann läuft auch kein Watchdog. Wer das Gerät ganz ohne
Autostart will: `mpremote rm :main.py`.
Der **Watchdog** (`config.WDT_TIMEOUT`, 120 s) startet das Gerät neu, wenn die
Poll-Schleife stehenbleibt. Gefüttert wird zu Beginn jedes Durchlaufs — auch nach
einem Fehler, denn „KEIN LINK" ist kein Stillstand: Die Schleife läuft ja weiter.
Der Watchdog greift nur, wenn gar nichts mehr geht.
Die 120 s müssen über allem liegen, was regulär blockieren darf: WLAN-Aufbau bis
20 s, Montierung 3 s, dazu ein MQTT-Verbindungsversuch. Ein knapper Wert wäre
eine Neustart-Schleife. **Ein gestarteter Watchdog lässt sich nicht mehr
abschalten** — deshalb legt ihn nur `main.py` an, nicht der Handbetrieb über die
REPL; sonst startete das Gerät neu, während man an der REPL nachdenkt.
Nach einem Watchdog-Neustart meldet `main.py` das beim Start
(`Vorheriger Lauf wurde vom Watchdog beendet`, über `machine.reset_cause()`) —
sonst sähe der Neustart aus wie ein gewöhnlicher Stromausfall. Eine **Ausnahme**
fängt `main.py` ab und startet nach `config.RESTART_DELAY` neu, statt in die REPL
zu fallen und die Anzeige einzufrieren.
Am Gerät bestätigt (2026-07-27): Autostart nach Reset, Watchdog löst bei
absichtlichem Stillstand aus und das Gerät kommt von allein wieder hoch, Ctrl-C
im Startfenster gibt die REPL frei.
### Was auf beiden Seiten gleich läuft
`displayloop.poll_loop` ist die gemeinsame Hauptschleife — Mac und ESP32 fahren
denselben Ablauf, drumherum liegt nur die Kommandozeile (`run_display.py`) bzw.
das WLAN-Setup (`run_esp32.py`). Fällt das WLAN aus, zeigt die Anzeige
`NOWIFI_LINES`, fällt die Montierung aus, `NOLINK_LINES`; in beiden Fällen läuft
die Schleife weiter und verbindet sich neu.
Stolpersteine, die beim Portieren aufgefallen sind und in den Tests
festgehalten sind (`test_esp32.py`, `test_display.py`):
- **MicroPython hat kein `str.ljust`** (auch kein `rjust`/`center`/`zfill`).
Das fiel erst am Gerät auf — auf dem Mac gibt es die Methode, alle Tests
waren grün. `display._emit_line` füllt jetzt von Hand auf.
`TestMicropythonVertraeglichkeit` sucht solche Aufrufe seitdem statisch.
- **`time.ticks_ms()` läuft nach ~12 Tagen über.** Ein einfaches
„jetzt + Timeout" ließe eine Warteschleife danach tagelang hängen. `ticks.py`
rechnet deshalb über `ticks_diff`, das den Überlauf richtig behandelt.
- **Der UART braucht denselben Puffer-Flush wie pyserial.** `UartTransport.write`
leert den Empfangspuffer vor jedem Senden, sonst sieht `read_response` alte
Störbytes statt der frischen Antwort.
- **Feste Anzeigetexte müssen in `CHARS_PER_LINE` passen.** `NOLINK_LINES` hieß
zuerst „MONTIERUNG" (10 Zeichen) und erschien als „MONTIERUN"; `_emit_line`
schneidet stillschweigend ab. `test_display.py` prüft die Breite jetzt.
(Seit `CHARS_PER_LINE = 10` würde „MONTIERUNG" wieder passen — die Texte sind
aber bei „KEIN LINK"/„TELESKOP" geblieben.)
Was MicroPython 1.28 entgegen der Erwartung **doch** kann (am Gerät nachgeprüft):
`%0*d` mit dynamischer Feldbreite, `%-11s`, `bytes.hex()`, `str.strip`/`lstrip`,
`str.isdigit`.
Ohne Hardware lässt sich der ESP32-Teil trotzdem weitgehend prüfen:
`test_esp32.py` hängt Attrappen für `machine` und `network` ein und fährt die
ganze Kette gegen einen echten Mock — UART-Transport, WLAN, ADC-Auswertung,
Zeitrechnung und die Poll-Schleife samt Ausfallverhalten.
### Gegen den Mock auf dem Mac testen
Solange die Montierung nicht erreichbar ist, spielt der Mock ihre Rolle. Er muss
dafür auf allen Schnittstellen lauschen, sonst kommt der ESP32 nicht heran:
```bash
python3 mount_mock.py --lan --move # gibt die IP aus, die einzutragen ist
```
Auf dem ESP32 dann:
```python
import run_esp32
run_esp32.selftest(host="192.168.178.26") # Bring-up prüfen
run_esp32.main(host="192.168.178.26", fake_display=True) # Dauerschleife
```
`selftest` geht die drei Stufen einzeln durch — WLAN, Montierung, Telegrammbau —
und sagt, an welcher es klemmt. `fake_display=True` schreibt die Telegramme nur
als Hex in die REPL, es braucht also weder Pegelwandler noch Anzeige.
Wenn keine Verbindung zustande kommt, obwohl der Mock lauscht: macOS fragt beim
ersten Mal, ob `python3` eingehende Verbindungen annehmen darf — das muss
erlaubt sein.
### Am Gerät bestätigt (2026-07-26)
XIAO ESP32-C3 mit MicroPython 1.28.0 (`ESP32_GENERIC_C3`), gegen den Mock auf dem
Mac:
- Alle zwölf Module importieren auf dem Gerät.
- Telegrammbau und Koordinaten-Formatierung sind **byte-identisch** zum Mac,
inklusive Gradzeichen über den Zeichensatzwechsel.
- WLAN verbindet sich, `selftest` geht durch alle drei Stufen.
- Die Poll-Schleife lief 20 s im Sekundentakt, 22 Abfragen ohne Aussetzer.
- **Ausfall und Erholung:** Mock gestoppt → „KEIN LINK / TELESKOP" erscheint, die
Schleife läuft weiter; Mock zurück → automatischer Reconnect, und der erste
Wiedereinstieg löscht einmal das Vollbild, sodass kein Rest stehen bleibt.
- UART1 öffnet auf GPIO21/20 mit 19200 8E1, sendet, und der Lese-Timeout greift
sauber (kein Hänger) — allerdings noch **ohne Gegenstelle**.
- ADC auf GPIO2 mit 11 dB Dämpfung liefert Werte.
## Offene Punkte
- **Echte Montierungs-IP:** In `config.py` steht `MOUNT_HOST` noch als Platzhalter.
Sobald die GM4000 im Netz erreichbar ist, dort die IP eintragen (Port 3490 ist
Vorgabe). Bis dahin läuft alles gegen den Mock (`run_display.py --mock`).
- Portierung auf den XIAO ESP32-C3 (MicroPython): `migra.py`, `lx200.py`,
`mount_client.py` sind bewusst abhängigkeitsfrei; nur `transport` (UART) und das
WLAN-Setup kommen hinzu.
- **Umschaltschwelle in der Sternwarte gegenprüfen.** Die Schwelle (1800) ist am
realen Aufbau gemessen (2026-07-27, siehe *Helligkeitsregelung*), aber am
Schreibtisch. Vor Ort bei Tag und Nacht die Rohwerte ablesen und die Schwelle in
`settings.json` bestätigen. Bis dahin läuft die Anzeige mit fester Helligkeit
(`run_esp32.main()` ohne `with_ldr`).
- **MQTT im Dauerbetrieb beobachten.** Die Strecke ist am echten Broker in beide
Richtungen bestätigt (2026-07-27); offen ist nur, wie sie sich über Tage verhält
(Reconnect nach Broker-Neustart, WLAN-Aussetzer).
- **Netzwerk zur echten Montierung.** Die GM4000 steht in `192.168.1.115`, das
Heimnetz des ESP32 ist `192.168.178.x` — der Mac erreicht sie per VPN, der
ESP32 so nicht. Muss geklärt werden, bevor es an die echte Montierung geht.
## Gradzeichen
@@ -108,24 +310,86 @@ Gradzeichen (deckt sich mit dem alten Programm, `asciitaf.h`: Code 0xAF → per
`& 0x7F` = 0x2F in Zeichensatz 1). Zur Ausgabe wird für dieses eine Zeichen kurz
auf Zeichensatz 1 umgeschaltet und wieder zurück (`config.DEGREE_*`, `display._emit_degree`).
**Fallstrick:** Ein Zeichensatzwechsel mitten im Telegramm beschädigt die Home-Zelle
(0,0) — das erste Zeichen der obersten Zeile wird zerstört. Deshalb zeichnet
`show_lines` die Zeilen **von unten nach oben** (oberste Zeile zuletzt), damit sie
diese Zelle überschreibt.
**Fallstrick:** Ein Zeichensatzwechsel beschädigt die Home-Zelle (0,0) — das erste
Zeichen der obersten Zeile wird zerstört. Deshalb schreibt `show_lines` die Zeilen
**von unten nach oben** (oberste Zeile zuletzt), damit sie diese Zelle überschreibt.
Das gilt weiterhin, seit jede Zeile ihr eigenes Telegramm bekommt: dann entscheidet
die Reihenfolge der Telegramme statt der Reihenfolge innerhalb eines Telegramms.
## Geometrie und Darstellung
- **9 Zeichen pro Zeile** in Sperrschrift (`CHARSET_SPACED = True`, am Gerät
ausgemessen). `probe.py ruler` misst es nach.
- Die Deklination wird deshalb als **9-Zeichen-Form ohne abschließendes `"`**
ausgegeben (`+38°47'01`) — Gradzeichen und Bogenminuten bleiben, die Bogensekunden
stehen als Ziffern da. `coords.format_dec` wählt automatisch die breiteste Form,
die in `CHARS_PER_LINE` passt.
- **Zyklische Updates flimmerfrei:** `show_lines` löscht nicht bei jeder Aktualisierung,
sondern überschreibt die Zeilen an Ort und Stelle (auf volle Breite aufgefüllt).
`clear=True` nur beim ersten Bild. Wichtig: Zeilen dürfen **nicht breiter** als
`CHARS_PER_LINE` aufgefüllt werden, sonst bricht das Füll-Leerzeichen um und
beschädigt die andere Zeile.
- **10 Zeichen pro Zeile zu 6 px** (`CHARS_PER_LINE = 10`, `CELL_WIDTH = 6`), am
Gerät entschieden mit `probe.py pitch 6` (2026-07-27).
Vorher stand hier 9 — das war am Zeilenumbruch abgezählt und maß etwas
anderes, als es zu messen schien. `probe.py pitch` stellt den Auto-Vorschub
der Anzeige gegen feste Pixelpositionen `x = i·cell` und trennt damit beides:
- Der **Auto-Vorschub** der Sperrschrift `ESC z` ist **7 px**. Eine Zeile in
einem Stück geschrieben bricht deshalb nach 9 Zeichen um (9·7 = 63 von 64 px).
- Die **Zeichenmatrix** ist aber nur **6 px** breit. Zehn Ziffern, einzeln auf
`x = i·6` gesetzt, stehen sauber getrennt nebeneinander (60 von 64 px).
Deshalb positioniert `display._emit_line` jedes Zeichen selbst, statt den
Auto-Vorschub laufen zu lassen. Der Preis sind längere Telegramme (rund 10 Byte
je Zeichen statt 1), der Gewinn ein volles Zeichen mehr pro Zeile.
```bash
python3 probe.py pitch 6 # stehen beide Zeilen untereinander -> 6 px/Zelle
python3 probe.py pitch 7 # ... -> 7 px/Zelle
```
- **Ein Telegramm je Zeile.** Früher gingen beide Zeilen in einem Telegramm raus.
Mit Einzelpositionierung kämen sie mit Gradzeichen und Vollbild-Löschen auf
224 der erlaubten 230 Bytes (`migra.MAX_DATA_UNIT`) — zu wenig Reserve für ein
Gerät, das monatelang durchläuft. Getrennte Telegramme halten jedes bei rund
der Hälfte; die Flusskontrolle über das Antworttelegramm macht die zweite
Sendung unproblematisch. Die Reihenfolge bleibt **unten vor oben** (Home-Zelle).
- Die Deklination passt damit in **voller Form mit abschließendem `"`**
(`+38°47'01"`, 10 Zeichen). `coords.format_dec` wählt automatisch die breiteste
Form, die in die Zeile passt — bei Niedrigpräzision entsprechend `+38°47'`
ohne Sekunden.
- **Schmale Zeichen bekommen weniger Platz** (`config.CELL_NARROW`). Der
Dezimalpunkt der Zehntelminuten und der Abstand zwischen Beschriftung und Wert
belegen statt 6 nur 3 px — in der Sperrschrift standen sie sonst sehr luftig.
Möglich ist das nur, weil ohnehin jedes Zeichen einzeln positioniert wird.
Für die Leerzeichen am Zeilenende gilt es *nicht*: Die sollen den Rest der
Zeile überschreiben und behalten die volle Breite.
Deshalb entscheidet die **Pixelbreite** über das Abschneiden, nicht die
Zeichenzahl: `RA 18h36.9m` sind 11 Zeichen und trotzdem nur 60 px, `DE +38°47'`
wären als 11 volle Zellen dagegen 66 px und damit zu breit.
- **Zeilen stehen linksbündig** ab `LINE_X = 0`. Zentriert waren sie
zwischenzeitlich, standen dann aber um wenige Pixel gegeneinander versetzt, weil
die RA-Zeile mit ihrem schmalen Dezimalpunkt kürzer ist als die DEC-Zeile.
- **Es wird nur geschrieben, was sich geändert hat.** `show_lines` vergleicht gegen
den zuletzt dargestellten Inhalt und schickt nur die betroffenen Zellen; eine
unveränderte Zeile erzeugt gar kein Telegramm. `clear=True` nur beim ersten Bild
und nach einem Verbindungsabriss.
Das ist nicht nur Sparsamkeit, sondern der Grund für eine ruhige Anzeige. Mit
Einzelpositionierung kostet eine volle Zeile rund 100 Bytes statt 20; bei 19200
Baud mit 8E1 (11 Bit je Byte) dauerte ein kompletter Bildwechsel damit **rund
300 ms**. Die Anzeige stellt schon während des Empfangs dar, also war der Aufbau
als Flackern zu sehen — bei Aktualisierung im Sekundentakt ein Drittel der Zeit.
Von einer Sekunde zur nächsten ändert sich aber fast nichts: `18h36m56s` →
`18h36m57s` ist eine einzige Ziffer. Am Gerät gemessen (2026-07-27):
**306 ms → 66 ms** pro Bildwechsel.
- **Der rechte Nachbar wird mitgeschrieben.** Die Anzeige malt je Zeichen 7 px
breit (daher der 7-px-Auto-Vorschub), gesetzt wird aber auf 6-px-Raster. Ein neu
geschriebenes Zeichen löscht deshalb die erste Pixelspalte des Zeichens rechts
daneben. Beim vollständigen Neuaufbau fiel das nie auf, weil der Nachbar gleich
danach ohnehin neu gemalt wurde — beim Schreiben einzelner Zellen blieb er
beschädigt stehen: dem `s` hinter den Sekunden fehlten die linken Pixel, dem `m`
hinter den Minuten ebenso. `_zelle_faellig` zieht den rechten Nachbarn deshalb mit.
- Wichtig bleibt: Zeilen dürfen **nicht breiter** als `CHARS_PER_LINE` aufgefüllt
werden, sonst bricht das Füll-Leerzeichen um und beschädigt die andere Zeile.
## Präzision (Low / High)
@@ -134,15 +398,23 @@ RA als `HH:MM.T` (Zehntel-Minuten), DEC als `sDD°MM` (ganze Bogenminuten). Das
Gradzeichen kommt als Byte `0xDF` — auf der Anzeige korrekt als Gradring dargestellt;
in der Terminal-Konsole erscheint der **Rohwert** dieses Bytes als `ß` (rein kosmetisch).
`config.HIGH_PRECISION` steuert die DEC-Darstellung:
- `False` (Vorgabe): DEC ohne Sekunden (`-00°55'`).
- `True`: DEC mit Sekunden (`+38°47'01`) — erst sinnvoll, wenn die Montierung per
`:U#` auf Hochpräzision gestellt ist (schreibendes Kommando, hier bewusst nicht
automatisch gesendet).
`config.HIGH_PRECISION` steuert die ganze Darstellung — sie folgt dem, was die
Montierung wirklich liefert:
Die **RA** wird immer mit Sekunden gezeigt (`11h36m54s`); bei Niedrigpräzision
kommen die Sekunden aus den Zehntel-Minuten und springen daher in 6-Sekunden-Schritten
(kein Dezimalpunkt — der wirkt in der Sperrschrift zu luftig).
| | Niedrigpräzision (Vorgabe) | Hochpräzision (nach `:U#`) |
|---|---|---|
| Zeile 1 | `RA 18h36.9m` | `18h36m56s` |
| Zeile 2 | `DE +38°47'` | `+38°47'01"` |
Bei Niedrigpräzision zeigt die **RA Zehntelminuten** statt Sekunden — genau die
Auflösung der Rohdaten. Bis 2026-07-27 wurde hier in Sekunden umgerechnet
(`11h36m54s`); die sprangen aber in 6-Sekunden-Schritten, weil die Montierung sie
gar nicht hergibt. Die kürzere Form lässt zugleich Platz für die **Beschriftung**
`RA` / `DE` (`config.LABEL_RA`, `LABEL_DEC`).
Bei Hochpräzision entfällt die Beschriftung: Dort braucht die Deklination mit
`+38°47'01"` alle Zellen. `:U#` ist ein schreibendes Kommando und wird hier
bewusst nicht automatisch gesendet.
`coords.format_ra`/`format_dec` nehmen `high_precision=` als Parameter (Vorgabe aus
`config`). Die Montierungs-Rohwerte werden im `coords`-Parser unverändert korrekt
@@ -150,27 +422,176 @@ zerlegt (Trennzeichen `:`, `*`, `0xDF`, `'` sind alle egal).
## Helligkeitsregelung
Portiert aus dem alten MSP430-Programm (`DoDimmen_V`, Werte aus `main.h`). Ein
LDR-Rohwert (12-Bit-ADC, 04095: je heller die Umgebung, desto höher der Wert)
wird über einen gleitenden Mittelwert (8 Werte) geglättet und auf eine
Helligkeitsstufe mit Hysterese abgebildet:
**Zwei Helligkeiten, ein Umschaltpunkt.** Unterhalb der Schwelle läuft die Anzeige
mit `dunkel_prozent`, oberhalb mit `hell_prozent`. Die vier abgestuften Helligkeiten
des alten MSP430-Programms (5/15/50/100 %) sind bewusst aufgegeben — für die
Sternwarte genügt der Wechsel Tag/Nacht.
| Stufe | Helligkeit | erreicht ab (hoch / runter) |
|------|-----------|------------------------------|
| 0 | 5 % | < 270 |
| 1 | 15 % | ≥ 330 / < 270 |
| 2 | 50 % | ≥ 2650 / < 2350 |
| 3 | 100 % | ≥ 3650 / < 3350 |
Ein LDR-Rohwert (12-Bit-ADC, 04095) wird über `raw_to_ldr` in die Zählrichtung
„groß = hell" gebracht, über einen gleitenden Mittelwert (8 Werte) geglättet und
gegen die Schwelle gehalten:
Schwellen-Mitten `300 / 2500 / 3500`, Hysterese `±30 / ±150 / ±150`. **300 und 2500
stammen aus dem alten Code**; die obere Grenze `HELL_MAX = 3500` für 100 % ist neu
(der alte Code hatte keine 4. Stufe) und in `brightness.py` anpassbar, sobald der
reale LDR-Wertebereich feststeht. Ein Helligkeitstelegramm wird nur bei
| Bereich | Helligkeit | Umschaltpunkt |
|---|---|---|
| unter der Schwelle | `dunkel_prozent` = 5 % | herunter ab < 1650 |
| über der Schwelle | `hell_prozent` = 50 % | hoch ab ≥ 1950 |
Schwelle `1800`, Hysterese `±150`. Beim allerersten Messwert entscheidet die
blanke Schwelle (1800) statt der Kante — sonst startete die Anzeige im Bereich
dazwischen fälschlich dunkel und wachte erst später auf.
### Die Werte stehen in `settings.json`, nicht im Code
`config.py` ist Programmcode und ändert sich nur beim Deployen. Die Helligkeiten
und die Schwelle sollen dagegen im Betrieb verstellbar sein — **später über MQTT**.
Sie liegen deshalb als JSON auf dem Gerät und werden über `settings.py` gelesen und
geschrieben:
```python
import settings
settings.load() # mit Vorgaben aufgefüllt
settings.update({"hell_prozent": 60}) # ändern und speichern
```
Ein laufender `BrightnessController` übernimmt Änderungen mit `reload()`.
Jeder Wert wird beim Laden **und** beim Schreiben geprüft (Typ und Bereich:
Prozente 0100, Schwelle 04095). Das ist der Grund, warum die Prüfung überhaupt
existiert: Sobald MQTT dazukommt, kommen die Werte von außen, und ein
Helligkeitswert von 500 % oder eine Schwelle von 1 würde die Anzeige sonst
unbrauchbar machen. Fehlt die Datei oder ist sie beschädigt, gelten die Vorgaben
aus `settings.DEFAULTS` — ein Gerät in der Sternwarte soll sich nicht an einer
kaputten Konfigurationsdatei aufhängen. Ein einzelner unbrauchbarer Wert verwirft
nur diesen, nicht die ganze Datei.
`settings.json` wird von `deploy.sh` **nicht** mitkopiert und steht in `.gitignore`:
Es ist Laufzeitzustand des Geräts, kein Quellcode. Würde es mitkopiert, wären die
eingestellten Werte nach jedem Deployen weg.
### Die Schwelle stammt aus einer Messung
**Am realen Aufbau gemessen (2026-07-27)**, XIAO ESP32-C3 mit LDR an GPIO2,
je 200 Messwerte, Median — Werte nach `raw_to_ldr`:
| Lichtsituation | roh | nach `raw_to_ldr` | Stufe |
|---|---|---|---|
| abgedeckt | 4095 (ADC-Anschlag) | 0 | dunkel → 5 % |
| Raumlicht | 1642 | 2453 | hell → 50 % |
| angeleuchtet (Taschenlampe) | 543 | 3552 | hell → 50 % |
Die Schwelle 1800 liegt mit Abstand zwischen dem Dunkelwert und dem Raumlicht.
**Die Hysterese ist nicht optional.** Der Rohwert streut bei Raumlicht um ±270,
nach der Mittelung bleiben rund ±32 (1σ). Ohne Hysterese würde die Anzeige am
Umschaltpunkt zwischen den beiden Helligkeiten hin- und herspringen; ±150 liegt
sicher darüber (≈4,7σ). Ein Helligkeitstelegramm wird ohnehin nur bei
**Stufenwechsel** gesendet (`ESC H 2 <ppp>`), wie im Original.
Der LDR-Wert kommt derzeit per Kommandozeile (`--ldr`); auf dem ESP32-C3 liefert
ihn später der ADC. `run_display.py --ldr <wert>` bindet die Regelung in die
Hauptschleife ein (`BrightnessController`).
**Im Dunkeln steht der ADC am Anschlag** (4095). Der Teiler treibt A0 über den
Messbereich, „Dämmerung" und „stockdunkel" sind daher nicht unterscheidbar. Bei
nur zwei Helligkeiten ist das ohne Belang — unterschieden werden muss nur hell
gegen dunkel, und dafür reicht die Auflösung weit. Eine feinere
Nachtabstufung ginge nur über den Vorwiderstand: sättigungsfrei wäre
`R2 ≥ R_LDR(dunkel) × (3,3 V U_max)/U_max`, also grob `R2 ≥ 0,32 × R_LDR(dunkel)`.
Bei einem typischen LDR (0,51 MΩ im Dunkeln) liefe das auf 160330 kΩ hinaus — dann
lägen Tag und Raumlicht aber bei ADC-Werten unter 15 und damit in der Totzone. Der
LDR überstreicht rund Faktor 4500; ein einzelner Teiler bildet davon nur etwa zwei
Dekaden ab. **1022 kΩ** wäre der Kompromiss, wenn mehr Nachtauflösung gewünscht ist.
Auf dem Mac kommt der LDR-Wert per Kommandozeile (`run_display.py --ldr <wert>`),
auf dem ESP32-C3 vom ADC an GPIO2 (`run_esp32.main(with_ldr=True)`).
**Die Kennlinie ist auf der Platine umgekehrt.** Der Teiler lautet
`3V3 —[R2 2k]— A0 —[LDR]— GND`; der LDR wird bei mehr Licht niederohmiger, die
Spannung an A0 sinkt also mit steigender Helligkeit — genau andersherum als beim
alten MSP430-Aufbau, auf den die Schwellen oben zugeschnitten sind. Der Rohwert
wird deshalb gespiegelt: `config.LDR_INVERTED = True`, angewendet in
`brightness.raw_to_ldr`. Wer die Hardware lieber umbaut (LDR nach 3V3, R2 nach
GND), setzt das Flag auf `False`.
Die Schwellen sind am Aufbau gemessen, aber **am Schreibtisch, nicht in der
Sternwarte**. Vor dem Echteinsatz dort bei Tag und Nacht gegenprüfen — deshalb
bleibt `with_ldr` in `run_esp32.main` vorerst auf `False`.
## MQTT
Der ESP32 nimmt Einstellungen über MQTT entgegen und veröffentlicht seinen
Zustand. Zugangsdaten kommen aus `mqtt_config.py` (gitignored, Vorlage
`mqtt_config_example.py`):
```bash
cp mqtt_config_example.py mqtt_config.py # Broker, Port, ggf. Benutzer/Passwort
./deploy.sh
```
**Fehlt die Datei, läuft die Anzeige einfach ohne MQTT** — `deploy.sh` weist nur
darauf hin und kopiert sie nicht.
### Topics
Empfangen (`<PREFIX>/set/<name>`, Nutzlast als Zahl):
| Topic | Beispiel | Wirkung |
|---|---|---|
| `grossanzeige/set/hell_prozent` | `60` | Helligkeit über der Schwelle |
| `grossanzeige/set/dunkel_prozent` | `5` | Helligkeit unter der Schwelle |
| `grossanzeige/set/schwelle` | `1800` | Umschaltpunkt (LDR-Wert) |
| `grossanzeige/set/hysterese` | `150` | Breite um den Umschaltpunkt |
Gesendet (`<PREFIX>/status/<name>`, alle *retained*):
| Topic | Beispiel |
|---|---|
| `grossanzeige/status/ra` | `18h36m56s` |
| `grossanzeige/status/dec` | `+38°47'01"` |
| `grossanzeige/status/ldr` | `2453` |
| `grossanzeige/status/helligkeit` | `50` |
| `grossanzeige/status/link` | `1` (Montierung erreichbar) |
| `grossanzeige/status/online` | `1` — **Last Will** setzt es auf `0` |
Zum Ausprobieren:
```bash
mosquitto_sub -h <broker> -t 'grossanzeige/#' -v
mosquitto_pub -h <broker> -t grossanzeige/set/hell_prozent -m 60
```
Eine empfangene Einstellung wird sofort wirksam: Sie geht durch dieselbe Prüfung
wie jede andere (`settings.update`), landet in `settings.json` und der laufende
`BrightnessController` lädt sie über `reload()` nach — ein Neustart ist nicht nötig.
**Wichtig dabei:** Nach dem Nachladen muss die Helligkeit auch *neu an die Anzeige
gesendet* werden. Ein Helligkeitstelegramm geht sonst nur beim *Wechsel* der Stufe
raus — eine per MQTT gesetzte Helligkeit bliebe unsichtbar, solange die Stufe
dieselbe bleibt. Das ist am Gerät aufgefallen (2026-07-27) und wird von
`test_mqtt.py` (`TestHelligkeitWirdSofortSichtbar`) festgehalten.
### Was dabei schiefgehen kann, und warum es nichts ausmacht
**Grundsatz: MQTT darf die Anzeige nie aufhalten.** Die Anzeige ist der Zweck des
Geräts, MQTT ist Beiwerk. Deshalb fängt `mqtt.py` seine Fehler selbst ab:
- **Broker nicht erreichbar** → Der Verbindungsversuch schlägt fehl, wird notiert,
und die Schleife läuft weiter. Wiederholt wird mit **wachsendem Abstand**
(5 s bis 120 s) — sonst würde ein dauerhaft toter Broker in *jedem*
Schleifendurchlauf Zeit kosten.
- **Verbindung bricht mitten im Betrieb ab** → Wird beim nächsten Senden oder
Empfangen bemerkt, die Brücke verwirft die Verbindung und baut sie später neu
auf. Nach dem Reconnect wird der gesamte Status erneut gesendet, damit der
Broker nicht auf veralteten Werten sitzenbleibt.
- **Unsinniger Wert von außen** (`hell_prozent = 500`, `schwelle = -1`, Text statt
Zahl, unbekanntes Topic) → wird verworfen und protokolliert; `settings.json`
bleibt unberührt. Genau dafür prüft `settings.py` jeden Wert auf Typ und Bereich.
- **Gerät fällt aus** → Der **Last Will** sorgt dafür, dass der Broker von sich aus
`online = 0` meldet. Ohne das bliebe `online = 1` stehen, obwohl niemand mehr da ist.
`test_mqtt.py` prüft all diese Fälle gegen einen eingehängten Fake-Broker — auch
die, die sich mit einem echten Server nur schwer herbeiführen lassen. Der wichtigste
Test ist `test_toter_broker_haelt_die_anzeige_nicht_auf`.
Der Empfang blockiert nicht: `check_msg()` kehrt sofort zurück, wenn nichts anliegt.
Statuswerte werden nur bei Änderung gesendet — die Koordinaten ändern sich ständig,
LDR und Helligkeit kaum.
## Antwortleitung (RX)
@@ -192,10 +613,18 @@ gegeneinander (verschliffener ~3-V-Pegel am Oszi). `loopback.py` grenzt so etwas
## Erledigt
- **RS232-Strecke ESP32 → MAX3232 → Anzeige bestätigt (2026-07-27).** Das war das
letzte ungetestete Kettenglied. Die Anzeige quittiert jedes Telegramm mit
Fehlercode 0, die volle Kette (Mock → WLAN → ESP32 → UART1 → MAX3232 → Anzeige)
läuft im Sekundentakt ohne Aussetzer. Damit ist die Portierung funktional
komplett; offen sind nur noch Betriebsthemen (LDR, Netzwerk, Autostart).
- **Phase 1 + 2 am Gerät bestätigt (2026-07-15/16):** Koordinaten erscheinen korrekt,
inkl. echtem Gradzeichen; im `run_display`-Betrieb aktualisieren beide Zeilen
laufend, sauber und ohne Flimmern, mit Antwort-Handshake der Anzeige. Protokoll,
19200 8E1, Zeichensatz und Zeilengeometrie stimmen.
- Schrift: Sperrschrift (`ESC z`, `CHARSET_SPACED = True`), 10 Zeichen/Zeile.
Die schmale `1` wirkt dadurch etwas luftig — Font-Eigenschaft, nur per eigenem
- Schrift: Sperrschrift (`ESC z`, `CHARSET_SPACED = True`), **10 Zeichen/Zeile**
bei fester Positionierung auf 6 px (gemessen 27.07., siehe *Geometrie und
Darstellung*). Zwischenzeitlich stand hier 9 — das war am Umbruch abgezählt und
traf den Auto-Vorschub (7 px), nicht die Zeichenbreite.
Die schmale `1` wirkt etwas luftig — Font-Eigenschaft, nur per eigenem
Font (microSYST-PC-Software) änderbar.
+104 -46
View File
@@ -1,73 +1,122 @@
#!/usr/bin/env python3
"""Helligkeitsregelung anhand eines LDR-Werts.
"""Helligkeitsumschaltung anhand eines LDR-Werts.
Portierung von DoDimmen_V aus dem alten MSP430-Programm (GrossAnzeige/main.c,
Konstanten aus main.h): gleitender Mittelwert, 3 Helligkeitsstufen mit Hysterese,
Prozentwerte aus hellTafel_ub. Der Fotowiderstand (LDR) liefert einen Rohwert
(alte Hardware: 12-bit-ADC, 0..4095) -- je heller die Umgebung, desto hoeher der
Wert und desto heller die Anzeige.
Zwei Helligkeiten, ein Umschaltpunkt: unterhalb der Schwelle laeuft die Anzeige
mit "dunkel", oberhalb mit "hell". Die vier abgestuften Helligkeiten des alten
MSP430-Programms (DoDimmen_V, 5/15/50/100 %) sind bewusst aufgegeben -- fuer die
Sternwarte genuegt der Wechsel Tag/Nacht.
Reines Python, laeuft auch unter MicroPython (dort spaeter vom ADC gespeist).
Die Werte stehen nicht hier, sondern in settings.json (siehe settings.py), damit
sie sich im Betrieb aendern lassen -- spaeter ueber MQTT.
python3 brightness.py --ldr 1500 # Stufe/Prozent berechnen und senden
Zwei Dinge aus der alten Regelung bleiben, weil die Messung am Geraet sie
verlangt (2026-07-27):
- **gleitender Mittelwert** ueber AVERAGE_WINDOW Werte: der ADC des ESP32-C3
rauscht kraeftig (Rohwert +/-270 bei Raumlicht).
- **Hysterese** um die Schwelle: nach der Mittelung bleiben rund +/-32
(1 Sigma). Ohne Hysterese wuerde die Anzeige am Umschaltpunkt zwischen den
beiden Helligkeiten hin- und herspringen.
Der Fotowiderstand liefert einen Rohwert (12-bit-ADC, 0..4095). Gerechnet wird in
der Richtung "grosser Wert = helle Umgebung"; auf der ESP32-Platine ist die
Kennlinie umgekehrt, deshalb laeuft der Rohwert zuerst durch raw_to_ldr().
Reines Python, laeuft auch unter MicroPython.
python3 brightness.py --ldr 1500 # Helligkeit berechnen und senden
python3 brightness.py --ldr 200 --dry-run
"""
import argparse
import sys
# argparse und sys.stderr gibt es unter MicroPython nicht -- beides wird nur vom
# CLI-Teil ganz unten gebraucht und darum erst dort importiert. So laesst sich
# dieses Modul auf dem ESP32 einfach importieren.
# -- Werte aus dem alten Programm (main.h) --------------------------------
HELL_LOW = 300 # Stufengrenze 0<->1 (aus dem alten Code)
HELL_HIGH = 2500 # Stufengrenze 1<->2 (aus dem alten Code)
HELL_MAX = 3500 # Stufengrenze 2<->3: NEU, nicht aus dem alten Code.
# Anpassen, sobald der reale LDR-Wertebereich bekannt ist.
HELL_HYST = 30 # Basis-Hysterese
AVERAGE_WINDOW = 8 # MAXHELLARRAY: gleitender Mittelwert ueber 8 Werte
# Helligkeits-Prozente je Stufe, aus hellTafel_ub (main.h): 5/15/50/100 %.
PERCENTS = (5, 15, 50, 100)
# Die beiden Helligkeitsstufen. DUNKEL gilt unterhalb der Schwelle, HELL darueber.
DUNKEL = 0
HELL = 1
# Mittelpunkte der Stufengrenzen und deren Hysterese (Stufe i <-> i+1).
# Der alte Code nutzte am unteren Uebergang +/-HYST, am oberen +/-5*HYST; das
# uebernehmen wir und ergaenzen die neue obere Grenze mit derselben Breite.
_CENTERS = (HELL_LOW, HELL_HIGH, HELL_MAX)
_HYSTS = (HELL_HYST, HELL_HYST * 5, HELL_HYST * 5)
def raw_to_ldr(raw, inverted=None, full_scale=None):
"""ADC-Rohwert in die Zaehlrichtung bringen, die die Schwellen erwarten.
Die Schwellen oben stammen vom alten MSP430-Aufbau: hoher Wert = helle
Umgebung. Auf der ESP32-Platine liegt der LDR gegen Masse und der 2k gegen
3V3, dort faellt die Spannung an A0 mit steigender Helligkeit. Der Wert wird
deshalb gespiegelt, wenn config.LDR_INVERTED gesetzt ist.
"""
import config
if inverted is None:
inverted = config.LDR_INVERTED
if full_scale is None:
full_scale = config.LDR_MAX
return (full_scale - raw) if inverted else raw
class BrightnessController:
"""Rechnet aus LDR-Rohwerten die Anzeigehelligkeit -- wie DoDimmen_V.
"""Schaltet anhand des LDR-Werts zwischen zwei Helligkeiten um.
update() gibt die neue Helligkeit in Prozent nur zurueck, wenn sich die Stufe
geaendert hat (sonst None). So wird -- wie im alten Programm -- nur bei
tatsaechlicher Aenderung ein Helligkeitstelegramm gesendet.
update() meldet zurueck, ob sich die Stufe geaendert hat -- nur dann wird ein
Helligkeitstelegramm gesendet, wie schon im alten Programm.
Die Werte kommen aus settings.json und werden beim Anlegen einmal gelesen.
Wer sie im Betrieb aendert (spaeter ueber MQTT), ruft danach reload() auf.
"""
def __init__(self):
def __init__(self, werte=None, pfad=None, log=None):
import settings
self._settings = settings
self._pfad = pfad
self._werte = settings.load(pfad, log=log) if werte is None else dict(werte)
self._buf = []
self._level = None # entspricht oldHellStufe_ub = 0xFF (>=2-Zweig)
self._level = None
@property
def level(self):
return self._level
def _next_level(self, val, old):
"""Stufe mit Hysterese bestimmen (verallgemeinert auf beliebig viele Stufen).
@property
def settings(self):
"""Die aktuell verwendeten Werte (Kopie)."""
return dict(self._werte)
Hochschalten, solange der Wert die obere Kante (Mitte + Hysterese) der
naechsten Grenze ueberschreitet; herunterschalten, solange er die untere
Kante (Mitte - Hysterese) unterschreitet. Beim ersten Aufruf (old=None)
wird von Stufe 0 aus hochgeschaltet -- die Anzeige startet also gedimmt
und hellt sich passend zum Messwert auf.
def reload(self, log=None):
"""Einstellungen neu aus der Datei lesen.
Die Stufe bleibt stehen; ob sie zu den neuen Werten noch passt, klaert
der naechste update() -- inklusive Hysterese, also ohne Sprung.
"""
level = 0 if old is None else old
while level < len(PERCENTS) - 1 and val >= _CENTERS[level] + _HYSTS[level]:
level += 1
while level > 0 and val < _CENTERS[level - 1] - _HYSTS[level - 1]:
level -= 1
return level
self._werte = self._settings.load(self._pfad, log=log)
return self.settings
def percent(self, level):
"""Prozentwert zu einer Stufe."""
return self._werte["hell_prozent" if level == HELL else "dunkel_prozent"]
def _next_level(self, val, old):
"""Stufe mit Hysterese bestimmen.
Umgeschaltet wird erst, wenn der Wert die Schwelle um die Hysterese
ueberschreitet -- nach oben auf HELL, nach unten auf DUNKEL. Dazwischen
bleibt es bei der bisherigen Stufe; genau das verhindert das Flattern.
Beim ersten Aufruf (old=None) gibt es keine bisherige Stufe. Dann
entscheidet die blanke Schwelle, damit die Anzeige sofort richtig
startet, statt erst nach der halben Hysterese aufzuwachen.
"""
schwelle = self._werte["schwelle"]
hyst = self._werte["hysterese"]
if old is None:
return HELL if val >= schwelle else DUNKEL
if val >= schwelle + hyst:
return HELL
if val < schwelle - hyst:
return DUNKEL
return old
def update(self, ldr):
"""Einen LDR-Rohwert einspeisen. Liefert (stufe, prozent, geaendert)."""
"""Einen LDR-Wert einspeisen. Liefert (stufe, prozent, geaendert)."""
self._buf.append(ldr)
if len(self._buf) > AVERAGE_WINDOW:
self._buf.pop(0)
@@ -76,10 +125,13 @@ class BrightnessController:
level = self._next_level(avg, self._level)
changed = level != self._level
self._level = level
return level, PERCENTS[level], changed
return level, self.percent(level), changed
def main(argv=None):
import argparse
import sys
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--ldr", type=int, required=True, help="LDR-Rohwert (0..4095)")
@@ -87,8 +139,13 @@ def main(argv=None):
p.add_argument("--dry-run", action="store_true", help="nur berechnen/anzeigen, nicht senden")
args = p.parse_args(argv)
level, percent, _ = BrightnessController().update(args.ldr)
print("LDR %d -> Stufe %d -> %d %%" % (args.ldr, level, percent))
c = BrightnessController()
level, percent, _ = c.update(args.ldr)
w = c.settings
print("Schwelle %d (+/-%d) -> dunkel %d %%, hell %d %%"
% (w["schwelle"], w["hysterese"], w["dunkel_prozent"], w["hell_prozent"]))
print("LDR %d -> %s -> %d %%"
% (args.ldr, "HELL" if level == HELL else "DUNKEL", percent))
import config
import migra
@@ -106,4 +163,5 @@ def main(argv=None):
if __name__ == "__main__":
import sys
sys.exit(main())
+143 -7
View File
@@ -1,16 +1,38 @@
"""Zentrale Einstellungen fuer die Ansteuerung der Grossanzeige."""
"""Zentrale Einstellungen fuer die Ansteuerung der Grossanzeige.
Laeuft unveraendert unter CPython (Mac) und MicroPython (XIAO ESP32-C3); was
sich zwischen beiden unterscheidet, haengt an MICROPYTHON.
"""
import sys
import migra
# True auf dem ESP32-C3, False auf dem Mac. Steuert, welchen Transport
# display.open_display waehlt und ob der LDR ueber den ADC gelesen werden kann.
MICROPYTHON = sys.implementation.name == "micropython"
# -- Serielle Schnittstelle -----------------------------------------------
# Auslieferzustand der Anzeige laut Handbuch (Kap. 4.1) und identisch zur
# UART-Init des alten MSP430-Programms: 19200 8E1.
PORT = "/dev/cu.usbserial-120" # Mac; auf dem ESP32-C3 spaeter die UART-Nummer
BAUDRATE = 19200
PARITY = "E"
BYTESIZE = 8
STOPBITS = 1
# CPython: Geraetedatei des USB-RS232-Wandlers.
PORT = "/dev/cu.usbserial-120"
# ESP32-C3: UART1 mit frei zugeordneten Pins. Die Nummern stammen aus dem
# Schaltplan (Hardware/KiCad/Grossanzeige) -- am XIAO ist Pad 7 = D6 = GPIO21
# und Pad 8 = D7 = GPIO20. Von dort geht es an den MAX3232:
# GPIO21 (TX) -> T2IN, T2OUT -> J5 Pin 3 -> Anzeige RxD (Sub-D Pin 2)
# GPIO20 (RX) <- R2OUT, R2IN <- J5 Pin 2 <- Anzeige TxD (Sub-D Pin 3)
# UART0 bleibt frei, das ist die REPL-Konsole ueber USB.
UART_ID = 1
UART_TX = 21
UART_RX = 20
# -- Telegramm ------------------------------------------------------------
DISPLAY_ADDR = 1 # Zieladresse der Anzeige (DA = 0x81)
HOST_ADDR = 0 # unsere Quelladresse (SA = 0x80)
@@ -26,14 +48,63 @@ QUIET_TIME = 0.4 # Pause zwischen Telegrammen, wenn ohne Antwort gefahren
# Zeichensatz 0, Zeile 1 ab Pixelzeile 0, Zeile 2 ab Pixelzeile 9 (aus dem alten
# C-Programm). Am echten Geraet ausgemessen (2026-07-15):
# CHARSET_SPACED=True -> ESC z (Sperrschrift, alle Zeichen gleich breit) -- vom
# Nutzer bevorzugt, weil sich sonst schmale Ziffern beruehren. In dieser Schrift
# passen mind. 10 Zeichen pro Zeile (RA/DEC brauchen max. 10).
# Nutzer bevorzugt, weil sich sonst schmale Ziffern beruehren.
# CHARSET_SPACED=False -> ESC Z (proportional, schmaler, ~11 Zeichen, aber die "1"
# klebt an Nachbarn).
CHARSET = 0
CHARSET_SPACED = True
LINE_Y = (0, 9)
CHARS_PER_LINE = 9 # in Sperrschrift am Geraet ausgemessen (2026-07-15)
# 6 px pro Zeichenzelle -- mit "probe.py pitch 6" am Geraet gemessen (2026-07-27).
#
# Die Messung stellt den Auto-Vorschub der Anzeige gegen feste Pixelabstaende und
# hat beides zugleich gezeigt:
# - Auto-Vorschub der Sperrschrift ESC z ist 7 px. Eine Zeile aus einem Stueck
# bricht deshalb nach 9 Zeichen um (9*7 = 63 von 64 px). Frueher stand hier
# darum 9 -- das war der Vorschub, nicht die Zellenbreite.
# - Die Zeichenmatrix selbst ist nur 6 px breit: zehn Ziffern, einzeln auf
# x = i*6 gesetzt, stehen sauber und getrennt nebeneinander (60 von 64 px).
#
# Deshalb positioniert display._emit_line jedes Zeichen selbst, statt den
# Auto-Vorschub laufen zu lassen.
CELL_WIDTH = 6 # Pixel pro Zeichenzelle
DISPLAY_WIDTH = 64 # Pixelbreite der Anzeige
# Obergrenze in *Zeichen*. Die harte Grenze ist DISPLAY_WIDTH in Pixeln: Seit
# schmale Zeichen weniger Platz brauchen (CELL_NARROW), haengt die Breite einer
# Zeile davon ab, welche Zeichen darin stehen. 11 gehen nur auf, wenn mindestens
# eines schmal ist -- "RA:18h51.0m" braucht mit seinem Dezimalpunkt 63 der 64 px,
# "DE:+38°47'" waere als 11 volle Zellen dagegen 66 px und damit zu breit.
# display._aufbereiten schneidet deshalb nach Pixeln ab, nicht nach Zeichen.
CHARS_PER_LINE = 11
# Breite, die die Anzeige beim Schreiben eines Zeichens tatsaechlich bemalt --
# mehr als CELL_WIDTH. Daher auch der Auto-Vorschub von 7 px. Wichtig fuer das
# Nachfuehren einzelner Zellen: Ein neu geschriebenes Zeichen loescht die erste
# Pixelspalte seines rechten Nachbarn, der muss also mitgeschrieben werden
# (siehe display._zelle_faellig).
GLYPH_WIDTH = 7
# Zeichen, die weniger Platz brauchen als eine volle Zelle -- in der Sperrschrift
# stehen sie sonst sehr luftig zwischen den Ziffern. Je 3 px am Geraet
# ausprobiert (2026-07-27); weil jedes Zeichen ohnehin einzeln positioniert wird,
# kostet das nichts.
# "." Dezimalpunkt der Zehntelminuten (RA 18h37.1m)
# " " Abstand zwischen Beschriftung und Wert (RA 18h37.1m)
# Achtung: Das gilt auch fuer die Leerzeichen, mit denen eine Zeile aufgefuellt
# wird. display._aufbereiten rechnet beim Auffuellen darum bewusst mit der vollen
# Zellenbreite -- sonst reichte die letzte Zelle ueber den Rand hinaus.
CELL_NARROW = {".": 3, " ": 3}
# Alle Zeilen beginnen linksbuendig bei LINE_X. Bis 2026-07-27 gab es einen
# Schalter zum Zentrieren; er ist entfallen, weil die Zeilen seit den schmalen
# Zeichen (CELL_NARROW) unterschiedlich breit sind und zentriert um wenige Pixel
# gegeneinander versetzt standen. Mit der Beschriftung "RA:" / "DE:" sollen sie
# buendig untereinander beginnen.
#
# LINE_X = 0, ganz am Rand: Die RA-Zeile braucht mit der Minutenangabe am Ende
# ("RA:18h51.0m") 63 der 64 Pixel -- fuer einen Rand ist da kein Platz mehr.
LINE_X = 0
COLOR = migra.RED # einfarbig rote Anzeige
BACKGROUND = migra.BLACK
@@ -52,7 +123,11 @@ DEGREE_FALLBACK = "d" # Ersatz, falls DEGREE_CHARSET = None
# -- Montierung (Phase 2) -------------------------------------------------
# 10Micron GM4000 HPS II: LX200-Protokoll ueber TCP. Die IP der Montierung
# eintragen, sobald sie erreichbar ist; Port 3490 ist die Vorgabe von 10Micron.
MOUNT_HOST = "192.168.1.115" # PLATZHALTER -- echte IP der Montierung eintragen
# Vorübergehend auf den Mock auf dem Mac gestellt (2026-07-27), weil die echte
# Montierung vom ESP32 aus nicht erreichbar ist: Sie steht in 192.168.1.115, der
# ESP32 im Heimnetz 192.168.178.x. Zum Umstellen die beiden Zeilen tauschen.
MOUNT_HOST = "192.168.178.26" # Mock auf dem Mac (mount_mock.py --lan --move --low)
# MOUNT_HOST = "192.168.1.115" # echte GM4000 (eingetragen 2026-07-18)
MOUNT_PORT = 3490
MOUNT_TIMEOUT = 3.0 # Sekunden fuer Verbindung/Antwort
POLL_INTERVAL = 1.0 # Sekunden zwischen zwei Koordinatenabfragen
@@ -64,5 +139,66 @@ POLL_INTERVAL = 1.0 # Sekunden zwischen zwei Koordinatenabfragen
# True = Hochpraezision (Montierung mit ":U#" umgestellt): RA/DEC mit Sekunden.
HIGH_PRECISION = False
# Beschriftung der beiden Zeilen:
#
# RA 18h37.1m
# DE +38°47'
#
# Der Abstand ist ein Leerzeichen, das ueber CELL_NARROW nur 3 px breit gesetzt
# wird -- ein Doppelpunkt stand hier zwischenzeitlich, wirkte in Zeichensatz 0
# aber zu wuchtig (2026-07-27).
#
# Die Beschriftung gibt es nur bei Niedrigpraezision: Dort liefert die Montierung
# ohnehin nur Zehntelminuten und ganze Bogenminuten, das laesst den Platz. Bei
# Hochpraezision braucht die Deklination alle Zellen (+38°47'01") -- dann
# entfaellt sie.
LABEL_RA = "RA "
LABEL_DEC = "DE "
# Anzeige, wenn die Montierung (noch) nicht erreichbar ist.
NOLINK_LINES = ("KEIN LINK", "MONTIERUNG")
# Achtung: hoechstens CHARS_PER_LINE Zeichen, sonst wird abgeschnitten --
# "MONTIERUNG" stand hier zuerst und erschien am Geraet als "MONTIERUN".
NOLINK_LINES = ("KEIN LINK", "TELESKOP")
# -- Dauerbetrieb (nur ESP32-C3) ------------------------------------------
# Watchdog: Kommt die Poll-Schleife so lange nicht mehr durch, startet die
# Hardware das Geraet neu. 120 s sind reichlich bemessen -- im Normalbetrieb
# wird jede Sekunde gefuettert. Der Wert muss ueber allem liegen, was regulaer
# blockieren darf: WLAN-Aufbau bis WIFI_TIMEOUT (20 s), Montierung
# MOUNT_TIMEOUT (3 s), dazu ein MQTT-Verbindungsversuch. Sonst startet das
# Geraet mitten im normalen Betrieb neu.
#
# Achtung: Ein einmal gestarteter Watchdog laesst sich nicht mehr abschalten.
# Deshalb legt ihn nur main.py an, nicht der Handbetrieb ueber die REPL.
WDT_TIMEOUT = 120.0 # Sekunden
# Wartezeit beim Autostart, bevor main.py die Schleife startet. In diesem
# Fenster bricht Ctrl-C ab und man kommt an die REPL -- ohne das waere ein
# Geraet mit fehlerhaftem main.py nur noch ueber ein Neuflashen erreichbar.
AUTOSTART_DELAY = 3.0 # Sekunden
# Nach einem Absturz in main.py: so lange warten, dann neu starten. Die Pause
# verhindert eine Neustart-Schleife im Sekundentakt, wenn etwas dauerhaft
# kaputt ist (und gibt Zeit, mit Ctrl-C einzugreifen).
RESTART_DELAY = 10.0 # Sekunden
# -- WLAN (nur ESP32-C3) --------------------------------------------------
# Zugangsdaten stehen in wifi_config.py (nicht im Git, siehe
# wifi_config_example.py). Hier nur das Zeitverhalten.
WIFI_TIMEOUT = 20.0 # Sekunden aufs Verbinden warten
WIFI_RETRY = 5.0 # Pause vor einem neuen Verbindungsversuch
# Anzeige, solange noch kein WLAN steht.
NOWIFI_LINES = ("KEIN WLAN", " ... ")
# -- Helligkeitssensor (LDR, nur ESP32-C3) --------------------------------
# LDR an Pad 1 (D0/A0) = GPIO2, auf dem C3 ein Kanal von ADC1.
LDR_PIN = 2
# Der Spannungsteiler auf der Platine ist 3V3 --[R2 2k]-- A0 --[LDR]-- GND.
# Der LDR wird bei mehr Licht niederohmiger, die Spannung an A0 sinkt also mit
# steigender Helligkeit -- genau andersherum als beim alten MSP430-Aufbau, auf
# den die Schwellen in brightness.py zugeschnitten sind. Deshalb wird der
# Rohwert gespiegelt (siehe brightness.raw_to_ldr).
LDR_INVERTED = True
LDR_MAX = 4095 # 12-bit-ADC, voller Ausschlag
+43 -12
View File
@@ -75,22 +75,53 @@ def _fit(candidates, width):
return candidates[-1][:width]
def format_ra(value, width=None, high_precision=None):
"""Rektaszension formatieren, immer mit Sekunden (HHhMMmSSs).
def _to_tenths(h, m, s):
"""(h, m, s) in (Stunden, Minuten, Zehntelminuten) umrechnen.
Bei Niedrigpraezision liefert die Montierung nur Zehntel-Minuten; die
Sekunden kommen dann in 6-Sekunden-Schritten. Das Format ist dasselbe wie
bei Hochpraezision -- der Unterschied steckt allein in der Aufloesung der
Rohdaten, nicht in der Darstellung. high_precision wird daher fuer RA nicht
ausgewertet (nur bei DEC relevant, siehe format_dec).
Ganzzahlig gerechnet, damit es unter MicroPython keine Float-Ueberraschungen
gibt. Der Uebertrag ist wichtig: 18:36:57 sind 36,95 Minuten und werden zu
18:37.0, nicht zu 18:36.10.
"""
zehntel = ((h * 3600 + m * 60 + s) * 10 + 30) // 60 # kaufmaennisch runden
minuten, t = divmod(zehntel, 10)
h, m = divmod(minuten, 60)
return h % 24, m, t
def format_ra(value, width=None, high_precision=None):
"""Rektaszension formatieren.
Die Darstellung folgt dem, was die Montierung wirklich liefert:
Niedrigpraezision (Vorgabe): **HHhMM.T** mit Zehntelminuten -- genau die
Aufloesung, die die GM4000 in diesem Modus sendet. Sieben Zeichen, das laesst
Platz fuer die Beschriftung "RA:" auf der Anzeige (siehe
displayloop.format_coordinates). Das "h" statt eines Doppelpunkts ist
Nutzerwunsch -- der Doppelpunkt wirkt in der Sperrschrift zu wuchtig.
Hochpraezision: HHhMMmSSs mit echten Sekunden.
Bis 2026-07-27 wurde auch bei Niedrigpraezision mit Sekunden angezeigt. Die
sprangen dort in 6-Sekunden-Schritten, weil die Rohdaten sie gar nicht
hergeben -- die Zehntelform ist ehrlicher und kuerzer.
"""
width = config.CHARS_PER_LINE if width is None else width
high = config.HIGH_PRECISION if high_precision is None else high_precision
h, m, s = parse_ra(value)
return _fit([
"%02dh%02dm%02ds" % (h, m, s),
"%02d:%02d:%02d" % (h, m, s),
"%02dh%02dm" % (h, m),
], width)
if high:
candidates = [
"%02dh%02dm%02ds" % (h, m, s),
"%02d:%02d:%02d" % (h, m, s),
"%02dh%02dm" % (h, m),
]
else:
h, m, t = _to_tenths(h, m, s)
candidates = [
"%02dh%02d.%dm" % (h, m, t), # 8: 18h36.9m
"%02dh%02d.%d" % (h, m, t), # 7: ohne Minutenzeichen
"%02dh%02dm" % (h, m), # 6: ohne Zehntel
]
return _fit(candidates, width)
def format_dec(value, width=None, high_precision=None):
Executable
+78
View File
@@ -0,0 +1,78 @@
#!/bin/sh
# Die Projektdateien auf den XIAO ESP32-C3 kopieren.
#
# ./deploy.sh # alle Dateien kopieren
# ./deploy.sh /dev/cu.usbmodem1101 # anderen Port erzwingen
#
# Braucht mpremote: pip install mpremote
# Danach am REPL (mpremote repl, mit Ctrl-] wieder raus):
#
# import run_esp32
# run_esp32.main()
set -e
PORT="${1:-auto}"
# Alles, was auf dem Geraet gebraucht wird. run_display.py, show_coords.py,
# mount_mock.py und die Tests bleiben auf dem Mac -- sie brauchen argparse
# bzw. threading.
#
# probe.py kommt mit aufs Geraet: Seit die Anzeige am ESP32 haengt und nicht mehr
# am Mac, laesst sich die Geometrie nur noch von dort ausmessen. Es benutzt zwar
# sys.argv, aber nur in main() -- die Messfunktionen ruft man am REPL direkt auf:
# import probe, display
# d = display.open_display(); probe.pitch(d, 6); d.close()
#
# settings.json steht bewusst NICHT in dieser Liste: Das ist Laufzeitzustand auf
# dem Geraet (Helligkeiten, Schwelle -- spaeter per MQTT verstellt). Wuerde
# deploy.sh sie mitkopieren, waeren diese Einstellungen nach jedem Deployen weg.
# Fehlt die Datei, gelten die Vorgaben aus settings.DEFAULTS.
#
# main.py startet die Anzeige nach jedem Reset von selbst. Wer am Geraet
# arbeiten will, drueckt im Startfenster Ctrl-C (siehe main.py) -- oder loescht
# sie mit: mpremote rm :main.py
FILES="
ticks.py
migra.py
config.py
settings.py
transport.py
display.py
coords.py
lx200.py
mount_client.py
brightness.py
displayloop.py
wifi.py
mqtt.py
run_esp32.py
probe.py
main.py
"
if [ ! -f wifi_config.py ]; then
echo "wifi_config.py fehlt." >&2
echo " cp wifi_config_example.py wifi_config.py und ausfuellen" >&2
exit 1
fi
# mqtt_config.py ist optional: Fehlt sie, laeuft die Anzeige ohne MQTT weiter.
EXTRA="wifi_config.py"
if [ -f mqtt_config.py ]; then
EXTRA="$EXTRA mqtt_config.py"
else
echo "Hinweis: mqtt_config.py fehlt -- laeuft ohne MQTT."
echo " cp mqtt_config_example.py mqtt_config.py und ausfuellen"
fi
for f in $FILES $EXTRA; do
echo "-> $f"
mpremote connect "$PORT" fs cp "$f" ":$f"
done
echo
echo "Fertig. Starten mit:"
echo " mpremote connect $PORT repl"
echo " >>> import run_esp32"
echo " >>> run_esp32.main()"
+243 -49
View File
@@ -24,6 +24,9 @@ class Display:
self._src = src
self._checksum = checksum
self._response = response
# Was gerade auf welcher Zeile steht. Grundlage fuer den Vergleich in
# show_lines: geschrieben wird nur, was sich geaendert hat.
self._gezeigt = {}
def send(self, data_unit):
"""Data-Unit rahmen, senden und ggf. auf das Antworttelegramm warten."""
@@ -44,6 +47,10 @@ class Display:
# -- Hoehere Funktionen ------------------------------------------------
def clear(self):
# Der gemerkte Inhalt gilt danach nicht mehr -- sonst hielte der
# Vergleich in show_lines Zellen faelschlich fuer schon vorhanden und
# die Anzeige bliebe nach dem Loeschen teilweise leer.
self._gezeigt = {}
self.send(migra.DataUnit().fill(config.BACKGROUND))
def set_brightness(self, percent):
@@ -52,84 +59,271 @@ class Display:
def show_lines(self, *lines, clear=False):
"""Zeilen ab Zeile 1 auf die Anzeige schreiben.
Baut ein einziges Telegramm: pro Zeile Cursor setzen und den Text
schreiben. Das Gradzeichen (config.DEGREE_MARKER) wird ueber einen kurzen
Zeichensatzwechsel eingefuegt.
Geschrieben wird nur, was sich seit dem letzten Mal geaendert hat --
Zelle fuer Zelle. Das ist der entscheidende Punkt fuer eine ruhige
Anzeige:
Jede Zeile wird mit Leerzeichen auf volle Breite (CHARS_PER_LINE)
aufgefuellt und damit *an Ort und Stelle* ueberschrieben -- ohne die
Anzeige vorher komplett zu loeschen. Das vermeidet das sichtbare Blitzen
bei zyklischen Updates und haelt das Telegramm kurz (sonst kommt das
naechste zu frueh und das letzte Zeichen kann verloren gehen).
clear=True erzwingt zusaetzlich ein Vollbild-Loeschen (einmal beim Start).
Seit jedes Zeichen einzeln positioniert wird (10 statt 9 Zeichen, siehe
_emit_line), kostet eine volle Zeile rund 100 Bytes statt 20. Bei 19200
Baud mit 8E1 (11 Bit je Byte) dauerte ein kompletter Bildwechsel damit
rund 300 ms. Die Anzeige stellt schon waehrend des Empfangs dar, also
war der Aufbau als Flackern zu sehen -- bei Aktualisierung im
Sekundentakt ein Drittel der Zeit. (Am Geraet gemessen 2026-07-27.)
Reihenfolge: Die Zeilen werden von unten nach oben gezeichnet, damit die
oberste Zeile zuletzt kommt. Grund: Ein Zeichensatzwechsel mitten im
Telegramm beschaedigt die Home-Zelle (0,0) -- die oberste Zeile muss also
nach allen Wechseln geschrieben werden, damit sie diese Zelle ueberschreibt.
(Am Geraet verifiziert 2026-07-15.)
Von einer Sekunde zur naechsten aendert sich aber fast nichts:
"18h36m56s" -> "18h36m57s" ist ein einziges Zeichen, die Deklination
steht meist voellig still. Mit dem Vergleich gegen den zuletzt
dargestellten Inhalt schrumpft der Bildwechsel auf ein bis zwei Zellen,
und eine unveraenderte Zeile erzeugt gar kein Telegramm mehr.
clear=True loescht das Vollbild und schreibt beide Zeilen komplett neu
(einmal beim Start und nach jedem Verbindungsabriss).
Reihenfolge: Die Zeilen werden von unten nach oben geschrieben, damit die
oberste zuletzt kommt. Grund: Ein Zeichensatzwechsel beschaedigt die
Home-Zelle (0,0), also den Anfang der obersten Zeile. Findet ein solcher
Wechsel statt (Gradzeichen), wird die oberste Zeile deshalb vollstaendig
nachgezogen -- siehe _zeile_schreiben. (Am Geraet verifiziert 2026-07-15.)
"""
if len(lines) > len(config.LINE_Y):
raise ValueError("Anzeige hat nur %d Zeilen" % len(config.LINE_Y))
du = migra.DataUnit()
if clear:
du.fill(config.BACKGROUND)
du.attributes(fg=config.COLOR, bg=config.BACKGROUND, blink=migra.STATIC)
self.clear()
self._gezeigt = {}
wechsel = False
for i in reversed(range(len(lines))):
self._emit_line(du, config.LINE_Y[i], lines[i])
self.send(du)
# Die oberste Zeile (i == 0) komplett neu, wenn weiter unten ein
# Zeichensatzwechsel war: der hat ihren Anfang zerstoert.
voll = wechsel and i == 0
if self._zeile_schreiben(i, lines[i], voll=voll):
wechsel = True
def show_line(self, index, text, clear=False):
"""Nur eine einzelne Zeile aktualisieren (eigenes, kurzes Telegramm).
"""Nur eine einzelne Zeile aktualisieren.
Fuer zyklische Updates: die Zeile ohne Gradzeichen (also ohne
Zeichensatzwechsel) laesst sich beliebig oft neu schreiben, ohne dass die
Anzeige Bytes verschluckt. Zeilen *mit* Gradzeichen sollten dagegen selten
und moeglichst per show_lines (Vollbild) gezeichnet werden.
Schreibt ebenfalls nur die geaenderten Zellen. Achtung: Enthaelt die
Zeile ein *neues* Gradzeichen, wird dafuer der Zeichensatz gewechselt und
damit die Home-Zelle beschaedigt -- die oberste Zeile muesste danach
nachgezogen werden. Wer beide Zeilen hat, nimmt darum show_lines.
"""
if not 0 <= index < len(config.LINE_Y):
raise ValueError("ungueltiger Zeilenindex %d" % index)
du = migra.DataUnit()
if clear:
du.fill(config.BACKGROUND)
self.clear()
self._gezeigt = {}
self._zeile_schreiben(index, text)
@staticmethod
def _breiten(text):
"""Breite jeder einzelnen Zelle in Pixeln.
Zeichen aus config.CELL_NARROW brauchen weniger Platz. Fuer Leerzeichen
gilt das aber nur *innerhalb* des Textes -- dort trennen sie Beschriftung
und Wert ("RA 18h36.9m"). Die Leerzeichen, mit denen die Zeile rechts
aufgefuellt wird, behalten die volle Breite: Sie sollen den Rest der
Zeile ueberschreiben, und je schmaler sie waeren, desto mehr Zellen
muesste man dafuer schreiben.
"""
ohne_fuellung = len(text.rstrip())
return [config.CELL_NARROW.get(ch, config.CELL_WIDTH)
if i < ohne_fuellung else config.CELL_WIDTH
for i, ch in enumerate(text)]
@staticmethod
def _breite(text):
"""Pixelbreite eines Textes."""
return sum(Display._breiten(text))
@staticmethod
def _aufbereiten(text):
"""Auf die Zeilenbreite bringen: abschneiden und auffuellen.
Massgeblich sind die **Pixel**, nicht die Zeichenzahl: Seit schmale
Zeichen weniger Platz brauchen (config.CELL_NARROW), passen mal 10 und
mal 11 Zeichen in dieselbe Zeile. Ein Text wird deshalb so lange
gekuerzt, bis er in DISPLAY_WIDTH passt, und nur so weit aufgefuellt, wie
eine weitere Zelle noch hineingeht. Ohne das ragte die letzte Zelle ueber
den Rand und die Anzeige quittierte mit Fehlercode 4.
"""
# Ohne str.ljust/center: die kennt MicroPython nicht (am Geraet aufgefallen).
platz = config.DISPLAY_WIDTH - config.LINE_X
text = text[:config.CHARS_PER_LINE]
while text and Display._breite(text) > platz:
text = text[:-1]
# Beim Auffuellen bewusst mit der *vollen* Zellenbreite rechnen, obwohl
# das Leerzeichen schmal gesetzt wird: Die Anzeige bemalt je Zeichen
# GLYPH_WIDTH Pixel, ein Leerzeichen am rechten Rand reichte sonst
# darueber hinaus.
while (len(text) < config.CHARS_PER_LINE
and Display._breite(text) + config.CELL_WIDTH <= platz):
text += " "
return text
def _zeile_schreiben(self, index, text, voll=False):
"""Eine Zeile nachfuehren. Liefert True, wenn der Zeichensatz gewechselt wurde.
voll=True schreibt alle Zellen, auch die unveraenderten.
"""
neu = self._aufbereiten(text)
pos = self._positionen(neu)
alt, alt_pos = self._gezeigt.get(index, (None, None))
if voll:
alt = None
elif alt_pos != pos:
# Die Zeile hat eine andere Form als vorher (z.B. ein Dezimalpunkt
# kommt dazu, der weniger Platz braucht). Dann sitzen alle Zellen
# woanders und der Vergleich waere sinnlos -- vollstaendig neu.
alt = None
elif alt == neu:
return False # nichts zu tun, kein Telegramm
du = migra.DataUnit()
du.attributes(fg=config.COLOR, bg=config.BACKGROUND, blink=migra.STATIC)
self._emit_line(du, config.LINE_Y[index], text)
self.send(du)
if alt is None:
# Erste Ausgabe dieser Zeile: Zeichensatz sicherheitshalber setzen.
# Danach behaelt die Anzeige ihn, und _emit_fremd schaltet nach
# jedem Sonderzeichen wieder zurueck -- ein Wechsel je Telegramm
# waere nur Ballast und wuerde die Home-Zelle beschaedigen.
du.charset(config.CHARSET, spaced=config.CHARSET_SPACED)
wechsel = self._emit_line(du, config.LINE_Y[index], neu, alt, pos)
try:
self.send(du)
except Exception:
# Ungewiss, wie viel die Anzeige bekommen hat. Den gemerkten Inhalt
# verwerfen, damit die Zeile beim naechsten Mal vollstaendig neu
# geschrieben wird -- sonst blieben Zellen stehen, die wir
# faelschlich fuer aktuell hielten.
self._gezeigt.pop(index, None)
raise
self._gezeigt[index] = (neu, pos)
return wechsel or alt is None
def _emit_line(self, du, y, text):
"""Eine Zeile bei Pixelzeile y ausgeben, auf volle Breite aufgefuellt."""
text = text[:config.CHARS_PER_LINE].ljust(config.CHARS_PER_LINE)
du.charset(config.CHARSET, spaced=config.CHARSET_SPACED)
du.cursor(0, y)
for i, part in enumerate(text.split(config.DEGREE_MARKER)):
if i > 0:
self._emit_degree(du)
if part:
du.text(part)
@staticmethod
def _positionen(text):
"""x-Position jeder Zelle einer Zeile.
def _emit_degree(self, du):
"""Das Gradzeichen einfuegen -- ueber Zeichensatzwechsel oder als Fallback."""
if config.DEGREE_CHARSET is None:
du.text(config.DEGREE_FALLBACK)
return
du.charset(config.DEGREE_CHARSET, spaced=config.CHARSET_SPACED)
du.text(config.DEGREE_CHAR)
du.charset(config.CHARSET, spaced=config.CHARSET_SPACED)
Normalerweise ein festes Raster aus CELL_WIDTH. Zeichen aus
config.CELL_NARROW bekommen weniger Platz -- der Dezimalpunkt der
Zehntelminuten stuende sonst sehr luftig zwischen den Ziffern.
"""
breiten = Display._breiten(text)
x = config.LINE_X
aus = []
for b in breiten:
aus.append(x)
x += b
return aus
@staticmethod
def _zelle_faellig(neu, alt, i, pos):
"""Muss Zelle i neu geschrieben werden?
Ja, wenn sie sich selbst geaendert hat -- oder wenn ihr **linker**
Nachbar neu geschrieben wird und bis in diese Zelle hineinragt: Die
Anzeige bemalt je Zeichen GLYPH_WIDTH Pixel, mehr als eine Zelle breit
ist, und loescht damit die erste Pixelspalte hier (siehe _emit_line).
"""
def anders(k):
return k >= len(alt) or alt[k] != neu[k]
if anders(i):
return True
return (i > 0 and anders(i - 1)
and pos[i] < pos[i - 1] + config.GLYPH_WIDTH)
def _emit_line(self, du, y, text, alt=None, pos=None):
"""Zellen einer Zeile ausgeben. Liefert True bei Zeichensatzwechsel.
Jedes Zeichen wird einzeln auf x = LINE_X + i*CELL_WIDTH gesetzt, statt
den Auto-Vorschub der Anzeige laufen zu lassen. Der Vorschub der
Sperrschrift ist 7 px und liesse nur 9 Zeichen zu; die Zeichenmatrix ist
aber nur 6 px breit, sodass 10 Zeichen sauber nebeneinander passen (am
Geraet gemessen 2026-07-27, siehe config.CHARS_PER_LINE).
alt = zuletzt dargestellter Inhalt derselben Zeile. Ist er gegeben,
werden nur die Zellen geschrieben, die sich unterscheiden -- das haelt
den Bildwechsel kurz und die Anzeige ruhig (siehe show_lines).
**Der rechte Nachbar muss dabei mit.** Die Anzeige malt je Zeichen 7 px
breit (daher auch der 7-px-Auto-Vorschub), wir setzen die Zeichen aber
auf 6-px-Raster. Ein neu geschriebenes Zeichen loescht damit die erste
Pixelspalte des Zeichens rechts daneben. Beim vollstaendigen Neuaufbau
fiel das nie auf, weil der Nachbar gleich danach ohnehin neu gemalt
wurde; beim Schreiben einzelner Zellen bliebe er beschaedigt stehen.
Am Geraet aufgefallen (2026-07-27): Beim Sekundenwechsel fehlten dem "s"
die linken Pixel, beim Minutenwechsel dem "m".
"""
if pos is None:
pos = self._positionen(text)
wechsel = False
for i in range(len(text)):
if alt is not None and not self._zelle_faellig(text, alt, i, pos):
continue
du.cursor(pos[i], y)
if self._emit_zeichen(du, text[i]):
wechsel = True
return wechsel
def _emit_zeichen(self, du, ch):
"""Ein Zeichen ausgeben. Liefert True, wenn der Zeichensatz wechseln musste.
Nur das Gradzeichen kommt aus einem anderen Zeichensatz -- in
Zeichensatz 0 gibt es keines.
Ein Zeichensatzwechsel beschaedigt die Home-Zelle (0,0), also den Anfang
der obersten Zeile. Solange alle Wechsel in der *unteren* Zeile
stattfinden, genuegt die Reihenfolge in show_lines (unten vor oben).
**Ein Wechsel mitten in der obersten Zeile laesst sich dagegen nicht
sauber reparieren.** Das erste Zeichen danach noch einmal zu schreiben,
wurde am Geraet ausprobiert (2026-07-27) und machte es schlimmer: Das
nachgezogene Zeichen bemalt GLYPH_WIDTH Pixel und frisst dabei die linke
Spalte seines rechten Nachbarn. Deshalb steht in der obersten Zeile
bewusst kein Sonderzeichen -- die Beschriftung nutzt ein schmales
Leerzeichen statt eines Doppelpunkts aus Zeichensatz 1.
"""
if ch == config.DEGREE_MARKER:
if config.DEGREE_CHARSET is None:
du.text(config.DEGREE_FALLBACK)
return False
du.charset(config.DEGREE_CHARSET, spaced=config.CHARSET_SPACED)
du.text(config.DEGREE_CHAR)
du.charset(config.CHARSET, spaced=config.CHARSET_SPACED)
return True
du.text(ch)
return False
def close(self):
self._t.close()
def open_display(port=config.PORT, fake=False, response=config.WANT_RESPONSE):
"""Transport passend zur Plattform oeffnen und Display-Objekt liefern."""
def open_display(port=None, fake=False, response=config.WANT_RESPONSE):
"""Transport passend zur Plattform oeffnen und Display-Objekt liefern.
Auf dem ESP32-C3 wird machine.UART genommen (Pins aus config), auf dem Mac
pyserial mit config.PORT. fake=True haengt stattdessen den Mitschreiber ein.
"""
if fake:
from transport import FakeTransport
t = FakeTransport(src=config.HOST_ADDR, display_addr=config.DISPLAY_ADDR)
elif config.MICROPYTHON:
from transport import UartTransport
t = UartTransport(
uart_id=config.UART_ID,
baudrate=config.BAUDRATE,
parity=config.PARITY,
bytesize=config.BYTESIZE,
stopbits=config.STOPBITS,
timeout=config.RESPONSE_TIMEOUT,
tx=config.UART_TX,
rx=config.UART_RX,
)
else:
from transport import SerialTransport
t = SerialTransport(
port=port,
port=config.PORT if port is None else port,
baudrate=config.BAUDRATE,
parity=config.PARITY,
bytesize=config.BYTESIZE,
+162
View File
@@ -0,0 +1,162 @@
"""Die Poll-Schleife: Koordinaten holen, formatieren, anzeigen.
Herausgeloest aus run_display.py, damit Mac und ESP32-C3 wirklich denselben
Ablauf fahren. Auf dem Mac kommt drumherum die Kommandozeile (run_display.py),
auf dem ESP32 das WLAN-Setup (run_esp32.py) -- die Schleife selbst ist gleich.
Reines Python ohne Abhaengigkeiten ausser den Projektmodulen; argparse und
sys.stderr bleiben bewusst draussen, beides gibt es unter MicroPython nicht.
"""
import time
import config
import coords
import migra
from mount_client import MountError
class LinkError(Exception):
"""Die Netzverbindung fehlt (auf dem ESP32: kein WLAN)."""
def format_coordinates(ra_raw, dec_raw):
"""Rohstrings der Montierung fuer die Anzeige aufbereiten.
Bei **Niedrigpraezision** bekommen die Zeilen eine Beschriftung:
RA:18h36.9
DE:+38°47'
Das geht genau auf, weil die Montierung in diesem Modus ohnehin nur
Zehntelminuten (7 Zeichen) und ganze Bogenminuten (7 Zeichen) liefert -- in
zehn Zellen bleiben also drei fuer "RA:"/"DE:" uebrig. Die Zeilen sind
linksbuendig gesetzt (config.CENTER_LINES), damit beide Beschriftungen
buendig untereinander beginnen.
Bei **Hochpraezision** entfaellt die Beschriftung: Dort braucht die
Deklination mit +38°47'01" alle zehn Zeichen.
"""
if config.HIGH_PRECISION:
return coords.format_ra(ra_raw), coords.format_dec(dec_raw)
breite_ra = config.CHARS_PER_LINE - len(config.LABEL_RA)
breite_dec = config.CHARS_PER_LINE - len(config.LABEL_DEC)
return (config.LABEL_RA + coords.format_ra(ra_raw, width=breite_ra),
config.LABEL_DEC + coords.format_dec(dec_raw, width=breite_dec))
def poll_loop(display, client, interval=None, once=False,
read_ldr=None, dimmer=None, ensure_link=None, log=None,
bridge=None, feed=None):
"""Zyklisch die Montierung abfragen und die Anzeige nachfuehren.
display offenes Display-Objekt
client MountClient (wird bei Bedarf selbst verbunden)
interval Sekunden zwischen zwei Abfragen (Vorgabe config.POLL_INTERVAL)
once nur ein Durchlauf, dann zurueck
read_ldr optional: Funktion ohne Argumente, liefert einen LDR-Rohwert
in der Zaehlrichtung "hoch = hell" (siehe brightness.raw_to_ldr)
dimmer optional: BrightnessController; noetig, wenn read_ldr gesetzt ist
ensure_link optional: Funktion, die vor jeder Abfrage die Netzverbindung
sicherstellt (auf dem ESP32 das WLAN)
log optional: Ausgabefunktion, Vorgabe print; None-freies Logging
erlaubt es, auf dem ESP32 still zu laufen (log=lambda *a: None)
bridge optional: mqtt.MqttBridge -- empfaengt Einstellungen und
veroeffentlicht den Zustand. Faellt der Broker aus, laeuft die
Anzeige unveraendert weiter (mqtt.py faengt seine Fehler selbst).
feed optional: Funktion ohne Argumente, die den Watchdog fuettert.
Wird zu Beginn **jedes** Durchlaufs gerufen -- auch nach einem
Fehler, denn eine gemeldete Stoerung ist kein Stillstand: Die
Schleife laeuft ja weiter und zeigt "KEIN LINK". Der Watchdog
soll nur greifen, wenn gar nichts mehr geht.
Laeuft bis KeyboardInterrupt oder -- bei once=True -- genau einmal. Fehler
von Anzeige und Montierung werden abgefangen: die Schleife meldet sie und
versucht es beim naechsten Durchlauf erneut, statt abzustuerzen.
"""
if interval is None:
interval = config.POLL_INTERVAL
if log is None:
log = print
last_shown = None
while True:
# Ganz am Anfang und ausserhalb des try: Der Watchdog misst, ob die
# Schleife noch laeuft -- nicht, ob sie fehlerfrei laeuft.
if feed is not None:
feed()
try:
# Zuerst MQTT abholen: So wirkt eine gerade gesetzte Helligkeit noch
# in diesem Durchlauf, statt eine Runde hinterherzuhinken.
if bridge is not None:
bridge.check()
if ensure_link is not None:
ensure_link()
if not client.connected:
client.connect()
# Helligkeit nachfuehren -- nur senden, wenn sich die Stufe aendert.
if read_ldr is not None and dimmer is not None:
raw = read_ldr()
level, percent, changed = dimmer.update(raw)
if changed:
display.set_brightness(percent)
log("LDR %d -> %s = %d %%"
% (raw, "HELL" if level else "DUNKEL", percent))
if bridge is not None:
bridge.publish("ldr", raw)
bridge.publish("helligkeit", percent)
ra_raw, dec_raw = client.get_coordinates()
ra_line, dec_line = format_coordinates(ra_raw, dec_raw)
if (ra_line, dec_line) != last_shown:
# Bei jeder Aenderung beide Zeilen neu zeichnen. Kein Vollbild-
# Loeschen (kein Flimmern); die umgekehrte Zeilenreihenfolge in
# show_lines schuetzt die Home-Zelle vor dem Gradzeichen-
# Zeichensatzwechsel, auch bei staendiger Aktualisierung.
first = last_shown is None or last_shown in (config.NOLINK_LINES,
config.NOWIFI_LINES)
display.show_lines(ra_line, dec_line, clear=first)
last_shown = (ra_line, dec_line)
log("RA %-11s DEC %-12s -> %s | %s"
% (ra_raw, dec_raw, ra_line, dec_line))
if bridge is not None:
bridge.publish("ra", ra_line)
bridge.publish("dec", dec_line)
if bridge is not None:
bridge.publish("link", 1)
except LinkError as e:
# Kein Netz (WLAN): die Montierung gar nicht erst versuchen.
log("Netz: %s" % e)
client.close()
last_shown = _show_notice(display, config.NOWIFI_LINES, last_shown, log)
except migra.MigraError as e:
# Anzeige-/Antwortfehler (z.B. gestoerte Antwort): nicht abstuerzen,
# nur melden und beim naechsten Durchlauf neu zeichnen.
log("Anzeige: %s" % e)
last_shown = None
except (MountError, ValueError) as e:
log("Montierung: %s" % e)
client.close()
last_shown = _show_notice(display, config.NOLINK_LINES, last_shown, log)
if bridge is not None:
bridge.publish("link", 0)
if once:
return last_shown
time.sleep(interval)
def _show_notice(display, lines, last_shown, log):
"""Hinweiszeilen anzeigen, wenn sie nicht schon stehen."""
if lines == last_shown:
return last_shown
try:
display.show_lines(lines[0], lines[1], clear=True)
except migra.MigraError as e:
log("Anzeige: %s" % e)
return None
return lines
+87
View File
@@ -0,0 +1,87 @@
"""Autostart auf dem ESP32-C3.
MicroPython fuehrt diese Datei nach boot.py bei jedem Reset selbst aus. Damit
laeuft die Anzeige nach Stromausfall oder Watchdog-Neustart von allein wieder an,
ohne dass jemand ein Notebook anschliessen muss.
**Das Abbruchfenster ist kein Komfort, sondern die Notbremse.** Ohne es waere ein
Geraet, dessen Programm beim Start haengt oder abstuerzt, nur noch durch
Neuflashen erreichbar -- die Endlosschleife belegt die REPL sofort nach dem
Reset. Deshalb wartet main.py erst config.AUTOSTART_DELAY Sekunden, in denen
Ctrl-C zurueck an die REPL fuehrt:
mpremote repl dann Ctrl-C druecken, solange "startet in ..." steht
Danach:
- Der **Watchdog** startet das Geraet neu, wenn die Poll-Schleife stehenbleibt
(siehe run_esp32.make_watchdog). Das faengt Haenger, die keine Ausnahme werfen.
- Eine **Ausnahme** faengt der Block unten ab und startet nach
config.RESTART_DELAY neu, statt in die REPL zu fallen und die Anzeige
einzufrieren.
"""
import sys
import time
import config
def _startbar():
"""Wartet das Abbruchfenster ab. False, wenn abgebrochen wurde."""
print("Grossanzeige startet in %.0f s -- Ctrl-C bricht ab."
% config.AUTOSTART_DELAY)
try:
time.sleep(config.AUTOSTART_DELAY)
except KeyboardInterrupt:
print("Autostart abgebrochen, REPL ist frei.")
return False
return True
def _neustart(grund):
"""Nach einem Absturz neu starten -- mit Pause und Abbruchmoeglichkeit."""
print("%s -- Neustart in %.0f s (Ctrl-C bricht ab)"
% (grund, config.RESTART_DELAY))
try:
time.sleep(config.RESTART_DELAY)
except KeyboardInterrupt:
print("Neustart abgebrochen, REPL ist frei.")
return
try:
import machine
machine.reset()
except ImportError:
sys.exit()
def start():
if not _startbar():
return
import run_esp32
# Nach einem Watchdog-Neustart ist das der einzige Hinweis darauf, dass
# ueberhaupt etwas gehangen hat -- ohne ihn sieht ein Neustart aus wie ein
# gewoehnlicher Stromausfall.
try:
import machine
if machine.reset_cause() == machine.WDT_RESET:
print("Vorheriger Lauf wurde vom Watchdog beendet.")
except (ImportError, AttributeError):
pass
# log=print, damit im Startprotokoll steht, dass der Watchdog laeuft --
# sonst bliebe unsichtbar, ob er ueberhaupt angelegt wurde.
feed = run_esp32.make_watchdog(log=print)
try:
run_esp32.main(with_ldr=True, feed=feed)
except KeyboardInterrupt:
# Von Hand beendet: nicht neu starten, sondern die REPL freigeben.
# Der Watchdog laeuft allerdings weiter und startet das Geraet in
# config.WDT_TIMEOUT Sekunden neu -- er laesst sich nicht abschalten.
print("\nbeendet (Watchdog startet in %.0f s neu)" % config.WDT_TIMEOUT)
except Exception as e:
_neustart("Absturz: %s" % e)
start()
+20 -4
View File
@@ -38,6 +38,16 @@ class MigraError(Exception):
"""Fehler im Protokoll oder von der Anzeige gemeldeter Fehlercode."""
def _hex(data):
"""Bytes als Hexstring, ohne sich auf bytes.hex() zu verlassen.
MicroPython 1.28 hat bytes.hex() (am Geraet nachgeprueft), aber nicht jede
Firmware ist damit gebaut. Wird nur in Fehlermeldungen gebraucht -- ein
AttributeError ausgerechnet dort wuerde den eigentlichen Fehler verdecken.
"""
return "".join("%02x" % b for b in data)
def encode_text(s):
"""Text in Anzeige-Bytes wandeln.
@@ -56,10 +66,16 @@ def encode_text(s):
def _digits(value, places):
"""Zahl als ASCII-Ziffern fester Laenge (Handbuch 3.7: alle Werte ASCII-codiert)."""
"""Zahl als ASCII-Ziffern fester Laenge (Handbuch 3.7: alle Werte ASCII-codiert).
Von Hand aufgefuellt statt mit "%0*d". MicroPython 1.28 beherrscht die
dynamische Feldbreite zwar (am Geraet nachgeprueft), sie stand aber lange
als nicht unterstuetzt in den Docs -- so bleibt es unabhaengig davon.
"""
if value < 0 or value >= 10 ** places:
raise MigraError("Wert %d passt nicht in %d Stellen" % (value, places))
return ("%0*d" % (places, value)).encode("ascii")
s = "%d" % value
return ("0" * (places - len(s)) + s).encode("ascii")
class DataUnit:
@@ -175,9 +191,9 @@ def parse_response(frame, src=0):
if not frame:
raise MigraError("keine Antwort von der Anzeige (Timeout)")
if len(frame) < 6 or frame[0] != STX or frame[-1] != ETX:
raise MigraError("unplausibles Antworttelegramm: %s" % frame.hex())
raise MigraError("unplausibles Antworttelegramm: %s" % _hex(frame))
if frame[1] != (0x80 | src):
raise MigraError("Antwort nicht an uns adressiert: %s" % frame.hex())
raise MigraError("Antwort nicht an uns adressiert: %s" % _hex(frame))
code = chr(frame[4])
if code != "0":
+22 -5
View File
@@ -29,10 +29,18 @@ class MountClient:
def connect(self):
self.close()
# Ueber getaddrinfo statt direkt mit (host, port): MicroPython kann ein
# Adresstupel mit Hostnamen nicht selbst aufloesen, mit der aufgeloesten
# Adresse laeuft connect() auf beiden Seiten gleich.
try:
addr = socket.getaddrinfo(self.host, self.port)[0][-1]
except (OSError, IndexError) as e:
raise MountError("Adresse %s:%d nicht aufloesbar: %s"
% (self.host, self.port, e))
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(self.timeout)
try:
sock.connect((self.host, self.port))
sock.connect(addr)
except OSError as e:
sock.close()
raise MountError("Verbindung zu %s:%d fehlgeschlagen: %s"
@@ -60,10 +68,19 @@ class MountClient:
"""Ein gerahmtes Kommando senden und die Antwort ohne '#' zurueckgeben."""
if not self.connected:
raise MountError("nicht verbunden")
try:
self._sock.sendall(framed_cmd.encode("latin-1"))
except OSError as e:
raise MountError("Senden gestoert: %s" % e)
# Von Hand schleifen statt sendall(): MicroPython laesst dessen Verhalten
# auf Sockets mit Timeout ausdruecklich offen. Die Kommandos sind vier
# Bytes lang, die Schleife laeuft also praktisch immer nur einmal.
data = framed_cmd.encode("latin-1")
sent = 0
while sent < len(data):
try:
n = self._sock.send(data[sent:])
except OSError as e:
raise MountError("Senden gestoert: %s" % e)
if not n:
raise MountError("Verbindung von der Montierung geschlossen")
sent += n
raw = self._recv_until_terminator().decode("latin-1")
return lx200.strip_terminator(raw)
+74 -14
View File
@@ -8,8 +8,13 @@ Client nicht haengt.
python3 mount_mock.py # feste Koordinaten, Port 3490
python3 mount_mock.py --ra 18:36:56 --dec +38:47:01
python3 mount_mock.py --move # RA laeuft (Sekunden ticken hoch)
python3 mount_mock.py --lan --move # auch vom ESP32 aus erreichbar
Nur fuer die Entwicklung (CPython). Auf dem ESP32 laeuft spaeter nur der Client.
Vorgabe ist 127.0.0.1, also nur lokal. Damit der ESP32 den Mock uebers WLAN
erreicht, braucht es --lan (bindet auf 0.0.0.0); die Adresse, die dann auf dem
ESP32 einzutragen ist, wird beim Start ausgegeben.
Nur fuer die Entwicklung (CPython). Auf dem ESP32 laeuft nur der Client.
"""
import argparse
@@ -53,31 +58,54 @@ def _numbers(text):
return [int(x) for x in out]
def _fmt_ra(total_s):
def _fmt_ra(total_s, high=True):
"""Rektaszension wie die GM4000.
high=True -> HH:MM:SS (Hochpraezision, nach ":U#")
high=False -> HH:MM.T (Zehntelminuten -- so liefert die Montierung im
Auslieferzustand, verifiziert 2026-07-18)
"""
total_s %= 86400
h, rest = divmod(total_s, 3600)
m, s = divmod(rest, 60)
return "%02d:%02d:%02d" % (h, m, s)
if high:
return "%02d:%02d:%02d" % (h, m, s)
zehntel = (total_s * 10 + 30) // 60 # in Zehntelminuten, gerundet
minuten, t = divmod(zehntel, 10)
h, m = divmod(minuten, 60)
return "%02d:%02d.%d" % (h % 24, m, t)
def _fmt_dec(total_arcsec):
def _fmt_dec(total_arcsec, high=True):
"""Deklination wie die GM4000.
high=True -> sDD*MM:SS (Hochpraezision)
high=False -> sDD*MM (ganze Bogenminuten, Auslieferzustand)
Gradzeichen als '*', Minuten/Sekunden mit ':' -- der coords-Parser kommt mit
beiden Trennzeichen klar. (Die echte Montierung sendet 0xDF als Gradzeichen.)
"""
sign = "-" if total_arcsec < 0 else "+"
total_arcsec = abs(total_arcsec)
d, rest = divmod(total_arcsec, 3600)
m, s = divmod(rest, 60)
# Gradzeichen als '*', Minuten/Sekunden mit ':' -- der coords-Parser
# kommt mit beiden Trennzeichen klar.
return "%s%02d*%02d:%02d" % (sign, d, m, s)
if high:
return "%s%02d*%02d:%02d" % (sign, d, m, s)
return "%s%02d*%02d" % (sign, d, m)
class MountMock:
"""Simuliert die Montierung auf TCP-Ebene."""
def __init__(self, host="127.0.0.1", port=3490,
ra="18:36:56", dec="+38:47:01", move=False):
ra="18:36:56", dec="+38:47:01", move=False, high=True):
self._ra0 = _parse_ra(ra)
self._dec = _parse_dec(dec)
self._move = move
# high=False bildet den Auslieferzustand der GM4000 nach: keine Sekunden,
# RA als Zehntelminuten. Vorgabe bleibt True, damit bestehende Tests mit
# ihren festen Erwartungen unveraendert durchlaufen.
self._high = high
self._tick = 0 # zaehlt bei --move pro RA-Abfrage hoch
self._dectick = 0 # zaehlt bei --move pro DEC-Abfrage hoch
self._lock = threading.Lock()
@@ -111,9 +139,9 @@ class MountMock:
def _reply_for(self, cmd):
"""Antwort (inkl. '#') fuer ein Kommando ohne ':' und ohne '#'."""
if cmd == "GR":
return _fmt_ra(self._current_ra()) + "#"
return _fmt_ra(self._current_ra(), self._high) + "#"
if cmd == "GD":
return _fmt_dec(self._current_dec()) + "#"
return _fmt_dec(self._current_dec(), self._high) + "#"
if cmd == "GVP":
return PRODUCT + "#"
if cmd == "GVN":
@@ -172,20 +200,52 @@ class MountMock:
self.stop()
def lan_address():
"""Die eigene LAN-Adresse ermitteln -- die, die der ESP32 ansprechen muss.
Ueber einen UDP-Socket ans Gateway: der verschickt nichts, verraet aber,
welche lokale Adresse das Betriebssystem fuer den Weg nach draussen nimmt.
"""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("192.168.1.1", 1))
return s.getsockname()[0]
except OSError:
return None
finally:
s.close()
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--lan", action="store_true",
help="auf allen Schnittstellen lauschen (0.0.0.0), damit der "
"ESP32 den Mock uebers WLAN erreicht")
p.add_argument("--port", type=int, default=3490)
p.add_argument("--ra", default="18:36:56")
p.add_argument("--dec", default="+38:47:01")
p.add_argument("--move", action="store_true", help="RA laeuft mit der Zeit hoch")
p.add_argument("--low", action="store_true",
help="Niedrigpraezision wie im Auslieferzustand der GM4000: "
"RA als HH:MM.T, DEC ohne Bogensekunden")
args = p.parse_args(argv)
mock = MountMock(host=args.host, port=args.port, ra=args.ra, dec=args.dec,
move=args.move)
print("Mock-Montierung auf %s:%d (RA=%s DEC=%s%s)"
% (mock.host, mock.port, args.ra, args.dec, ", move" if args.move else ""))
host = "0.0.0.0" if args.lan else args.host
mock = MountMock(host=host, port=args.port, ra=args.ra, dec=args.dec,
move=args.move, high=not args.low)
print("Mock-Montierung auf %s:%d (RA=%s DEC=%s%s, %s)"
% (mock.host, mock.port, args.ra, args.dec,
", move" if args.move else "",
"Niedrigpraezision" if args.low else "Hochpraezision"))
if host == "0.0.0.0":
ip = lan_address()
if ip:
print("Vom ESP32 aus erreichbar unter: %s:%d" % (ip, mock.port))
print(" run_esp32.main(host=\"%s\", fake_display=True)" % ip)
print("Falls keine Verbindung zustande kommt: macOS fragt beim ersten Mal,")
print("ob python3 eingehende Verbindungen annehmen darf -- das muss erlaubt sein.")
print("Beenden mit Ctrl-C.")
mock.serve_forever()
return 0
+251
View File
@@ -0,0 +1,251 @@
"""MQTT-Anbindung: Einstellungen empfangen, Zustand veroeffentlichen.
<PREFIX>/set/hell_prozent 60 eingehend, schreibt settings.json
<PREFIX>/set/dunkel_prozent 5
<PREFIX>/set/schwelle 1800
<PREFIX>/set/hysterese 150
<PREFIX>/status/ra 18h36m56s ausgehend
<PREFIX>/status/dec +38°47'01"
<PREFIX>/status/ldr 2453
<PREFIX>/status/helligkeit 50
<PREFIX>/status/link 1 Montierung erreichbar
<PREFIX>/status/online 1 retained, mit Last Will auf 0
**Grundsatz: MQTT darf die Anzeige nie aufhalten.** Die Anzeige ist der Zweck des
Geraets, MQTT ist Beiwerk. Deshalb faengt jede Methode hier ihre Fehler selbst ab
und meldet sie nur zurueck; ein toter Broker, ein abgezogenes Netzwerkkabel oder
ein unsinniger Wert duerfen die Poll-Schleife nicht unterbrechen. Aus demselben
Grund wird ein Verbindungsverlust nicht sofort und nicht endlos neu versucht,
sondern mit wachsendem Abstand (RETRY_START..RETRY_MAX) -- sonst haengt die
Schleife bei jedem Durchlauf im Verbindungsaufbau.
Der Empfang laeuft ueber check_msg(), das nicht blockiert: Liegt nichts an,
kehrt es sofort zurueck.
Ohne mqtt_config.py (oder ohne umqtt) gibt connect_from_config() None zurueck --
die Anzeige laeuft dann ohne MQTT weiter.
"""
import settings
from ticks import deadline as _deadline, expired as _expired
try: # MicroPython
from umqtt.simple import MQTTClient
except ImportError: # CPython: nur zum Testen, Client wird injiziert
MQTTClient = None
# Wartezeiten fuer den Wiederverbindungsversuch (Sekunden).
RETRY_START = 5.0
RETRY_MAX = 120.0
# Diese Einstellungen lassen sich ueber <PREFIX>/set/<name> aendern. Bewusst
# aus settings.DEFAULTS abgeleitet: Was dort nicht steht, ist auch per MQTT
# nicht setzbar, und settings prueft jeden Wert zusaetzlich auf seinen Bereich.
SETZBAR = tuple(sorted(settings.DEFAULTS))
class MqttBridge:
"""Haelt die Broker-Verbindung und uebersetzt in beide Richtungen.
Wird der Client von aussen gegeben (Tests), unterbleibt der Aufbau eines
echten MQTTClient. So laesst sich die ganze Logik ohne Broker pruefen.
"""
def __init__(self, broker=None, port=1883, user="", password="",
prefix="grossanzeige", client_id="grossanzeige",
client=None, log=None, on_change=None):
self._broker = broker
self._port = port
self._user = user
self._password = password
self._prefix = prefix
self._client_id = client_id
self._client = client
self._log = log or (lambda *a: None)
self._on_change = on_change # wird nach jeder Aenderung gerufen
self.connected = False
self._retry = RETRY_START
self._naechster_versuch = None
self._zuletzt = {} # Topic -> zuletzt gesendeter Wert
# -- Topics ------------------------------------------------------------
def _set_topic(self, name=None):
return "%s/set/%s" % (self._prefix, "+" if name is None else name)
def _status_topic(self, name):
return "%s/status/%s" % (self._prefix, name)
# -- Verbindung --------------------------------------------------------
def _neuer_client(self):
if MQTTClient is None:
raise OSError("umqtt.simple nicht verfuegbar")
return MQTTClient(self._client_id, self._broker, port=self._port,
user=self._user or None,
password=self._password or None,
keepalive=60)
def connect(self):
"""Verbinden, Set-Topics abonnieren, online melden.
Liefert True bei Erfolg. Schlaegt es fehl, wird das nur protokolliert --
der naechste check() versucht es nach der Wartezeit erneut.
"""
try:
if self._client is None:
self._client = self._neuer_client()
self._client.set_callback(self._on_message)
# Last Will: Bricht die Verbindung weg, meldet der Broker selbst
# offline. Ohne das bliebe online=1 stehen, obwohl niemand da ist.
self._client.set_last_will(self._status_topic("online"), b"0",
retain=True)
self._client.connect()
self._client.subscribe(self._set_topic())
self._client.publish(self._status_topic("online"), b"1", retain=True)
self.connected = True
self._retry = RETRY_START
self._zuletzt = {} # nach Reconnect alles neu senden
self._log("MQTT: verbunden mit %s:%d" % (self._broker, self._port))
return True
except Exception as e: # OSError, MQTTException, ...
self._log("MQTT: Verbindung fehlgeschlagen (%s)" % e)
self._abwerfen()
return False
def _abwerfen(self):
"""Verbindung als tot markieren und den naechsten Versuch terminieren."""
self.connected = False
try:
if self._client is not None:
self._client.disconnect()
except Exception:
pass # beim Aufraeumen ist alles egal
self._client = None
self._naechster_versuch = _deadline(self._retry)
# Beim naechsten Mal laenger warten, damit ein dauerhaft toter Broker
# nicht bei jedem Schleifendurchlauf Zeit kostet.
self._retry = min(self._retry * 2, RETRY_MAX)
def ensure(self):
"""Verbindung herstellen, sofern die Wartezeit abgelaufen ist."""
if self.connected:
return True
if self._naechster_versuch is not None and not _expired(self._naechster_versuch):
return False
return self.connect()
# -- Empfang -----------------------------------------------------------
def _on_message(self, topic, payload):
"""Callback von umqtt. Faengt alles ab -- hier darf nichts durchschlagen."""
try:
name = topic.decode().rsplit("/", 1)[-1]
roh = payload.decode().strip()
except Exception:
self._log("MQTT: unlesbare Nachricht verworfen")
return
if name not in SETZBAR:
self._log("MQTT: unbekannte Einstellung %r verworfen" % name)
return
try:
wert = int(roh)
except ValueError:
self._log("MQTT: %s=%r ist keine ganze Zahl" % (name, roh))
return
try:
neu = settings.update({name: wert})
except settings.SettingsError as e:
self._log("MQTT: %s" % e)
return
self._log("MQTT: %s = %d uebernommen" % (name, wert))
if self._on_change:
try:
self._on_change(neu)
except Exception as e:
self._log("MQTT: on_change fehlgeschlagen (%s)" % e)
def check(self):
"""Anstehende Nachrichten verarbeiten. Blockiert nicht."""
if not self.ensure():
return False
try:
self._client.check_msg()
return True
except Exception as e:
self._log("MQTT: Empfang gestoert (%s)" % e)
self._abwerfen()
return False
# -- Senden ------------------------------------------------------------
def publish(self, name, wert, retain=True, nur_bei_aenderung=True):
"""Einen Statuswert senden.
nur_bei_aenderung spart Funkverkehr: Die Koordinaten aendern sich zwar
staendig, LDR und Helligkeit aber kaum. Mit retain=True holt sich ein
neu verbundener Client den letzten Stand von selbst ab.
"""
if not self.ensure():
return False
topic = self._status_topic(name)
text = str(wert)
if nur_bei_aenderung and self._zuletzt.get(topic) == text:
return True
try:
self._client.publish(topic, text.encode(), retain=retain)
self._zuletzt[topic] = text
return True
except Exception as e:
self._log("MQTT: Senden von %s gestoert (%s)" % (name, e))
self._abwerfen()
return False
def publish_many(self, werte, retain=True):
"""Mehrere Statuswerte senden. Liefert True, wenn alle durchgingen."""
ok = True
for name, wert in werte.items():
if not self.publish(name, wert, retain=retain):
ok = False
return ok
def close(self):
"""Sauber abmelden -- online=0 bleibt als retained Nachricht stehen."""
try:
if self.connected and self._client is not None:
self._client.publish(self._status_topic("online"), b"0",
retain=True)
self._client.disconnect()
except Exception:
pass
self.connected = False
self._client = None
def connect_from_config(log=None, on_change=None):
"""Bruecke aus mqtt_config.py bauen und verbinden.
Liefert None, wenn keine Konfiguration da ist -- dann laeuft die Anzeige
ohne MQTT weiter, was ausdruecklich in Ordnung ist.
"""
log = log or (lambda *a: None)
try:
import mqtt_config
except ImportError:
log("MQTT: mqtt_config.py fehlt, laeuft ohne MQTT")
return None
bridge = MqttBridge(
broker=getattr(mqtt_config, "BROKER", None),
port=getattr(mqtt_config, "PORT", 1883),
user=getattr(mqtt_config, "USER", ""),
password=getattr(mqtt_config, "PASSWORD", ""),
prefix=getattr(mqtt_config, "PREFIX", "grossanzeige"),
client_id=getattr(mqtt_config, "CLIENT_ID", "grossanzeige"),
log=log,
on_change=on_change,
)
bridge.connect() # scheitert es, versucht check() es spaeter erneut
return bridge
+25
View File
@@ -0,0 +1,25 @@
"""Vorlage fuer die MQTT-Zugangsdaten.
cp mqtt_config_example.py mqtt_config.py
und dort die eigenen Werte eintragen. mqtt_config.py steht in .gitignore --
Zugangsdaten gehoeren nicht ins Repository.
Fehlt die Datei, laeuft die Anzeige einfach ohne MQTT weiter.
"""
BROKER = "192.168.178.30" # IP oder Hostname des Brokers
PORT = 1883 # 1883 = unverschluesselt, 8883 = TLS (nicht unterstuetzt)
# Leer lassen, wenn der Broker keine Anmeldung verlangt.
USER = ""
PASSWORD = ""
# Wurzel aller Topics. Darunter entstehen:
# <PREFIX>/set/<name> eingehend (hell_prozent, dunkel_prozent, schwelle, hysterese)
# <PREFIX>/status/<name> ausgehend (ra, dec, ldr, helligkeit, link, online)
PREFIX = "grossanzeige"
# Name des Geraets am Broker. Muss eindeutig sein, sonst werfen sich zwei
# Clients gegenseitig aus der Verbindung.
CLIENT_ID = "grossanzeige"
+60 -3
View File
@@ -5,6 +5,8 @@ LINE_Y und CHARS_PER_LINE in config.py sind aus dem alten C-Programm abgeleitet,
nicht aus einem Datenblatt. Dieses Skript hilft, die echten Werte zu finden.
python3 probe.py ruler Ziffernlineal 12345678901234... -> Zeichen zaehlen
python3 probe.py pitch 6 Zellenbreite messen: Auto-Vorschub gegen 6 px
python3 probe.py pitch 7 dasselbe gegen 7 px
python3 probe.py lines Zeile 1 und Zeile 2 an den Positionen aus config.py
python3 probe.py scan-y dieselbe Zeile schrittweise nach unten schieben
python3 probe.py corners Punkte in die vier Ecken -> Pixelaufloesung pruefen
@@ -37,6 +39,51 @@ def ruler(d):
print("Das ist CHARS_PER_LINE (aktuell in config.py: %d)." % config.CHARS_PER_LINE)
def pitch(d, cell=6):
"""Zellenbreite direkt messen, statt Zeichen zu zaehlen.
Der Cursor arbeitet auf *Pixel*positionen -- das laesst sich ausnutzen:
Zeile 1: zehn Ziffern als ein Text ab x=0. Die Anzeige rueckt selbst vor,
mit der echten Zellenbreite.
Zeile 2: dieselben Ziffern einzeln auf x = i*cell gesetzt.
Stehen beide Zeilen exakt untereinander, ist `cell` die Zellenbreite. Ein
Unterschied von nur 1 px summiert sich ueber zehn Zeichen auf 9 px auf und
ist nicht zu uebersehen -- viel eindeutiger als das Abzaehlen am Umbruch.
5x7-Matrix mit 1 px Luecke waere cell=6 (dann passen 10 Zeichen in 64 px),
Sperrschrift mit 2 px Luecke waere cell=7 (dann 9 Zeichen).
"""
ziffern = "0123456789"
du = migra.DataUnit()
du.fill(config.BACKGROUND)
du.attributes(fg=config.COLOR, bg=config.BACKGROUND)
du.charset(config.CHARSET, spaced=config.CHARSET_SPACED)
# Untere Zeile zuerst -- der Zeichensatzwechsel wuerde sonst die Home-Zelle
# der oberen Zeile beschaedigen (siehe display.show_lines).
for i, ch in enumerate(ziffern):
x = i * cell
if x + cell > 64:
break
du.cursor(x, config.LINE_Y[1])
du.text(ch)
du.cursor(0, config.LINE_Y[0])
du.text(ziffern)
d.send(du)
print("Obere Zeile: Anzeige rueckt selbst vor.")
print("Untere Zeile: Ziffern fest auf x = i*%d gesetzt." % cell)
print("")
print("Stehen sie exakt untereinander? -> Zellenbreite ist %d px," % cell)
print("also CHARS_PER_LINE = %d bei 64 px Breite." % (64 // cell))
print("Laeuft die obere Zeile nach rechts weg? -> Zelle ist breiter als %d px." % cell)
print("Dann mit einem groesseren Wert wiederholen: probe.py pitch %d" % (cell + 1))
def lines(d):
d.show_lines("Zeile 1", "Zeile 2")
print("Stehen beide Zeilen sauber untereinander? Sonst LINE_Y anpassen.")
@@ -71,18 +118,28 @@ def corners(d):
print("Punkt %3d/%-3d -> %s" % (x, y, e))
MODES = {"ruler": ruler, "lines": lines, "scan-y": scan_y, "corners": corners}
MODES = {"ruler": ruler, "pitch": pitch, "lines": lines, "scan-y": scan_y,
"corners": corners}
def main():
args = [a for a in sys.argv[1:] if not a.startswith("-")]
if len(args) != 1 or args[0] not in MODES:
if not args or args[0] not in MODES:
print(__doc__)
return 2
extra = []
if args[0] == "pitch" and len(args) > 1:
try:
extra = [int(args[1])]
except ValueError:
print("pitch braucht eine Pixelzahl, z.B.: probe.py pitch 6")
return 2
response = config.WANT_RESPONSE and "--no-response" not in sys.argv
d = open_display(response=response)
try:
MODES[args[0]](d)
MODES[args[0]](d, *extra)
finally:
d.close()
return 0
+13 -54
View File
@@ -13,19 +13,12 @@ Hintergrund erneut verbunden.
import argparse
import sys
import time
import config
import coords
import migra
import displayloop
from brightness import BrightnessController
from display import open_display
from mount_client import MountClient, MountError
def _format(ra_raw, dec_raw):
"""Rohstrings der Montierung fuer die Anzeige aufbereiten."""
return coords.format_ra(ra_raw), coords.format_dec(dec_raw)
from mount_client import MountClient
def main(argv=None):
@@ -56,54 +49,20 @@ def main(argv=None):
d = open_display(fake=args.dry_run)
client = MountClient(host, port, timeout=config.MOUNT_TIMEOUT)
last_shown = None
# Helligkeitsregelung: derzeit ein fester LDR-Wert von der Kommandozeile;
# spaeter (ESP32) wird hier pro Durchlauf der ADC gelesen.
dimmer = BrightnessController() if args.ldr is not None else None
# Helligkeitsregelung: hier ein fester LDR-Wert von der Kommandozeile; auf
# dem ESP32 liest run_esp32.py stattdessen den ADC.
dimmer = read_ldr = None
if args.ldr is not None:
dimmer = BrightnessController()
read_ldr = lambda: args.ldr
try:
while True:
try:
if not client.connected:
client.connect()
# Helligkeit nachfuehren (nur senden, wenn sich die Stufe aendert)
if dimmer is not None:
level, percent, changed = dimmer.update(args.ldr)
if changed:
d.set_brightness(percent)
print("LDR %d -> Helligkeit Stufe %d = %d %%"
% (args.ldr, level, percent))
ra_raw, dec_raw = client.get_coordinates()
ra_line, dec_line = _format(ra_raw, dec_raw)
if (ra_line, dec_line) != last_shown:
# Bei jeder Aenderung beide Zeilen neu zeichnen. Kein Vollbild-
# Loeschen (kein Flimmern); die umgekehrte Zeilenreihenfolge in
# show_lines schuetzt die Home-Zelle vor dem Gradzeichen-
# Zeichensatzwechsel, auch bei staendiger Aktualisierung.
first = last_shown is None or last_shown == config.NOLINK_LINES
d.show_lines(ra_line, dec_line, clear=first)
last_shown = (ra_line, dec_line)
print("RA %-11s DEC %-12s -> %s | %s"
% (ra_raw, dec_raw, ra_line, dec_line))
except migra.MigraError as e:
# Anzeige-/Antwortfehler (z.B. gestoerte Antwort): nicht abstuerzen,
# nur melden und beim naechsten Durchlauf neu zeichnen.
print("Anzeige: %s" % e, file=sys.stderr)
last_shown = None
except (MountError, ValueError) as e:
print("Montierung: %s" % e, file=sys.stderr)
client.close()
if config.NOLINK_LINES != last_shown:
d.show_lines(*config.NOLINK_LINES, clear=True)
last_shown = config.NOLINK_LINES
if args.once:
break
time.sleep(args.interval)
displayloop.poll_loop(d, client,
interval=args.interval,
once=args.once,
read_ldr=read_ldr,
dimmer=dimmer)
except KeyboardInterrupt:
print("\nbeendet")
finally:
+200
View File
@@ -0,0 +1,200 @@
"""Hauptprogramm auf dem XIAO ESP32-C3 (MicroPython).
Das Gegenstueck zu run_display.py: dort die Kommandozeile auf dem Mac, hier das
WLAN-Setup auf dem Geraet. Die Poll-Schleife selbst ist dieselbe (displayloop).
Am REPL starten:
import run_esp32
run_esp32.selftest(host="192.168.178.26") # Bring-up pruefen
run_esp32.main(host="192.168.178.26", fake_display=True) # gegen den Mock
run_esp32.main() # echte Montierung, echte Anzeige
Absichtlich *nicht* main.py genannt: MicroPython startet eine Datei dieses
Namens nach jedem Reset von selbst. Solange wir am Aufbau arbeiten, ist der
manuelle Start angenehmer -- der Autostart ist spaeter eine main.py mit den
zwei Zeilen von oben.
Verdrahtung (aus dem Schaltplan, siehe Hardware/KiCad/Grossanzeige):
XIAO Pad 7 (D6/GPIO21) --> MAX3232 T2IN, T2OUT --> Anzeige RxD (Sub-D 2)
XIAO Pad 8 (D7/GPIO20) <-- MAX3232 R2OUT, R2IN <-- Anzeige TxD (Sub-D 3)
XIAO Pad 1 (D0/GPIO2) <-- LDR-Teiler (3V3 --[2k]-- A0 --[LDR]-- GND)
MAX3232 VCC = 3V3 vom XIAO, GND gemeinsam
"""
import config
import displayloop
import wifi
from brightness import BrightnessController, raw_to_ldr
from display import open_display
from mount_client import MountClient
try: # MicroPython
from machine import ADC, Pin
except ImportError:
ADC = Pin = None
def make_ldr_reader():
"""Funktion liefern, die den LDR als Wert in Zaehlrichtung "hoch = hell" gibt.
Gibt None zurueck, wenn kein ADC verfuegbar ist -- dann laeuft die Anzeige
einfach mit fester Helligkeit weiter.
"""
if ADC is None:
return None
adc = ADC(Pin(config.LDR_PIN))
# Voller Eingangsbereich bis ~3,3 V; ohne Daempfung endet der ADC bei ~1 V.
try:
adc.atten(ADC.ATTN_11DB)
except AttributeError:
pass
def read():
# read_u16() gibt es auf allen Ports; auf 12 bit zurueckskalieren, weil
# die Schwellen in brightness.py aus dem 12-bit-ADC des MSP430 stammen.
raw = adc.read_u16() * config.LDR_MAX // 65535
return raw_to_ldr(raw)
return read
def selftest(host=None, port=None, log=print):
"""Bring-up in einem Rutsch pruefen, ohne die Dauerschleife zu starten.
WLAN aufbauen, IP zeigen, einmal die Montierung (oder den Mock auf dem Mac)
abfragen und ein Anzeige-Telegramm bauen -- letzteres nur als Hex, ohne
serielle Hardware. Damit laesst sich Stufe fuer Stufe eingrenzen, wo es
klemmt, bevor Anzeige und Pegelwandler ueberhaupt dranhaengen.
import run_esp32
run_esp32.selftest(host="192.168.178.26")
"""
log("1) WLAN ...")
cfg = wifi.connect(log=log)
log(" IP %s, Gateway %s" % (cfg[0], cfg[2]))
host = config.MOUNT_HOST if host is None else host
port = config.MOUNT_PORT if port is None else port
log("2) Montierung %s:%d ..." % (host, port))
client = MountClient(host, port, timeout=config.MOUNT_TIMEOUT)
try:
client.connect()
ra_raw, dec_raw = client.get_coordinates()
log(" RA %s DEC %s" % (ra_raw, dec_raw))
finally:
client.close()
log("3) Anzeige-Telegramm (nur Hex, keine Hardware) ...")
d = open_display(fake=True)
try:
d.show_lines(*displayloop.format_coordinates(ra_raw, dec_raw), clear=True)
finally:
d.close()
log("Selbsttest bestanden.")
def make_watchdog(timeout=None, log=None):
"""Watchdog starten und seine feed-Funktion liefern.
Liefert None, wenn es keinen gibt (CPython). **Ein gestarteter Watchdog
laesst sich nicht mehr abschalten** -- deshalb ruft nur main.py das hier
auf, nicht der Handbetrieb ueber die REPL: Sonst startet das Geraet mitten
im Entwickeln neu, sobald man an der REPL nachdenkt.
"""
log = log or (lambda *a: None)
try:
from machine import WDT
except ImportError:
log("Watchdog: machine.WDT nicht verfuegbar")
return None
sekunden = config.WDT_TIMEOUT if timeout is None else timeout
wdt = WDT(timeout=int(sekunden * 1000))
log("Watchdog: aktiv, %d s" % sekunden)
return wdt.feed
def main(host=None, port=None, with_ldr=False, fake_display=False,
once=False, with_mqtt=True, feed=None, log=print):
"""WLAN aufbauen, Anzeige oeffnen und die Poll-Schleife fahren.
host/port Montierung; Vorgabe aus config.py. Zum Testen gegen den Mock
auf dem Mac dessen LAN-Adresse angeben.
with_ldr Helligkeitsregelung ueber den ADC dazuschalten. Die Schwellen
sind am realen LDR gemessen (2026-07-27, siehe brightness.py),
aber am Schreibtisch, nicht in der Sternwarte -- deshalb bleibt
die Vorgabe aus, bis sie vor Ort bestaetigt sind.
fake_display Anzeige nur als Hex in die REPL schreiben, statt ueber den
UART zu senden -- fuer den Test ohne Pegelwandler und Anzeige.
once nur ein Durchlauf, dann zurueck.
with_mqtt MQTT-Anbindung versuchen. Ohne mqtt_config.py passiert nichts,
deshalb ist die Vorgabe an: fehlt die Datei, laeuft alles wie
bisher weiter.
feed Watchdog-Funktion aus make_watchdog(). Vorgabe None: Von Hand
gestartet laeuft die Schleife ohne Watchdog, sonst wuerde das
Geraet beim Nachdenken an der REPL neu starten. main.py legt
ihn an.
"""
d = open_display(fake=fake_display)
client = MountClient(config.MOUNT_HOST if host is None else host,
config.MOUNT_PORT if port is None else port,
timeout=config.MOUNT_TIMEOUT)
dimmer = read_ldr = None
if with_ldr:
read_ldr = make_ldr_reader()
if read_ldr is None:
log("LDR: kein ADC verfuegbar, Helligkeitsregelung bleibt aus")
else:
dimmer = BrightnessController()
bridge = None
try:
# Einmal vorab verbinden, damit ein Fehler gleich sichtbar wird; danach
# haelt ensure_link die Verbindung in der Schleife.
try:
wifi.connect(log=log)
except wifi.WifiError as e:
log("WLAN: %s" % e)
if with_mqtt:
import mqtt
def uebernehmen(werte):
"""Per MQTT geaenderte Einstellungen sofort sichtbar machen.
Der Dimmer muss die neuen Werte nicht nur lesen -- die Helligkeit
muss auch neu an die Anzeige. Sonst bliebe eine gesetzte
Helligkeit unsichtbar, solange die Stufe dieselbe bleibt: ein
Telegramm geht sonst nur beim *Wechsel* raus. (Am Geraet
aufgefallen, 2026-07-27.)
"""
try:
if dimmer is not None:
dimmer.reload()
if dimmer.level is not None:
d.set_brightness(dimmer.percent(dimmer.level))
else:
# Ohne LDR gibt es keine Stufen -- dann gilt "hell".
d.set_brightness(werte["hell_prozent"])
except Exception as e:
log("Anzeige: %s" % e)
bridge = mqtt.connect_from_config(log=log, on_change=uebernehmen)
displayloop.poll_loop(d, client,
once=once,
read_ldr=read_ldr,
dimmer=dimmer,
ensure_link=lambda: wifi.ensure(log=log),
bridge=bridge,
feed=feed,
log=log)
except KeyboardInterrupt:
log("\nbeendet")
finally:
if bridge is not None:
bridge.close()
client.close()
d.close()
+117
View File
@@ -0,0 +1,117 @@
"""Einstellungen, die sich im Betrieb aendern lassen -- als JSON auf dem Geraet.
config.py ist Programmcode: Pinbelegung, Protokoll, Geometrie. Das aendert sich
nur beim Deployen. Werte, die im laufenden Betrieb verstellt werden sollen --
spaeter ueber MQTT -- gehoeren dagegen hierher. Sie liegen als JSON im
Dateisystem (settings.json) und lassen sich lesen und zurueckschreiben, ohne das
Programm neu zu spielen.
import settings
w = settings.load() # mit Vorgaben aufgefuellt
settings.update({"hell_prozent": 60}) # aendern und speichern
Fehlt die Datei oder ist sie beschaedigt, gelten die Vorgaben aus DEFAULTS --
die Anzeige laeuft dann weiter, statt beim Start auszufallen. Das ist Absicht:
ein Geraet in der Sternwarte soll sich nicht an einer kaputten Konfigurationsdatei
aufhaengen.
Alle Werte werden beim Laden UND beim Schreiben geprueft (siehe _pruefe). Das ist
wichtig, sobald MQTT dazukommt: Von dort kommen fremde Eingaben, und ein
Helligkeitswert von 500 % oder eine Schwelle von -1 wuerde die Anzeige sonst in
einen unbrauchbaren Zustand bringen.
"""
import json
PFAD = "settings.json"
# Vorgaben. Die Helligkeiten und die Schwelle stammen aus der LDR-Messung vom
# 2026-07-27 (siehe README, Abschnitt Helligkeitsregelung): abgedeckt ergab 0,
# Raumlicht 2453, angeleuchtet 3552 -- 1800 liegt mit Abstand zwischen dunkel
# und Raumlicht. Die Hysterese deckt das gemessene ADC-Rauschen ab (~32 nach
# der Mittelung, 1 Sigma).
DEFAULTS = {
"dunkel_prozent": 5, # Helligkeit unterhalb der Schwelle
"hell_prozent": 50, # Helligkeit oberhalb der Schwelle
"schwelle": 1800, # LDR-Wert (nach raw_to_ldr) fuer den Umschaltpunkt
"hysterese": 150, # Breite um die Schwelle, gegen Flattern
}
# Erlaubte Bereiche: (minimum, maximum). Die Prozente gehen an ESC H, das nur
# 0..100 kennt; die LDR-Werte kommen aus einem 12-Bit-ADC.
_GRENZEN = {
"dunkel_prozent": (0, 100),
"hell_prozent": (0, 100),
"schwelle": (0, 4095),
"hysterese": (0, 2000),
}
class SettingsError(ValueError):
"""Ein Wert liegt ausserhalb des erlaubten Bereichs oder ist unbekannt."""
def _pruefe(werte):
"""Werte auf Typ und Bereich pruefen. Liefert ein geprueftes neues dict."""
geprueft = {}
for name, wert in werte.items():
if name not in DEFAULTS:
raise SettingsError("unbekannte Einstellung %r" % (name,))
# bool ist in Python ein int -- hier aber sicher ein Irrtum.
if isinstance(wert, bool) or not isinstance(wert, int):
raise SettingsError("%s: %r ist keine ganze Zahl" % (name, wert))
low, high = _GRENZEN[name]
if not low <= wert <= high:
raise SettingsError("%s: %d liegt ausserhalb %d..%d"
% (name, wert, low, high))
geprueft[name] = wert
return geprueft
def load(pfad=None, log=None):
"""Einstellungen lesen, fehlende Werte aus DEFAULTS ergaenzen.
Wirft nicht: Fehlt die Datei, ist sie unlesbar oder steht Unsinn darin, gibt
es die Vorgaben. Ein einzelner unbrauchbarer Wert verwirft nur diesen, nicht
die ganze Datei.
"""
pfad = PFAD if pfad is None else pfad
werte = dict(DEFAULTS)
try:
with open(pfad) as f:
gelesen = json.load(f)
except (OSError, ValueError):
return werte # keine Datei oder kaputtes JSON
if not isinstance(gelesen, dict):
return werte
for name, wert in gelesen.items():
try:
werte.update(_pruefe({name: wert}))
except SettingsError as e:
if log:
log("settings: %s -- Vorgabe %r bleibt"
% (e, DEFAULTS.get(name)))
return werte
def save(werte, pfad=None):
"""Gepruefte Einstellungen schreiben. Liefert die gespeicherten Werte."""
pfad = PFAD if pfad is None else pfad
vollstaendig = dict(DEFAULTS)
vollstaendig.update(_pruefe(werte))
with open(pfad, "w") as f:
json.dump(vollstaendig, f)
return vollstaendig
def update(aenderungen, pfad=None, log=None):
"""Einzelne Werte aendern und speichern -- der Weg fuer MQTT.
Erst pruefen, dann mit dem Bestand mischen, dann schreiben. Ist etwas
ungueltig, bleibt die Datei unveraendert.
"""
geprueft = _pruefe(aenderungen)
werte = load(pfad, log=log)
werte.update(geprueft)
return save(werte, pfad)
+125 -45
View File
@@ -1,79 +1,159 @@
#!/usr/bin/env python3
"""Tests fuer die Helligkeitsregelung (verallgemeinert aus DoDimmen_V).
"""Tests fuer die Helligkeitsumschaltung (zwei Stufen, ein Umschaltpunkt).
python3 -m unittest -v test_brightness
Grenzen: Mitten (300, 2500, 3500) mit Hysterese (30, 150, 150)
-> hoch bei 330 / 2650 / 3650, runter bei 270 / 2350 / 3350.
Die Tests geben ihre Werte selbst vor, statt settings.json zu lesen -- so
haengen sie nicht daran, was gerade auf einem Geraet konfiguriert ist.
Vorgabewerte: Schwelle 1800 +/- 150 -> hoch ab 1950, runter unter 1650.
"""
import unittest
import brightness
from brightness import BrightnessController
from brightness import BrightnessController, DUNKEL, HELL
WERTE = {"dunkel_prozent": 5, "hell_prozent": 50,
"schwelle": 1800, "hysterese": 150}
class TestStufen(unittest.TestCase):
def controller(**abweichend):
werte = dict(WERTE)
werte.update(abweichend)
return BrightnessController(werte=werte)
class TestUmschalten(unittest.TestCase):
def setUp(self):
self.c = BrightnessController()
self.c = controller()
def test_erststart_schaltet_von_0_hoch(self):
self.assertEqual(self.c._next_level(329, None), 0)
self.assertEqual(self.c._next_level(330, None), 1)
self.assertEqual(self.c._next_level(2649, None), 1)
self.assertEqual(self.c._next_level(2650, None), 2)
self.assertEqual(self.c._next_level(3649, None), 2)
self.assertEqual(self.c._next_level(3650, None), 3) # 100 % erreichbar
def test_erststart_entscheidet_an_der_blanken_schwelle(self):
"""Ohne bisherige Stufe zaehlt die Schwelle selbst, nicht ihre Kante.
def test_hysterese_von_stufe_1(self):
self.assertEqual(self.c._next_level(270, 1), 1)
self.assertEqual(self.c._next_level(269, 1), 0)
self.assertEqual(self.c._next_level(2649, 1), 1)
self.assertEqual(self.c._next_level(2650, 1), 2)
Sonst startete die Anzeige im Bereich zwischen Schwelle und Kante
faelschlich dunkel und wachte erst spaeter auf.
"""
self.assertEqual(self.c._next_level(1799, None), DUNKEL)
self.assertEqual(self.c._next_level(1800, None), HELL)
def test_hysterese_von_stufe_2(self):
self.assertEqual(self.c._next_level(2350, 2), 2)
self.assertEqual(self.c._next_level(2349, 2), 1)
self.assertEqual(self.c._next_level(3649, 2), 2)
self.assertEqual(self.c._next_level(3650, 2), 3)
def test_hochschalten_erst_ueber_der_oberen_kante(self):
self.assertEqual(self.c._next_level(1949, DUNKEL), DUNKEL)
self.assertEqual(self.c._next_level(1950, DUNKEL), HELL)
def test_hysterese_von_stufe_3(self):
self.assertEqual(self.c._next_level(3350, 3), 3)
self.assertEqual(self.c._next_level(3349, 3), 2)
def test_herunterschalten_erst_unter_der_unteren_kante(self):
self.assertEqual(self.c._next_level(1650, HELL), HELL)
self.assertEqual(self.c._next_level(1649, HELL), DUNKEL)
def test_prozente(self):
self.assertEqual(brightness.PERCENTS, (5, 15, 50, 100))
def test_im_hysteresefenster_bleibt_die_stufe_stehen(self):
"""Der Kern der Sache: dazwischen aendert sich nichts, egal von wo."""
for wert in (1650, 1800, 1949):
self.assertEqual(self.c._next_level(wert, DUNKEL), DUNKEL)
self.assertEqual(self.c._next_level(wert, HELL), HELL)
def test_prozente_kommen_aus_den_einstellungen(self):
c = controller(dunkel_prozent=7, hell_prozent=80)
self.assertEqual(c.percent(DUNKEL), 7)
self.assertEqual(c.percent(HELL), 80)
def test_gemessene_lichtlagen(self):
"""Die am Geraet gemessenen Lagen (2026-07-27) in Stufen uebersetzt."""
self.assertEqual(self.c._next_level(0, None), DUNKEL) # abgedeckt
self.assertEqual(self.c._next_level(2453, None), HELL) # Raumlicht
self.assertEqual(self.c._next_level(3552, None), HELL) # angeleuchtet
class TestUpdate(unittest.TestCase):
def test_erste_aenderung_meldet_changed(self):
c = BrightnessController()
self.assertEqual(c.update(100), (0, 5, True))
def test_erste_meldung_gilt_als_aenderung(self):
c = controller()
level, prozent, changed = c.update(100)
self.assertEqual((level, prozent), (DUNKEL, 5))
self.assertTrue(changed)
def test_gleicher_wert_keine_aenderung(self):
c = BrightnessController()
def test_gleicher_wert_meldet_keine_aenderung(self):
c = controller()
c.update(100)
level, percent, changed = c.update(100)
_, prozent, changed = c.update(100)
self.assertFalse(changed)
self.assertEqual(percent, 5)
self.assertEqual(prozent, 5)
def test_dunkel_zu_hell_erreicht_100(self):
c = BrightnessController()
c.update(100)
level = percent = None
for _ in range(brightness.AVERAGE_WINDOW + 2):
level, percent, _ = c.update(4000)
self.assertEqual(level, 3)
self.assertEqual(percent, 100)
def test_dunkel_nach_hell_und_zurueck(self):
c = controller()
c.update(0)
self.assertEqual(c.level, DUNKEL)
for _ in range(brightness.AVERAGE_WINDOW):
c.update(3500)
self.assertEqual(c.level, HELL)
self.assertEqual(c.percent(c.level), 50)
for _ in range(brightness.AVERAGE_WINDOW):
c.update(0)
self.assertEqual(c.level, DUNKEL)
def test_gleitender_mittelwert_fenster(self):
c = BrightnessController()
def test_rauschen_um_die_schwelle_schaltet_nicht(self):
"""Gemessen wurden rund +/-32 Streuung nach der Mittelung.
Genau dagegen ist die Hysterese da -- ohne sie wuerde die Anzeige an der
Schwelle zwischen den beiden Helligkeiten springen.
"""
c = controller()
for _ in range(brightness.AVERAGE_WINDOW):
c.update(1800)
stufe = c.level
for delta in (+60, -60, +55, -50, +45, -60, +60, -55):
neu, _, changed = c.update(1800 + delta)
self.assertFalse(changed, "Rauschen hat umgeschaltet")
self.assertEqual(neu, stufe)
def test_gleitender_mittelwert_begrenzt_das_fenster(self):
c = controller()
for _ in range(20):
c.update(1000)
self.assertLessEqual(len(c._buf), brightness.AVERAGE_WINDOW)
def test_mittelwert_daempft_einen_ausreisser(self):
"""Ein einzelner heller Messwert darf die Anzeige nicht aufblitzen lassen."""
c = controller()
for _ in range(brightness.AVERAGE_WINDOW):
c.update(0)
_, _, changed = c.update(4095)
self.assertFalse(changed)
self.assertEqual(c.level, DUNKEL)
class TestReload(unittest.TestCase):
"""Geaenderte Einstellungen uebernehmen -- der Weg, den MQTT spaeter geht."""
def test_reload_liest_die_datei_neu(self):
import os
import settings
pfad = "test_settings_tmp.json"
try:
settings.save({"schwelle": 1000, "hell_prozent": 90}, pfad)
c = BrightnessController(pfad=pfad)
self.assertEqual(c.settings["schwelle"], 1000)
self.assertEqual(c.percent(HELL), 90)
settings.save({"schwelle": 3000, "hell_prozent": 20}, pfad)
c.reload()
self.assertEqual(c.settings["schwelle"], 3000)
self.assertEqual(c.percent(HELL), 20)
finally:
try:
os.remove(pfad)
except OSError:
pass
class TestRawToLdr(unittest.TestCase):
def test_invertiert_spiegelt_den_rohwert(self):
self.assertEqual(brightness.raw_to_ldr(4095, inverted=True, full_scale=4095), 0)
self.assertEqual(brightness.raw_to_ldr(0, inverted=True, full_scale=4095), 4095)
def test_nicht_invertiert_laesst_den_rohwert_stehen(self):
self.assertEqual(brightness.raw_to_ldr(1234, inverted=False, full_scale=4095), 1234)
if __name__ == "__main__":
unittest.main(verbosity=2)
+418
View File
@@ -0,0 +1,418 @@
"""Tests fuer den Anzeigetreiber -- Zeilenaufbau und die festen Hinweistexte.
python3 -m unittest test_display
"""
import unittest
import config
import migra
from display import Display
from transport import FakeTransport
def _lesbar(frame):
"""Telegramm als lesbarer Text; Steuerzeichen werden zu '.'."""
return "".join(chr(b) if 32 <= b < 127 else "." for b in frame)
def zellen(frames):
"""Aus gesendeten Telegrammen zurueckrechnen, was wo auf der Anzeige steht.
Seit jedes Zeichen einzeln positioniert wird (config.CELL_WIDTH), steht der
Text nicht mehr am Stueck im Telegramm -- zwischen je zwei Zeichen liegt eine
Cursor-Sequenz. Ein Test auf "AB " im Rohtelegramm ginge deshalb ins Leere.
Diese Funktion liest die Cursor-Sequenzen mit und liefert {(x, y): zeichen},
also das, was die Anzeige am Ende wirklich zeigt.
Aufbau je Zeichen: ESC 'C' xxx yyy SEP <zeichen>
Beim Gradzeichen schiebt sich ein Zeichensatzwechsel dazwischen
(ESC 'z' nn), das Zeichen ist dann das erste nach dem naechsten SEP.
"""
out = {}
for frame in frames:
i = 0
while i < len(frame):
if frame[i] == migra.ESC and i + 7 < len(frame) and frame[i + 1:i + 2] == b"C":
x = int(frame[i + 2:i + 5])
y = int(frame[i + 5:i + 8])
j = frame.find(bytes([migra.SEP]), i + 8)
if j >= 0 and j + 1 < len(frame):
ch = chr(frame[j + 1])
# Das Gradzeichen entsteht ueber einen Zeichensatzwechsel und
# steht als DEGREE_CHAR ("/") im Telegramm. Hier wieder in das
# zurueckuebersetzen, was auf der Anzeige zu sehen ist.
if (frame[i + 8:j].startswith(b"\x1bz")
and ch == config.DEGREE_CHAR):
ch = config.DEGREE_MARKER
out[(x, y)] = ch
i += 8
else:
i += 1
return out
def zeile(frames, index):
"""Den Text einer Anzeigezeile aus den Telegrammen rekonstruieren."""
y = config.LINE_Y[index]
treffer = [(x, ch) for (x, yy), ch in zellen(frames).items() if yy == y]
return "".join(ch for _, ch in sorted(treffer))
# So viele *volle* Zellen passen in eine Zeile. Das ist weniger als
# config.CHARS_PER_LINE: Dessen 11 Zeichen gehen nur auf, wenn mindestens ein
# schmales dabei ist (config.CELL_NARROW). Fuer Tests mit normalen Zeichen ist
# das hier die Zeilenbreite.
VOLLE_ZELLEN = (config.DISPLAY_WIDTH - config.LINE_X) // config.CELL_WIDTH
def aufbereitet(text):
"""Erwartungswert: Text so, wie er auf der Zeile landet.
Nutzt bewusst dieselbe Funktion wie der Treiber. Wie breit eine Zeile ist,
haengt seit config.CELL_NARROW davon ab, welche Zeichen darin stehen -- eine
zweite Nachbildung hier waere nur eine Fehlerquelle. Was der Wert *bedeutet*,
pruefen die Tests darunter mit eigenen Zusicherungen.
"""
return Display._aufbereiten(text)
class TestFesteTexte(unittest.TestCase):
"""Die Hinweiszeilen muessen in die Anzeige passen.
Am Geraet aufgefallen: "MONTIERUNG" hat 10 Zeichen, die Anzeige fasst 9 --
angezeigt wurde "MONTIERUN". _emit_line schneidet stillschweigend ab, also
faellt so etwas sonst erst auf der Anzeige auf.
"""
def test_hinweiszeilen_passen_in_die_zeilenbreite(self):
for name in ("NOLINK_LINES", "NOWIFI_LINES"):
for zeile in getattr(config, name):
self.assertLessEqual(
len(zeile), config.CHARS_PER_LINE,
"config.%s: %r hat %d Zeichen, die Anzeige fasst %d"
% (name, zeile, len(zeile), config.CHARS_PER_LINE))
def test_es_gibt_nicht_mehr_hinweiszeilen_als_zeilen(self):
for name in ("NOLINK_LINES", "NOWIFI_LINES"):
self.assertLessEqual(len(getattr(config, name)), len(config.LINE_Y))
class TestNurGeaenderteZellen(unittest.TestCase):
"""Der Grund fuer den Vergleich: eine ruhige Anzeige.
Mit Einzelpositionierung kostet eine volle Zeile rund 100 Bytes; bei 19200
Baud dauerte ein kompletter Bildwechsel damit rund 300 ms, und der Aufbau
war als Flackern zu sehen (am Geraet gemessen 2026-07-27). Deshalb wird nur
geschrieben, was sich geaendert hat.
"""
def setUp(self):
self.t = FakeTransport(echo=False)
self.d = Display(self.t, response=True)
def _neu(self):
"""Telegramme seit dem letzten Aufruf."""
frames = self.t.frames[:]
self.t.frames.clear()
return frames
def test_sekundenwechsel_schreibt_nur_die_noetigen_zellen(self):
"""Der Alltagsfall: RA tickt, alles andere steht still.
Geschrieben werden die geaenderte Ziffer *und* ihr rechter Nachbar --
siehe test_rechter_nachbar_wird_mitgeschrieben.
"""
self.d.show_lines("18h36m56s", "+38°47'01\"")
self._neu()
self.d.show_lines("18h36m57s", "+38°47'01\"")
frames = self._neu()
# Die Deklination ist unveraendert -- dafuer darf gar nichts gesendet werden.
self.assertEqual(len(frames), 1)
x = lambda i: config.LINE_X + i * config.CELL_WIDTH
self.assertEqual(zellen(frames), {(x(7), config.LINE_Y[0]): "7",
(x(8), config.LINE_Y[0]): "s"})
def test_rechter_nachbar_wird_mitgeschrieben(self):
"""Am Geraet aufgefallen (2026-07-27): dem "s" fehlten die linken Pixel.
Die Anzeige malt je Zeichen 7 px breit, gesetzt wird auf 6-px-Raster --
ein neu geschriebenes Zeichen loescht die erste Pixelspalte seines
rechten Nachbarn. Der muss deshalb mit, sonst bleibt er beschaedigt.
"""
self.d.show_lines("AAAAAAAAAA")
self._neu()
self.d.show_lines("AAAAXAAAAA") # nur Zelle 4 aendert sich
gesetzt = zellen(self._neu())
x = lambda i: config.LINE_X + i * config.CELL_WIDTH
y = config.LINE_Y[0]
self.assertIn((x(4), y), gesetzt) # die geaenderte Zelle
self.assertIn((x(5), y), gesetzt) # ihr rechter Nachbar
self.assertNotIn((x(3), y), gesetzt) # links davon bleibt unberuehrt
self.assertNotIn((x(6), y), gesetzt) # und uebernaechste auch
def test_letzte_zelle_hat_keinen_nachbarn(self):
"""Aendert sich die aeusserste rechte Zelle, gibt es nichts nachzuziehen."""
self.d.show_lines("AAAAAAAAAA")
self._neu()
self.d.show_lines("AAAAAAAAAX")
gesetzt = zellen(self._neu())
x = lambda i: config.LINE_X + i * config.CELL_WIDTH
self.assertEqual(list(gesetzt), [(x(9), config.LINE_Y[0])])
def test_unveraenderte_zeilen_erzeugen_kein_telegramm(self):
self.d.show_lines("18h36m56s", "+38°47'01\"")
self._neu()
self.d.show_lines("18h36m56s", "+38°47'01\"")
self.assertEqual(self._neu(), [])
def test_telegramm_wird_deutlich_kuerzer(self):
"""Vorher-nachher in Bytes -- das ist die eigentliche Wirkung."""
self.d.show_lines("18h36m56s", "+38°47'01\"")
voll = sum(len(f) for f in self._neu())
self.d.show_lines("18h36m57s", "+38°47'01\"")
diff = sum(len(f) for f in self._neu())
# Gemessen: 236 Bytes gegen 30 (Faktor knapp 8). Die Schranke liegt
# bewusst darunter, damit der Test nicht bei jeder Kleinigkeit kippt --
# er soll die Groessenordnung sichern, nicht eine exakte Zahl.
self.assertLess(diff * 5, voll,
"Der Sekundenwechsel sollte um ein Vielfaches "
"kuerzer sein als der Vollaufbau (%d vs %d Bytes)"
% (diff, voll))
def test_clear_erzwingt_vollstaendiges_neuschreiben(self):
self.d.show_lines("18h36m56s", "+38°47'01\"")
self._neu()
self.d.show_lines("18h36m56s", "+38°47'01\"", clear=True)
frames = self._neu()
# Loeschtelegramm + beide Zeilen vollstaendig
self.assertEqual(len(frames), 3)
self.assertEqual(len(zeile(frames, 0)), VOLLE_ZELLEN)
self.assertEqual(len(zeile(frames, 1)), VOLLE_ZELLEN)
def test_nach_clear_kein_rest_der_alten_anzeige(self):
"""clear muss den gemerkten Inhalt verwerfen.
Sonst haelt der Vergleich Zellen faelschlich fuer schon vorhanden und
die Anzeige bliebe nach dem Loeschen teilweise leer.
"""
self.d.show_lines("ABCDEFGHIJ", "0123456789")
self._neu()
self.d.clear()
self._neu()
self.d.show_lines("ABCDEFGHIJ", "0123456789")
frames = self._neu()
self.assertEqual(zeile(frames, 0), "ABCDEFGHIJ")
self.assertEqual(zeile(frames, 1), "0123456789")
def test_nach_sendefehler_wird_die_zeile_voll_neu_geschrieben(self):
"""Bei einem Fehler ist ungewiss, was ankam -- also nichts annehmen."""
self.d.show_lines("18h36m56s", "+38°47'01\"")
self._neu()
class Kaputt(Exception):
pass
def platzt(_):
raise Kaputt()
echt, self.d._t.write = self.d._t.write, platzt
with self.assertRaises(Kaputt):
self.d.show_lines("18h36m57s", "+38°47'01\"")
self.d._t.write = echt
self._neu()
# Dieselbe Zeile noch einmal: muss vollstaendig geschrieben werden.
self.d.show_lines("18h36m57s", "+38°47'01\"")
self.assertEqual(len(zeile(self._neu(), 0)), VOLLE_ZELLEN)
def test_gradzeichen_wird_nicht_staendig_neu_gesetzt(self):
"""Spart nicht nur Bytes: Jeder Zeichensatzwechsel beschaedigt (0,0)."""
self.d.show_lines("18h36m56s", "+38°47'01\"")
self._neu()
self.d.show_lines("18h36m57s", "+38°47'02\"")
gesendet = b"".join(self._neu())
self.assertNotIn(b"\x1bz01", gesendet)
def test_neues_gradzeichen_zieht_die_oberste_zeile_nach(self):
"""Wird der Zeichensatz doch gewechselt, ist die Home-Zelle hin.
Dann muss die oberste Zeile vollstaendig nachgezogen werden, sonst
bleibt ihr erstes Zeichen zerstoert.
"""
self.d.show_lines("18h36m56s", "kein Grad")
self._neu()
self.d.show_lines("18h36m56s", "+38°47'01\"") # Gradzeichen kommt neu dazu
frames = self._neu()
self.assertIn(b"\x1bz01", b"".join(frames)) # Wechsel fand statt
# Obere Zeile trotz unveraendertem Text komplett neu geschrieben.
self.assertEqual(len(zeile(frames, 0)), VOLLE_ZELLEN)
class TestSchmaleZeichen(unittest.TestCase):
"""Der Dezimalpunkt der Zehntelminuten braucht weniger als eine volle Zelle.
In der Sperrschrift stand "RA 18:37.1" sonst sehr luftig -- der Punkt belegt
6 px, obwohl er nur ein, zwei Pixel breit ist. 3 px am Geraet ausprobiert
(2026-07-27). Moeglich ist das nur, weil jedes Zeichen ohnehin einzeln
positioniert wird.
"""
def setUp(self):
self.t = FakeTransport(echo=False)
self.d = Display(self.t, response=True)
def _neu(self):
frames = self.t.frames[:]
self.t.frames.clear()
return frames
def test_punkt_bekommt_weniger_platz(self):
pos = Display._positionen("1.2")
self.assertEqual(pos[1] - pos[0], config.CELL_WIDTH) # vor dem Punkt
self.assertEqual(pos[2] - pos[1], config.CELL_NARROW["."]) # nach dem Punkt
def test_zeile_mit_punkt_wird_schmaler_und_bleibt_mittig(self):
def breite(positionen):
return positionen[-1] + config.CELL_WIDTH - positionen[0]
ohne = Display._positionen("RA 18:3701") # gleiche Laenge, kein Punkt
mit = Display._positionen("RA 18:37.1")
self.assertEqual(breite(ohne) - breite(mit),
config.CELL_WIDTH - config.CELL_NARROW["."])
# Weiterhin vollstaendig innerhalb der Anzeige.
self.assertGreaterEqual(mit[0], 0)
self.assertLessEqual(mit[-1] + config.CELL_WIDTH, config.DISPLAY_WIDTH)
def test_zehntel_wechsel_schreibt_weiter_nur_wenige_zellen(self):
"""Der Alltagsfall bei Niedrigpraezision -- das Nachfuehren muss greifen."""
self.d.show_lines("RA 18:37.1", "DE +38°47'")
self._neu()
self.d.show_lines("RA 18:37.2", "DE +38°47'")
frames = self._neu()
self.assertEqual(len(frames), 1) # DEC unveraendert
self.assertLessEqual(len(zellen(frames)), 2) # nur die Zehntelziffer
def test_strukturwechsel_schreibt_die_zeile_vollstaendig_neu(self):
"""Kommt ein Punkt dazu, sitzen alle folgenden Zellen woanders.
Ein Vergleich Zeichen fuer Zeichen waere dann sinnlos -- es muss alles
neu geschrieben werden.
"""
self.d.show_lines("RA 18:3701") # ohne Punkt: 10 volle Zellen
self._neu()
self.d.show_lines("RA 18:37.1") # mit Punkt: andere Positionen
gesetzt = zellen(self._neu())
# Alles neu -- und dank des schmalen Punktes passt sogar ein Zeichen mehr.
self.assertEqual(len(gesetzt), len(Display._aufbereiten("RA 18:37.1")))
self.assertGreater(len(gesetzt), VOLLE_ZELLEN)
class TestZeilenaufbau(unittest.TestCase):
def setUp(self):
self.t = FakeTransport(echo=False)
self.d = Display(self.t, response=True)
def _alle(self):
return self.t.frames
def test_zeile_wird_auf_volle_breite_aufgefuellt(self):
"""Ohne Auffuellen blieben Reste der vorigen Anzeige stehen.
Gleichzeitig die Regressionsprobe fuer str.ljust, das MicroPython
nicht kennt (am Geraet aufgefallen).
"""
self.d.show_lines("AB", "CD")
self.assertEqual(zeile(self._alle(), 0), aufbereitet("AB"))
self.assertEqual(zeile(self._alle(), 1), aufbereitet("CD"))
# Es wird immer die volle Breite geschrieben, damit nichts stehen bleibt.
self.assertEqual(len(zeile(self._alle(), 0)), VOLLE_ZELLEN)
def test_kuerzere_zeile_steht_linksbuendig(self):
"""Nutzerwunsch 2026-07-27: beide Zeilen buendig untereinander.
Zentriert standen sie um wenige Pixel gegeneinander versetzt, weil die
RA-Zeile mit ihrem schmalen Dezimalpunkt kuerzer ist als die DEC-Zeile.
"""
self.d.show_lines("+38°47'") # 7 Zeichen in 10 Zellen
text = zeile(self._alle(), 0)
self.assertEqual(text, "+38°47'" + " " * (VOLLE_ZELLEN - 7))
def test_beide_zeilen_beginnen_an_derselben_stelle(self):
"""Der eigentliche Punkt: RA und DE muessen buendig starten."""
self.d.show_lines("RA:18h37.1", "DE:+38°47'")
gesetzt = zellen(self._alle())
erste_oben = min(x for (x, y) in gesetzt if y == config.LINE_Y[0])
erste_unten = min(x for (x, y) in gesetzt if y == config.LINE_Y[1])
self.assertEqual(erste_oben, erste_unten)
def test_jedes_zeichen_sitzt_auf_seiner_zelle(self):
"""Der Auto-Vorschub der Sperrschrift ist 7 px und liesse nur 9 Zeichen
zu; die Matrix ist aber 6 px breit. Deshalb wird jedes Zeichen selbst
gesetzt -- x muss genau LINE_X + i*CELL_WIDTH sein (am Geraet gemessen)."""
self.d.show_lines("0123456789")
gesetzt = zellen(self._alle())
y = config.LINE_Y[0]
for i in range(VOLLE_ZELLEN):
self.assertIn((config.LINE_X + i * config.CELL_WIDTH, y), gesetzt)
def test_zeile_passt_in_die_anzeigenbreite(self):
"""Auch die letzte Zelle darf nicht ueber den rechten Rand hinausragen --
sonst quittiert die Anzeige mit Fehlercode 4 (ausserhalb)."""
rechts = config.LINE_X + VOLLE_ZELLEN * config.CELL_WIDTH
self.assertLessEqual(rechts, config.DISPLAY_WIDTH)
self.assertGreaterEqual(config.LINE_X, 0)
def test_zu_lange_zeile_wird_abgeschnitten(self):
"""Zu breit aufgefuellt bricht das Fuell-Leerzeichen um und
beschaedigt die andere Zeile -- deshalb hart abschneiden."""
lang = "X" * (config.CHARS_PER_LINE + 5)
self.d.show_lines(lang)
self.assertEqual(zeile(self._alle(), 0), "X" * VOLLE_ZELLEN)
def test_oberste_zeile_wird_zuletzt_gezeichnet(self):
"""Schutz der Home-Zelle: ein Zeichensatzwechsel zerstoert (0,0),
die oberste Zeile muss danach kommen und sie ueberschreiben."""
self.d.show_lines("OBEN", "UNTEN")
frames = self._alle()
self.assertEqual(len(frames), 2)
# Erst das Telegramm der unteren Zeile, dann das der oberen.
self.assertEqual(zeile(frames[:1], 1).strip(), "UNTEN")
self.assertEqual(zeile(frames[1:], 0).strip(), "OBEN")
def test_telegramme_bleiben_unter_der_groessengrenze(self):
"""Mit Einzelpositionierung kosten beide Zeilen zusammen 224 der
erlaubten 230 Bytes -- deshalb ein Telegramm je Zeile. Der Test haelt
fest, dass die Reserve nicht wieder verloren geht."""
self.d.show_lines("18h36m56s", "+38" + config.DEGREE_MARKER + "47'01\"",
clear=True)
for frame in self._alle():
self.assertLessEqual(len(frame), migra.MAX_DATA_UNIT)
def test_clear_erzeugt_genau_ein_vollbild_loeschen(self):
"""clear=True darf nur einmal loeschen -- sonst loescht das zweite
Telegramm die gerade geschriebene Zeile wieder weg."""
fuellen = bytes([migra.ESC]) + b"F" + config.BACKGROUND.encode("ascii")
self.d.show_lines("A", "B", clear=True)
mit = self.t.frames[:]
self.assertEqual(sum(f.count(fuellen) for f in mit), 1)
self.t.frames.clear()
self.d.show_lines("A", "B", clear=False)
self.assertEqual(sum(f.count(fuellen) for f in self.t.frames), 0)
def test_mehr_zeilen_als_die_anzeige_hat(self):
with self.assertRaises(ValueError):
self.d.show_lines(*(["X"] * (len(config.LINE_Y) + 1)))
if __name__ == "__main__":
unittest.main()
+734
View File
@@ -0,0 +1,734 @@
"""Tests fuer den MicroPython-Teil -- ohne ESP32, mit Ersatzmodulen.
machine und network gibt es auf dem Mac nicht. Hier werden sie durch Attrappen
ersetzt, damit sich UART-Transport, WLAN-Anbindung, ADC-Auswertung und die
ueberlaufsichere Zeitrechnung auch ohne Geraet pruefen lassen. Getestet wird
damit die Logik, nicht die Hardware -- ob 19200 8E1 auf dem realen UART
ankommen, zeigt erst der Aufbau.
python3 -m unittest test_esp32
"""
import importlib
import sys
import types
import unittest
import migra
import ticks
# -- Ersatzmodule ----------------------------------------------------------
class FakeUART:
"""machine.UART-Attrappe: merkt sich Parameter und Gesendetes."""
last = None
def __init__(self, uart_id, **kwargs):
self.uart_id = uart_id
self.kwargs = kwargs
self.rx = bytearray() # was die Anzeige "zurueckschickt"
self.written = []
self.deinit_called = False
FakeUART.last = self
def any(self):
return len(self.rx)
def read(self, n=None):
if n is None:
n = len(self.rx)
chunk = bytes(self.rx[:n])
del self.rx[:n]
return chunk or None
def write(self, data):
self.written.append(bytes(data))
return len(data)
def deinit(self):
self.deinit_called = True
class FakePin:
def __init__(self, number):
self.number = number
def __eq__(self, other):
return isinstance(other, FakePin) and other.number == self.number
def __repr__(self):
return "FakePin(%d)" % self.number
class FakeADC:
ATTN_11DB = 3
last = None
def __init__(self, pin):
self.pin = pin
self.value = 0
self.attenuation = None
FakeADC.last = self
def atten(self, value):
self.attenuation = value
def read_u16(self):
return self.value
class FakeWDT:
"""machine.WDT-Attrappe: merkt sich Timeout und feed-Aufrufe."""
last = None
def __init__(self, timeout=None):
self.timeout = timeout
self.feeds = 0
FakeWDT.last = self
def feed(self):
self.feeds += 1
class FakeWLAN:
"""network.WLAN-Attrappe. connect() gelingt, wenn succeed gesetzt ist."""
succeed = True
last = None
def __init__(self, mode):
self.mode = mode
self._active = False
self._connected = False
self.credentials = None
self.disconnect_calls = 0
FakeWLAN.last = self
def active(self, value=None):
if value is not None:
self._active = value
return self._active
def connect(self, ssid, password):
self.credentials = (ssid, password)
self._connected = FakeWLAN.succeed
def isconnected(self):
return self._connected
def disconnect(self):
self.disconnect_calls += 1
self._connected = False
def ifconfig(self):
return ("192.168.1.77", "255.255.255.0", "192.168.1.1", "192.168.1.1")
def _install_machine():
"""Ein Ersatz-machine bereitstellen und transport neu laden."""
m = types.ModuleType("machine")
m.UART = FakeUART
m.Pin = FakePin
m.ADC = FakeADC
m.WDT = FakeWDT
m.reset_cause = lambda: 1
m.PWRON_RESET, m.WDT_RESET, m.SOFT_RESET, m.DEEPSLEEP_RESET = 1, 3, 5, 4
sys.modules["machine"] = m
return m
def _install_network():
n = types.ModuleType("network")
n.STA_IF = 0
n.WLAN = FakeWLAN
sys.modules["network"] = n
return n
def _remove(*names):
for name in names:
sys.modules.pop(name, None)
# -- UART-Transport --------------------------------------------------------
class TestUartTransport(unittest.TestCase):
def setUp(self):
_install_machine()
import transport
self.transport = importlib.reload(transport)
import config
self.config = config
def tearDown(self):
_remove("machine")
import transport
importlib.reload(transport) # zurueck auf UART = None
def _open(self, timeout=1.0):
return self.transport.UartTransport(
uart_id=self.config.UART_ID,
baudrate=self.config.BAUDRATE,
parity=self.config.PARITY,
bytesize=self.config.BYTESIZE,
stopbits=self.config.STOPBITS,
timeout=timeout,
tx=self.config.UART_TX,
rx=self.config.UART_RX,
)
def test_uart_parameter_aus_config(self):
"""19200 8E1 auf UART1, Pins wie im Schaltplan (GPIO21/GPIO20)."""
self._open()
u = FakeUART.last
self.assertEqual(u.uart_id, 1)
self.assertEqual(u.kwargs["baudrate"], 19200)
self.assertEqual(u.kwargs["bits"], 8)
self.assertEqual(u.kwargs["parity"], 0) # 0 = even
self.assertEqual(u.kwargs["stop"], 1)
self.assertEqual(u.kwargs["tx"], FakePin(21))
self.assertEqual(u.kwargs["rx"], FakePin(20))
def test_write_leert_erst_den_empfangspuffer(self):
"""Stoerbytes vor dem Senden verwerfen -- wie SerialTransport."""
t = self._open()
FakeUART.last.rx.extend(b"\xff\x00Muell")
t.write(b"\x02\x81\x80\x81\x03")
self.assertEqual(FakeUART.last.any(), 0)
self.assertEqual(FakeUART.last.written, [b"\x02\x81\x80\x81\x03"])
def test_read_response_liest_bis_etx(self):
t = self._open()
FakeUART.last.rx.extend(b"\x02\x80\x81\x800\x03")
frame = t.read_response()
self.assertEqual(frame, b"\x02\x80\x81\x800\x03")
self.assertEqual(migra.parse_response(frame), "0")
def test_read_response_schneidet_nachlauf_ab(self):
"""Nach ETX ist Schluss; Nachlauf wuerde parse_response scheitern lassen."""
t = self._open()
FakeUART.last.rx.extend(b"\x02\x80\x81\x800\x03\xff\xfe")
self.assertEqual(t.read_response(), b"\x02\x80\x81\x800\x03")
def test_read_response_timeout_liefert_leer(self):
t = self._open(timeout=0.05)
self.assertEqual(t.read_response(), b"")
def test_close_gibt_den_uart_frei(self):
t = self._open()
t.close()
self.assertTrue(FakeUART.last.deinit_called)
class TestOpenDisplayAufMicropython(unittest.TestCase):
"""open_display muss auf dem ESP32 den UART nehmen, nicht pyserial."""
def setUp(self):
_install_machine()
import transport
importlib.reload(transport)
import config
self.config = config
self._orig = config.MICROPYTHON
config.MICROPYTHON = True
def tearDown(self):
self.config.MICROPYTHON = self._orig
_remove("machine")
import transport
importlib.reload(transport)
def test_waehlt_uart_transport(self):
import display
d = display.open_display()
self.assertEqual(FakeUART.last.uart_id, self.config.UART_ID)
d.close()
# -- Zeitrechnung ----------------------------------------------------------
class FakeTicks:
"""time.ticks_*-Attrappe von MicroPython, inklusive Ueberlauf bei 2^30."""
MASK = (1 << 30) - 1
def __init__(self, now=0):
self.now = now
def ticks_ms(self):
return self.now
def ticks_add(self, t, delta):
return (t + delta) & self.MASK
def ticks_diff(self, a, b):
d = (a - b) & self.MASK
if d > self.MASK // 2:
d -= self.MASK + 1
return d
class TestTicks(unittest.TestCase):
def setUp(self):
self._orig_time = ticks.time
self.clock = FakeTicks()
ticks.time = self.clock
def tearDown(self):
ticks.time = self._orig_time
def test_frist_laeuft_normal_ab(self):
self.clock.now = 1000
end = ticks.ticks_deadline(1.5)
self.assertFalse(ticks.ticks_expired(end))
self.clock.now = 2499
self.assertFalse(ticks.ticks_expired(end))
self.clock.now = 2500
self.assertTrue(ticks.ticks_expired(end))
def test_frist_ueberlebt_den_ueberlauf(self):
"""Der Kern: 500 ms vor dem Ueberlauf eine 1-s-Frist setzen.
Mit "jetzt + timeout" und einfachem Vergleich haenge die Warteschleife
hier fast 12 Tage, weil der Zaehler zurueckspringt. Mit ticks_diff
laeuft die Frist wie erwartet nach 1000 ms ab.
"""
self.clock.now = FakeTicks.MASK - 500
end = ticks.ticks_deadline(1.0)
self.assertLess(end, self.clock.now) # der Zaehler ist umgelaufen
self.assertFalse(ticks.ticks_expired(end)) # sofort: noch nicht
self.clock.now = 400 # 900 ms spaeter
self.assertFalse(ticks.ticks_expired(end))
self.clock.now = 499 # genau 1000 ms
self.assertTrue(ticks.ticks_expired(end))
self.clock.now = 600
self.assertTrue(ticks.ticks_expired(end))
class TestTicksCPython(unittest.TestCase):
"""Eigene Klasse: hier laeuft die echte Uhr, nicht die Attrappe."""
def test_monotonic_variante(self):
self.assertTrue(ticks.monotonic_expired(ticks.monotonic_deadline(0.0)))
self.assertFalse(ticks.monotonic_expired(ticks.monotonic_deadline(30.0)))
def test_auf_dem_mac_ist_monotonic_ausgewaehlt(self):
self.assertIs(ticks.deadline, ticks.monotonic_deadline)
self.assertIs(ticks.expired, ticks.monotonic_expired)
# -- Helligkeit ------------------------------------------------------------
class TestLdrKennlinie(unittest.TestCase):
"""Der Teiler auf der Platine dreht die Kennlinie um (siehe config)."""
def test_invertiert_spiegelt_den_rohwert(self):
import brightness
self.assertEqual(brightness.raw_to_ldr(0, inverted=True, full_scale=4095), 4095)
self.assertEqual(brightness.raw_to_ldr(4095, inverted=True, full_scale=4095), 0)
self.assertEqual(brightness.raw_to_ldr(1000, inverted=True, full_scale=4095), 3095)
def test_nicht_invertiert_laesst_durch(self):
import brightness
self.assertEqual(brightness.raw_to_ldr(1234, inverted=False), 1234)
def test_vorgabe_kommt_aus_config(self):
import brightness
import config
self.assertEqual(brightness.raw_to_ldr(0),
config.LDR_MAX if config.LDR_INVERTED else 0)
def test_helle_umgebung_ergibt_die_helle_stufe(self):
"""Viel Licht -> niedrige ADC-Spannung -> nach Spiegelung hoher Wert."""
import brightness
# Feste Werte statt settings.json: der Test soll nicht davon abhaengen,
# was auf einem Geraet gerade konfiguriert ist.
werte = {"dunkel_prozent": 5, "hell_prozent": 50,
"schwelle": 1800, "hysterese": 150}
hell = brightness.raw_to_ldr(100, inverted=True, full_scale=4095)
dunkel = brightness.raw_to_ldr(4000, inverted=True, full_scale=4095)
c = brightness.BrightnessController(werte=werte)
self.assertEqual(c.update(hell)[0], brightness.HELL)
c = brightness.BrightnessController(werte=werte)
self.assertEqual(c.update(dunkel)[0], brightness.DUNKEL)
class TestWatchdog(unittest.TestCase):
"""Dauerbetrieb: Der Watchdog holt das Geraet aus einem Stillstand zurueck."""
def setUp(self):
_install_machine()
import transport
importlib.reload(transport)
_remove("run_esp32")
import run_esp32
self.run_esp32 = run_esp32
def tearDown(self):
_remove("machine", "run_esp32")
import transport
importlib.reload(transport)
def test_timeout_kommt_aus_config_und_ist_in_millisekunden(self):
import config
self.run_esp32.make_watchdog()
self.assertEqual(FakeWDT.last.timeout, int(config.WDT_TIMEOUT * 1000))
def test_gelieferte_funktion_fuettert(self):
feed = self.run_esp32.make_watchdog()
feed()
feed()
self.assertEqual(FakeWDT.last.feeds, 2)
def test_timeout_liegt_ueber_den_regulaeren_wartezeiten(self):
"""Sonst startet das Geraet mitten im normalen Betrieb neu.
Der WLAN-Aufbau darf allein WIFI_TIMEOUT Sekunden dauern; kommt die
Montierung dazu, waere ein knapper Watchdog eine Neustart-Schleife.
"""
import config
self.assertGreater(config.WDT_TIMEOUT,
config.WIFI_TIMEOUT + config.MOUNT_TIMEOUT + 10)
def test_ohne_machine_gibt_es_keinen_watchdog(self):
"""Auf dem Mac laeuft dieselbe Schleife -- ohne Watchdog, ohne Fehler."""
_remove("machine", "run_esp32")
import run_esp32
meldungen = []
self.assertIsNone(run_esp32.make_watchdog(log=meldungen.append))
self.assertTrue(any("WDT" in m for m in meldungen))
class TestWatchdogInDerSchleife(unittest.TestCase):
"""poll_loop muss den Watchdog jede Runde fuettern -- auch bei Stoerung."""
def _lauf(self, client, once=True):
import displayloop
from display import Display
from transport import FakeTransport
gefuettert = []
d = Display(FakeTransport(echo=False), response=True)
displayloop.poll_loop(d, client, once=once,
feed=lambda: gefuettert.append(1),
log=lambda *a: None)
return gefuettert
def test_normaler_durchlauf_fuettert(self):
class Client:
connected = True
def connect(self):
pass
def close(self):
pass
def get_coordinates(self):
return "18:36:56", "+38*47:01"
self.assertEqual(len(self._lauf(Client())), 1)
def test_auch_bei_ausgefallener_montierung_wird_gefuettert(self):
"""Der wichtige Fall: "KEIN LINK" ist kein Stillstand.
Die Schleife laeuft weiter und zeigt den Hinweis -- der Watchdog darf
deshalb nicht zuschlagen, sonst startet das Geraet neu, solange die
Montierung aus ist.
"""
from mount_client import MountError
class Client:
connected = False
def connect(self):
raise MountError("keine Verbindung")
def close(self):
pass
def get_coordinates(self):
raise MountError("keine Verbindung")
self.assertEqual(len(self._lauf(Client())), 1)
class TestLdrLeser(unittest.TestCase):
"""run_esp32.make_ldr_reader: ADC lesen, auf 12 bit bringen, spiegeln."""
def setUp(self):
_install_machine()
import transport
importlib.reload(transport)
_remove("run_esp32")
import run_esp32
self.run_esp32 = run_esp32
def tearDown(self):
_remove("machine", "run_esp32")
import transport
importlib.reload(transport)
def test_liest_den_konfigurierten_pin(self):
import config
self.run_esp32.make_ldr_reader()
self.assertEqual(FakeADC.last.pin, FakePin(config.LDR_PIN))
self.assertEqual(FakeADC.last.attenuation, FakeADC.ATTN_11DB)
def test_skaliert_und_spiegelt(self):
import config
read = self.run_esp32.make_ldr_reader()
FakeADC.last.value = 0 # dunkelste ADC-Spannung
self.assertEqual(read(), config.LDR_MAX)
FakeADC.last.value = 65535 # voller Ausschlag
self.assertEqual(read(), 0)
# -- WLAN ------------------------------------------------------------------
class TestWifi(unittest.TestCase):
def setUp(self):
_install_network()
FakeWLAN.succeed = True
_remove("wifi", "wifi_config")
import wifi
self.wifi = wifi
# Zugangsdaten unterschieben, damit der Test ohne wifi_config.py laeuft
cfg = types.ModuleType("wifi_config")
cfg.SSID = "Sternwarte"
cfg.PASSWORD = "geheim"
sys.modules["wifi_config"] = cfg
def tearDown(self):
_remove("network", "wifi", "wifi_config")
def _quiet(self, *args):
pass
def test_connect_meldet_sich_mit_den_zugangsdaten_an(self):
cfg = self.wifi.connect(log=self._quiet)
self.assertEqual(FakeWLAN.last.credentials, ("Sternwarte", "geheim"))
self.assertTrue(FakeWLAN.last.active())
self.assertEqual(cfg[0], "192.168.1.77")
def test_connect_wirft_wifierror_bei_zeitablauf(self):
FakeWLAN.succeed = False
with self.assertRaises(self.wifi.WifiError):
self.wifi.connect(timeout=0.2, log=self._quiet)
# Der haengende Versuch muss abgebrochen werden, sonst kollidiert er
# mit dem naechsten connect().
self.assertEqual(FakeWLAN.last.disconnect_calls, 1)
def test_wifierror_ist_ein_linkerror(self):
"""Damit die Poll-Schleife den Ausfall selbst abfaengt."""
import displayloop
self.assertTrue(issubclass(self.wifi.WifiError, displayloop.LinkError))
def test_ensure_verbindet_nur_wenn_noetig(self):
self.wifi.connect(log=self._quiet)
wlan = FakeWLAN.last
wlan.credentials = None
self.wifi.ensure(log=self._quiet)
self.assertIsNone(wlan.credentials) # kein zweiter connect()
def test_fehlende_zugangsdaten_melden_sich_deutlich(self):
# None in sys.modules laesst "import wifi_config" mit ImportError
# scheitern -- unabhaengig davon, ob auf diesem Rechner schon eine
# echte wifi_config.py liegt. Ein blosses _remove() wuerde die Datei
# einfach neu laden und der Test haenge am Zustand des Entwicklerrechners.
sys.modules["wifi_config"] = None
with self.assertRaises(self.wifi.WifiError) as ctx:
self.wifi.credentials()
self.assertIn("wifi_config", str(ctx.exception))
# -- Vertraeglichkeit mit MicroPython --------------------------------------
# Die Dateien, die auf dem Geraet landen -- muss zu deploy.sh passen.
DEPLOYED = (
"ticks.py", "migra.py", "config.py", "settings.py", "transport.py",
"display.py", "coords.py", "lx200.py", "mount_client.py", "brightness.py",
"displayloop.py", "wifi.py", "mqtt.py", "run_esp32.py", "probe.py",
"main.py",
)
class TestMicropythonVertraeglichkeit(unittest.TestCase):
"""Statische Pruefung dessen, was CPython kann und MicroPython nicht.
Solche Fehler entgehen den ueblichen Tests, weil sie hier auf CPython
laufen: str.ljust gibt es auf dem Mac, auf dem ESP32 nicht -- aufgefallen
ist das erst am Geraet. Diese Liste haelt fest, was dort nachgeprueft
wurde (MicroPython 1.28, ESP32_GENERIC_C3).
"""
# Auf dem Geraet mit hasattr geprueft: fehlen.
FEHLENDE_STRINGMETHODEN = (".ljust(", ".rjust(", ".center(",
".zfill(", ".casefold(", ".expandtabs(")
# Module, die es unter MicroPython nicht gibt. Sie duerfen in den
# deployten Dateien hoechstens innerhalb einer Funktion importiert werden
# (wie in brightness.main), nicht auf Modulebene.
FEHLENDE_MODULE = ("argparse", "threading", "subprocess", "unittest")
def _quelle(self, name):
with open(name, encoding="utf-8") as f:
return f.read()
def test_keine_fehlenden_stringmethoden(self):
for name in DEPLOYED:
src = self._quelle(name)
for methode in self.FEHLENDE_STRINGMETHODEN:
self.assertNotIn(
methode, src,
"%s benutzt %s -- das gibt es unter MicroPython nicht" % (name, methode))
def test_keine_fehlenden_module_auf_modulebene(self):
import ast
for name in DEPLOYED:
baum = ast.parse(self._quelle(name), filename=name)
for knoten in baum.body: # nur die oberste Ebene
if isinstance(knoten, ast.Import):
namen = [a.name.split(".")[0] for a in knoten.names]
elif isinstance(knoten, ast.ImportFrom):
namen = [(knoten.module or "").split(".")[0]]
else:
continue
for modul in namen:
self.assertNotIn(
modul, self.FEHLENDE_MODULE,
"%s importiert %s auf Modulebene -- unter MicroPython "
"scheitert damit schon der Import" % (name, modul))
def test_deploy_sh_kopiert_genau_diese_dateien(self):
"""Sonst laeuft die Liste hier und die im Deploy-Skript auseinander."""
with open("deploy.sh", encoding="utf-8") as f:
skript = f.read()
block = skript.split('FILES="', 1)[1].split('"', 1)[0]
im_skript = tuple(z.strip() for z in block.split() if z.strip())
self.assertEqual(sorted(im_skript), sorted(DEPLOYED))
# -- Gesamtkette -----------------------------------------------------------
class TestEsp32GegenMock(unittest.TestCase):
"""Der ESP32-Pfad gegen einen echten Mock, ueber eine echte TCP-Verbindung.
Alles ausser der Hardware ist hier zusammengeschaltet: WLAN-Attrappe,
mount_client, displayloop, Telegrammbau. Das ist derselbe Ablauf, der auf
dem Geraet gegen den Mock auf dem Mac laufen soll -- nur eben auf einem
Rechner und mit Attrappe statt Funk.
"""
def setUp(self):
_install_machine()
_install_network()
FakeWLAN.succeed = True
_remove("wifi", "wifi_config", "run_esp32")
cfg = types.ModuleType("wifi_config")
cfg.SSID = "Sternwarte"
cfg.PASSWORD = "geheim"
sys.modules["wifi_config"] = cfg
import transport
importlib.reload(transport)
from mount_mock import MountMock
self.mock = MountMock(host="127.0.0.1", port=0, move=True)
self.mock.start()
import run_esp32
self.run_esp32 = run_esp32
self.log = []
def tearDown(self):
self.mock.stop()
_remove("machine", "network", "wifi", "wifi_config", "run_esp32")
import transport
importlib.reload(transport)
def _run(self, fn, **kwargs):
"""Aufrufen und dabei die Hex-Ausgabe des FakeTransport wegfangen."""
import contextlib
import io
with contextlib.redirect_stdout(io.StringIO()) as out:
fn(host=self.mock.host, port=self.mock.port,
log=self.log.append, **kwargs)
return out.getvalue()
def test_selftest_geht_durch_alle_stufen(self):
self._run(self.run_esp32.selftest)
text = "\n".join(self.log)
self.assertIn("WLAN", text)
self.assertIn("192.168.1.77", text) # IP der WLAN-Attrappe
self.assertIn("18:36:56", text) # RA vom Mock
self.assertIn("Selbsttest bestanden", text)
def test_main_once_zeigt_die_koordinaten(self):
"""Der Mock sendet 18:36:56; bei Niedrigpraezision (config-Vorgabe) wird
daraus die Zehntelform mit Beschriftung."""
self._run(self.run_esp32.main, fake_display=True, once=True,
with_mqtt=False)
text = "\n".join(self.log)
self.assertIn("RA 18h36.9m", text)
self.assertIn("DE +38", text)
def test_main_once_baut_ein_gueltiges_telegramm(self):
hexdump = self._run(self.run_esp32.main, fake_display=True, once=True,
with_mqtt=False)
self.assertIn("TX: 02 81 80 81", hexdump) # STX, DA, SA, FC
self.assertTrue(hexdump.strip().endswith("03")) # ETX
def test_ohne_mock_zeigt_die_anzeige_kein_link(self):
"""Faellt die Gegenstelle aus, laeuft die Schleife weiter."""
import config
import contextlib
import io
self.mock.stop()
with contextlib.redirect_stdout(io.StringIO()) as out:
self.run_esp32.main(host="127.0.0.1", port=self.mock.port,
fake_display=True, once=True, with_mqtt=False,
log=self.log.append)
self.assertTrue(any("Montierung" in line for line in self.log))
# Die Hinweiszeilen stehen wirklich im Telegramm -- ohne den Text hier
# zu wiederholen, sonst bricht der Test bei jeder Umformulierung.
# Seit jedes Zeichen einzeln positioniert wird, steht der Text nicht mehr
# am Stueck im Telegramm; deshalb ueber die Cursor-Sequenzen zurueckrechnen.
from test_display import zeile as _zeile
frames = [bytes(int(b, 16) for b in z.split(":", 1)[1].split())
for z in out.getvalue().splitlines() if z.startswith("TX:")]
for i, erwartet in enumerate(config.NOLINK_LINES):
self.assertEqual(_zeile(frames, i).strip(), erwartet)
def test_ohne_wlan_zeigt_die_anzeige_kein_wlan(self):
import config
FakeWLAN.succeed = False
# Den WLAN-Timeout kurz drehen, sonst wartet der Test zweimal die
# vollen config.WIFI_TIMEOUT Sekunden ab (vorab-connect und ensure).
orig = config.WIFI_TIMEOUT
config.WIFI_TIMEOUT = 0.2
try:
import contextlib
import io
with contextlib.redirect_stdout(io.StringIO()):
self.run_esp32.main(host=self.mock.host, port=self.mock.port,
fake_display=True, once=True, with_mqtt=False,
log=self.log.append)
finally:
config.WIFI_TIMEOUT = orig
text = "\n".join(self.log)
self.assertIn("WLAN", text)
self.assertIn("Netz", text) # LinkError wurde in der Schleife gefangen
if __name__ == "__main__":
unittest.main()
+2 -1
View File
@@ -81,7 +81,8 @@ class TestClientGegenMock(unittest.TestCase):
def test_get_coordinates_und_formatierung(self):
ra_raw, dec_raw = self.client.get_coordinates()
self.assertEqual(coords.format_ra(ra_raw, high_precision=True), "18h36m56s")
self.assertEqual(coords.format_dec(dec_raw, high_precision=True), "+38°47'01")
# Seit CHARS_PER_LINE = 10 passt die volle Form mit abschliessendem ".
self.assertEqual(coords.format_dec(dec_raw, high_precision=True), "+38°47'01\"")
def test_produktname(self):
self.assertEqual(self.client.query(lx200.GET_PRODUCT), mount_mock.PRODUCT)
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Tests fuer den Autostart (main.py).
python3 -m unittest -v test_main
main.py startet beim Import die Schleife -- deshalb wird es hier nicht importiert,
sondern die Quelle geladen und in einer eigenen Umgebung ausgefuehrt, in der
time.sleep, run_esp32 und machine durch Attrappen ersetzt sind.
Der wichtigste Test ist das Abbruchfenster: Ohne es waere ein Geraet mit
fehlerhaftem main.py nur noch durch Neuflashen erreichbar.
"""
import unittest
import config
class FakeRunEsp32:
def __init__(self, wirft=None):
self.aufrufe = []
self.watchdogs = 0
self.log_beim_watchdog = None
self._wirft = wirft
def make_watchdog(self, timeout=None, log=None):
self.watchdogs += 1
self.log_beim_watchdog = log
return lambda: None
def main(self, **kwargs):
self.aufrufe.append(kwargs)
if self._wirft is not None:
raise self._wirft
class FakeMachine:
WDT_RESET = 3
PWRON_RESET = 1
def __init__(self, ursache=1):
self.resets = 0
self._ursache = ursache
def reset(self):
self.resets += 1
def reset_cause(self):
return self._ursache
def lade_main(run_esp32, machine=None, sleep_wirft=None):
"""main.py in einer kontrollierten Umgebung ausfuehren.
sleep_wirft: Ausnahme, die time.sleep beim n-ten Aufruf wirft -- damit
laesst sich Ctrl-C im Abbruchfenster nachstellen.
"""
import sys
import types
schlaefe = []
class FakeTime:
@staticmethod
def sleep(s):
schlaefe.append(s)
if sleep_wirft is not None and len(schlaefe) == 1:
raise sleep_wirft
ausgaben = []
umgebung = {
"__name__": "main",
"print": lambda *a: ausgaben.append(" ".join(str(x) for x in a)),
}
gemerkt = {name: sys.modules.get(name)
for name in ("time", "run_esp32", "machine")}
try:
sys.modules["time"] = FakeTime
sys.modules["run_esp32"] = run_esp32
if machine is not None:
sys.modules["machine"] = machine
else:
sys.modules.pop("machine", None)
with open("main.py", encoding="utf-8") as f:
quelle = f.read()
exec(compile(quelle, "main.py", "exec"), umgebung)
finally:
for name, modul in gemerkt.items():
if modul is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = modul
return ausgaben, schlaefe
class TestAbbruchfenster(unittest.TestCase):
"""Die Notbremse: Ctrl-C im Startfenster fuehrt zurueck an die REPL."""
def test_ohne_abbruch_startet_die_schleife(self):
r = FakeRunEsp32()
ausgaben, schlaefe = lade_main(r)
self.assertEqual(len(r.aufrufe), 1)
self.assertEqual(schlaefe[0], config.AUTOSTART_DELAY)
def test_ctrl_c_im_startfenster_bricht_ab(self):
"""Ohne das waere ein Geraet mit kaputtem main.py nur noch flashbar."""
r = FakeRunEsp32()
ausgaben, _ = lade_main(r, sleep_wirft=KeyboardInterrupt())
self.assertEqual(r.aufrufe, [])
self.assertEqual(r.watchdogs, 0, "Watchdog darf dann nicht laufen")
self.assertTrue(any("abgebrochen" in a for a in ausgaben))
def test_wartezeit_ist_lang_genug_zum_eingreifen(self):
self.assertGreaterEqual(config.AUTOSTART_DELAY, 2)
class TestStart(unittest.TestCase):
def test_watchdog_wird_angelegt_und_durchgereicht(self):
r = FakeRunEsp32()
lade_main(r)
self.assertEqual(r.watchdogs, 1)
self.assertIn("feed", r.aufrufe[0])
self.assertIsNotNone(r.aufrufe[0]["feed"])
def test_watchdog_meldet_sich_im_startprotokoll(self):
"""Sonst bliebe unsichtbar, ob er ueberhaupt laeuft (am Geraet aufgefallen)."""
r = FakeRunEsp32()
lade_main(r)
self.assertIsNotNone(r.log_beim_watchdog,
"make_watchdog wurde ohne log aufgerufen")
def test_helligkeitsregelung_ist_an(self):
"""Nutzerentscheidung 2026-07-27: Autostart faehrt mit LDR."""
r = FakeRunEsp32()
lade_main(r)
self.assertTrue(r.aufrufe[0]["with_ldr"])
def test_watchdog_neustart_wird_gemeldet(self):
"""Sonst sieht ein Watchdog-Neustart aus wie ein Stromausfall."""
r = FakeRunEsp32()
m = FakeMachine(ursache=FakeMachine.WDT_RESET)
ausgaben, _ = lade_main(r, machine=m)
self.assertTrue(any("Watchdog" in a for a in ausgaben))
def test_gewoehnlicher_start_meldet_keinen_watchdog(self):
r = FakeRunEsp32()
m = FakeMachine(ursache=FakeMachine.PWRON_RESET)
ausgaben, _ = lade_main(r, machine=m)
self.assertFalse(any("Watchdog beendet" in a for a in ausgaben))
class TestAbsturz(unittest.TestCase):
"""Eine Ausnahme darf nicht in der REPL enden -- die Anzeige stuende still."""
def test_absturz_loest_neustart_aus(self):
r = FakeRunEsp32(wirft=RuntimeError("kaputt"))
m = FakeMachine()
ausgaben, schlaefe = lade_main(r, machine=m)
self.assertEqual(m.resets, 1)
self.assertIn(config.RESTART_DELAY, schlaefe)
self.assertTrue(any("Absturz" in a for a in ausgaben))
def test_neustartpause_verhindert_dauerschleife(self):
"""Ist etwas dauerhaft kaputt, soll nicht im Sekundentakt neu gestartet
werden -- die Pause gibt auch Zeit, mit Ctrl-C einzugreifen."""
self.assertGreaterEqual(config.RESTART_DELAY, 5)
def test_ctrl_c_im_betrieb_startet_nicht_neu(self):
"""Von Hand beendet heisst: die REPL soll frei werden."""
r = FakeRunEsp32(wirft=KeyboardInterrupt())
m = FakeMachine()
ausgaben, _ = lade_main(r, machine=m)
self.assertEqual(m.resets, 0)
self.assertTrue(any("beendet" in a for a in ausgaben))
if __name__ == "__main__":
unittest.main(verbosity=2)
+73 -24
View File
@@ -6,6 +6,7 @@
import unittest
import config
import coords
import migra
from display import Display
@@ -135,41 +136,80 @@ class TestKoordinaten(unittest.TestCase):
self.assertEqual(coords.format_dec("+45d12m30s", width=7, **hp), "+45°12'")
def test_formatierung_niedrigpraezision(self):
"""RA in Zehntelminuten -- so, wie die Montierung sie liefert.
Bis 2026-07-27 wurde hier in Sekunden umgerechnet (11h36m54s). Die
sprangen aber in 6-Sekunden-Schritten, weil die Rohdaten sie nicht
hergeben. Die Zehntelform ist ehrlicher und laesst mit sieben Zeichen
Platz fuer die Beschriftung "RA:".
"""
lp = dict(high_precision=False)
# RA: Zehntel-Minute (0.9 min) kommt als Sekunden (54 s) heraus
self.assertEqual(coords.format_ra("11:36.9", width=9, **lp), "11h36m54s")
# Breiteste passende Form: mit Minutenzeichen am Ende, sonst ohne.
self.assertEqual(coords.format_ra("11:36.9", width=9, **lp), "11h36.9m")
self.assertEqual(coords.format_ra("11:36.9", width=8, **lp), "11h36.9m")
self.assertEqual(coords.format_ra("11:36.9", width=7, **lp), "11h36.9")
self.assertEqual(coords.format_ra("11:36.9", width=6, **lp), "11h36m")
# DEC: ohne Sekunden
self.assertEqual(coords.format_dec("-00°55", width=9, **lp), "-00°55'")
self.assertEqual(coords.format_dec("-00°55", width=6, **lp), "-00:55")
def test_zehntelminuten_mit_uebertrag(self):
"""18:36:57 sind 36,95 Minuten -- das muss auf 18:37.0 aufgehen."""
lp = dict(high_precision=False, width=7)
self.assertEqual(coords.format_ra("18:36:57", **lp), "18h37.0")
self.assertEqual(coords.format_ra("18:36:00", **lp), "18h36.0")
self.assertEqual(coords.format_ra("18:36:54", **lp), "18h36.9")
# Auch ueber die Stunde und ueber Mitternacht hinweg.
self.assertEqual(coords.format_ra("18:59:58", **lp), "19h00.0")
self.assertEqual(coords.format_ra("23:59:58", **lp), "00h00.0")
def test_hochpraezision_behaelt_die_sekundenform(self):
hp = dict(high_precision=True, width=10)
self.assertEqual(coords.format_ra("18:36:56", **hp), "18h36m56s")
self.assertEqual(coords.format_dec("+38*47:01", **hp), "+38°47'01\"")
class TestDisplay(unittest.TestCase):
def test_zeilen_ergeben_ein_telegramm(self):
def test_je_zeile_ein_telegramm(self):
"""Eine Zeile je Telegramm, jeweils sauber gerahmt.
Frueher war beides ein Telegramm. Seit jedes Zeichen einzeln
positioniert wird (10 statt 9 Zeichen), waeren beide Zeilen zusammen zu
dicht an migra.MAX_DATA_UNIT -- siehe display.show_lines.
"""
t = FakeTransport(echo=False)
Display(t, response=True).show_lines("12:34:56", "+45:12:30", clear=True)
self.assertEqual(len(t.frames), 1)
frame = t.frames[0]
self.assertEqual(frame[:4], bytes([0x02, 0x81, 0x80, 0x81]))
self.assertEqual(frame[-1], 0x03)
# clear=True ist ein eigenes, sehr kurzes Telegramm, danach je Zeile eins.
self.assertEqual(len(t.frames), 3)
for frame in t.frames:
self.assertEqual(frame[:4], bytes([0x02, 0x81, 0x80, 0x81]))
self.assertEqual(frame[-1], 0x03)
self.assertLessEqual(len(frame), migra.MAX_DATA_UNIT)
data = frame[4:-1]
self.assertIn(b"\x1bF0", data) # mit clear=True: vorher loeschen
self.assertIn(b"\x1bC000000\x1f12:34:56", data) # Zeile 1 bei y=0
self.assertIn(b"\x1bC000009\x1f+45:12:30", data) # Zeile 2 bei y=9
loeschen, unten, oben = t.frames # untere Zeile zuerst
self.assertIn(b"\x1bF0", loeschen)
self.assertNotIn(b"\x1bF0", unten)
self.assertNotIn(b"\x1bF0", oben)
# Jedes Zeichen sitzt auf seiner eigenen Zelle: x = LINE_X + i*CELL_WIDTH.
def zelle(i, y):
return b"\x1bC%03d%03d" % (config.LINE_X + i * config.CELL_WIDTH, y)
# Linksbuendig (config.CENTER_LINES): beide Zeilen beginnen bei Zelle 0.
self.assertIn(zelle(0, 0) + b"\x1f1", oben)
self.assertIn(zelle(1, 0) + b"\x1f2", oben)
self.assertIn(zelle(0, 9) + b"\x1f+", unten)
def test_ohne_clear_kein_fill_aber_aufgefuellt(self):
"""Standard (clear=False): kein Vollbild-Loeschen, Zeilen auf volle Breite."""
from test_display import zeile, aufbereitet
t = FakeTransport(echo=False)
Display(t, response=True).show_lines("12:34:56", clear=False)
data = t.frames[0][4:-1]
self.assertNotIn(b"\x1bF0", data)
# auf CHARS_PER_LINE mit Leerzeichen aufgefuellt
import config
padded = "12:34:56".ljust(config.CHARS_PER_LINE).encode()
self.assertIn(padded, data)
self.assertNotIn(b"\x1bF0", t.frames[0])
# auf CHARS_PER_LINE mit Leerzeichen aufgefuellt (mittig, siehe CENTER_LINES)
self.assertEqual(zeile(t.frames, 0), aufbereitet("12:34:56"))
def test_gradzeichen_via_zeichensatz(self):
"""Gradzeichen wird durch kurzen Wechsel auf Zeichensatz 1 erzeugt."""
@@ -180,11 +220,20 @@ class TestDisplay(unittest.TestCase):
self.assertIn(b"\x1bz01\x1f/\x1bz00", data)
def test_oberste_zeile_wird_zuletzt_gezeichnet(self):
"""Wegen der Home-Zellen-Stoerung muss y=0 nach y=9 im Telegramm stehen."""
"""Wegen der Home-Zellen-Stoerung muss y=0 nach y=9 gesendet werden.
Seit es ein Telegramm je Zeile gibt, entscheidet die Reihenfolge der
Telegramme statt der Reihenfolge innerhalb eines Telegramms.
"""
t = FakeTransport(echo=False)
Display(t, response=True).show_lines("12h34m56s", "+45°12'30\"")
data = t.frames[0][4:-1]
self.assertLess(data.index(b"C000009"), data.index(b"C000000"))
from test_display import zellen
self.assertEqual(len(t.frames), 2)
# Auf die y-Koordinaten schauen, nicht auf feste Cursor-Bytes.
y_zuerst = set(y for _, y in zellen(t.frames[:1]))
y_zuletzt = set(y for _, y in zellen(t.frames[1:]))
self.assertEqual(y_zuerst, {config.LINE_Y[1]}) # untere Zeile zuerst
self.assertEqual(y_zuletzt, {config.LINE_Y[0]}) # oberste Zeile zuletzt
def test_gradzeichen_fallback_ohne_zeichensatz(self):
"""Ist DEGREE_CHARSET None, wird der Fallbacktext statt der Umschaltung genutzt."""
@@ -208,12 +257,12 @@ class TestDisplay(unittest.TestCase):
def test_show_line_nur_eine_zeile(self):
"""show_line schreibt nur die angegebene Zeile, ohne Vollbild-Loeschen."""
from test_display import zellen
t = FakeTransport(echo=False)
Display(t, response=True).show_line(0, "18h36m56s")
data = t.frames[0][4:-1]
self.assertNotIn(b"\x1bF0", data) # kein Loeschen
self.assertIn(b"\x1bC000000", data) # Cursor auf Zeile 1 (y=0)
self.assertNotIn(b"C000009", data) # Zeile 2 unberuehrt
self.assertNotIn(b"\x1bF0", t.frames[0]) # kein Loeschen
y_werte = set(y for _, y in zellen(t.frames))
self.assertEqual(y_werte, {config.LINE_Y[0]}) # nur Zeile 1, Zeile 2 unberuehrt
def test_show_line_ungueltiger_index(self):
t = FakeTransport(echo=False)
+406
View File
@@ -0,0 +1,406 @@
#!/usr/bin/env python3
"""Tests fuer die MQTT-Anbindung.
python3 -m unittest -v test_mqtt
Statt eines echten Brokers wird ein FakeClient eingehaengt, der dieselbe
Schnittstelle wie umqtt.simple.MQTTClient bietet. So laesst sich alles pruefen,
was im Betrieb schiefgehen kann -- auch die Faelle, die man mit einem echten
Broker nur schwer herbeifuehrt: Verbindungsabbruch beim Senden, Muell im Topic,
ein Broker, der gar nicht antwortet.
Schwerpunkt ist der Grundsatz aus mqtt.py: **MQTT darf die Anzeige nie
aufhalten.** Kein Test hier darf eine Ausnahme nach aussen durchlassen.
"""
import os
import unittest
import mqtt
import settings
class FakeClient:
"""Ersatz fuer umqtt.simple.MQTTClient."""
def __init__(self, **kw):
self.published = [] # (topic, payload, retain)
self.subscribed = []
self.last_will = None
self.callback = None
self.connected = False
self.disconnected = 0
self.eingang = [] # (topic, payload) -- werden bei check_msg zugestellt
self.fail_connect = False
self.fail_publish = False
self.fail_check = False
def set_callback(self, f):
self.callback = f
def set_last_will(self, topic, msg, retain=False):
self.last_will = (topic, msg, retain)
def connect(self):
if self.fail_connect:
raise OSError("Broker nicht erreichbar")
self.connected = True
def subscribe(self, topic):
self.subscribed.append(topic)
def publish(self, topic, msg, retain=False):
if self.fail_publish:
raise OSError("Verbindung weg")
self.published.append((topic, msg, retain))
def check_msg(self):
if self.fail_check:
raise OSError("Verbindung weg")
while self.eingang:
topic, payload = self.eingang.pop(0)
if self.callback:
self.callback(topic, payload)
def disconnect(self):
self.disconnected += 1
self.connected = False
class MqttTest(unittest.TestCase):
"""Basis: eine Bruecke mit FakeClient und eigener settings-Datei."""
def setUp(self):
self.pfad = "test_mqtt_settings_tmp.json"
self._weg()
# settings.PFAD umbiegen, damit die Tests nicht die echte Datei anfassen.
self._alt = settings.PFAD
settings.PFAD = self.pfad
settings.save(settings.DEFAULTS, self.pfad)
self.meldungen = []
self.geaendert = []
self.client = FakeClient()
self.bridge = mqtt.MqttBridge(
broker="test", prefix="ga", client=self.client,
log=self.meldungen.append,
on_change=self.geaendert.append)
def tearDown(self):
settings.PFAD = self._alt
self._weg()
def _weg(self):
try:
os.remove(self.pfad)
except OSError:
pass
def sende(self, topic, payload):
"""Eine Nachricht vom Broker zustellen lassen."""
self.client.eingang.append((topic.encode(), payload.encode()))
self.bridge.check()
def topics(self):
return [t.decode() if isinstance(t, bytes) else t
for t, _, _ in self.client.published]
class TestVerbinden(MqttTest):
def test_connect_abonniert_und_meldet_online(self):
self.assertTrue(self.bridge.connect())
self.assertTrue(self.bridge.connected)
self.assertEqual(self.client.subscribed, ["ga/set/+"])
self.assertIn(("ga/status/online", b"1", True), self.client.published)
def test_last_will_meldet_offline(self):
"""Bricht die Verbindung weg, muss der Broker selbst offline melden."""
self.bridge.connect()
self.assertEqual(self.client.last_will, ("ga/status/online", b"0", True))
def test_fehlgeschlagene_verbindung_wirft_nicht(self):
self.client.fail_connect = True
self.assertFalse(self.bridge.connect())
self.assertFalse(self.bridge.connected)
self.assertTrue(any("fehlgeschlagen" in m for m in self.meldungen))
def test_close_meldet_offline_ab(self):
self.bridge.connect()
self.bridge.close()
self.assertIn(("ga/status/online", b"0", True), self.client.published)
self.assertFalse(self.bridge.connected)
class TestEmpfang(MqttTest):
def setUp(self):
super().setUp()
self.bridge.connect()
def test_gueltiger_wert_wird_uebernommen(self):
self.sende("ga/set/hell_prozent", "60")
self.assertEqual(settings.load(self.pfad)["hell_prozent"], 60)
self.assertEqual(self.geaendert[-1]["hell_prozent"], 60)
def test_alle_setzbaren_werte(self):
for name in mqtt.SETZBAR:
self.assertIn(name, settings.DEFAULTS)
self.sende("ga/set/schwelle", "2000")
self.sende("ga/set/dunkel_prozent", "3")
self.sende("ga/set/hysterese", "100")
w = settings.load(self.pfad)
self.assertEqual((w["schwelle"], w["dunkel_prozent"], w["hysterese"]),
(2000, 3, 100))
def test_wert_ausserhalb_des_bereichs_wird_abgelehnt(self):
"""Der Kern der Pruefung: von aussen darf nichts Unsinniges durch."""
self.sende("ga/set/hell_prozent", "500")
self.assertEqual(settings.load(self.pfad)["hell_prozent"],
settings.DEFAULTS["hell_prozent"])
self.assertEqual(self.geaendert, [])
self.assertTrue(any("500" in m for m in self.meldungen))
def test_keine_zahl_wird_abgelehnt(self):
self.sende("ga/set/hell_prozent", "hell bitte")
self.assertEqual(settings.load(self.pfad)["hell_prozent"],
settings.DEFAULTS["hell_prozent"])
self.assertTrue(any("ganze Zahl" in m for m in self.meldungen))
def test_unbekanntes_topic_wird_verworfen(self):
self.sende("ga/set/gibtsnicht", "1")
self.assertEqual(self.geaendert, [])
self.assertTrue(any("unbekannt" in m for m in self.meldungen))
def test_leere_nachricht_wirft_nicht(self):
self.sende("ga/set/hell_prozent", "")
self.assertEqual(self.geaendert, [])
def test_kaputtes_on_change_bricht_nichts_ab(self):
"""Ein Fehler im Rueckruf darf den Empfang nicht toeten."""
def kaputt(_):
raise RuntimeError("absichtlich")
self.bridge._on_change = kaputt
self.sende("ga/set/hell_prozent", "60") # darf nicht werfen
self.assertEqual(settings.load(self.pfad)["hell_prozent"], 60)
self.assertTrue(any("on_change" in m for m in self.meldungen))
class TestSenden(MqttTest):
def setUp(self):
super().setUp()
self.bridge.connect()
self.client.published.clear()
def test_status_wird_veroeffentlicht(self):
self.bridge.publish("ra", "18h36m56s")
self.assertIn(("ga/status/ra", b"18h36m56s", True), self.client.published)
def test_gleicher_wert_wird_nicht_erneut_gesendet(self):
"""Spart Funkverkehr: LDR und Helligkeit aendern sich kaum."""
self.bridge.publish("ldr", 2453)
self.bridge.publish("ldr", 2453)
self.assertEqual(self.topics().count("ga/status/ldr"), 1)
def test_geaenderter_wert_wird_gesendet(self):
self.bridge.publish("ldr", 2453)
self.bridge.publish("ldr", 2600)
self.assertEqual(self.topics().count("ga/status/ldr"), 2)
def test_publish_many(self):
self.bridge.publish_many({"ra": "1", "dec": "2"})
self.assertIn("ga/status/ra", self.topics())
self.assertIn("ga/status/dec", self.topics())
def test_nach_reconnect_wird_alles_neu_gesendet(self):
"""Sonst fehlte dem Broker nach einem Abriss der aktuelle Stand."""
self.bridge.publish("ldr", 2453)
self.bridge._abwerfen()
self.bridge._client = self.client
self.bridge._naechster_versuch = None
self.bridge.connect()
self.client.published.clear()
self.bridge.publish("ldr", 2453)
self.assertIn("ga/status/ldr", self.topics())
class TestStoerungen(MqttTest):
"""Der eigentliche Zweck: die Anzeige darf nie stehenbleiben."""
def test_senden_bei_toter_verbindung_wirft_nicht(self):
self.bridge.connect()
self.client.fail_publish = True
self.assertFalse(self.bridge.publish("ra", "x"))
self.assertFalse(self.bridge.connected)
def test_empfang_bei_toter_verbindung_wirft_nicht(self):
self.bridge.connect()
self.client.fail_check = True
self.assertFalse(self.bridge.check())
self.assertFalse(self.bridge.connected)
def test_ohne_verbindung_wird_nicht_bei_jedem_aufruf_versucht(self):
"""Sonst kostet ein toter Broker in jedem Schleifendurchlauf Zeit."""
b = mqtt.MqttBridge(broker="test", prefix="ga", client=FakeClient(),
log=self.meldungen.append)
b._client.fail_connect = True
self.assertFalse(b.connect())
vorher = len(self.meldungen)
for _ in range(5):
self.assertFalse(b.check())
# Kein einziger neuer Versuch, solange die Wartezeit laeuft.
self.assertEqual(len(self.meldungen), vorher)
def test_wartezeit_waechst_und_ist_begrenzt(self):
b = mqtt.MqttBridge(broker="test", prefix="ga", client=FakeClient())
b._client.fail_connect = True
werte = []
for _ in range(12):
b._client = FakeClient()
b._client.fail_connect = True
b._naechster_versuch = None
b.connect()
werte.append(b._retry)
self.assertGreater(werte[1], werte[0]) # waechst
self.assertLessEqual(max(werte), mqtt.RETRY_MAX) # aber begrenzt
def test_publish_ohne_verbindung_meldet_nur_false(self):
b = mqtt.MqttBridge(broker="test", prefix="ga", client=FakeClient())
b._client.fail_connect = True
b.connect()
self.assertFalse(b.publish("ra", "x")) # wirft nicht
class TestHelligkeitWirdSofortSichtbar(MqttTest):
"""Am Geraet aufgefallen (2026-07-27): Einstellung uebernommen, Anzeige blieb.
Ein Helligkeitstelegramm geht nur beim *Wechsel* der Stufe raus. Wird per
MQTT hell_prozent geaendert, die Stufe bleibt aber dieselbe, muss die
Helligkeit trotzdem neu gesetzt werden -- sonst wirkt die Aenderung erst
beim naechsten zufaelligen Stufenwechsel.
"""
def _dimmer_und_display(self):
import brightness
from display import Display
from transport import FakeTransport
d = Display(FakeTransport(echo=False), response=True)
dimmer = brightness.BrightnessController(pfad=self.pfad)
dimmer.update(3000) # einschwingen auf HELL
return d, dimmer
def _uebernehmen_bauen(self, d, dimmer, log):
"""Dieselbe Logik wie in run_esp32.main."""
def uebernehmen(werte):
try:
if dimmer is not None:
dimmer.reload()
if dimmer.level is not None:
d.set_brightness(dimmer.percent(dimmer.level))
else:
d.set_brightness(werte["hell_prozent"])
except Exception as e:
log("Anzeige: %s" % e)
return uebernehmen
def _helligkeiten(self, transport):
"""Aus den Telegrammen die gesendeten Helligkeitswerte herausziehen."""
out = []
for frame in transport.frames:
i = frame.find(b"\x1bH")
if i >= 0:
out.append(int(frame[i + 3:i + 6]))
return out
def test_geaenderte_helligkeit_geht_an_die_anzeige(self):
d, dimmer = self._dimmer_und_display()
self.bridge._on_change = self._uebernehmen_bauen(d, dimmer,
self.meldungen.append)
self.bridge.connect()
d._t.frames.clear()
self.sende("ga/set/hell_prozent", "100")
self.assertEqual(self._helligkeiten(d._t), [100],
"Helligkeit wurde nicht an die Anzeige gesendet")
def test_ohne_dimmer_gilt_die_helle_stufe(self):
from display import Display
from transport import FakeTransport
d = Display(FakeTransport(echo=False), response=True)
self.bridge._on_change = self._uebernehmen_bauen(d, None,
self.meldungen.append)
self.bridge.connect()
d._t.frames.clear()
self.sende("ga/set/hell_prozent", "80")
self.assertEqual(self._helligkeiten(d._t), [80])
class TestOhneKonfiguration(unittest.TestCase):
def test_fehlende_mqtt_config_liefert_none(self):
"""Ohne mqtt_config.py laeuft die Anzeige ohne MQTT weiter."""
import sys
gemerkt = sys.modules.pop("mqtt_config", None)
pfad = sys.path[:]
try:
# Sicherstellen, dass mqtt_config wirklich nicht importierbar ist.
sys.path = [p for p in sys.path if p not in ("", ".", os.getcwd())]
meldungen = []
self.assertIsNone(mqtt.connect_from_config(log=meldungen.append))
self.assertTrue(any("mqtt_config" in m for m in meldungen))
finally:
sys.path = pfad
if gemerkt is not None:
sys.modules["mqtt_config"] = gemerkt
class TestSchleifenanbindung(MqttTest):
"""poll_loop mit Bruecke -- die Anzeige laeuft, auch wenn MQTT streikt."""
def _loop_einmal(self, bridge):
import displayloop
from display import Display
from transport import FakeTransport
class Client:
connected = True
def connect(self):
pass
def close(self):
pass
def get_coordinates(self):
return "18:36:56", "+38*47:01"
d = Display(FakeTransport(echo=False), response=True)
return displayloop.poll_loop(d, Client(), once=True, bridge=bridge,
log=lambda *a: None)
def test_koordinaten_gehen_raus(self):
self.bridge.connect()
self.client.published.clear()
self._loop_einmal(self.bridge)
self.assertIn("ga/status/ra", self.topics())
self.assertIn("ga/status/dec", self.topics())
self.assertIn("ga/status/link", self.topics())
def test_toter_broker_haelt_die_anzeige_nicht_auf(self):
"""Der wichtigste Test des Moduls."""
self.bridge.connect()
self.client.fail_publish = True
self.client.fail_check = True
gezeigt = self._loop_einmal(self.bridge)
self.assertIsNotNone(gezeigt) # Anzeige wurde trotzdem bedient
# Bei Niedrigpraezision (config-Vorgabe) die beschriftete Zehntelform.
self.assertEqual(gezeigt[0], "RA 18h36.9m")
if __name__ == "__main__":
unittest.main(verbosity=2)
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Tests fuer die zur Laufzeit aenderbaren Einstellungen (settings.json).
python3 -m unittest -v test_settings
Schwerpunkt ist die Pruefung der Werte: Sobald MQTT dazukommt, kommen sie von
aussen. Ein Helligkeitswert von 500 % oder eine kaputte Datei darf die Anzeige
nicht lahmlegen.
"""
import json
import os
import unittest
import settings
class Tempdatei(unittest.TestCase):
def setUp(self):
self.pfad = "test_settings_tmp.json"
self._weg()
def tearDown(self):
self._weg()
def _weg(self):
try:
os.remove(self.pfad)
except OSError:
pass
def _schreibe_roh(self, inhalt):
with open(self.pfad, "w") as f:
f.write(inhalt)
class TestLaden(Tempdatei):
def test_ohne_datei_gelten_die_vorgaben(self):
self.assertEqual(settings.load(self.pfad), settings.DEFAULTS)
def test_fehlende_werte_werden_ergaenzt(self):
self._schreibe_roh(json.dumps({"schwelle": 1234}))
w = settings.load(self.pfad)
self.assertEqual(w["schwelle"], 1234)
self.assertEqual(w["hell_prozent"], settings.DEFAULTS["hell_prozent"])
def test_kaputtes_json_faellt_auf_die_vorgaben_zurueck(self):
"""Ein Geraet in der Sternwarte darf daran nicht haengenbleiben."""
self._schreibe_roh("{das ist kein json")
self.assertEqual(settings.load(self.pfad), settings.DEFAULTS)
def test_json_das_kein_objekt_ist(self):
self._schreibe_roh("[1, 2, 3]")
self.assertEqual(settings.load(self.pfad), settings.DEFAULTS)
def test_einzelner_unbrauchbarer_wert_verwirft_nur_diesen(self):
self._schreibe_roh(json.dumps({"schwelle": 99999, "hell_prozent": 42}))
meldungen = []
w = settings.load(self.pfad, log=meldungen.append)
self.assertEqual(w["schwelle"], settings.DEFAULTS["schwelle"]) # verworfen
self.assertEqual(w["hell_prozent"], 42) # uebernommen
self.assertTrue(any("schwelle" in m for m in meldungen))
def test_unbekannter_schluessel_wird_ignoriert(self):
self._schreibe_roh(json.dumps({"voellig_unbekannt": 1}))
w = settings.load(self.pfad)
self.assertEqual(w, settings.DEFAULTS)
self.assertNotIn("voellig_unbekannt", w)
class TestSpeichern(Tempdatei):
def test_speichern_und_wieder_lesen(self):
settings.save({"schwelle": 2000, "dunkel_prozent": 3}, self.pfad)
w = settings.load(self.pfad)
self.assertEqual(w["schwelle"], 2000)
self.assertEqual(w["dunkel_prozent"], 3)
def test_gespeichert_wird_immer_vollstaendig(self):
settings.save({"schwelle": 2000}, self.pfad)
with open(self.pfad) as f:
roh = json.load(f)
self.assertEqual(set(roh), set(settings.DEFAULTS))
def test_update_aendert_nur_das_angegebene(self):
settings.save({"schwelle": 1000, "hell_prozent": 30}, self.pfad)
settings.update({"hell_prozent": 70}, self.pfad)
w = settings.load(self.pfad)
self.assertEqual(w["hell_prozent"], 70)
self.assertEqual(w["schwelle"], 1000) # unveraendert
def test_ungueltiges_update_laesst_die_datei_unberuehrt(self):
settings.save({"schwelle": 1000}, self.pfad)
with self.assertRaises(settings.SettingsError):
settings.update({"hell_prozent": 500}, self.pfad)
self.assertEqual(settings.load(self.pfad)["schwelle"], 1000)
class TestPruefung(unittest.TestCase):
"""Die Grenzen, an denen spaeter MQTT-Eingaben haengenbleiben sollen."""
def test_prozent_ueber_100_wird_abgelehnt(self):
with self.assertRaises(settings.SettingsError):
settings.save({"hell_prozent": 101}, "/dev/null")
def test_negative_werte_werden_abgelehnt(self):
with self.assertRaises(settings.SettingsError):
settings.save({"schwelle": -1}, "/dev/null")
def test_schwelle_ueber_adc_bereich_wird_abgelehnt(self):
with self.assertRaises(settings.SettingsError):
settings.save({"schwelle": 4096}, "/dev/null")
def test_unbekannter_name_wird_abgelehnt(self):
with self.assertRaises(settings.SettingsError):
settings.save({"gibtsnicht": 1}, "/dev/null")
def test_nichtganzzahlige_werte_werden_abgelehnt(self):
for wert in (1.5, "50", None, True):
with self.assertRaises(settings.SettingsError):
settings.save({"hell_prozent": wert}, "/dev/null")
def test_die_raender_sind_erlaubt(self):
geprueft = settings._pruefe({"hell_prozent": 100, "dunkel_prozent": 0,
"schwelle": 4095, "hysterese": 0})
self.assertEqual(geprueft["hell_prozent"], 100)
self.assertEqual(geprueft["schwelle"], 4095)
if __name__ == "__main__":
unittest.main(verbosity=2)
+45
View File
@@ -0,0 +1,45 @@
"""Timeouts, die unter CPython und MicroPython gleich funktionieren.
CPython rechnet mit time.monotonic() in Sekunden. MicroPython hat das nicht,
sondern time.ticks_ms(); dessen Zaehler laeuft nach etwa 12 Tagen ueber. Ein
blosses "jetzt + timeout" laege danach fuer Tage in der Zukunft und wuerde eine
Warteschleife entsprechend lange haengen lassen -- fuer ein Geraet, das in der
Sternwarte monatelang durchlaeuft, ein echter Stolperstein. Deshalb dort die
Differenz immer ueber ticks_diff bilden, das den Ueberlauf richtig behandelt.
end = deadline(1.5)
while not expired(end):
...
Beide Varianten sind immer definiert, ausgewaehlt wird beim Import. So laesst
sich die MicroPython-Variante auch auf dem Mac testen (test_esp32.py haengt
dafuer ein Ersatz-time an dieses Modul).
"""
import time
# -- CPython ---------------------------------------------------------------
def monotonic_deadline(seconds):
return time.monotonic() + seconds
def monotonic_expired(end):
return time.monotonic() >= end
# -- MicroPython -----------------------------------------------------------
def ticks_deadline(seconds):
return time.ticks_add(time.ticks_ms(), int(seconds * 1000))
def ticks_expired(end):
return time.ticks_diff(end, time.ticks_ms()) <= 0
if hasattr(time, "monotonic"):
deadline = monotonic_deadline
expired = monotonic_expired
else:
deadline = ticks_deadline
expired = ticks_expired
+43 -16
View File
@@ -12,15 +12,22 @@ except ImportError:
serial = None
try: # MicroPython
from machine import UART
from machine import UART, Pin
except ImportError:
UART = None
Pin = None
try:
import time
_monotonic = time.monotonic
except AttributeError: # MicroPython kennt kein time.monotonic
_monotonic = lambda: time.ticks_ms() / 1000.0
from ticks import deadline as _deadline, expired as _expired
def _truncate_at_etx(buf):
"""Alles ab dem ersten ETX abschneiden (einschliesslich).
Nach ETX ist das Antworttelegramm zu Ende; alles danach waere Stoerung und
wuerde die Plausibilitaetspruefung in migra.parse_response scheitern lassen.
"""
idx = buf.find(b"\x03")
return bytes(buf if idx < 0 else buf[:idx + 1])
class SerialTransport:
@@ -49,9 +56,9 @@ class SerialTransport:
def read_response(self):
"""Bytes bis einschliesslich ETX lesen, oder bis der Timeout greift."""
deadline = _monotonic() + self._timeout
deadline = _deadline(self._timeout)
buf = bytearray()
while _monotonic() < deadline:
while not _expired(deadline):
chunk = self._port.read(1)
if not chunk:
continue
@@ -65,7 +72,12 @@ class SerialTransport:
class UartTransport:
"""Serieller Port ueber machine.UART (MicroPython / ESP32-C3)."""
"""Serieller Port ueber machine.UART (MicroPython / ESP32-C3).
Auf dem XIAO ESP32-C3 haengt die Anzeige ueber einen MAX3232 an UART1:
TX = GPIO21 (Pad 7 / D6) -> T2IN, RX = GPIO20 (Pad 8 / D7) <- R2OUT.
Die Pins stehen in config.py, weil UART1 auf dem C3 frei zuordenbar ist.
"""
def __init__(self, uart_id, baudrate, parity, bytesize, stopbits, timeout,
tx=None, rx=None):
@@ -80,27 +92,42 @@ class UartTransport:
stop=stopbits,
timeout=int(timeout * 1000),
)
# Die Pins als Pin-Objekt uebergeben -- die esp32-Portierung akzeptiert
# zwar meist auch die nackte GPIO-Nummer, Pin() ist aber ueberall gueltig.
if tx is not None:
kwargs["tx"] = tx
kwargs["tx"] = Pin(tx) if Pin is not None else tx
if rx is not None:
kwargs["rx"] = rx
kwargs["rx"] = Pin(rx) if Pin is not None else rx
self._uart = UART(uart_id, **kwargs)
self._timeout = timeout
def _drain(self):
"""Empfangspuffer leeren (wie SerialTransport.reset_input_buffer)."""
n = self._uart.any()
while n:
self._uart.read(n)
n = self._uart.any()
def write(self, data):
# Wie bei pyserial: vor dem Senden den Empfangspuffer leeren, damit
# read_response nur die frische Antwort auf DIESES Telegramm sieht.
self._drain()
self._uart.write(data)
def read_response(self):
deadline = _monotonic() + self._timeout
deadline = _deadline(self._timeout)
buf = bytearray()
while _monotonic() < deadline:
chunk = self._uart.read(1)
while not _expired(deadline):
n = self._uart.any()
if not n:
continue
chunk = self._uart.read(n)
if not chunk:
continue
buf.extend(chunk)
if chunk[0] == migra.ETX:
if migra.ETX in chunk:
break
return bytes(buf)
return _truncate_at_etx(buf)
def close(self):
self._uart.deinit()
+126
View File
@@ -0,0 +1,126 @@
"""WLAN-Anbindung des XIAO ESP32-C3 (MicroPython).
Die Zugangsdaten stehen in wifi_config.py, das nicht im Git liegt -- Vorlage ist
wifi_config_example.py. Zeitverhalten kommt aus config.py.
import wifi
wifi.connect() # einmal verbinden, wirft WifiError bei Misserfolg
wifi.ensure() # vor jeder Abfrage: verbunden halten
WifiError erbt von displayloop.LinkError, damit die Poll-Schleife den Ausfall
selbst abfaengt und "KEIN WLAN" anzeigt, statt abzustuerzen.
"""
import time
import config
import ticks
from displayloop import LinkError
try: # MicroPython
import network
except ImportError: # auf dem Mac nicht vorhanden
network = None
class WifiError(LinkError):
"""WLAN nicht verfuegbar oder Zugangsdaten fehlen."""
_wlan = None
def credentials():
"""(SSID, Passwort) aus wifi_config.py holen."""
try:
import wifi_config
except ImportError:
raise WifiError("wifi_config.py fehlt -- wifi_config_example.py "
"kopieren, ausfuellen und auf den ESP32 kopieren")
try:
return wifi_config.SSID, wifi_config.PASSWORD
except AttributeError:
raise WifiError("wifi_config.py braucht SSID und PASSWORD")
def station():
"""Das WLAN-Interface im Station-Modus, aktiviert."""
global _wlan
if network is None:
raise WifiError("network fehlt -- laeuft dieser Code auf dem ESP32?")
if _wlan is None:
_wlan = network.WLAN(network.STA_IF)
if not _wlan.active():
_wlan.active(True)
return _wlan
def connected():
"""True, wenn eine WLAN-Verbindung steht."""
if network is None or _wlan is None:
return False
return _wlan.isconnected()
def connect(timeout=None, log=None):
"""Verbinden und warten, bis eine IP steht. Liefert die ifconfig-Tupel."""
if timeout is None:
timeout = config.WIFI_TIMEOUT
if log is None:
log = print
wlan = station()
if wlan.isconnected():
return wlan.ifconfig()
ssid, password = credentials()
log("WLAN: verbinde mit %s ..." % ssid)
wlan.connect(ssid, password)
end = ticks.deadline(timeout)
while not wlan.isconnected():
# Kurz schlafen statt hart zu pollen: der Verbindungsaufbau laeuft im
# Hintergrund und braucht die Rechenzeit selbst.
time.sleep(0.2)
if ticks.expired(end):
# Die Verbindung abbrechen, sonst versucht die Firmware im
# Hintergrund weiter und der naechste connect() kollidiert damit.
try:
wlan.disconnect()
except OSError:
pass
raise WifiError("keine Verbindung zu %s nach %g s" % (ssid, timeout))
cfg = wlan.ifconfig()
log("WLAN: verbunden, IP %s" % cfg[0])
return cfg
def ensure(timeout=None, log=None):
"""Sicherstellen, dass das WLAN steht -- fuer displayloop.ensure_link.
Steht die Verbindung schon, kostet der Aufruf praktisch nichts. Sonst wird
neu verbunden; scheitert das, fliegt WifiError (= LinkError) und die
Poll-Schleife zeigt "KEIN WLAN" an und versucht es beim naechsten Durchlauf
erneut.
"""
if connected():
return
connect(timeout=timeout, log=log)
def ifconfig():
"""(IP, Netzmaske, Gateway, DNS) oder None, wenn nicht verbunden."""
return _wlan.ifconfig() if connected() else None
def disconnect():
"""Verbindung trennen und das Interface abschalten."""
global _wlan
if _wlan is not None:
try:
_wlan.disconnect()
_wlan.active(False)
except OSError:
pass
_wlan = None
+10
View File
@@ -0,0 +1,10 @@
"""Vorlage fuer die WLAN-Zugangsdaten des ESP32-C3.
Kopieren nach wifi_config.py, ausfuellen und mit auf den ESP32 kopieren.
wifi_config.py steht in .gitignore -- das Passwort gehoert nicht ins Repository.
cp wifi_config_example.py wifi_config.py
"""
SSID = "NetzwerkName"
PASSWORD = "geheim"