Files
grossanzeige/README.md
T
admin 4461035777 Adresse der Montierung per MQTT umstellbar und gespeichert
Zwischen Mock und echter GM4000 umschalten, ohne zu deployen und ohne Neustart:

    mosquitto_pub -t grossanzeige/set/mount_host -m 192.168.1.115
    mosquitto_pub -t grossanzeige/set/mount_host -m 192.168.178.26
    mosquitto_pub -t grossanzeige/set/mount_host -m ''

Die Adresse liegt in settings.json auf dem Geraet und uebersteht damit
Stromausfall und Watchdog-Neustart. deploy.sh kopiert die Datei bewusst nicht
mit, die Einstellung ueberlebt also auch ein Deployen.

**Leer heisst: config.MOUNT_HOST gilt.** mount_client.resolve_host loest auf in
der Reihenfolge: ausdruecklich mitgegebener Host (REPL, run_display --host) ->
mount_host aus settings.json -> config.MOUNT_HOST. Ohne diesen Rueckweg wuerde
eine einmal per MQTT gesetzte Adresse die Zeile in config.py fuer immer
verdecken, auch nach einem Deployen mit geaendertem Code.

Pruefung: settings._pruefe unterscheidet jetzt Zahl und Text am Typ der Vorgabe
in DEFAULTS -- eine neue Einstellung wird weiterhin an genau einer Stelle
eingetragen. Fuer mount_host sind nur Buchstaben, Ziffern, Punkt und
Bindestrich erlaubt, hoechstens 63 Zeichen: Der Wert geht ungeprueft in
socket.getaddrinfo, und von MQTT kommt alles Moegliche. Ungueltiges wird
verworfen, settings.json bleibt unberuehrt.

Sofort wirksam: Der Rueckruf traegt die neue Adresse in den MountClient ein und
trennt nur die bestehende Verbindung -- neu verbindet die Poll-Schleife im
naechsten Durchlauf, mit der gewohnten Fehlerbehandlung. Antwortet dort niemand,
steht "KEIN LINK", und eine weitere Nachricht holt das Geraet zurueck.
grossanzeige/status/mount_host meldet, welche Adresse gerade abgefragt wird --
die Rueckmeldung steht direkt hinter bridge.check() und nicht im Erfolgspfad,
kommt also auch dann, wenn die Montierung schweigt.

