Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0876d8e827 | |||
| 142a621066 | |||
| 433c53aabd | |||
| dae7694a07 | |||
| 41f5eb1821 | |||
| bf39b75ac1 | |||
| 0a893d0684 | |||
| e8a5b5d5e3 | |||
| 4f7375add3 | |||
| 42fc7f52d4 |
+12
-1
@@ -10,4 +10,15 @@ venv/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
#Hardware-Sachern nicht ist Git
|
#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
|
||||||
@@ -3,10 +3,10 @@
|
|||||||
Ansteuerung einer microSYST **migra SC/MC Seriell** LED-Großanzeige mit den
|
Ansteuerung einer microSYST **migra SC/MC Seriell** LED-Großanzeige mit den
|
||||||
Koordinaten (Rektaszension / Deklination) einer 10Micron GM4000 HPS II.
|
Koordinaten (Rektaszension / Deklination) einer 10Micron GM4000 HPS II.
|
||||||
|
|
||||||
Endziel ist ein XIAO ESP32-C3, der die Montierung per WLAN abfragt und die
|
Zielgerät 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)
|
Koordinaten über UART/RS232 auf die Anzeige schreibt. Entwickelt und getestet
|
||||||
ist das Testprogramm auf dem Mac: Koordinaten von Hand übergeben, Ausgabe über
|
wird auf dem Mac (Koordinaten von Hand bzw. von der Montierung, Ausgabe über
|
||||||
einen USB-RS232-Wandler.
|
einen USB-RS232-Wandler); dieselben Module laufen auf dem ESP32.
|
||||||
|
|
||||||
## Benutzung
|
## 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 # ohne Teleskop: lokaler Mock, RA läuft
|
||||||
python3 run_display.py --mock --move --ldr 2000 # zusätzlich mit Helligkeitsregelung
|
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 # 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:
|
# 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
|
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
|
## Aufbau
|
||||||
|
|
||||||
| Datei | Zweck |
|
Alles, was auf beiden Plattformen läuft, ist bewusst abhängigkeitsfrei
|
||||||
|---|---|
|
gehalten. Was sich unterscheidet, steckt in `transport.py` (UART) und in den
|
||||||
| `migra.py` | Protokoll: Telegrammrahmen, Prüfsumme, ESC-Sequenzen. Ohne Abhängigkeiten, **läuft unverändert unter MicroPython**. |
|
beiden Startprogrammen.
|
||||||
| `transport.py` | UART: `pyserial` (Mac), `machine.UART` (ESP32-C3), `FakeTransport` (Trockenlauf). |
|
|
||||||
| `display.py` | Treiber: Telegramm senden, Antwort auswerten. |
|
| Datei | Zweck | ESP32 |
|
||||||
| `coords.py` | Koordinaten einlesen und auf die Zeilenbreite formatieren. |
|
|---|---|---|
|
||||||
| `config.py` | Port, Adresse, Geometrie, Helligkeit — alles an einer Stelle. |
|
| `migra.py` | Protokoll: Telegrammrahmen, Prüfsumme, ESC-Sequenzen. | ✓ |
|
||||||
| `show_coords.py` | Das Testprogramm (Koordinaten von Hand). |
|
| `transport.py` | UART: `pyserial` (Mac), `machine.UART` (ESP32-C3), `FakeTransport` (Trockenlauf). | ✓ |
|
||||||
| `brightness.py` | Helligkeitsregelung: LDR-Wert → Stufe/Prozent (portabel + CLI). |
|
| `ticks.py` | Timeouts, die unter CPython und MicroPython gleich funktionieren. | ✓ |
|
||||||
| `run_display.py` | Hauptschleife Phase 2: Montierung pollen, anzeigen, Helligkeit regeln. |
|
| `display.py` | Treiber: Telegramm senden, Antwort auswerten. | ✓ |
|
||||||
| `mount_client.py` / `mount_mock.py` | TCP-Client zur GM4000 bzw. Simulator (LX200). |
|
| `coords.py` | Koordinaten einlesen und auf die Zeilenbreite formatieren. | ✓ |
|
||||||
| `lx200.py` | LX200-Kommandorahmen. |
|
| `config.py` | Port/Pins, Adresse, Geometrie, WLAN — fest, ändert sich nur beim Deployen. | ✓ |
|
||||||
| `probe.py` | Testmuster zum Ausmessen von Zeilenbreite und Zeilenposition. |
|
| `settings.py` | Zur Laufzeit änderbare Werte (Helligkeiten, Schwelle) in `settings.json`. | ✓ |
|
||||||
| `test_migra.py` / `test_lx200.py` / `test_brightness.py` | Tests. |
|
| `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)
|
## 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
|
dazwischen. Data-Unit max. 230 Zeichen. `migra.DataUnit` setzt das Trennzeichen
|
||||||
selbst.
|
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
|
## Offene Punkte
|
||||||
|
|
||||||
- **Echte Montierungs-IP:** In `config.py` steht `MOUNT_HOST` noch als Platzhalter.
|
- **Umschaltschwelle in der Sternwarte gegenprüfen.** Die Schwelle (1800) ist am
|
||||||
Sobald die GM4000 im Netz erreichbar ist, dort die IP eintragen (Port 3490 ist
|
realen Aufbau gemessen (2026-07-27, siehe *Helligkeitsregelung*), aber am
|
||||||
Vorgabe). Bis dahin läuft alles gegen den Mock (`run_display.py --mock`).
|
Schreibtisch. Vor Ort bei Tag und Nacht die Rohwerte ablesen und die Schwelle in
|
||||||
- Portierung auf den XIAO ESP32-C3 (MicroPython): `migra.py`, `lx200.py`,
|
`settings.json` bestätigen. Bis dahin läuft die Anzeige mit fester Helligkeit
|
||||||
`mount_client.py` sind bewusst abhängigkeitsfrei; nur `transport` (UART) und das
|
(`run_esp32.main()` ohne `with_ldr`).
|
||||||
WLAN-Setup kommen hinzu.
|
- **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
|
## 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
|
`& 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`).
|
auf Zeichensatz 1 umgeschaltet und wieder zurück (`config.DEGREE_*`, `display._emit_degree`).
|
||||||
|
|
||||||
**Fallstrick:** Ein Zeichensatzwechsel mitten im Telegramm beschädigt die Home-Zelle
|
**Fallstrick:** Ein Zeichensatzwechsel beschädigt die Home-Zelle (0,0) — das erste
|
||||||
(0,0) — das erste Zeichen der obersten Zeile wird zerstört. Deshalb zeichnet
|
Zeichen der obersten Zeile wird zerstört. Deshalb schreibt `show_lines` die Zeilen
|
||||||
`show_lines` die Zeilen **von unten nach oben** (oberste Zeile zuletzt), damit sie
|
**von unten nach oben** (oberste Zeile zuletzt), damit sie diese Zelle überschreibt.
|
||||||
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
|
## Geometrie und Darstellung
|
||||||
|
|
||||||
- **9 Zeichen pro Zeile** in Sperrschrift (`CHARSET_SPACED = True`, am Gerät
|
- **10 Zeichen pro Zeile zu 6 px** (`CHARS_PER_LINE = 10`, `CELL_WIDTH = 6`), am
|
||||||
ausgemessen). `probe.py ruler` misst es nach.
|
Gerät entschieden mit `probe.py pitch 6` (2026-07-27).
|
||||||
- Die Deklination wird deshalb als **9-Zeichen-Form ohne abschließendes `"`**
|
|
||||||
ausgegeben (`+38°47'01`) — Gradzeichen und Bogenminuten bleiben, die Bogensekunden
|
Vorher stand hier 9 — das war am Zeilenumbruch abgezählt und maß etwas
|
||||||
stehen als Ziffern da. `coords.format_dec` wählt automatisch die breiteste Form,
|
anderes, als es zu messen schien. `probe.py pitch` stellt den Auto-Vorschub
|
||||||
die in `CHARS_PER_LINE` passt.
|
der Anzeige gegen feste Pixelpositionen `x = i·cell` und trennt damit beides:
|
||||||
- **Zyklische Updates flimmerfrei:** `show_lines` löscht nicht bei jeder Aktualisierung,
|
|
||||||
sondern überschreibt die Zeilen an Ort und Stelle (auf volle Breite aufgefüllt).
|
- Der **Auto-Vorschub** der Sperrschrift `ESC z` ist **7 px**. Eine Zeile in
|
||||||
`clear=True` nur beim ersten Bild. Wichtig: Zeilen dürfen **nicht breiter** als
|
einem Stück geschrieben bricht deshalb nach 9 Zeichen um (9·7 = 63 von 64 px).
|
||||||
`CHARS_PER_LINE` aufgefüllt werden, sonst bricht das Füll-Leerzeichen um und
|
- Die **Zeichenmatrix** ist aber nur **6 px** breit. Zehn Ziffern, einzeln auf
|
||||||
beschädigt die andere Zeile.
|
`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)
|
## 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;
|
Gradzeichen kommt als Byte `0xDF` — auf der Anzeige korrekt als Gradring dargestellt;
|
||||||
in der Terminal-Konsole erscheint der **Rohwert** dieses Bytes als `ß` (rein kosmetisch).
|
in der Terminal-Konsole erscheint der **Rohwert** dieses Bytes als `ß` (rein kosmetisch).
|
||||||
|
|
||||||
`config.HIGH_PRECISION` steuert die DEC-Darstellung:
|
`config.HIGH_PRECISION` steuert die ganze Darstellung — sie folgt dem, was die
|
||||||
- `False` (Vorgabe): DEC ohne Sekunden (`-00°55'`).
|
Montierung wirklich liefert:
|
||||||
- `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).
|
|
||||||
|
|
||||||
Die **RA** wird immer mit Sekunden gezeigt (`11h36m54s`); bei Niedrigpräzision
|
| | Niedrigpräzision (Vorgabe) | Hochpräzision (nach `:U#`) |
|
||||||
kommen die Sekunden aus den Zehntel-Minuten und springen daher in 6-Sekunden-Schritten
|
|---|---|---|
|
||||||
(kein Dezimalpunkt — der wirkt in der Sperrschrift zu luftig).
|
| 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
|
`coords.format_ra`/`format_dec` nehmen `high_precision=` als Parameter (Vorgabe aus
|
||||||
`config`). Die Montierungs-Rohwerte werden im `coords`-Parser unverändert korrekt
|
`config`). Die Montierungs-Rohwerte werden im `coords`-Parser unverändert korrekt
|
||||||
@@ -150,27 +422,176 @@ zerlegt (Trennzeichen `:`, `*`, `0xDF`, `'` sind alle egal).
|
|||||||
|
|
||||||
## Helligkeitsregelung
|
## Helligkeitsregelung
|
||||||
|
|
||||||
Portiert aus dem alten MSP430-Programm (`DoDimmen_V`, Werte aus `main.h`). Ein
|
**Zwei Helligkeiten, ein Umschaltpunkt.** Unterhalb der Schwelle läuft die Anzeige
|
||||||
LDR-Rohwert (12-Bit-ADC, 0–4095: je heller die Umgebung, desto höher der Wert)
|
mit `dunkel_prozent`, oberhalb mit `hell_prozent`. Die vier abgestuften Helligkeiten
|
||||||
wird über einen gleitenden Mittelwert (8 Werte) geglättet und auf eine
|
des alten MSP430-Programms (5/15/50/100 %) sind bewusst aufgegeben — für die
|
||||||
Helligkeitsstufe mit Hysterese abgebildet:
|
Sternwarte genügt der Wechsel Tag/Nacht.
|
||||||
|
|
||||||
| Stufe | Helligkeit | erreicht ab (hoch / runter) |
|
Ein LDR-Rohwert (12-Bit-ADC, 0–4095) wird über `raw_to_ldr` in die Zählrichtung
|
||||||
|------|-----------|------------------------------|
|
„groß = hell" gebracht, über einen gleitenden Mittelwert (8 Werte) geglättet und
|
||||||
| 0 | 5 % | < 270 |
|
gegen die Schwelle gehalten:
|
||||||
| 1 | 15 % | ≥ 330 / < 270 |
|
|
||||||
| 2 | 50 % | ≥ 2650 / < 2350 |
|
|
||||||
| 3 | 100 % | ≥ 3650 / < 3350 |
|
|
||||||
|
|
||||||
Schwellen-Mitten `300 / 2500 / 3500`, Hysterese `±30 / ±150 / ±150`. **300 und 2500
|
| Bereich | Helligkeit | Umschaltpunkt |
|
||||||
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
|
| unter der Schwelle | `dunkel_prozent` = 5 % | herunter ab < 1650 |
|
||||||
reale LDR-Wertebereich feststeht. Ein Helligkeitstelegramm wird nur bei
|
| ü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 0–100, Schwelle 0–4095). 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.
|
**Stufenwechsel** gesendet (`ESC H 2 <ppp>`), wie im Original.
|
||||||
|
|
||||||
Der LDR-Wert kommt derzeit per Kommandozeile (`--ldr`); auf dem ESP32-C3 liefert
|
**Im Dunkeln steht der ADC am Anschlag** (4095). Der Teiler treibt A0 über den
|
||||||
ihn später der ADC. `run_display.py --ldr <wert>` bindet die Regelung in die
|
Messbereich, „Dämmerung" und „stockdunkel" sind daher nicht unterscheidbar. Bei
|
||||||
Hauptschleife ein (`BrightnessController`).
|
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,5–1 MΩ im Dunkeln) liefe das auf 160–330 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. **10–22 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)
|
## Antwortleitung (RX)
|
||||||
|
|
||||||
@@ -192,10 +613,18 @@ gegeneinander (verschliffener ~3-V-Pegel am Oszi). `loopback.py` grenzt so etwas
|
|||||||
|
|
||||||
## Erledigt
|
## 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,
|
- **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
|
inkl. echtem Gradzeichen; im `run_display`-Betrieb aktualisieren beide Zeilen
|
||||||
laufend, sauber und ohne Flimmern, mit Antwort-Handshake der Anzeige. Protokoll,
|
laufend, sauber und ohne Flimmern, mit Antwort-Handshake der Anzeige. Protokoll,
|
||||||
19200 8E1, Zeichensatz und Zeilengeometrie stimmen.
|
19200 8E1, Zeichensatz und Zeilengeometrie stimmen.
|
||||||
- Schrift: Sperrschrift (`ESC z`, `CHARSET_SPACED = True`), 10 Zeichen/Zeile.
|
- Schrift: Sperrschrift (`ESC z`, `CHARSET_SPACED = True`), **10 Zeichen/Zeile**
|
||||||
Die schmale `1` wirkt dadurch etwas luftig — Font-Eigenschaft, nur per eigenem
|
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.
|
Font (microSYST-PC-Software) änderbar.
|
||||||
|
|||||||
+104
-46
@@ -1,73 +1,122 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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,
|
Zwei Helligkeiten, ein Umschaltpunkt: unterhalb der Schwelle laeuft die Anzeige
|
||||||
Konstanten aus main.h): gleitender Mittelwert, 3 Helligkeitsstufen mit Hysterese,
|
mit "dunkel", oberhalb mit "hell". Die vier abgestuften Helligkeiten des alten
|
||||||
Prozentwerte aus hellTafel_ub. Der Fotowiderstand (LDR) liefert einen Rohwert
|
MSP430-Programms (DoDimmen_V, 5/15/50/100 %) sind bewusst aufgegeben -- fuer die
|
||||||
(alte Hardware: 12-bit-ADC, 0..4095) -- je heller die Umgebung, desto hoeher der
|
Sternwarte genuegt der Wechsel Tag/Nacht.
|
||||||
Wert und desto heller die Anzeige.
|
|
||||||
|
|
||||||
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
|
python3 brightness.py --ldr 200 --dry-run
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
# argparse und sys.stderr gibt es unter MicroPython nicht -- beides wird nur vom
|
||||||
import sys
|
# 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
|
AVERAGE_WINDOW = 8 # MAXHELLARRAY: gleitender Mittelwert ueber 8 Werte
|
||||||
|
|
||||||
# Helligkeits-Prozente je Stufe, aus hellTafel_ub (main.h): 5/15/50/100 %.
|
# Die beiden Helligkeitsstufen. DUNKEL gilt unterhalb der Schwelle, HELL darueber.
|
||||||
PERCENTS = (5, 15, 50, 100)
|
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
|
def raw_to_ldr(raw, inverted=None, full_scale=None):
|
||||||
# uebernehmen wir und ergaenzen die neue obere Grenze mit derselben Breite.
|
"""ADC-Rohwert in die Zaehlrichtung bringen, die die Schwellen erwarten.
|
||||||
_CENTERS = (HELL_LOW, HELL_HIGH, HELL_MAX)
|
|
||||||
_HYSTS = (HELL_HYST, HELL_HYST * 5, HELL_HYST * 5)
|
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:
|
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
|
update() meldet zurueck, ob sich die Stufe geaendert hat -- nur dann wird ein
|
||||||
geaendert hat (sonst None). So wird -- wie im alten Programm -- nur bei
|
Helligkeitstelegramm gesendet, wie schon im alten Programm.
|
||||||
tatsaechlicher Aenderung ein Helligkeitstelegramm gesendet.
|
|
||||||
|
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._buf = []
|
||||||
self._level = None # entspricht oldHellStufe_ub = 0xFF (>=2-Zweig)
|
self._level = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def level(self):
|
def level(self):
|
||||||
return self._level
|
return self._level
|
||||||
|
|
||||||
def _next_level(self, val, old):
|
@property
|
||||||
"""Stufe mit Hysterese bestimmen (verallgemeinert auf beliebig viele Stufen).
|
def settings(self):
|
||||||
|
"""Die aktuell verwendeten Werte (Kopie)."""
|
||||||
|
return dict(self._werte)
|
||||||
|
|
||||||
Hochschalten, solange der Wert die obere Kante (Mitte + Hysterese) der
|
def reload(self, log=None):
|
||||||
naechsten Grenze ueberschreitet; herunterschalten, solange er die untere
|
"""Einstellungen neu aus der Datei lesen.
|
||||||
Kante (Mitte - Hysterese) unterschreitet. Beim ersten Aufruf (old=None)
|
|
||||||
wird von Stufe 0 aus hochgeschaltet -- die Anzeige startet also gedimmt
|
Die Stufe bleibt stehen; ob sie zu den neuen Werten noch passt, klaert
|
||||||
und hellt sich passend zum Messwert auf.
|
der naechste update() -- inklusive Hysterese, also ohne Sprung.
|
||||||
"""
|
"""
|
||||||
level = 0 if old is None else old
|
self._werte = self._settings.load(self._pfad, log=log)
|
||||||
while level < len(PERCENTS) - 1 and val >= _CENTERS[level] + _HYSTS[level]:
|
return self.settings
|
||||||
level += 1
|
|
||||||
while level > 0 and val < _CENTERS[level - 1] - _HYSTS[level - 1]:
|
def percent(self, level):
|
||||||
level -= 1
|
"""Prozentwert zu einer Stufe."""
|
||||||
return level
|
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):
|
def update(self, ldr):
|
||||||
"""Einen LDR-Rohwert einspeisen. Liefert (stufe, prozent, geaendert)."""
|
"""Einen LDR-Wert einspeisen. Liefert (stufe, prozent, geaendert)."""
|
||||||
self._buf.append(ldr)
|
self._buf.append(ldr)
|
||||||
if len(self._buf) > AVERAGE_WINDOW:
|
if len(self._buf) > AVERAGE_WINDOW:
|
||||||
self._buf.pop(0)
|
self._buf.pop(0)
|
||||||
@@ -76,10 +125,13 @@ class BrightnessController:
|
|||||||
level = self._next_level(avg, self._level)
|
level = self._next_level(avg, self._level)
|
||||||
changed = level != self._level
|
changed = level != self._level
|
||||||
self._level = level
|
self._level = level
|
||||||
return level, PERCENTS[level], changed
|
return level, self.percent(level), changed
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
p = argparse.ArgumentParser(description=__doc__,
|
p = argparse.ArgumentParser(description=__doc__,
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
p.add_argument("--ldr", type=int, required=True, help="LDR-Rohwert (0..4095)")
|
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")
|
p.add_argument("--dry-run", action="store_true", help="nur berechnen/anzeigen, nicht senden")
|
||||||
args = p.parse_args(argv)
|
args = p.parse_args(argv)
|
||||||
|
|
||||||
level, percent, _ = BrightnessController().update(args.ldr)
|
c = BrightnessController()
|
||||||
print("LDR %d -> Stufe %d -> %d %%" % (args.ldr, level, percent))
|
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 config
|
||||||
import migra
|
import migra
|
||||||
@@ -106,4 +163,5 @@ def main(argv=None):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
@@ -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
|
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 -----------------------------------------------
|
# -- Serielle Schnittstelle -----------------------------------------------
|
||||||
# Auslieferzustand der Anzeige laut Handbuch (Kap. 4.1) und identisch zur
|
# Auslieferzustand der Anzeige laut Handbuch (Kap. 4.1) und identisch zur
|
||||||
# UART-Init des alten MSP430-Programms: 19200 8E1.
|
# UART-Init des alten MSP430-Programms: 19200 8E1.
|
||||||
PORT = "/dev/cu.usbserial-120" # Mac; auf dem ESP32-C3 spaeter die UART-Nummer
|
|
||||||
BAUDRATE = 19200
|
BAUDRATE = 19200
|
||||||
PARITY = "E"
|
PARITY = "E"
|
||||||
BYTESIZE = 8
|
BYTESIZE = 8
|
||||||
STOPBITS = 1
|
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 ------------------------------------------------------------
|
# -- Telegramm ------------------------------------------------------------
|
||||||
DISPLAY_ADDR = 1 # Zieladresse der Anzeige (DA = 0x81)
|
DISPLAY_ADDR = 1 # Zieladresse der Anzeige (DA = 0x81)
|
||||||
HOST_ADDR = 0 # unsere Quelladresse (SA = 0x80)
|
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
|
# Zeichensatz 0, Zeile 1 ab Pixelzeile 0, Zeile 2 ab Pixelzeile 9 (aus dem alten
|
||||||
# C-Programm). Am echten Geraet ausgemessen (2026-07-15):
|
# C-Programm). Am echten Geraet ausgemessen (2026-07-15):
|
||||||
# CHARSET_SPACED=True -> ESC z (Sperrschrift, alle Zeichen gleich breit) -- vom
|
# CHARSET_SPACED=True -> ESC z (Sperrschrift, alle Zeichen gleich breit) -- vom
|
||||||
# Nutzer bevorzugt, weil sich sonst schmale Ziffern beruehren. In dieser Schrift
|
# Nutzer bevorzugt, weil sich sonst schmale Ziffern beruehren.
|
||||||
# passen mind. 10 Zeichen pro Zeile (RA/DEC brauchen max. 10).
|
|
||||||
# CHARSET_SPACED=False -> ESC Z (proportional, schmaler, ~11 Zeichen, aber die "1"
|
# CHARSET_SPACED=False -> ESC Z (proportional, schmaler, ~11 Zeichen, aber die "1"
|
||||||
# klebt an Nachbarn).
|
# klebt an Nachbarn).
|
||||||
CHARSET = 0
|
CHARSET = 0
|
||||||
CHARSET_SPACED = True
|
CHARSET_SPACED = True
|
||||||
LINE_Y = (0, 9)
|
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
|
COLOR = migra.RED # einfarbig rote Anzeige
|
||||||
BACKGROUND = migra.BLACK
|
BACKGROUND = migra.BLACK
|
||||||
@@ -52,7 +123,11 @@ DEGREE_FALLBACK = "d" # Ersatz, falls DEGREE_CHARSET = None
|
|||||||
# -- Montierung (Phase 2) -------------------------------------------------
|
# -- Montierung (Phase 2) -------------------------------------------------
|
||||||
# 10Micron GM4000 HPS II: LX200-Protokoll ueber TCP. Die IP der Montierung
|
# 10Micron GM4000 HPS II: LX200-Protokoll ueber TCP. Die IP der Montierung
|
||||||
# eintragen, sobald sie erreichbar ist; Port 3490 ist die Vorgabe von 10Micron.
|
# 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_PORT = 3490
|
||||||
MOUNT_TIMEOUT = 3.0 # Sekunden fuer Verbindung/Antwort
|
MOUNT_TIMEOUT = 3.0 # Sekunden fuer Verbindung/Antwort
|
||||||
POLL_INTERVAL = 1.0 # Sekunden zwischen zwei Koordinatenabfragen
|
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.
|
# True = Hochpraezision (Montierung mit ":U#" umgestellt): RA/DEC mit Sekunden.
|
||||||
HIGH_PRECISION = False
|
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.
|
# 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
|
||||||
|
|||||||
@@ -75,22 +75,53 @@ def _fit(candidates, width):
|
|||||||
return candidates[-1][:width]
|
return candidates[-1][:width]
|
||||||
|
|
||||||
|
|
||||||
def format_ra(value, width=None, high_precision=None):
|
def _to_tenths(h, m, s):
|
||||||
"""Rektaszension formatieren, immer mit Sekunden (HHhMMmSSs).
|
"""(h, m, s) in (Stunden, Minuten, Zehntelminuten) umrechnen.
|
||||||
|
|
||||||
Bei Niedrigpraezision liefert die Montierung nur Zehntel-Minuten; die
|
Ganzzahlig gerechnet, damit es unter MicroPython keine Float-Ueberraschungen
|
||||||
Sekunden kommen dann in 6-Sekunden-Schritten. Das Format ist dasselbe wie
|
gibt. Der Uebertrag ist wichtig: 18:36:57 sind 36,95 Minuten und werden zu
|
||||||
bei Hochpraezision -- der Unterschied steckt allein in der Aufloesung der
|
18:37.0, nicht zu 18:36.10.
|
||||||
Rohdaten, nicht in der Darstellung. high_precision wird daher fuer RA nicht
|
"""
|
||||||
ausgewertet (nur bei DEC relevant, siehe format_dec).
|
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
|
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)
|
h, m, s = parse_ra(value)
|
||||||
return _fit([
|
if high:
|
||||||
"%02dh%02dm%02ds" % (h, m, s),
|
candidates = [
|
||||||
"%02d:%02d:%02d" % (h, m, s),
|
"%02dh%02dm%02ds" % (h, m, s),
|
||||||
"%02dh%02dm" % (h, m),
|
"%02d:%02d:%02d" % (h, m, s),
|
||||||
], width)
|
"%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):
|
def format_dec(value, width=None, high_precision=None):
|
||||||
|
|||||||
@@ -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
@@ -24,6 +24,9 @@ class Display:
|
|||||||
self._src = src
|
self._src = src
|
||||||
self._checksum = checksum
|
self._checksum = checksum
|
||||||
self._response = response
|
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):
|
def send(self, data_unit):
|
||||||
"""Data-Unit rahmen, senden und ggf. auf das Antworttelegramm warten."""
|
"""Data-Unit rahmen, senden und ggf. auf das Antworttelegramm warten."""
|
||||||
@@ -44,6 +47,10 @@ class Display:
|
|||||||
|
|
||||||
# -- Hoehere Funktionen ------------------------------------------------
|
# -- Hoehere Funktionen ------------------------------------------------
|
||||||
def clear(self):
|
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))
|
self.send(migra.DataUnit().fill(config.BACKGROUND))
|
||||||
|
|
||||||
def set_brightness(self, percent):
|
def set_brightness(self, percent):
|
||||||
@@ -52,84 +59,271 @@ class Display:
|
|||||||
def show_lines(self, *lines, clear=False):
|
def show_lines(self, *lines, clear=False):
|
||||||
"""Zeilen ab Zeile 1 auf die Anzeige schreiben.
|
"""Zeilen ab Zeile 1 auf die Anzeige schreiben.
|
||||||
|
|
||||||
Baut ein einziges Telegramm: pro Zeile Cursor setzen und den Text
|
Geschrieben wird nur, was sich seit dem letzten Mal geaendert hat --
|
||||||
schreiben. Das Gradzeichen (config.DEGREE_MARKER) wird ueber einen kurzen
|
Zelle fuer Zelle. Das ist der entscheidende Punkt fuer eine ruhige
|
||||||
Zeichensatzwechsel eingefuegt.
|
Anzeige:
|
||||||
|
|
||||||
Jede Zeile wird mit Leerzeichen auf volle Breite (CHARS_PER_LINE)
|
Seit jedes Zeichen einzeln positioniert wird (10 statt 9 Zeichen, siehe
|
||||||
aufgefuellt und damit *an Ort und Stelle* ueberschrieben -- ohne die
|
_emit_line), kostet eine volle Zeile rund 100 Bytes statt 20. Bei 19200
|
||||||
Anzeige vorher komplett zu loeschen. Das vermeidet das sichtbare Blitzen
|
Baud mit 8E1 (11 Bit je Byte) dauerte ein kompletter Bildwechsel damit
|
||||||
bei zyklischen Updates und haelt das Telegramm kurz (sonst kommt das
|
rund 300 ms. Die Anzeige stellt schon waehrend des Empfangs dar, also
|
||||||
naechste zu frueh und das letzte Zeichen kann verloren gehen).
|
war der Aufbau als Flackern zu sehen -- bei Aktualisierung im
|
||||||
clear=True erzwingt zusaetzlich ein Vollbild-Loeschen (einmal beim Start).
|
Sekundentakt ein Drittel der Zeit. (Am Geraet gemessen 2026-07-27.)
|
||||||
|
|
||||||
Reihenfolge: Die Zeilen werden von unten nach oben gezeichnet, damit die
|
Von einer Sekunde zur naechsten aendert sich aber fast nichts:
|
||||||
oberste Zeile zuletzt kommt. Grund: Ein Zeichensatzwechsel mitten im
|
"18h36m56s" -> "18h36m57s" ist ein einziges Zeichen, die Deklination
|
||||||
Telegramm beschaedigt die Home-Zelle (0,0) -- die oberste Zeile muss also
|
steht meist voellig still. Mit dem Vergleich gegen den zuletzt
|
||||||
nach allen Wechseln geschrieben werden, damit sie diese Zelle ueberschreibt.
|
dargestellten Inhalt schrumpft der Bildwechsel auf ein bis zwei Zellen,
|
||||||
(Am Geraet verifiziert 2026-07-15.)
|
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):
|
if len(lines) > len(config.LINE_Y):
|
||||||
raise ValueError("Anzeige hat nur %d Zeilen" % len(config.LINE_Y))
|
raise ValueError("Anzeige hat nur %d Zeilen" % len(config.LINE_Y))
|
||||||
|
|
||||||
du = migra.DataUnit()
|
|
||||||
if clear:
|
if clear:
|
||||||
du.fill(config.BACKGROUND)
|
self.clear()
|
||||||
du.attributes(fg=config.COLOR, bg=config.BACKGROUND, blink=migra.STATIC)
|
self._gezeigt = {}
|
||||||
|
|
||||||
|
wechsel = False
|
||||||
for i in reversed(range(len(lines))):
|
for i in reversed(range(len(lines))):
|
||||||
self._emit_line(du, config.LINE_Y[i], lines[i])
|
# Die oberste Zeile (i == 0) komplett neu, wenn weiter unten ein
|
||||||
self.send(du)
|
# 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):
|
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
|
Schreibt ebenfalls nur die geaenderten Zellen. Achtung: Enthaelt die
|
||||||
Zeichensatzwechsel) laesst sich beliebig oft neu schreiben, ohne dass die
|
Zeile ein *neues* Gradzeichen, wird dafuer der Zeichensatz gewechselt und
|
||||||
Anzeige Bytes verschluckt. Zeilen *mit* Gradzeichen sollten dagegen selten
|
damit die Home-Zelle beschaedigt -- die oberste Zeile muesste danach
|
||||||
und moeglichst per show_lines (Vollbild) gezeichnet werden.
|
nachgezogen werden. Wer beide Zeilen hat, nimmt darum show_lines.
|
||||||
"""
|
"""
|
||||||
if not 0 <= index < len(config.LINE_Y):
|
if not 0 <= index < len(config.LINE_Y):
|
||||||
raise ValueError("ungueltiger Zeilenindex %d" % index)
|
raise ValueError("ungueltiger Zeilenindex %d" % index)
|
||||||
du = migra.DataUnit()
|
|
||||||
if clear:
|
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)
|
du.attributes(fg=config.COLOR, bg=config.BACKGROUND, blink=migra.STATIC)
|
||||||
self._emit_line(du, config.LINE_Y[index], text)
|
if alt is None:
|
||||||
self.send(du)
|
# 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):
|
@staticmethod
|
||||||
"""Eine Zeile bei Pixelzeile y ausgeben, auf volle Breite aufgefuellt."""
|
def _positionen(text):
|
||||||
text = text[:config.CHARS_PER_LINE].ljust(config.CHARS_PER_LINE)
|
"""x-Position jeder Zelle einer Zeile.
|
||||||
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)
|
|
||||||
|
|
||||||
def _emit_degree(self, du):
|
Normalerweise ein festes Raster aus CELL_WIDTH. Zeichen aus
|
||||||
"""Das Gradzeichen einfuegen -- ueber Zeichensatzwechsel oder als Fallback."""
|
config.CELL_NARROW bekommen weniger Platz -- der Dezimalpunkt der
|
||||||
if config.DEGREE_CHARSET is None:
|
Zehntelminuten stuende sonst sehr luftig zwischen den Ziffern.
|
||||||
du.text(config.DEGREE_FALLBACK)
|
"""
|
||||||
return
|
breiten = Display._breiten(text)
|
||||||
du.charset(config.DEGREE_CHARSET, spaced=config.CHARSET_SPACED)
|
x = config.LINE_X
|
||||||
du.text(config.DEGREE_CHAR)
|
aus = []
|
||||||
du.charset(config.CHARSET, spaced=config.CHARSET_SPACED)
|
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):
|
def close(self):
|
||||||
self._t.close()
|
self._t.close()
|
||||||
|
|
||||||
|
|
||||||
def open_display(port=config.PORT, fake=False, response=config.WANT_RESPONSE):
|
def open_display(port=None, fake=False, response=config.WANT_RESPONSE):
|
||||||
"""Transport passend zur Plattform oeffnen und Display-Objekt liefern."""
|
"""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:
|
if fake:
|
||||||
from transport import FakeTransport
|
from transport import FakeTransport
|
||||||
t = FakeTransport(src=config.HOST_ADDR, display_addr=config.DISPLAY_ADDR)
|
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:
|
else:
|
||||||
from transport import SerialTransport
|
from transport import SerialTransport
|
||||||
t = SerialTransport(
|
t = SerialTransport(
|
||||||
port=port,
|
port=config.PORT if port is None else port,
|
||||||
baudrate=config.BAUDRATE,
|
baudrate=config.BAUDRATE,
|
||||||
parity=config.PARITY,
|
parity=config.PARITY,
|
||||||
bytesize=config.BYTESIZE,
|
bytesize=config.BYTESIZE,
|
||||||
|
|||||||
+162
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -38,6 +38,16 @@ class MigraError(Exception):
|
|||||||
"""Fehler im Protokoll oder von der Anzeige gemeldeter Fehlercode."""
|
"""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):
|
def encode_text(s):
|
||||||
"""Text in Anzeige-Bytes wandeln.
|
"""Text in Anzeige-Bytes wandeln.
|
||||||
|
|
||||||
@@ -56,10 +66,16 @@ def encode_text(s):
|
|||||||
|
|
||||||
|
|
||||||
def _digits(value, places):
|
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:
|
if value < 0 or value >= 10 ** places:
|
||||||
raise MigraError("Wert %d passt nicht in %d Stellen" % (value, 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:
|
class DataUnit:
|
||||||
@@ -175,9 +191,9 @@ def parse_response(frame, src=0):
|
|||||||
if not frame:
|
if not frame:
|
||||||
raise MigraError("keine Antwort von der Anzeige (Timeout)")
|
raise MigraError("keine Antwort von der Anzeige (Timeout)")
|
||||||
if len(frame) < 6 or frame[0] != STX or frame[-1] != ETX:
|
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):
|
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])
|
code = chr(frame[4])
|
||||||
if code != "0":
|
if code != "0":
|
||||||
|
|||||||
+22
-5
@@ -29,10 +29,18 @@ class MountClient:
|
|||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
self.close()
|
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 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
sock.settimeout(self.timeout)
|
sock.settimeout(self.timeout)
|
||||||
try:
|
try:
|
||||||
sock.connect((self.host, self.port))
|
sock.connect(addr)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
sock.close()
|
sock.close()
|
||||||
raise MountError("Verbindung zu %s:%d fehlgeschlagen: %s"
|
raise MountError("Verbindung zu %s:%d fehlgeschlagen: %s"
|
||||||
@@ -60,10 +68,19 @@ class MountClient:
|
|||||||
"""Ein gerahmtes Kommando senden und die Antwort ohne '#' zurueckgeben."""
|
"""Ein gerahmtes Kommando senden und die Antwort ohne '#' zurueckgeben."""
|
||||||
if not self.connected:
|
if not self.connected:
|
||||||
raise MountError("nicht verbunden")
|
raise MountError("nicht verbunden")
|
||||||
try:
|
# Von Hand schleifen statt sendall(): MicroPython laesst dessen Verhalten
|
||||||
self._sock.sendall(framed_cmd.encode("latin-1"))
|
# auf Sockets mit Timeout ausdruecklich offen. Die Kommandos sind vier
|
||||||
except OSError as e:
|
# Bytes lang, die Schleife laeuft also praktisch immer nur einmal.
|
||||||
raise MountError("Senden gestoert: %s" % e)
|
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")
|
raw = self._recv_until_terminator().decode("latin-1")
|
||||||
return lx200.strip_terminator(raw)
|
return lx200.strip_terminator(raw)
|
||||||
|
|
||||||
|
|||||||
+74
-14
@@ -8,8 +8,13 @@ Client nicht haengt.
|
|||||||
python3 mount_mock.py # feste Koordinaten, Port 3490
|
python3 mount_mock.py # feste Koordinaten, Port 3490
|
||||||
python3 mount_mock.py --ra 18:36:56 --dec +38:47:01
|
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 --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
|
import argparse
|
||||||
@@ -53,31 +58,54 @@ def _numbers(text):
|
|||||||
return [int(x) for x in out]
|
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
|
total_s %= 86400
|
||||||
h, rest = divmod(total_s, 3600)
|
h, rest = divmod(total_s, 3600)
|
||||||
m, s = divmod(rest, 60)
|
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 "+"
|
sign = "-" if total_arcsec < 0 else "+"
|
||||||
total_arcsec = abs(total_arcsec)
|
total_arcsec = abs(total_arcsec)
|
||||||
d, rest = divmod(total_arcsec, 3600)
|
d, rest = divmod(total_arcsec, 3600)
|
||||||
m, s = divmod(rest, 60)
|
m, s = divmod(rest, 60)
|
||||||
# Gradzeichen als '*', Minuten/Sekunden mit ':' -- der coords-Parser
|
if high:
|
||||||
# kommt mit beiden Trennzeichen klar.
|
return "%s%02d*%02d:%02d" % (sign, d, m, s)
|
||||||
return "%s%02d*%02d:%02d" % (sign, d, m, s)
|
return "%s%02d*%02d" % (sign, d, m)
|
||||||
|
|
||||||
|
|
||||||
class MountMock:
|
class MountMock:
|
||||||
"""Simuliert die Montierung auf TCP-Ebene."""
|
"""Simuliert die Montierung auf TCP-Ebene."""
|
||||||
|
|
||||||
def __init__(self, host="127.0.0.1", port=3490,
|
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._ra0 = _parse_ra(ra)
|
||||||
self._dec = _parse_dec(dec)
|
self._dec = _parse_dec(dec)
|
||||||
self._move = move
|
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._tick = 0 # zaehlt bei --move pro RA-Abfrage hoch
|
||||||
self._dectick = 0 # zaehlt bei --move pro DEC-Abfrage hoch
|
self._dectick = 0 # zaehlt bei --move pro DEC-Abfrage hoch
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
@@ -111,9 +139,9 @@ class MountMock:
|
|||||||
def _reply_for(self, cmd):
|
def _reply_for(self, cmd):
|
||||||
"""Antwort (inkl. '#') fuer ein Kommando ohne ':' und ohne '#'."""
|
"""Antwort (inkl. '#') fuer ein Kommando ohne ':' und ohne '#'."""
|
||||||
if cmd == "GR":
|
if cmd == "GR":
|
||||||
return _fmt_ra(self._current_ra()) + "#"
|
return _fmt_ra(self._current_ra(), self._high) + "#"
|
||||||
if cmd == "GD":
|
if cmd == "GD":
|
||||||
return _fmt_dec(self._current_dec()) + "#"
|
return _fmt_dec(self._current_dec(), self._high) + "#"
|
||||||
if cmd == "GVP":
|
if cmd == "GVP":
|
||||||
return PRODUCT + "#"
|
return PRODUCT + "#"
|
||||||
if cmd == "GVN":
|
if cmd == "GVN":
|
||||||
@@ -172,20 +200,52 @@ class MountMock:
|
|||||||
self.stop()
|
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):
|
def main(argv=None):
|
||||||
p = argparse.ArgumentParser(description=__doc__,
|
p = argparse.ArgumentParser(description=__doc__,
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
p.add_argument("--host", default="127.0.0.1")
|
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("--port", type=int, default=3490)
|
||||||
p.add_argument("--ra", default="18:36:56")
|
p.add_argument("--ra", default="18:36:56")
|
||||||
p.add_argument("--dec", default="+38:47:01")
|
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("--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)
|
args = p.parse_args(argv)
|
||||||
|
|
||||||
mock = MountMock(host=args.host, port=args.port, ra=args.ra, dec=args.dec,
|
host = "0.0.0.0" if args.lan else args.host
|
||||||
move=args.move)
|
mock = MountMock(host=host, port=args.port, ra=args.ra, dec=args.dec,
|
||||||
print("Mock-Montierung auf %s:%d (RA=%s DEC=%s%s)"
|
move=args.move, high=not args.low)
|
||||||
% (mock.host, mock.port, args.ra, args.dec, ", move" if args.move else ""))
|
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.")
|
print("Beenden mit Ctrl-C.")
|
||||||
mock.serve_forever()
|
mock.serve_forever()
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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.
|
nicht aus einem Datenblatt. Dieses Skript hilft, die echten Werte zu finden.
|
||||||
|
|
||||||
python3 probe.py ruler Ziffernlineal 12345678901234... -> Zeichen zaehlen
|
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 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 scan-y dieselbe Zeile schrittweise nach unten schieben
|
||||||
python3 probe.py corners Punkte in die vier Ecken -> Pixelaufloesung pruefen
|
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)
|
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):
|
def lines(d):
|
||||||
d.show_lines("Zeile 1", "Zeile 2")
|
d.show_lines("Zeile 1", "Zeile 2")
|
||||||
print("Stehen beide Zeilen sauber untereinander? Sonst LINE_Y anpassen.")
|
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))
|
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():
|
def main():
|
||||||
args = [a for a in sys.argv[1:] if not a.startswith("-")]
|
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__)
|
print(__doc__)
|
||||||
return 2
|
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
|
response = config.WANT_RESPONSE and "--no-response" not in sys.argv
|
||||||
d = open_display(response=response)
|
d = open_display(response=response)
|
||||||
try:
|
try:
|
||||||
MODES[args[0]](d)
|
MODES[args[0]](d, *extra)
|
||||||
finally:
|
finally:
|
||||||
d.close()
|
d.close()
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
+13
-54
@@ -13,19 +13,12 @@ Hintergrund erneut verbunden.
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
|
|
||||||
import config
|
import config
|
||||||
import coords
|
import displayloop
|
||||||
import migra
|
|
||||||
from brightness import BrightnessController
|
from brightness import BrightnessController
|
||||||
from display import open_display
|
from display import open_display
|
||||||
from mount_client import MountClient, MountError
|
from mount_client import MountClient
|
||||||
|
|
||||||
|
|
||||||
def _format(ra_raw, dec_raw):
|
|
||||||
"""Rohstrings der Montierung fuer die Anzeige aufbereiten."""
|
|
||||||
return coords.format_ra(ra_raw), coords.format_dec(dec_raw)
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
@@ -56,54 +49,20 @@ def main(argv=None):
|
|||||||
|
|
||||||
d = open_display(fake=args.dry_run)
|
d = open_display(fake=args.dry_run)
|
||||||
client = MountClient(host, port, timeout=config.MOUNT_TIMEOUT)
|
client = MountClient(host, port, timeout=config.MOUNT_TIMEOUT)
|
||||||
last_shown = None
|
|
||||||
|
|
||||||
# Helligkeitsregelung: derzeit ein fester LDR-Wert von der Kommandozeile;
|
# Helligkeitsregelung: hier ein fester LDR-Wert von der Kommandozeile; auf
|
||||||
# spaeter (ESP32) wird hier pro Durchlauf der ADC gelesen.
|
# dem ESP32 liest run_esp32.py stattdessen den ADC.
|
||||||
dimmer = BrightnessController() if args.ldr is not None else None
|
dimmer = read_ldr = None
|
||||||
|
if args.ldr is not None:
|
||||||
|
dimmer = BrightnessController()
|
||||||
|
read_ldr = lambda: args.ldr
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
displayloop.poll_loop(d, client,
|
||||||
try:
|
interval=args.interval,
|
||||||
if not client.connected:
|
once=args.once,
|
||||||
client.connect()
|
read_ldr=read_ldr,
|
||||||
|
dimmer=dimmer)
|
||||||
# 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)
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\nbeendet")
|
print("\nbeendet")
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+200
@@ -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
@@ -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
@@ -1,79 +1,159 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
python3 -m unittest -v test_brightness
|
||||||
|
|
||||||
Grenzen: Mitten (300, 2500, 3500) mit Hysterese (30, 150, 150)
|
Die Tests geben ihre Werte selbst vor, statt settings.json zu lesen -- so
|
||||||
-> hoch bei 330 / 2650 / 3650, runter bei 270 / 2350 / 3350.
|
haengen sie nicht daran, was gerade auf einem Geraet konfiguriert ist.
|
||||||
|
Vorgabewerte: Schwelle 1800 +/- 150 -> hoch ab 1950, runter unter 1650.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import brightness
|
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):
|
def setUp(self):
|
||||||
self.c = BrightnessController()
|
self.c = controller()
|
||||||
|
|
||||||
def test_erststart_schaltet_von_0_hoch(self):
|
def test_erststart_entscheidet_an_der_blanken_schwelle(self):
|
||||||
self.assertEqual(self.c._next_level(329, None), 0)
|
"""Ohne bisherige Stufe zaehlt die Schwelle selbst, nicht ihre Kante.
|
||||||
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_hysterese_von_stufe_1(self):
|
Sonst startete die Anzeige im Bereich zwischen Schwelle und Kante
|
||||||
self.assertEqual(self.c._next_level(270, 1), 1)
|
faelschlich dunkel und wachte erst spaeter auf.
|
||||||
self.assertEqual(self.c._next_level(269, 1), 0)
|
"""
|
||||||
self.assertEqual(self.c._next_level(2649, 1), 1)
|
self.assertEqual(self.c._next_level(1799, None), DUNKEL)
|
||||||
self.assertEqual(self.c._next_level(2650, 1), 2)
|
self.assertEqual(self.c._next_level(1800, None), HELL)
|
||||||
|
|
||||||
def test_hysterese_von_stufe_2(self):
|
def test_hochschalten_erst_ueber_der_oberen_kante(self):
|
||||||
self.assertEqual(self.c._next_level(2350, 2), 2)
|
self.assertEqual(self.c._next_level(1949, DUNKEL), DUNKEL)
|
||||||
self.assertEqual(self.c._next_level(2349, 2), 1)
|
self.assertEqual(self.c._next_level(1950, DUNKEL), HELL)
|
||||||
self.assertEqual(self.c._next_level(3649, 2), 2)
|
|
||||||
self.assertEqual(self.c._next_level(3650, 2), 3)
|
|
||||||
|
|
||||||
def test_hysterese_von_stufe_3(self):
|
def test_herunterschalten_erst_unter_der_unteren_kante(self):
|
||||||
self.assertEqual(self.c._next_level(3350, 3), 3)
|
self.assertEqual(self.c._next_level(1650, HELL), HELL)
|
||||||
self.assertEqual(self.c._next_level(3349, 3), 2)
|
self.assertEqual(self.c._next_level(1649, HELL), DUNKEL)
|
||||||
|
|
||||||
def test_prozente(self):
|
def test_im_hysteresefenster_bleibt_die_stufe_stehen(self):
|
||||||
self.assertEqual(brightness.PERCENTS, (5, 15, 50, 100))
|
"""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):
|
class TestUpdate(unittest.TestCase):
|
||||||
|
|
||||||
def test_erste_aenderung_meldet_changed(self):
|
def test_erste_meldung_gilt_als_aenderung(self):
|
||||||
c = BrightnessController()
|
c = controller()
|
||||||
self.assertEqual(c.update(100), (0, 5, True))
|
level, prozent, changed = c.update(100)
|
||||||
|
self.assertEqual((level, prozent), (DUNKEL, 5))
|
||||||
|
self.assertTrue(changed)
|
||||||
|
|
||||||
def test_gleicher_wert_keine_aenderung(self):
|
def test_gleicher_wert_meldet_keine_aenderung(self):
|
||||||
c = BrightnessController()
|
c = controller()
|
||||||
c.update(100)
|
c.update(100)
|
||||||
level, percent, changed = c.update(100)
|
_, prozent, changed = c.update(100)
|
||||||
self.assertFalse(changed)
|
self.assertFalse(changed)
|
||||||
self.assertEqual(percent, 5)
|
self.assertEqual(prozent, 5)
|
||||||
|
|
||||||
def test_dunkel_zu_hell_erreicht_100(self):
|
def test_dunkel_nach_hell_und_zurueck(self):
|
||||||
c = BrightnessController()
|
c = controller()
|
||||||
c.update(100)
|
c.update(0)
|
||||||
level = percent = None
|
self.assertEqual(c.level, DUNKEL)
|
||||||
for _ in range(brightness.AVERAGE_WINDOW + 2):
|
for _ in range(brightness.AVERAGE_WINDOW):
|
||||||
level, percent, _ = c.update(4000)
|
c.update(3500)
|
||||||
self.assertEqual(level, 3)
|
self.assertEqual(c.level, HELL)
|
||||||
self.assertEqual(percent, 100)
|
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):
|
def test_rauschen_um_die_schwelle_schaltet_nicht(self):
|
||||||
c = BrightnessController()
|
"""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):
|
for _ in range(20):
|
||||||
c.update(1000)
|
c.update(1000)
|
||||||
self.assertLessEqual(len(c._buf), brightness.AVERAGE_WINDOW)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
+418
@@ -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
@@ -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
@@ -81,7 +81,8 @@ class TestClientGegenMock(unittest.TestCase):
|
|||||||
def test_get_coordinates_und_formatierung(self):
|
def test_get_coordinates_und_formatierung(self):
|
||||||
ra_raw, dec_raw = self.client.get_coordinates()
|
ra_raw, dec_raw = self.client.get_coordinates()
|
||||||
self.assertEqual(coords.format_ra(ra_raw, high_precision=True), "18h36m56s")
|
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):
|
def test_produktname(self):
|
||||||
self.assertEqual(self.client.query(lx200.GET_PRODUCT), mount_mock.PRODUCT)
|
self.assertEqual(self.client.query(lx200.GET_PRODUCT), mount_mock.PRODUCT)
|
||||||
|
|||||||
+180
@@ -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
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
import config
|
||||||
import coords
|
import coords
|
||||||
import migra
|
import migra
|
||||||
from display import Display
|
from display import Display
|
||||||
@@ -135,41 +136,80 @@ class TestKoordinaten(unittest.TestCase):
|
|||||||
self.assertEqual(coords.format_dec("+45d12m30s", width=7, **hp), "+45°12'")
|
self.assertEqual(coords.format_dec("+45d12m30s", width=7, **hp), "+45°12'")
|
||||||
|
|
||||||
def test_formatierung_niedrigpraezision(self):
|
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)
|
lp = dict(high_precision=False)
|
||||||
# RA: Zehntel-Minute (0.9 min) kommt als Sekunden (54 s) heraus
|
# Breiteste passende Form: mit Minutenzeichen am Ende, sonst ohne.
|
||||||
self.assertEqual(coords.format_ra("11:36.9", width=9, **lp), "11h36m54s")
|
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")
|
self.assertEqual(coords.format_ra("11:36.9", width=6, **lp), "11h36m")
|
||||||
# DEC: ohne Sekunden
|
# DEC: ohne Sekunden
|
||||||
self.assertEqual(coords.format_dec("-00°55", width=9, **lp), "-00°55'")
|
self.assertEqual(coords.format_dec("-00°55", width=9, **lp), "-00°55'")
|
||||||
self.assertEqual(coords.format_dec("-00°55", width=6, **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):
|
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)
|
t = FakeTransport(echo=False)
|
||||||
Display(t, response=True).show_lines("12:34:56", "+45:12:30", clear=True)
|
Display(t, response=True).show_lines("12:34:56", "+45:12:30", clear=True)
|
||||||
|
|
||||||
self.assertEqual(len(t.frames), 1)
|
# clear=True ist ein eigenes, sehr kurzes Telegramm, danach je Zeile eins.
|
||||||
frame = t.frames[0]
|
self.assertEqual(len(t.frames), 3)
|
||||||
self.assertEqual(frame[:4], bytes([0x02, 0x81, 0x80, 0x81]))
|
for frame in t.frames:
|
||||||
self.assertEqual(frame[-1], 0x03)
|
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]
|
loeschen, unten, oben = t.frames # untere Zeile zuerst
|
||||||
self.assertIn(b"\x1bF0", data) # mit clear=True: vorher loeschen
|
self.assertIn(b"\x1bF0", loeschen)
|
||||||
self.assertIn(b"\x1bC000000\x1f12:34:56", data) # Zeile 1 bei y=0
|
self.assertNotIn(b"\x1bF0", unten)
|
||||||
self.assertIn(b"\x1bC000009\x1f+45:12:30", data) # Zeile 2 bei y=9
|
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):
|
def test_ohne_clear_kein_fill_aber_aufgefuellt(self):
|
||||||
"""Standard (clear=False): kein Vollbild-Loeschen, Zeilen auf volle Breite."""
|
"""Standard (clear=False): kein Vollbild-Loeschen, Zeilen auf volle Breite."""
|
||||||
|
from test_display import zeile, aufbereitet
|
||||||
t = FakeTransport(echo=False)
|
t = FakeTransport(echo=False)
|
||||||
Display(t, response=True).show_lines("12:34:56", clear=False)
|
Display(t, response=True).show_lines("12:34:56", clear=False)
|
||||||
data = t.frames[0][4:-1]
|
self.assertNotIn(b"\x1bF0", t.frames[0])
|
||||||
self.assertNotIn(b"\x1bF0", data)
|
# auf CHARS_PER_LINE mit Leerzeichen aufgefuellt (mittig, siehe CENTER_LINES)
|
||||||
# auf CHARS_PER_LINE mit Leerzeichen aufgefuellt
|
self.assertEqual(zeile(t.frames, 0), aufbereitet("12:34:56"))
|
||||||
import config
|
|
||||||
padded = "12:34:56".ljust(config.CHARS_PER_LINE).encode()
|
|
||||||
self.assertIn(padded, data)
|
|
||||||
|
|
||||||
def test_gradzeichen_via_zeichensatz(self):
|
def test_gradzeichen_via_zeichensatz(self):
|
||||||
"""Gradzeichen wird durch kurzen Wechsel auf Zeichensatz 1 erzeugt."""
|
"""Gradzeichen wird durch kurzen Wechsel auf Zeichensatz 1 erzeugt."""
|
||||||
@@ -180,11 +220,20 @@ class TestDisplay(unittest.TestCase):
|
|||||||
self.assertIn(b"\x1bz01\x1f/\x1bz00", data)
|
self.assertIn(b"\x1bz01\x1f/\x1bz00", data)
|
||||||
|
|
||||||
def test_oberste_zeile_wird_zuletzt_gezeichnet(self):
|
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)
|
t = FakeTransport(echo=False)
|
||||||
Display(t, response=True).show_lines("12h34m56s", "+45°12'30\"")
|
Display(t, response=True).show_lines("12h34m56s", "+45°12'30\"")
|
||||||
data = t.frames[0][4:-1]
|
from test_display import zellen
|
||||||
self.assertLess(data.index(b"C000009"), data.index(b"C000000"))
|
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):
|
def test_gradzeichen_fallback_ohne_zeichensatz(self):
|
||||||
"""Ist DEGREE_CHARSET None, wird der Fallbacktext statt der Umschaltung genutzt."""
|
"""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):
|
def test_show_line_nur_eine_zeile(self):
|
||||||
"""show_line schreibt nur die angegebene Zeile, ohne Vollbild-Loeschen."""
|
"""show_line schreibt nur die angegebene Zeile, ohne Vollbild-Loeschen."""
|
||||||
|
from test_display import zellen
|
||||||
t = FakeTransport(echo=False)
|
t = FakeTransport(echo=False)
|
||||||
Display(t, response=True).show_line(0, "18h36m56s")
|
Display(t, response=True).show_line(0, "18h36m56s")
|
||||||
data = t.frames[0][4:-1]
|
self.assertNotIn(b"\x1bF0", t.frames[0]) # kein Loeschen
|
||||||
self.assertNotIn(b"\x1bF0", data) # kein Loeschen
|
y_werte = set(y for _, y in zellen(t.frames))
|
||||||
self.assertIn(b"\x1bC000000", data) # Cursor auf Zeile 1 (y=0)
|
self.assertEqual(y_werte, {config.LINE_Y[0]}) # nur Zeile 1, Zeile 2 unberuehrt
|
||||||
self.assertNotIn(b"C000009", data) # Zeile 2 unberuehrt
|
|
||||||
|
|
||||||
def test_show_line_ungueltiger_index(self):
|
def test_show_line_ungueltiger_index(self):
|
||||||
t = FakeTransport(echo=False)
|
t = FakeTransport(echo=False)
|
||||||
|
|||||||
+406
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -12,15 +12,22 @@ except ImportError:
|
|||||||
serial = None
|
serial = None
|
||||||
|
|
||||||
try: # MicroPython
|
try: # MicroPython
|
||||||
from machine import UART
|
from machine import UART, Pin
|
||||||
except ImportError:
|
except ImportError:
|
||||||
UART = None
|
UART = None
|
||||||
|
Pin = None
|
||||||
|
|
||||||
try:
|
from ticks import deadline as _deadline, expired as _expired
|
||||||
import time
|
|
||||||
_monotonic = time.monotonic
|
|
||||||
except AttributeError: # MicroPython kennt kein time.monotonic
|
def _truncate_at_etx(buf):
|
||||||
_monotonic = lambda: time.ticks_ms() / 1000.0
|
"""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:
|
class SerialTransport:
|
||||||
@@ -49,9 +56,9 @@ class SerialTransport:
|
|||||||
|
|
||||||
def read_response(self):
|
def read_response(self):
|
||||||
"""Bytes bis einschliesslich ETX lesen, oder bis der Timeout greift."""
|
"""Bytes bis einschliesslich ETX lesen, oder bis der Timeout greift."""
|
||||||
deadline = _monotonic() + self._timeout
|
deadline = _deadline(self._timeout)
|
||||||
buf = bytearray()
|
buf = bytearray()
|
||||||
while _monotonic() < deadline:
|
while not _expired(deadline):
|
||||||
chunk = self._port.read(1)
|
chunk = self._port.read(1)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
continue
|
continue
|
||||||
@@ -65,7 +72,12 @@ class SerialTransport:
|
|||||||
|
|
||||||
|
|
||||||
class UartTransport:
|
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,
|
def __init__(self, uart_id, baudrate, parity, bytesize, stopbits, timeout,
|
||||||
tx=None, rx=None):
|
tx=None, rx=None):
|
||||||
@@ -80,27 +92,42 @@ class UartTransport:
|
|||||||
stop=stopbits,
|
stop=stopbits,
|
||||||
timeout=int(timeout * 1000),
|
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:
|
if tx is not None:
|
||||||
kwargs["tx"] = tx
|
kwargs["tx"] = Pin(tx) if Pin is not None else tx
|
||||||
if rx is not None:
|
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._uart = UART(uart_id, **kwargs)
|
||||||
self._timeout = timeout
|
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):
|
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)
|
self._uart.write(data)
|
||||||
|
|
||||||
def read_response(self):
|
def read_response(self):
|
||||||
deadline = _monotonic() + self._timeout
|
deadline = _deadline(self._timeout)
|
||||||
buf = bytearray()
|
buf = bytearray()
|
||||||
while _monotonic() < deadline:
|
while not _expired(deadline):
|
||||||
chunk = self._uart.read(1)
|
n = self._uart.any()
|
||||||
|
if not n:
|
||||||
|
continue
|
||||||
|
chunk = self._uart.read(n)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
continue
|
continue
|
||||||
buf.extend(chunk)
|
buf.extend(chunk)
|
||||||
if chunk[0] == migra.ETX:
|
if migra.ETX in chunk:
|
||||||
break
|
break
|
||||||
return bytes(buf)
|
return _truncate_at_etx(buf)
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self._uart.deinit()
|
self._uart.deinit()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user