Der on_change-Rueckruf ist als run_esp32.make_on_change aus main() herausgeloest.
test_mqtt.py prueft ihn bisher als Abschrift ("Dieselbe Logik wie in
run_esp32.main"); jetzt laeuft der echte Code durch die Tests.

201 Tests gruen. Neu, unter anderem:
- TestUmschaltenImLaufendenBetrieb faehrt die ganze Kette an echten Sockets:
  erst "KEIN LINK" an einer toten Adresse, nach dem set/mount_host die
  Koordinaten vom Mock.
- TestAdresswahl (test_lx200) haelt die Reihenfolge von resolve_host fest.
- test_esp32: main() ohne host nimmt nach einem Neustart, was gespeichert ist.

Nicht angefasst: run_display.py auf dem Mac behaelt config.MOUNT_HOST als
Vorgabe fuer --host. Dort gibt es keine MQTT-Bruecke, und settings.json ist
Zustand des Geraets, nicht des Macs.

Am Geraet bestaetigt (2026-07-29), am Broker mitgeschnitten. Umschalten im
laufenden Betrieb, mit dem Mock auf dem Mac als Ausgangslage:

    0.0s  status/mount_host  192.168.178.26   Ausgangslage, link = 1
    ->  set/mount_host = 192.168.178.99
    5.1s  status/mount_host  192.168.178.99   Rueckmeldung
    5.9s  status/link        0                dort antwortet niemand
    ->  set/mount_host = ''
   17.8s  status/mount_host  192.168.178.26   Vorgabe aus config.py
   18.6s  status/link        1                Koordinaten laufen wieder

Und ueber den Neustart hinweg -- der eigentliche Zweck des Speicherns:

    ->  set/mount_host = 192.168.178.99
    1.6s  status/mount_host  192.168.178.99
    settings.json auf dem Geraet:
      {"hysterese": 150, "mount_host": "192.168.178.99", "schwelle": 1800, ...}
    ->  Hardware-Reset
    8.6s  status/online      0                Last Will
   15.5s  status/online      1                Autostart durch
   15.5s  status/mount_host  192.168.178.99   aus der Datei, nicht aus config.py

Die alte settings.json ohne mount_host wird beim Laden einfach um die Vorgabe
ergaenzt; die eingestellten Helligkeiten bleiben unberuehrt. Eine Migration war
nicht noetig.

set/mount_host wird ohne Retain-Flag geschickt: Das ist ein Befehl, kein
Zustand. Retained laege er im Broker und wuerde bei jedem Reconnect erneut
zugestellt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:34:09 +02:00

683 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Großanzeige
Ansteuerung einer microSYST **migra SC/MC Seriell** LED-Großanzeige mit den
Koordinaten (Rektaszension / Deklination) einer 10Micron GM4000 HPS II.
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
```bash
# Phase 1 -- Koordinaten von Hand:
python3 show_coords.py 12h34m56s +45d12m30s # Koordinaten anzeigen
python3 show_coords.py 12:34:56 -07:15:00 # Trennzeichen sind egal
python3 show_coords.py --clear # Anzeige löschen
python3 show_coords.py 12h34m56s +45d12m30s --dry-run # nur Telegramm als Hex
# Phase 2 -- Koordinaten von der Montierung (10Micron GM4000):
python3 run_display.py # Montierung aus config.py pollen
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 -> Helligkeit -> senden
python3 -m unittest discover -p 'test_*.py' # alle Tests
python3 probe.py ruler # Geometrie der Anzeige ausmessen
```
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
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)
Referenz: Benutzerhandbuch `X-M31-9AXX5X-006 (V4.20)`, Kapitel 3.53.9.
**Schnittstelle:** 19200 Baud, 8 Datenbits, **Even Parity**, 1 Stopbit.
9-pol. Sub-D: Pin 2 = RxD, Pin 3 = TxD, Pin 5 = GND.
**Telegramm:**
```
STX | DA | SA | FC | [LEN-H LEN-L] | Data-Unit | [CHK-H CHK-L] | ETX
02 81 80 81 03
```
- `DA`/`SA`: Adressen mit gesetztem Bit 7 → Anzeige = 1 (`0x81`), Host = 0 (`0x80`).
- `FC`: Bit 7 fest 1, Bit 1 = Prüfsumme, Bit 0 = Antwort anfordern.
`0x81` = ohne Prüfsumme, mit Antwort (so fuhr auch das alte MSP430-Programm).
- Ohne Prüfsumme dürfen `LEN-H/L` und `CHK-H/L` **nicht** mitgesendet werden.
- Prüfsumme = Low-Byte der Summe aus `DA, SA, FC, LEN-H, LEN-L` und allen Datenbytes,
als zwei Nibbles mit gesetztem High-Nibble (`0xF0 | nibble`).
**Antwort:** `02 80 81 80 <Fehlercode> 03`, Fehlercode `'0'` = kein Fehler.
Nach Erhalt der Antwort darf sofort das nächste Telegramm folgen — das ist die
Flusskontrolle. Ohne Antwort muss zwischen den Telegrammen gewartet werden
(Empfangstimeout + ca. 150 ms).
**ESC-Sequenzen** (alle Zahlen ASCII-codiert, Position 123 → `'1' '2' '3'`):
| Sequenz | Bedeutung |
|---|---|
| `ESC z NN` / `ESC Z NN` | Zeichensatz NN (`z` = Sperrschrift, gleiche Zeichenbreite) |
| `ESC C xxxyyy` | Cursor auf **Pixel**position x/y, 0/0 ist links oben |
| `ESC A <fg><bg><blink>` | Attribute; Farbe 0 = schwarz, 1 = grün, 2 = rot, 3 = gelb |
| `ESC F <Farbe>` | Anzeige füllen; `ESC F 0` = löschen |
| `ESC H <Farbe><ppp>` | Helligkeit 0100 % |
| `ESC T <+/-><nnn>` | gespeicherten Text ein-/ausblenden |
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)
>>> run_esp32.main(host="192.168.1.115") # feste Adresse, statt der eingestellten
```
Ohne `host` gilt die per MQTT gesetzte Adresse aus `settings.json`, sonst
`config.MOUNT_HOST` (siehe *Zwischen Mock und Montierung umschalten*).
### 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
- **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.
Das *Umschalten* zwischen Mock und Montierung ist dagegen erledigt: Es geht
über MQTT (`set/mount_host`, siehe dort), ohne Deployen und ohne Neustart.
## Gradzeichen
Der Standard-Zeichensatz 0 der Anzeige hat **kein** `°`. Der von der Sternwarte
geladene **Zeichensatz 1** enthält an Position `/` (0x2F) ein hochgestelltes
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 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
- **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)
Die GM4000 läuft standardmäßig in **Niedrigpräzision** und sendet keine Sekunden:
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 ganze Darstellung — sie folgt dem, was die
Montierung wirklich liefert:
| | 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
zerlegt (Trennzeichen `:`, `*`, `0xDF`, `'` sind alle egal).
## Helligkeitsregelung
**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.
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:
| 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,
die Schwelle und die Adresse der Montierung sollen dagegen im Betrieb verstellbar
sein — **ü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
settings.update({"mount_host": "192.168.1.115"}) # Montierung umstellen
```
Ein laufender `BrightnessController` übernimmt Änderungen mit `reload()`.
Jeder Wert wird beim Laden **und** beim Schreiben geprüft (Typ und Bereich:
Prozente 0100, Schwelle 04095, `mount_host` nur Zeichen aus IP-Adressen und
Hostnamen). Ob eine Einstellung Zahl oder Text ist, entscheidet der Typ ihrer
Vorgabe in `settings.DEFAULTS` — so gibt es nur eine Stelle, an der eine neue
Einstellung einzutragen ist. 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.
**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>`):
| 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 |
| `grossanzeige/set/mount_host` | `192.168.1.115` | Adresse der Montierung (leer = Vorgabe aus `config.py`) |
Alle Nutzlasten außer `mount_host` sind ganze Zahlen; `mount_host` ist Text
(IP-Adresse oder Hostname).
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/mount_host` | `192.168.1.115` (Adresse, die gerade abgefragt wird) |
| `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.
### Zwischen Mock und Montierung umschalten (`mount_host`)
Welche Adresse abgefragt wird, ist im Betrieb umstellbar — ohne Deployen, ohne
Neustart:
```bash
mosquitto_pub -h <broker> -t grossanzeige/set/mount_host -m 192.168.1.115 # echte GM4000
mosquitto_pub -h <broker> -t grossanzeige/set/mount_host -m 192.168.178.26 # Mock auf dem Mac
mosquitto_pub -h <broker> -t grossanzeige/set/mount_host -m '' # zurück zu config.py
```
Die Adresse landet in `settings.json` und **übersteht damit einen Stromausfall**:
Nach dem Neustart fragt das Gerät dieselbe Adresse wieder ab. Welche gerade gilt,
klärt `mount_client.resolve_host()` in dieser Reihenfolge:
1. eine beim Aufruf ausdrücklich genannte Adresse (`run_esp32.main(host=...)` an
der REPL, `run_display.py --host` auf dem Mac),
2. `mount_host` aus `settings.json` (das per MQTT Gesetzte),
3. `config.MOUNT_HOST` — was beim Deployen im Code stand.
**Eine leere Nachricht ist der Weg zurück.** Ohne sie würde eine einmal gesetzte
Adresse die Vorgabe aus `config.py` für immer verdecken, auch nach einem Deployen
mit geändertem Code. Deshalb heißt „leer" hier nicht „keine Adresse", sondern
„nimm die aus dem Code".
Wirksam wird die Umstellung sofort: Der Rückruf trägt die neue Adresse in den
`MountClient` ein und **trennt die bestehende Verbindung** — neu verbunden wird im
nächsten Durchlauf der Poll-Schleife, mit der gewohnten Fehlerbehandlung. Antwortet
unter der neuen Adresse niemand, steht „KEIN LINK" auf der Anzeige, und eine weitere
Nachricht holt das Gerät wieder zurück. Was gerade abgefragt wird, meldet
`grossanzeige/status/mount_host` — die Rückmeldung kommt auch dann, wenn dort
niemand antwortet.
Der Wert geht in `socket.getaddrinfo` und kommt von außen, deshalb prüft
`settings.py` ihn eng: nur Buchstaben, Ziffern, Punkt und Bindestrich, höchstens
63 Zeichen. Alles andere wird verworfen, `settings.json` bleibt unberührt.
### 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)
Der Rückkanal ist verdrahtet und verifiziert (2026-07-16): Anzeige Pin 3 (TxD) →
MAX232-Empfängereingang (R_IN) → R_OUT → Adapter RxD. Die Anzeige quittiert jedes
Telegramm mit `02 80 81 80 30 03` (Fehlercode `0` = kein Fehler). `WANT_RESPONSE = True`
ist damit aktiv: Nach der Antwort darf sofort das nächste Telegramm folgen, der
Fehlercode wird ausgewertet. `resp_test.py` prüft den Rückweg (sendet mit Antwort-
Anforderung und zeigt die empfangenen Antworten).
Wichtig: `SerialTransport.write` leert vor jedem Senden den Empfangspuffer, damit
`read_response` nur die frische Antwort sieht (sonst können Störbytes zwischen den
Telegrammen das Parsen stören). `run_display` fängt Anzeige-Fehler ab und zeichnet
beim nächsten Durchlauf neu, statt abzustürzen.
Diagnose-Historie: Der Rückweg war anfangs falsch verdrahtet — Pin 3 lag an einem
MAX232-*Sender*ausgang statt einem Empfängereingang; zwei Ausgänge trieben
gegeneinander (verschliffener ~3-V-Pegel am Oszi). `loopback.py` grenzt so etwas ein.
## 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**
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.