Compare commits
10 Commits
d8a9953b73
...
0876d8e827
| Author | SHA1 | Date | |
|---|---|---|---|
| 0876d8e827 | |||
| 142a621066 | |||
| 433c53aabd | |||
| dae7694a07 | |||
| 41f5eb1821 | |||
| bf39b75ac1 | |||
| 0a893d0684 | |||
| e8a5b5d5e3 | |||
| 4f7375add3 | |||
| 42fc7f52d4 |
+12
-1
@@ -10,4 +10,15 @@ venv/
|
||||
.DS_Store
|
||||
|
||||
#Hardware-Sachern nicht ist Git
|
||||
Hardware/
|
||||
Hardware/
|
||||
|
||||
# WLAN-Zugangsdaten des ESP32 (Vorlage: wifi_config_example.py)
|
||||
wifi_config.py
|
||||
|
||||
# MQTT-Zugangsdaten (Vorlage: mqtt_config_example.py)
|
||||
mqtt_config.py
|
||||
|
||||
# Laufzeit-Einstellungen (Helligkeiten, Schwelle -- spaeter per MQTT verstellt).
|
||||
# Gehoert dem Geraet, nicht dem Repo; die Vorgaben stehen in settings.DEFAULTS.
|
||||
settings.json
|
||||
test_settings_tmp.json
|
||||
@@ -3,10 +3,10 @@
|
||||
Ansteuerung einer microSYST **migra SC/MC Seriell** LED-Großanzeige mit den
|
||||
Koordinaten (Rektaszension / Deklination) einer 10Micron GM4000 HPS II.
|
||||
|
||||
Endziel ist ein XIAO ESP32-C3, der die Montierung per WLAN abfragt und die
|
||||
Koordinaten über UART/RS232 auf die Anzeige schreibt. **Phase 1** (dieser Stand)
|
||||
ist das Testprogramm auf dem Mac: Koordinaten von Hand übergeben, Ausgabe über
|
||||
einen USB-RS232-Wandler.
|
||||
Zielgerät ist ein XIAO ESP32-C3, der die Montierung per WLAN abfragt und die
|
||||
Koordinaten über UART/RS232 auf die Anzeige schreibt. Entwickelt und getestet
|
||||
wird auf dem Mac (Koordinaten von Hand bzw. von der Montierung, Ausgabe über
|
||||
einen USB-RS232-Wandler); dieselben Module laufen auf dem ESP32.
|
||||
|
||||
## Benutzung
|
||||
|
||||
@@ -23,32 +23,59 @@ python3 run_display.py --host 10.0.0.42 # andere IP
|
||||
python3 run_display.py --mock --move # ohne Teleskop: lokaler Mock, RA läuft
|
||||
python3 run_display.py --mock --move --ldr 2000 # zusätzlich mit Helligkeitsregelung
|
||||
python3 mount_mock.py --move # Mock separat starten
|
||||
python3 mount_mock.py --move --low # ... im Auslieferformat der GM4000
|
||||
# (RA als HH:MM.T, DEC ohne Sekunden)
|
||||
|
||||
# Helligkeit aus einem LDR-Wert setzen:
|
||||
python3 brightness.py --ldr 1500 # LDR-Rohwert -> Stufe/Prozent -> senden
|
||||
python3 brightness.py --ldr 1500 # LDR-Rohwert -> Helligkeit -> senden
|
||||
|
||||
python3 -m unittest test_migra test_lx200 test_brightness # alle Tests
|
||||
python3 -m unittest discover -p 'test_*.py' # alle Tests
|
||||
python3 probe.py ruler # Geometrie der Anzeige ausmessen
|
||||
```
|
||||
|
||||
Einzige Abhängigkeit auf dem Mac ist `pyserial`.
|
||||
Auf dem ESP32-C3 (siehe Abschnitt [ESP32-C3](#esp32-c3)):
|
||||
|
||||
```bash
|
||||
cp wifi_config_example.py wifi_config.py # WLAN-Zugangsdaten eintragen
|
||||
./deploy.sh # Dateien auf den ESP32 kopieren
|
||||
mpremote repl # dann: import run_esp32
|
||||
# run_esp32.main()
|
||||
```
|
||||
|
||||
Einzige Abhängigkeit auf dem Mac ist `pyserial`, fürs Übertragen zusätzlich
|
||||
`mpremote`. Auf dem ESP32 laufen die Module ohne jede Fremdbibliothek.
|
||||
|
||||
## Aufbau
|
||||
|
||||
| Datei | Zweck |
|
||||
|---|---|
|
||||
| `migra.py` | Protokoll: Telegrammrahmen, Prüfsumme, ESC-Sequenzen. Ohne Abhängigkeiten, **läuft unverändert unter MicroPython**. |
|
||||
| `transport.py` | UART: `pyserial` (Mac), `machine.UART` (ESP32-C3), `FakeTransport` (Trockenlauf). |
|
||||
| `display.py` | Treiber: Telegramm senden, Antwort auswerten. |
|
||||
| `coords.py` | Koordinaten einlesen und auf die Zeilenbreite formatieren. |
|
||||
| `config.py` | Port, Adresse, Geometrie, Helligkeit — alles an einer Stelle. |
|
||||
| `show_coords.py` | Das Testprogramm (Koordinaten von Hand). |
|
||||
| `brightness.py` | Helligkeitsregelung: LDR-Wert → Stufe/Prozent (portabel + CLI). |
|
||||
| `run_display.py` | Hauptschleife Phase 2: Montierung pollen, anzeigen, Helligkeit regeln. |
|
||||
| `mount_client.py` / `mount_mock.py` | TCP-Client zur GM4000 bzw. Simulator (LX200). |
|
||||
| `lx200.py` | LX200-Kommandorahmen. |
|
||||
| `probe.py` | Testmuster zum Ausmessen von Zeilenbreite und Zeilenposition. |
|
||||
| `test_migra.py` / `test_lx200.py` / `test_brightness.py` | Tests. |
|
||||
Alles, was auf beiden Plattformen läuft, ist bewusst abhängigkeitsfrei
|
||||
gehalten. Was sich unterscheidet, steckt in `transport.py` (UART) und in den
|
||||
beiden Startprogrammen.
|
||||
|
||||
| Datei | Zweck | ESP32 |
|
||||
|---|---|---|
|
||||
| `migra.py` | Protokoll: Telegrammrahmen, Prüfsumme, ESC-Sequenzen. | ✓ |
|
||||
| `transport.py` | UART: `pyserial` (Mac), `machine.UART` (ESP32-C3), `FakeTransport` (Trockenlauf). | ✓ |
|
||||
| `ticks.py` | Timeouts, die unter CPython und MicroPython gleich funktionieren. | ✓ |
|
||||
| `display.py` | Treiber: Telegramm senden, Antwort auswerten. | ✓ |
|
||||
| `coords.py` | Koordinaten einlesen und auf die Zeilenbreite formatieren. | ✓ |
|
||||
| `config.py` | Port/Pins, Adresse, Geometrie, WLAN — fest, ändert sich nur beim Deployen. | ✓ |
|
||||
| `settings.py` | Zur Laufzeit änderbare Werte (Helligkeiten, Schwelle) in `settings.json`. | ✓ |
|
||||
| `displayloop.py` | Die Poll-Schleife: abfragen, formatieren, anzeigen, Fehler abfangen. | ✓ |
|
||||
| `brightness.py` | Helligkeit: LDR-Wert → dunkel/hell, Schwelle aus `settings.json`. | ✓ |
|
||||
| `mount_client.py` | TCP-Client zur GM4000 (LX200). | ✓ |
|
||||
| `lx200.py` | LX200-Kommandorahmen. | ✓ |
|
||||
| `wifi.py` | WLAN-Anbindung des ESP32 (verbinden, Verbindung halten). | ✓ |
|
||||
| `mqtt.py` | MQTT: Einstellungen empfangen, Zustand veröffentlichen. | ✓ |
|
||||
| `run_esp32.py` | Startprogramm auf dem ESP32: WLAN, Anzeige, Schleife. | ✓ |
|
||||
| `main.py` | Autostart nach jedem Reset, mit Watchdog und Abbruchfenster. | ✓ |
|
||||
| `wifi_config.py` | WLAN-Zugangsdaten, **nicht im Git** (Vorlage `wifi_config_example.py`). | ✓ |
|
||||
| `mqtt_config.py` | MQTT-Zugangsdaten, **nicht im Git** (Vorlage `mqtt_config_example.py`). | ✓ |
|
||||
| `run_display.py` | Startprogramm auf dem Mac: dieselbe Schleife mit Kommandozeile. | |
|
||||
| `show_coords.py` | Testprogramm (Koordinaten von Hand). | |
|
||||
| `mount_mock.py` | Simulator der Montierung (LX200) fürs Testen ohne Teleskop. | |
|
||||
| `probe.py` | Testmuster zum Ausmessen von Zeilenbreite und Zeilenposition. | |
|
||||
| `deploy.sh` | Kopiert die ESP32-Dateien per `mpremote` auf das Gerät. | |
|
||||
| `test_*.py` | Tests (migra, lx200, brightness, settings, display, esp32). | |
|
||||
|
||||
## Protokoll (Kurzfassung)
|
||||
|
||||
@@ -91,14 +118,189 @@ Folgt auf eine ESC-Sequenz ein Online-Text, muss **`0x1F` als Trennzeichen**
|
||||
dazwischen. Data-Unit max. 230 Zeichen. `migra.DataUnit` setzt das Trennzeichen
|
||||
selbst.
|
||||
|
||||
## ESP32-C3
|
||||
|
||||
### Verdrahtung
|
||||
|
||||
Die Pins stammen aus dem Schaltplan (`Hardware/KiCad/Grossanzeige`) und stehen
|
||||
in `config.py`. Am XIAO ESP32-C3 ist UART1 frei zuordenbar; UART0 bleibt für die
|
||||
REPL-Konsole über USB reserviert.
|
||||
|
||||
| XIAO | GPIO | Netz | weiter zu |
|
||||
|---|---|---|---|
|
||||
| Pad 7 (D6) | 21 | TX | MAX3232 `T2IN` → `T2OUT` → J5.3 → Anzeige RxD (Sub-D Pin 2) |
|
||||
| Pad 8 (D7) | 20 | RX | MAX3232 `R2OUT` ← `R2IN` ← J5.2 ← Anzeige TxD (Sub-D Pin 3) |
|
||||
| Pad 1 (D0/A0) | 2 | LDR | Spannungsteiler J2, siehe unten |
|
||||
| Pad 12 | — | 3V3 | MAX3232 `VCC` |
|
||||
| Pad 13 | — | GND | gemeinsame Masse |
|
||||
|
||||
Die Anzeige will RS232-Pegel, der ESP32-C3 kann nur TTL — der MAX3232 dazwischen
|
||||
ist zwingend.
|
||||
|
||||
### Einrichten
|
||||
|
||||
MicroPython für den ESP32-C3 flashen (einmalig; Firmware von
|
||||
[micropython.org/download/ESP32_GENERIC_C3](https://micropython.org/download/ESP32_GENERIC_C3/)):
|
||||
|
||||
```bash
|
||||
pip install esptool mpremote
|
||||
esptool.py --chip esp32c3 --port /dev/cu.usbmodem1101 erase_flash
|
||||
esptool.py --chip esp32c3 --port /dev/cu.usbmodem1101 --baud 460800 \
|
||||
write_flash -z 0x0 ESP32_GENERIC_C3-*.bin
|
||||
```
|
||||
|
||||
Dann die Zugangsdaten anlegen und alles übertragen:
|
||||
|
||||
```bash
|
||||
cp wifi_config_example.py wifi_config.py # SSID und Passwort eintragen
|
||||
./deploy.sh # oder ./deploy.sh /dev/cu.usbmodem1101
|
||||
```
|
||||
|
||||
`wifi_config.py` steht in `.gitignore` — das WLAN-Passwort gehört nicht ins
|
||||
Repository.
|
||||
|
||||
### Starten
|
||||
|
||||
```bash
|
||||
mpremote repl
|
||||
>>> import run_esp32
|
||||
>>> run_esp32.main() # ohne Helligkeitsregelung
|
||||
>>> run_esp32.main(with_ldr=True) # mit LDR über den ADC
|
||||
>>> run_esp32.main(with_mqtt=False) # ohne MQTT (sonst an, sofern konfiguriert)
|
||||
```
|
||||
|
||||
### Dauerbetrieb: Autostart und Watchdog
|
||||
|
||||
`main.py` startet die Anzeige nach jedem Reset von selbst — nach Stromausfall
|
||||
oder Watchdog-Neustart läuft sie also ohne Notebook wieder an.
|
||||
|
||||
**Das Abbruchfenster ist die Notbremse.** Ohne es wäre ein Gerät, dessen Programm
|
||||
beim Start hängt, nur noch durch Neuflashen erreichbar: Die Endlosschleife belegt
|
||||
die REPL sofort nach dem Reset. Deshalb wartet `main.py` erst
|
||||
`config.AUTOSTART_DELAY` Sekunden:
|
||||
|
||||
```
|
||||
Grossanzeige startet in 3 s -- Ctrl-C bricht ab.
|
||||
Watchdog: aktiv, 120 s
|
||||
WLAN: verbinde mit Mizar ...
|
||||
```
|
||||
|
||||
In diesen drei Sekunden führt Ctrl-C zurück an die REPL (`Autostart abgebrochen,
|
||||
REPL ist frei`) — dann läuft auch kein Watchdog. Wer das Gerät ganz ohne
|
||||
Autostart will: `mpremote rm :main.py`.
|
||||
|
||||
Der **Watchdog** (`config.WDT_TIMEOUT`, 120 s) startet das Gerät neu, wenn die
|
||||
Poll-Schleife stehenbleibt. Gefüttert wird zu Beginn jedes Durchlaufs — auch nach
|
||||
einem Fehler, denn „KEIN LINK" ist kein Stillstand: Die Schleife läuft ja weiter.
|
||||
Der Watchdog greift nur, wenn gar nichts mehr geht.
|
||||
|
||||
Die 120 s müssen über allem liegen, was regulär blockieren darf: WLAN-Aufbau bis
|
||||
20 s, Montierung 3 s, dazu ein MQTT-Verbindungsversuch. Ein knapper Wert wäre
|
||||
eine Neustart-Schleife. **Ein gestarteter Watchdog lässt sich nicht mehr
|
||||
abschalten** — deshalb legt ihn nur `main.py` an, nicht der Handbetrieb über die
|
||||
REPL; sonst startete das Gerät neu, während man an der REPL nachdenkt.
|
||||
|
||||
Nach einem Watchdog-Neustart meldet `main.py` das beim Start
|
||||
(`Vorheriger Lauf wurde vom Watchdog beendet`, über `machine.reset_cause()`) —
|
||||
sonst sähe der Neustart aus wie ein gewöhnlicher Stromausfall. Eine **Ausnahme**
|
||||
fängt `main.py` ab und startet nach `config.RESTART_DELAY` neu, statt in die REPL
|
||||
zu fallen und die Anzeige einzufrieren.
|
||||
|
||||
Am Gerät bestätigt (2026-07-27): Autostart nach Reset, Watchdog löst bei
|
||||
absichtlichem Stillstand aus und das Gerät kommt von allein wieder hoch, Ctrl-C
|
||||
im Startfenster gibt die REPL frei.
|
||||
|
||||
### Was auf beiden Seiten gleich läuft
|
||||
|
||||
`displayloop.poll_loop` ist die gemeinsame Hauptschleife — Mac und ESP32 fahren
|
||||
denselben Ablauf, drumherum liegt nur die Kommandozeile (`run_display.py`) bzw.
|
||||
das WLAN-Setup (`run_esp32.py`). Fällt das WLAN aus, zeigt die Anzeige
|
||||
`NOWIFI_LINES`, fällt die Montierung aus, `NOLINK_LINES`; in beiden Fällen läuft
|
||||
die Schleife weiter und verbindet sich neu.
|
||||
|
||||
Stolpersteine, die beim Portieren aufgefallen sind und in den Tests
|
||||
festgehalten sind (`test_esp32.py`, `test_display.py`):
|
||||
|
||||
- **MicroPython hat kein `str.ljust`** (auch kein `rjust`/`center`/`zfill`).
|
||||
Das fiel erst am Gerät auf — auf dem Mac gibt es die Methode, alle Tests
|
||||
waren grün. `display._emit_line` füllt jetzt von Hand auf.
|
||||
`TestMicropythonVertraeglichkeit` sucht solche Aufrufe seitdem statisch.
|
||||
- **`time.ticks_ms()` läuft nach ~12 Tagen über.** Ein einfaches
|
||||
„jetzt + Timeout" ließe eine Warteschleife danach tagelang hängen. `ticks.py`
|
||||
rechnet deshalb über `ticks_diff`, das den Überlauf richtig behandelt.
|
||||
- **Der UART braucht denselben Puffer-Flush wie pyserial.** `UartTransport.write`
|
||||
leert den Empfangspuffer vor jedem Senden, sonst sieht `read_response` alte
|
||||
Störbytes statt der frischen Antwort.
|
||||
- **Feste Anzeigetexte müssen in `CHARS_PER_LINE` passen.** `NOLINK_LINES` hieß
|
||||
zuerst „MONTIERUNG" (10 Zeichen) und erschien als „MONTIERUN"; `_emit_line`
|
||||
schneidet stillschweigend ab. `test_display.py` prüft die Breite jetzt.
|
||||
(Seit `CHARS_PER_LINE = 10` würde „MONTIERUNG" wieder passen — die Texte sind
|
||||
aber bei „KEIN LINK"/„TELESKOP" geblieben.)
|
||||
|
||||
Was MicroPython 1.28 entgegen der Erwartung **doch** kann (am Gerät nachgeprüft):
|
||||
`%0*d` mit dynamischer Feldbreite, `%-11s`, `bytes.hex()`, `str.strip`/`lstrip`,
|
||||
`str.isdigit`.
|
||||
|
||||
Ohne Hardware lässt sich der ESP32-Teil trotzdem weitgehend prüfen:
|
||||
`test_esp32.py` hängt Attrappen für `machine` und `network` ein und fährt die
|
||||
ganze Kette gegen einen echten Mock — UART-Transport, WLAN, ADC-Auswertung,
|
||||
Zeitrechnung und die Poll-Schleife samt Ausfallverhalten.
|
||||
|
||||
### Gegen den Mock auf dem Mac testen
|
||||
|
||||
Solange die Montierung nicht erreichbar ist, spielt der Mock ihre Rolle. Er muss
|
||||
dafür auf allen Schnittstellen lauschen, sonst kommt der ESP32 nicht heran:
|
||||
|
||||
```bash
|
||||
python3 mount_mock.py --lan --move # gibt die IP aus, die einzutragen ist
|
||||
```
|
||||
|
||||
Auf dem ESP32 dann:
|
||||
|
||||
```python
|
||||
import run_esp32
|
||||
run_esp32.selftest(host="192.168.178.26") # Bring-up prüfen
|
||||
run_esp32.main(host="192.168.178.26", fake_display=True) # Dauerschleife
|
||||
```
|
||||
|
||||
`selftest` geht die drei Stufen einzeln durch — WLAN, Montierung, Telegrammbau —
|
||||
und sagt, an welcher es klemmt. `fake_display=True` schreibt die Telegramme nur
|
||||
als Hex in die REPL, es braucht also weder Pegelwandler noch Anzeige.
|
||||
|
||||
Wenn keine Verbindung zustande kommt, obwohl der Mock lauscht: macOS fragt beim
|
||||
ersten Mal, ob `python3` eingehende Verbindungen annehmen darf — das muss
|
||||
erlaubt sein.
|
||||
|
||||
### Am Gerät bestätigt (2026-07-26)
|
||||
|
||||
XIAO ESP32-C3 mit MicroPython 1.28.0 (`ESP32_GENERIC_C3`), gegen den Mock auf dem
|
||||
Mac:
|
||||
|
||||
- Alle zwölf Module importieren auf dem Gerät.
|
||||
- Telegrammbau und Koordinaten-Formatierung sind **byte-identisch** zum Mac,
|
||||
inklusive Gradzeichen über den Zeichensatzwechsel.
|
||||
- WLAN verbindet sich, `selftest` geht durch alle drei Stufen.
|
||||
- Die Poll-Schleife lief 20 s im Sekundentakt, 22 Abfragen ohne Aussetzer.
|
||||
- **Ausfall und Erholung:** Mock gestoppt → „KEIN LINK / TELESKOP" erscheint, die
|
||||
Schleife läuft weiter; Mock zurück → automatischer Reconnect, und der erste
|
||||
Wiedereinstieg löscht einmal das Vollbild, sodass kein Rest stehen bleibt.
|
||||
- UART1 öffnet auf GPIO21/20 mit 19200 8E1, sendet, und der Lese-Timeout greift
|
||||
sauber (kein Hänger) — allerdings noch **ohne Gegenstelle**.
|
||||
- ADC auf GPIO2 mit 11 dB Dämpfung liefert Werte.
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
- **Echte Montierungs-IP:** In `config.py` steht `MOUNT_HOST` noch als Platzhalter.
|
||||
Sobald die GM4000 im Netz erreichbar ist, dort die IP eintragen (Port 3490 ist
|
||||
Vorgabe). Bis dahin läuft alles gegen den Mock (`run_display.py --mock`).
|
||||
- Portierung auf den XIAO ESP32-C3 (MicroPython): `migra.py`, `lx200.py`,
|
||||
`mount_client.py` sind bewusst abhängigkeitsfrei; nur `transport` (UART) und das
|
||||
WLAN-Setup kommen hinzu.
|
||||
- **Umschaltschwelle in der Sternwarte gegenprüfen.** Die Schwelle (1800) ist am
|
||||
realen Aufbau gemessen (2026-07-27, siehe *Helligkeitsregelung*), aber am
|
||||
Schreibtisch. Vor Ort bei Tag und Nacht die Rohwerte ablesen und die Schwelle in
|
||||
`settings.json` bestätigen. Bis dahin läuft die Anzeige mit fester Helligkeit
|
||||
(`run_esp32.main()` ohne `with_ldr`).
|
||||
- **MQTT im Dauerbetrieb beobachten.** Die Strecke ist am echten Broker in beide
|
||||
Richtungen bestätigt (2026-07-27); offen ist nur, wie sie sich über Tage verhält
|
||||
(Reconnect nach Broker-Neustart, WLAN-Aussetzer).
|
||||
- **Netzwerk zur echten Montierung.** Die GM4000 steht in `192.168.1.115`, das
|
||||
Heimnetz des ESP32 ist `192.168.178.x` — der Mac erreicht sie per VPN, der
|
||||
ESP32 so nicht. Muss geklärt werden, bevor es an die echte Montierung geht.
|
||||
|
||||
## Gradzeichen
|
||||
|
||||
@@ -108,24 +310,86 @@ Gradzeichen (deckt sich mit dem alten Programm, `asciitaf.h`: Code 0xAF → per
|
||||
`& 0x7F` = 0x2F in Zeichensatz 1). Zur Ausgabe wird für dieses eine Zeichen kurz
|
||||
auf Zeichensatz 1 umgeschaltet und wieder zurück (`config.DEGREE_*`, `display._emit_degree`).
|
||||
|
||||
**Fallstrick:** Ein Zeichensatzwechsel mitten im Telegramm beschädigt die Home-Zelle
|
||||
(0,0) — das erste Zeichen der obersten Zeile wird zerstört. Deshalb zeichnet
|
||||
`show_lines` die Zeilen **von unten nach oben** (oberste Zeile zuletzt), damit sie
|
||||
diese Zelle überschreibt.
|
||||
**Fallstrick:** Ein Zeichensatzwechsel beschädigt die Home-Zelle (0,0) — das erste
|
||||
Zeichen der obersten Zeile wird zerstört. Deshalb schreibt `show_lines` die Zeilen
|
||||
**von unten nach oben** (oberste Zeile zuletzt), damit sie diese Zelle überschreibt.
|
||||
Das gilt weiterhin, seit jede Zeile ihr eigenes Telegramm bekommt: dann entscheidet
|
||||
die Reihenfolge der Telegramme statt der Reihenfolge innerhalb eines Telegramms.
|
||||
|
||||
## Geometrie und Darstellung
|
||||
|
||||
- **9 Zeichen pro Zeile** in Sperrschrift (`CHARSET_SPACED = True`, am Gerät
|
||||
ausgemessen). `probe.py ruler` misst es nach.
|
||||
- Die Deklination wird deshalb als **9-Zeichen-Form ohne abschließendes `"`**
|
||||
ausgegeben (`+38°47'01`) — Gradzeichen und Bogenminuten bleiben, die Bogensekunden
|
||||
stehen als Ziffern da. `coords.format_dec` wählt automatisch die breiteste Form,
|
||||
die in `CHARS_PER_LINE` passt.
|
||||
- **Zyklische Updates flimmerfrei:** `show_lines` löscht nicht bei jeder Aktualisierung,
|
||||
sondern überschreibt die Zeilen an Ort und Stelle (auf volle Breite aufgefüllt).
|
||||
`clear=True` nur beim ersten Bild. Wichtig: Zeilen dürfen **nicht breiter** als
|
||||
`CHARS_PER_LINE` aufgefüllt werden, sonst bricht das Füll-Leerzeichen um und
|
||||
beschädigt die andere Zeile.
|
||||
- **10 Zeichen pro Zeile zu 6 px** (`CHARS_PER_LINE = 10`, `CELL_WIDTH = 6`), am
|
||||
Gerät entschieden mit `probe.py pitch 6` (2026-07-27).
|
||||
|
||||
Vorher stand hier 9 — das war am Zeilenumbruch abgezählt und maß etwas
|
||||
anderes, als es zu messen schien. `probe.py pitch` stellt den Auto-Vorschub
|
||||
der Anzeige gegen feste Pixelpositionen `x = i·cell` und trennt damit beides:
|
||||
|
||||
- Der **Auto-Vorschub** der Sperrschrift `ESC z` ist **7 px**. Eine Zeile in
|
||||
einem Stück geschrieben bricht deshalb nach 9 Zeichen um (9·7 = 63 von 64 px).
|
||||
- Die **Zeichenmatrix** ist aber nur **6 px** breit. Zehn Ziffern, einzeln auf
|
||||
`x = i·6` gesetzt, stehen sauber getrennt nebeneinander (60 von 64 px).
|
||||
|
||||
Deshalb positioniert `display._emit_line` jedes Zeichen selbst, statt den
|
||||
Auto-Vorschub laufen zu lassen. Der Preis sind längere Telegramme (rund 10 Byte
|
||||
je Zeichen statt 1), der Gewinn ein volles Zeichen mehr pro Zeile.
|
||||
|
||||
```bash
|
||||
python3 probe.py pitch 6 # stehen beide Zeilen untereinander -> 6 px/Zelle
|
||||
python3 probe.py pitch 7 # ... -> 7 px/Zelle
|
||||
```
|
||||
|
||||
- **Ein Telegramm je Zeile.** Früher gingen beide Zeilen in einem Telegramm raus.
|
||||
Mit Einzelpositionierung kämen sie mit Gradzeichen und Vollbild-Löschen auf
|
||||
224 der erlaubten 230 Bytes (`migra.MAX_DATA_UNIT`) — zu wenig Reserve für ein
|
||||
Gerät, das monatelang durchläuft. Getrennte Telegramme halten jedes bei rund
|
||||
der Hälfte; die Flusskontrolle über das Antworttelegramm macht die zweite
|
||||
Sendung unproblematisch. Die Reihenfolge bleibt **unten vor oben** (Home-Zelle).
|
||||
|
||||
- Die Deklination passt damit in **voller Form mit abschließendem `"`**
|
||||
(`+38°47'01"`, 10 Zeichen). `coords.format_dec` wählt automatisch die breiteste
|
||||
Form, die in die Zeile passt — bei Niedrigpräzision entsprechend `+38°47'`
|
||||
ohne Sekunden.
|
||||
|
||||
- **Schmale Zeichen bekommen weniger Platz** (`config.CELL_NARROW`). Der
|
||||
Dezimalpunkt der Zehntelminuten und der Abstand zwischen Beschriftung und Wert
|
||||
belegen statt 6 nur 3 px — in der Sperrschrift standen sie sonst sehr luftig.
|
||||
Möglich ist das nur, weil ohnehin jedes Zeichen einzeln positioniert wird.
|
||||
Für die Leerzeichen am Zeilenende gilt es *nicht*: Die sollen den Rest der
|
||||
Zeile überschreiben und behalten die volle Breite.
|
||||
|
||||
Deshalb entscheidet die **Pixelbreite** über das Abschneiden, nicht die
|
||||
Zeichenzahl: `RA 18h36.9m` sind 11 Zeichen und trotzdem nur 60 px, `DE +38°47'`
|
||||
wären als 11 volle Zellen dagegen 66 px und damit zu breit.
|
||||
|
||||
- **Zeilen stehen linksbündig** ab `LINE_X = 0`. Zentriert waren sie
|
||||
zwischenzeitlich, standen dann aber um wenige Pixel gegeneinander versetzt, weil
|
||||
die RA-Zeile mit ihrem schmalen Dezimalpunkt kürzer ist als die DEC-Zeile.
|
||||
|
||||
- **Es wird nur geschrieben, was sich geändert hat.** `show_lines` vergleicht gegen
|
||||
den zuletzt dargestellten Inhalt und schickt nur die betroffenen Zellen; eine
|
||||
unveränderte Zeile erzeugt gar kein Telegramm. `clear=True` nur beim ersten Bild
|
||||
und nach einem Verbindungsabriss.
|
||||
|
||||
Das ist nicht nur Sparsamkeit, sondern der Grund für eine ruhige Anzeige. Mit
|
||||
Einzelpositionierung kostet eine volle Zeile rund 100 Bytes statt 20; bei 19200
|
||||
Baud mit 8E1 (11 Bit je Byte) dauerte ein kompletter Bildwechsel damit **rund
|
||||
300 ms**. Die Anzeige stellt schon während des Empfangs dar, also war der Aufbau
|
||||
als Flackern zu sehen — bei Aktualisierung im Sekundentakt ein Drittel der Zeit.
|
||||
Von einer Sekunde zur nächsten ändert sich aber fast nichts: `18h36m56s` →
|
||||
`18h36m57s` ist eine einzige Ziffer. Am Gerät gemessen (2026-07-27):
|
||||
**306 ms → 66 ms** pro Bildwechsel.
|
||||
|
||||
- **Der rechte Nachbar wird mitgeschrieben.** Die Anzeige malt je Zeichen 7 px
|
||||
breit (daher der 7-px-Auto-Vorschub), gesetzt wird aber auf 6-px-Raster. Ein neu
|
||||
geschriebenes Zeichen löscht deshalb die erste Pixelspalte des Zeichens rechts
|
||||
daneben. Beim vollständigen Neuaufbau fiel das nie auf, weil der Nachbar gleich
|
||||
danach ohnehin neu gemalt wurde — beim Schreiben einzelner Zellen blieb er
|
||||
beschädigt stehen: dem `s` hinter den Sekunden fehlten die linken Pixel, dem `m`
|
||||
hinter den Minuten ebenso. `_zelle_faellig` zieht den rechten Nachbarn deshalb mit.
|
||||
|
||||
- Wichtig bleibt: Zeilen dürfen **nicht breiter** als `CHARS_PER_LINE` aufgefüllt
|
||||
werden, sonst bricht das Füll-Leerzeichen um und beschädigt die andere Zeile.
|
||||
|
||||
## Präzision (Low / High)
|
||||
|
||||
@@ -134,15 +398,23 @@ RA als `HH:MM.T` (Zehntel-Minuten), DEC als `sDD°MM` (ganze Bogenminuten). Das
|
||||
Gradzeichen kommt als Byte `0xDF` — auf der Anzeige korrekt als Gradring dargestellt;
|
||||
in der Terminal-Konsole erscheint der **Rohwert** dieses Bytes als `ß` (rein kosmetisch).
|
||||
|
||||
`config.HIGH_PRECISION` steuert die DEC-Darstellung:
|
||||
- `False` (Vorgabe): DEC ohne Sekunden (`-00°55'`).
|
||||
- `True`: DEC mit Sekunden (`+38°47'01`) — erst sinnvoll, wenn die Montierung per
|
||||
`:U#` auf Hochpräzision gestellt ist (schreibendes Kommando, hier bewusst nicht
|
||||
automatisch gesendet).
|
||||
`config.HIGH_PRECISION` steuert die ganze Darstellung — sie folgt dem, was die
|
||||
Montierung wirklich liefert:
|
||||
|
||||
Die **RA** wird immer mit Sekunden gezeigt (`11h36m54s`); bei Niedrigpräzision
|
||||
kommen die Sekunden aus den Zehntel-Minuten und springen daher in 6-Sekunden-Schritten
|
||||
(kein Dezimalpunkt — der wirkt in der Sperrschrift zu luftig).
|
||||
| | Niedrigpräzision (Vorgabe) | Hochpräzision (nach `:U#`) |
|
||||
|---|---|---|
|
||||
| Zeile 1 | `RA 18h36.9m` | `18h36m56s` |
|
||||
| Zeile 2 | `DE +38°47'` | `+38°47'01"` |
|
||||
|
||||
Bei Niedrigpräzision zeigt die **RA Zehntelminuten** statt Sekunden — genau die
|
||||
Auflösung der Rohdaten. Bis 2026-07-27 wurde hier in Sekunden umgerechnet
|
||||
(`11h36m54s`); die sprangen aber in 6-Sekunden-Schritten, weil die Montierung sie
|
||||
gar nicht hergibt. Die kürzere Form lässt zugleich Platz für die **Beschriftung**
|
||||
`RA` / `DE` (`config.LABEL_RA`, `LABEL_DEC`).
|
||||
|
||||
Bei Hochpräzision entfällt die Beschriftung: Dort braucht die Deklination mit
|
||||
`+38°47'01"` alle Zellen. `:U#` ist ein schreibendes Kommando und wird hier
|
||||
bewusst nicht automatisch gesendet.
|
||||
|
||||
`coords.format_ra`/`format_dec` nehmen `high_precision=` als Parameter (Vorgabe aus
|
||||
`config`). Die Montierungs-Rohwerte werden im `coords`-Parser unverändert korrekt
|
||||
@@ -150,27 +422,176 @@ zerlegt (Trennzeichen `:`, `*`, `0xDF`, `'` sind alle egal).
|
||||
|
||||
## Helligkeitsregelung
|
||||
|
||||
Portiert aus dem alten MSP430-Programm (`DoDimmen_V`, Werte aus `main.h`). Ein
|
||||
LDR-Rohwert (12-Bit-ADC, 0–4095: je heller die Umgebung, desto höher der Wert)
|
||||
wird über einen gleitenden Mittelwert (8 Werte) geglättet und auf eine
|
||||
Helligkeitsstufe mit Hysterese abgebildet:
|
||||
**Zwei Helligkeiten, ein Umschaltpunkt.** Unterhalb der Schwelle läuft die Anzeige
|
||||
mit `dunkel_prozent`, oberhalb mit `hell_prozent`. Die vier abgestuften Helligkeiten
|
||||
des alten MSP430-Programms (5/15/50/100 %) sind bewusst aufgegeben — für die
|
||||
Sternwarte genügt der Wechsel Tag/Nacht.
|
||||
|
||||
| Stufe | Helligkeit | erreicht ab (hoch / runter) |
|
||||
|------|-----------|------------------------------|
|
||||
| 0 | 5 % | < 270 |
|
||||
| 1 | 15 % | ≥ 330 / < 270 |
|
||||
| 2 | 50 % | ≥ 2650 / < 2350 |
|
||||
| 3 | 100 % | ≥ 3650 / < 3350 |
|
||||
Ein LDR-Rohwert (12-Bit-ADC, 0–4095) wird über `raw_to_ldr` in die Zählrichtung
|
||||
„groß = hell" gebracht, über einen gleitenden Mittelwert (8 Werte) geglättet und
|
||||
gegen die Schwelle gehalten:
|
||||
|
||||
Schwellen-Mitten `300 / 2500 / 3500`, Hysterese `±30 / ±150 / ±150`. **300 und 2500
|
||||
stammen aus dem alten Code**; die obere Grenze `HELL_MAX = 3500` für 100 % ist neu
|
||||
(der alte Code hatte keine 4. Stufe) und in `brightness.py` anpassbar, sobald der
|
||||
reale LDR-Wertebereich feststeht. Ein Helligkeitstelegramm wird nur bei
|
||||
| Bereich | Helligkeit | Umschaltpunkt |
|
||||
|---|---|---|
|
||||
| unter der Schwelle | `dunkel_prozent` = 5 % | herunter ab < 1650 |
|
||||
| über der Schwelle | `hell_prozent` = 50 % | hoch ab ≥ 1950 |
|
||||
|
||||
Schwelle `1800`, Hysterese `±150`. Beim allerersten Messwert entscheidet die
|
||||
blanke Schwelle (1800) statt der Kante — sonst startete die Anzeige im Bereich
|
||||
dazwischen fälschlich dunkel und wachte erst später auf.
|
||||
|
||||
### Die Werte stehen in `settings.json`, nicht im Code
|
||||
|
||||
`config.py` ist Programmcode und ändert sich nur beim Deployen. Die Helligkeiten
|
||||
und die Schwelle sollen dagegen im Betrieb verstellbar sein — **später über MQTT**.
|
||||
Sie liegen deshalb als JSON auf dem Gerät und werden über `settings.py` gelesen und
|
||||
geschrieben:
|
||||
|
||||
```python
|
||||
import settings
|
||||
settings.load() # mit Vorgaben aufgefüllt
|
||||
settings.update({"hell_prozent": 60}) # ändern und speichern
|
||||
```
|
||||
|
||||
Ein laufender `BrightnessController` übernimmt Änderungen mit `reload()`.
|
||||
|
||||
Jeder Wert wird beim Laden **und** beim Schreiben geprüft (Typ und Bereich:
|
||||
Prozente 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.
|
||||
|
||||
Der LDR-Wert kommt derzeit per Kommandozeile (`--ldr`); auf dem ESP32-C3 liefert
|
||||
ihn später der ADC. `run_display.py --ldr <wert>` bindet die Regelung in die
|
||||
Hauptschleife ein (`BrightnessController`).
|
||||
**Im Dunkeln steht der ADC am Anschlag** (4095). Der Teiler treibt A0 über den
|
||||
Messbereich, „Dämmerung" und „stockdunkel" sind daher nicht unterscheidbar. Bei
|
||||
nur zwei Helligkeiten ist das ohne Belang — unterschieden werden muss nur hell
|
||||
gegen dunkel, und dafür reicht die Auflösung weit. Eine feinere
|
||||
Nachtabstufung ginge nur über den Vorwiderstand: sättigungsfrei wäre
|
||||
`R2 ≥ R_LDR(dunkel) × (3,3 V − U_max)/U_max`, also grob `R2 ≥ 0,32 × R_LDR(dunkel)`.
|
||||
Bei einem typischen LDR (0,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)
|
||||
|
||||
@@ -192,10 +613,18 @@ gegeneinander (verschliffener ~3-V-Pegel am Oszi). `loopback.py` grenzt so etwas
|
||||
|
||||
## Erledigt
|
||||
|
||||
- **RS232-Strecke ESP32 → MAX3232 → Anzeige bestätigt (2026-07-27).** Das war das
|
||||
letzte ungetestete Kettenglied. Die Anzeige quittiert jedes Telegramm mit
|
||||
Fehlercode 0, die volle Kette (Mock → WLAN → ESP32 → UART1 → MAX3232 → Anzeige)
|
||||
läuft im Sekundentakt ohne Aussetzer. Damit ist die Portierung funktional
|
||||
komplett; offen sind nur noch Betriebsthemen (LDR, Netzwerk, Autostart).
|
||||
- **Phase 1 + 2 am Gerät bestätigt (2026-07-15/16):** Koordinaten erscheinen korrekt,
|
||||
inkl. echtem Gradzeichen; im `run_display`-Betrieb aktualisieren beide Zeilen
|
||||
laufend, sauber und ohne Flimmern, mit Antwort-Handshake der Anzeige. Protokoll,
|
||||
19200 8E1, Zeichensatz und Zeilengeometrie stimmen.
|
||||
- Schrift: Sperrschrift (`ESC z`, `CHARSET_SPACED = True`), 10 Zeichen/Zeile.
|
||||
Die schmale `1` wirkt dadurch etwas luftig — Font-Eigenschaft, nur per eigenem
|
||||
- Schrift: Sperrschrift (`ESC z`, `CHARSET_SPACED = True`), **10 Zeichen/Zeile**
|
||||
bei fester Positionierung auf 6 px (gemessen 27.07., siehe *Geometrie und
|
||||
Darstellung*). Zwischenzeitlich stand hier 9 — das war am Umbruch abgezählt und
|
||||
traf den Auto-Vorschub (7 px), nicht die Zeichenbreite.
|
||||
Die schmale `1` wirkt etwas luftig — Font-Eigenschaft, nur per eigenem
|
||||
Font (microSYST-PC-Software) änderbar.
|
||||
|
||||
+104
-46
@@ -1,73 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Helligkeitsregelung anhand eines LDR-Werts.
|
||||
"""Helligkeitsumschaltung anhand eines LDR-Werts.
|
||||
|
||||
Portierung von DoDimmen_V aus dem alten MSP430-Programm (GrossAnzeige/main.c,
|
||||
Konstanten aus main.h): gleitender Mittelwert, 3 Helligkeitsstufen mit Hysterese,
|
||||
Prozentwerte aus hellTafel_ub. Der Fotowiderstand (LDR) liefert einen Rohwert
|
||||
(alte Hardware: 12-bit-ADC, 0..4095) -- je heller die Umgebung, desto hoeher der
|
||||
Wert und desto heller die Anzeige.
|
||||
Zwei Helligkeiten, ein Umschaltpunkt: unterhalb der Schwelle laeuft die Anzeige
|
||||
mit "dunkel", oberhalb mit "hell". Die vier abgestuften Helligkeiten des alten
|
||||
MSP430-Programms (DoDimmen_V, 5/15/50/100 %) sind bewusst aufgegeben -- fuer die
|
||||
Sternwarte genuegt der Wechsel Tag/Nacht.
|
||||
|
||||
Reines Python, laeuft auch unter MicroPython (dort spaeter vom ADC gespeist).
|
||||
Die Werte stehen nicht hier, sondern in settings.json (siehe settings.py), damit
|
||||
sie sich im Betrieb aendern lassen -- spaeter ueber MQTT.
|
||||
|
||||
python3 brightness.py --ldr 1500 # Stufe/Prozent berechnen und senden
|
||||
Zwei Dinge aus der alten Regelung bleiben, weil die Messung am Geraet sie
|
||||
verlangt (2026-07-27):
|
||||
- **gleitender Mittelwert** ueber AVERAGE_WINDOW Werte: der ADC des ESP32-C3
|
||||
rauscht kraeftig (Rohwert +/-270 bei Raumlicht).
|
||||
- **Hysterese** um die Schwelle: nach der Mittelung bleiben rund +/-32
|
||||
(1 Sigma). Ohne Hysterese wuerde die Anzeige am Umschaltpunkt zwischen den
|
||||
beiden Helligkeiten hin- und herspringen.
|
||||
|
||||
Der Fotowiderstand liefert einen Rohwert (12-bit-ADC, 0..4095). Gerechnet wird in
|
||||
der Richtung "grosser Wert = helle Umgebung"; auf der ESP32-Platine ist die
|
||||
Kennlinie umgekehrt, deshalb laeuft der Rohwert zuerst durch raw_to_ldr().
|
||||
|
||||
Reines Python, laeuft auch unter MicroPython.
|
||||
|
||||
python3 brightness.py --ldr 1500 # Helligkeit berechnen und senden
|
||||
python3 brightness.py --ldr 200 --dry-run
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
# argparse und sys.stderr gibt es unter MicroPython nicht -- beides wird nur vom
|
||||
# CLI-Teil ganz unten gebraucht und darum erst dort importiert. So laesst sich
|
||||
# dieses Modul auf dem ESP32 einfach importieren.
|
||||
|
||||
# -- Werte aus dem alten Programm (main.h) --------------------------------
|
||||
HELL_LOW = 300 # Stufengrenze 0<->1 (aus dem alten Code)
|
||||
HELL_HIGH = 2500 # Stufengrenze 1<->2 (aus dem alten Code)
|
||||
HELL_MAX = 3500 # Stufengrenze 2<->3: NEU, nicht aus dem alten Code.
|
||||
# Anpassen, sobald der reale LDR-Wertebereich bekannt ist.
|
||||
HELL_HYST = 30 # Basis-Hysterese
|
||||
AVERAGE_WINDOW = 8 # MAXHELLARRAY: gleitender Mittelwert ueber 8 Werte
|
||||
|
||||
# Helligkeits-Prozente je Stufe, aus hellTafel_ub (main.h): 5/15/50/100 %.
|
||||
PERCENTS = (5, 15, 50, 100)
|
||||
# Die beiden Helligkeitsstufen. DUNKEL gilt unterhalb der Schwelle, HELL darueber.
|
||||
DUNKEL = 0
|
||||
HELL = 1
|
||||
|
||||
# Mittelpunkte der Stufengrenzen und deren Hysterese (Stufe i <-> i+1).
|
||||
# Der alte Code nutzte am unteren Uebergang +/-HYST, am oberen +/-5*HYST; das
|
||||
# uebernehmen wir und ergaenzen die neue obere Grenze mit derselben Breite.
|
||||
_CENTERS = (HELL_LOW, HELL_HIGH, HELL_MAX)
|
||||
_HYSTS = (HELL_HYST, HELL_HYST * 5, HELL_HYST * 5)
|
||||
|
||||
def raw_to_ldr(raw, inverted=None, full_scale=None):
|
||||
"""ADC-Rohwert in die Zaehlrichtung bringen, die die Schwellen erwarten.
|
||||
|
||||
Die Schwellen oben stammen vom alten MSP430-Aufbau: hoher Wert = helle
|
||||
Umgebung. Auf der ESP32-Platine liegt der LDR gegen Masse und der 2k gegen
|
||||
3V3, dort faellt die Spannung an A0 mit steigender Helligkeit. Der Wert wird
|
||||
deshalb gespiegelt, wenn config.LDR_INVERTED gesetzt ist.
|
||||
"""
|
||||
import config
|
||||
if inverted is None:
|
||||
inverted = config.LDR_INVERTED
|
||||
if full_scale is None:
|
||||
full_scale = config.LDR_MAX
|
||||
return (full_scale - raw) if inverted else raw
|
||||
|
||||
|
||||
class BrightnessController:
|
||||
"""Rechnet aus LDR-Rohwerten die Anzeigehelligkeit -- wie DoDimmen_V.
|
||||
"""Schaltet anhand des LDR-Werts zwischen zwei Helligkeiten um.
|
||||
|
||||
update() gibt die neue Helligkeit in Prozent nur zurueck, wenn sich die Stufe
|
||||
geaendert hat (sonst None). So wird -- wie im alten Programm -- nur bei
|
||||
tatsaechlicher Aenderung ein Helligkeitstelegramm gesendet.
|
||||
update() meldet zurueck, ob sich die Stufe geaendert hat -- nur dann wird ein
|
||||
Helligkeitstelegramm gesendet, wie schon im alten Programm.
|
||||
|
||||
Die Werte kommen aus settings.json und werden beim Anlegen einmal gelesen.
|
||||
Wer sie im Betrieb aendert (spaeter ueber MQTT), ruft danach reload() auf.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, werte=None, pfad=None, log=None):
|
||||
import settings
|
||||
self._settings = settings
|
||||
self._pfad = pfad
|
||||
self._werte = settings.load(pfad, log=log) if werte is None else dict(werte)
|
||||
self._buf = []
|
||||
self._level = None # entspricht oldHellStufe_ub = 0xFF (>=2-Zweig)
|
||||
self._level = None
|
||||
|
||||
@property
|
||||
def level(self):
|
||||
return self._level
|
||||
|
||||
def _next_level(self, val, old):
|
||||
"""Stufe mit Hysterese bestimmen (verallgemeinert auf beliebig viele Stufen).
|
||||
@property
|
||||
def settings(self):
|
||||
"""Die aktuell verwendeten Werte (Kopie)."""
|
||||
return dict(self._werte)
|
||||
|
||||
Hochschalten, solange der Wert die obere Kante (Mitte + Hysterese) der
|
||||
naechsten Grenze ueberschreitet; herunterschalten, solange er die untere
|
||||
Kante (Mitte - Hysterese) unterschreitet. Beim ersten Aufruf (old=None)
|
||||
wird von Stufe 0 aus hochgeschaltet -- die Anzeige startet also gedimmt
|
||||
und hellt sich passend zum Messwert auf.
|
||||
def reload(self, log=None):
|
||||
"""Einstellungen neu aus der Datei lesen.
|
||||
|
||||
Die Stufe bleibt stehen; ob sie zu den neuen Werten noch passt, klaert
|
||||
der naechste update() -- inklusive Hysterese, also ohne Sprung.
|
||||
"""
|
||||
level = 0 if old is None else old
|
||||
while level < len(PERCENTS) - 1 and val >= _CENTERS[level] + _HYSTS[level]:
|
||||
level += 1
|
||||
while level > 0 and val < _CENTERS[level - 1] - _HYSTS[level - 1]:
|
||||
level -= 1
|
||||
return level
|
||||
self._werte = self._settings.load(self._pfad, log=log)
|
||||
return self.settings
|
||||
|
||||
def percent(self, level):
|
||||
"""Prozentwert zu einer Stufe."""
|
||||
return self._werte["hell_prozent" if level == HELL else "dunkel_prozent"]
|
||||
|
||||
def _next_level(self, val, old):
|
||||
"""Stufe mit Hysterese bestimmen.
|
||||
|
||||
Umgeschaltet wird erst, wenn der Wert die Schwelle um die Hysterese
|
||||
ueberschreitet -- nach oben auf HELL, nach unten auf DUNKEL. Dazwischen
|
||||
bleibt es bei der bisherigen Stufe; genau das verhindert das Flattern.
|
||||
|
||||
Beim ersten Aufruf (old=None) gibt es keine bisherige Stufe. Dann
|
||||
entscheidet die blanke Schwelle, damit die Anzeige sofort richtig
|
||||
startet, statt erst nach der halben Hysterese aufzuwachen.
|
||||
"""
|
||||
schwelle = self._werte["schwelle"]
|
||||
hyst = self._werte["hysterese"]
|
||||
if old is None:
|
||||
return HELL if val >= schwelle else DUNKEL
|
||||
if val >= schwelle + hyst:
|
||||
return HELL
|
||||
if val < schwelle - hyst:
|
||||
return DUNKEL
|
||||
return old
|
||||
|
||||
def update(self, ldr):
|
||||
"""Einen LDR-Rohwert einspeisen. Liefert (stufe, prozent, geaendert)."""
|
||||
"""Einen LDR-Wert einspeisen. Liefert (stufe, prozent, geaendert)."""
|
||||
self._buf.append(ldr)
|
||||
if len(self._buf) > AVERAGE_WINDOW:
|
||||
self._buf.pop(0)
|
||||
@@ -76,10 +125,13 @@ class BrightnessController:
|
||||
level = self._next_level(avg, self._level)
|
||||
changed = level != self._level
|
||||
self._level = level
|
||||
return level, PERCENTS[level], changed
|
||||
return level, self.percent(level), changed
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
p = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--ldr", type=int, required=True, help="LDR-Rohwert (0..4095)")
|
||||
@@ -87,8 +139,13 @@ def main(argv=None):
|
||||
p.add_argument("--dry-run", action="store_true", help="nur berechnen/anzeigen, nicht senden")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
level, percent, _ = BrightnessController().update(args.ldr)
|
||||
print("LDR %d -> Stufe %d -> %d %%" % (args.ldr, level, percent))
|
||||
c = BrightnessController()
|
||||
level, percent, _ = c.update(args.ldr)
|
||||
w = c.settings
|
||||
print("Schwelle %d (+/-%d) -> dunkel %d %%, hell %d %%"
|
||||
% (w["schwelle"], w["hysterese"], w["dunkel_prozent"], w["hell_prozent"]))
|
||||
print("LDR %d -> %s -> %d %%"
|
||||
% (args.ldr, "HELL" if level == HELL else "DUNKEL", percent))
|
||||
|
||||
import config
|
||||
import migra
|
||||
@@ -106,4 +163,5 @@ def main(argv=None):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
sys.exit(main())
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
"""Zentrale Einstellungen fuer die Ansteuerung der Grossanzeige."""
|
||||
"""Zentrale Einstellungen fuer die Ansteuerung der Grossanzeige.
|
||||
|
||||
Laeuft unveraendert unter CPython (Mac) und MicroPython (XIAO ESP32-C3); was
|
||||
sich zwischen beiden unterscheidet, haengt an MICROPYTHON.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import migra
|
||||
|
||||
# True auf dem ESP32-C3, False auf dem Mac. Steuert, welchen Transport
|
||||
# display.open_display waehlt und ob der LDR ueber den ADC gelesen werden kann.
|
||||
MICROPYTHON = sys.implementation.name == "micropython"
|
||||
|
||||
# -- Serielle Schnittstelle -----------------------------------------------
|
||||
# Auslieferzustand der Anzeige laut Handbuch (Kap. 4.1) und identisch zur
|
||||
# UART-Init des alten MSP430-Programms: 19200 8E1.
|
||||
PORT = "/dev/cu.usbserial-120" # Mac; auf dem ESP32-C3 spaeter die UART-Nummer
|
||||
BAUDRATE = 19200
|
||||
PARITY = "E"
|
||||
BYTESIZE = 8
|
||||
STOPBITS = 1
|
||||
|
||||
# CPython: Geraetedatei des USB-RS232-Wandlers.
|
||||
PORT = "/dev/cu.usbserial-120"
|
||||
|
||||
# ESP32-C3: UART1 mit frei zugeordneten Pins. Die Nummern stammen aus dem
|
||||
# Schaltplan (Hardware/KiCad/Grossanzeige) -- am XIAO ist Pad 7 = D6 = GPIO21
|
||||
# und Pad 8 = D7 = GPIO20. Von dort geht es an den MAX3232:
|
||||
# GPIO21 (TX) -> T2IN, T2OUT -> J5 Pin 3 -> Anzeige RxD (Sub-D Pin 2)
|
||||
# GPIO20 (RX) <- R2OUT, R2IN <- J5 Pin 2 <- Anzeige TxD (Sub-D Pin 3)
|
||||
# UART0 bleibt frei, das ist die REPL-Konsole ueber USB.
|
||||
UART_ID = 1
|
||||
UART_TX = 21
|
||||
UART_RX = 20
|
||||
|
||||
# -- Telegramm ------------------------------------------------------------
|
||||
DISPLAY_ADDR = 1 # Zieladresse der Anzeige (DA = 0x81)
|
||||
HOST_ADDR = 0 # unsere Quelladresse (SA = 0x80)
|
||||
@@ -26,14 +48,63 @@ QUIET_TIME = 0.4 # Pause zwischen Telegrammen, wenn ohne Antwort gefahren
|
||||
# Zeichensatz 0, Zeile 1 ab Pixelzeile 0, Zeile 2 ab Pixelzeile 9 (aus dem alten
|
||||
# C-Programm). Am echten Geraet ausgemessen (2026-07-15):
|
||||
# CHARSET_SPACED=True -> ESC z (Sperrschrift, alle Zeichen gleich breit) -- vom
|
||||
# Nutzer bevorzugt, weil sich sonst schmale Ziffern beruehren. In dieser Schrift
|
||||
# passen mind. 10 Zeichen pro Zeile (RA/DEC brauchen max. 10).
|
||||
# Nutzer bevorzugt, weil sich sonst schmale Ziffern beruehren.
|
||||
# CHARSET_SPACED=False -> ESC Z (proportional, schmaler, ~11 Zeichen, aber die "1"
|
||||
# klebt an Nachbarn).
|
||||
CHARSET = 0
|
||||
CHARSET_SPACED = True
|
||||
LINE_Y = (0, 9)
|
||||
CHARS_PER_LINE = 9 # in Sperrschrift am Geraet ausgemessen (2026-07-15)
|
||||
|
||||
# 6 px pro Zeichenzelle -- mit "probe.py pitch 6" am Geraet gemessen (2026-07-27).
|
||||
#
|
||||
# Die Messung stellt den Auto-Vorschub der Anzeige gegen feste Pixelabstaende und
|
||||
# hat beides zugleich gezeigt:
|
||||
# - Auto-Vorschub der Sperrschrift ESC z ist 7 px. Eine Zeile aus einem Stueck
|
||||
# bricht deshalb nach 9 Zeichen um (9*7 = 63 von 64 px). Frueher stand hier
|
||||
# darum 9 -- das war der Vorschub, nicht die Zellenbreite.
|
||||
# - Die Zeichenmatrix selbst ist nur 6 px breit: zehn Ziffern, einzeln auf
|
||||
# x = i*6 gesetzt, stehen sauber und getrennt nebeneinander (60 von 64 px).
|
||||
#
|
||||
# Deshalb positioniert display._emit_line jedes Zeichen selbst, statt den
|
||||
# Auto-Vorschub laufen zu lassen.
|
||||
CELL_WIDTH = 6 # Pixel pro Zeichenzelle
|
||||
DISPLAY_WIDTH = 64 # Pixelbreite der Anzeige
|
||||
|
||||
# Obergrenze in *Zeichen*. Die harte Grenze ist DISPLAY_WIDTH in Pixeln: Seit
|
||||
# schmale Zeichen weniger Platz brauchen (CELL_NARROW), haengt die Breite einer
|
||||
# Zeile davon ab, welche Zeichen darin stehen. 11 gehen nur auf, wenn mindestens
|
||||
# eines schmal ist -- "RA:18h51.0m" braucht mit seinem Dezimalpunkt 63 der 64 px,
|
||||
# "DE:+38°47'" waere als 11 volle Zellen dagegen 66 px und damit zu breit.
|
||||
# display._aufbereiten schneidet deshalb nach Pixeln ab, nicht nach Zeichen.
|
||||
CHARS_PER_LINE = 11
|
||||
|
||||
# Breite, die die Anzeige beim Schreiben eines Zeichens tatsaechlich bemalt --
|
||||
# mehr als CELL_WIDTH. Daher auch der Auto-Vorschub von 7 px. Wichtig fuer das
|
||||
# Nachfuehren einzelner Zellen: Ein neu geschriebenes Zeichen loescht die erste
|
||||
# Pixelspalte seines rechten Nachbarn, der muss also mitgeschrieben werden
|
||||
# (siehe display._zelle_faellig).
|
||||
GLYPH_WIDTH = 7
|
||||
|
||||
# Zeichen, die weniger Platz brauchen als eine volle Zelle -- in der Sperrschrift
|
||||
# stehen sie sonst sehr luftig zwischen den Ziffern. Je 3 px am Geraet
|
||||
# ausprobiert (2026-07-27); weil jedes Zeichen ohnehin einzeln positioniert wird,
|
||||
# kostet das nichts.
|
||||
# "." Dezimalpunkt der Zehntelminuten (RA 18h37.1m)
|
||||
# " " Abstand zwischen Beschriftung und Wert (RA 18h37.1m)
|
||||
# Achtung: Das gilt auch fuer die Leerzeichen, mit denen eine Zeile aufgefuellt
|
||||
# wird. display._aufbereiten rechnet beim Auffuellen darum bewusst mit der vollen
|
||||
# Zellenbreite -- sonst reichte die letzte Zelle ueber den Rand hinaus.
|
||||
CELL_NARROW = {".": 3, " ": 3}
|
||||
|
||||
# Alle Zeilen beginnen linksbuendig bei LINE_X. Bis 2026-07-27 gab es einen
|
||||
# Schalter zum Zentrieren; er ist entfallen, weil die Zeilen seit den schmalen
|
||||
# Zeichen (CELL_NARROW) unterschiedlich breit sind und zentriert um wenige Pixel
|
||||
# gegeneinander versetzt standen. Mit der Beschriftung "RA:" / "DE:" sollen sie
|
||||
# buendig untereinander beginnen.
|
||||
#
|
||||
# LINE_X = 0, ganz am Rand: Die RA-Zeile braucht mit der Minutenangabe am Ende
|
||||
# ("RA:18h51.0m") 63 der 64 Pixel -- fuer einen Rand ist da kein Platz mehr.
|
||||
LINE_X = 0
|
||||
|
||||
COLOR = migra.RED # einfarbig rote Anzeige
|
||||
BACKGROUND = migra.BLACK
|
||||
@@ -52,7 +123,11 @@ DEGREE_FALLBACK = "d" # Ersatz, falls DEGREE_CHARSET = None
|
||||
# -- Montierung (Phase 2) -------------------------------------------------
|
||||
# 10Micron GM4000 HPS II: LX200-Protokoll ueber TCP. Die IP der Montierung
|
||||
# eintragen, sobald sie erreichbar ist; Port 3490 ist die Vorgabe von 10Micron.
|
||||
MOUNT_HOST = "192.168.1.115" # PLATZHALTER -- echte IP der Montierung eintragen
|
||||
# Vorübergehend auf den Mock auf dem Mac gestellt (2026-07-27), weil die echte
|
||||
# Montierung vom ESP32 aus nicht erreichbar ist: Sie steht in 192.168.1.115, der
|
||||
# ESP32 im Heimnetz 192.168.178.x. Zum Umstellen die beiden Zeilen tauschen.
|
||||
MOUNT_HOST = "192.168.178.26" # Mock auf dem Mac (mount_mock.py --lan --move --low)
|
||||
# MOUNT_HOST = "192.168.1.115" # echte GM4000 (eingetragen 2026-07-18)
|
||||
MOUNT_PORT = 3490
|
||||
MOUNT_TIMEOUT = 3.0 # Sekunden fuer Verbindung/Antwort
|
||||
POLL_INTERVAL = 1.0 # Sekunden zwischen zwei Koordinatenabfragen
|
||||
@@ -64,5 +139,66 @@ POLL_INTERVAL = 1.0 # Sekunden zwischen zwei Koordinatenabfragen
|
||||
# True = Hochpraezision (Montierung mit ":U#" umgestellt): RA/DEC mit Sekunden.
|
||||
HIGH_PRECISION = False
|
||||
|
||||
# Beschriftung der beiden Zeilen:
|
||||
#
|
||||
# RA 18h37.1m
|
||||
# DE +38°47'
|
||||
#
|
||||
# Der Abstand ist ein Leerzeichen, das ueber CELL_NARROW nur 3 px breit gesetzt
|
||||
# wird -- ein Doppelpunkt stand hier zwischenzeitlich, wirkte in Zeichensatz 0
|
||||
# aber zu wuchtig (2026-07-27).
|
||||
#
|
||||
# Die Beschriftung gibt es nur bei Niedrigpraezision: Dort liefert die Montierung
|
||||
# ohnehin nur Zehntelminuten und ganze Bogenminuten, das laesst den Platz. Bei
|
||||
# Hochpraezision braucht die Deklination alle Zellen (+38°47'01") -- dann
|
||||
# entfaellt sie.
|
||||
LABEL_RA = "RA "
|
||||
LABEL_DEC = "DE "
|
||||
|
||||
# Anzeige, wenn die Montierung (noch) nicht erreichbar ist.
|
||||
NOLINK_LINES = ("KEIN LINK", "MONTIERUNG")
|
||||
# Achtung: hoechstens CHARS_PER_LINE Zeichen, sonst wird abgeschnitten --
|
||||
# "MONTIERUNG" stand hier zuerst und erschien am Geraet als "MONTIERUN".
|
||||
NOLINK_LINES = ("KEIN LINK", "TELESKOP")
|
||||
|
||||
# -- Dauerbetrieb (nur ESP32-C3) ------------------------------------------
|
||||
# Watchdog: Kommt die Poll-Schleife so lange nicht mehr durch, startet die
|
||||
# Hardware das Geraet neu. 120 s sind reichlich bemessen -- im Normalbetrieb
|
||||
# wird jede Sekunde gefuettert. Der Wert muss ueber allem liegen, was regulaer
|
||||
# blockieren darf: WLAN-Aufbau bis WIFI_TIMEOUT (20 s), Montierung
|
||||
# MOUNT_TIMEOUT (3 s), dazu ein MQTT-Verbindungsversuch. Sonst startet das
|
||||
# Geraet mitten im normalen Betrieb neu.
|
||||
#
|
||||
# Achtung: Ein einmal gestarteter Watchdog laesst sich nicht mehr abschalten.
|
||||
# Deshalb legt ihn nur main.py an, nicht der Handbetrieb ueber die REPL.
|
||||
WDT_TIMEOUT = 120.0 # Sekunden
|
||||
|
||||
# Wartezeit beim Autostart, bevor main.py die Schleife startet. In diesem
|
||||
# Fenster bricht Ctrl-C ab und man kommt an die REPL -- ohne das waere ein
|
||||
# Geraet mit fehlerhaftem main.py nur noch ueber ein Neuflashen erreichbar.
|
||||
AUTOSTART_DELAY = 3.0 # Sekunden
|
||||
|
||||
# Nach einem Absturz in main.py: so lange warten, dann neu starten. Die Pause
|
||||
# verhindert eine Neustart-Schleife im Sekundentakt, wenn etwas dauerhaft
|
||||
# kaputt ist (und gibt Zeit, mit Ctrl-C einzugreifen).
|
||||
RESTART_DELAY = 10.0 # Sekunden
|
||||
|
||||
# -- WLAN (nur ESP32-C3) --------------------------------------------------
|
||||
# Zugangsdaten stehen in wifi_config.py (nicht im Git, siehe
|
||||
# wifi_config_example.py). Hier nur das Zeitverhalten.
|
||||
WIFI_TIMEOUT = 20.0 # Sekunden aufs Verbinden warten
|
||||
WIFI_RETRY = 5.0 # Pause vor einem neuen Verbindungsversuch
|
||||
|
||||
# Anzeige, solange noch kein WLAN steht.
|
||||
NOWIFI_LINES = ("KEIN WLAN", " ... ")
|
||||
|
||||
# -- Helligkeitssensor (LDR, nur ESP32-C3) --------------------------------
|
||||
# LDR an Pad 1 (D0/A0) = GPIO2, auf dem C3 ein Kanal von ADC1.
|
||||
LDR_PIN = 2
|
||||
|
||||
# Der Spannungsteiler auf der Platine ist 3V3 --[R2 2k]-- A0 --[LDR]-- GND.
|
||||
# Der LDR wird bei mehr Licht niederohmiger, die Spannung an A0 sinkt also mit
|
||||
# steigender Helligkeit -- genau andersherum als beim alten MSP430-Aufbau, auf
|
||||
# den die Schwellen in brightness.py zugeschnitten sind. Deshalb wird der
|
||||
# Rohwert gespiegelt (siehe brightness.raw_to_ldr).
|
||||
LDR_INVERTED = True
|
||||
LDR_MAX = 4095 # 12-bit-ADC, voller Ausschlag
|
||||
|
||||
@@ -75,22 +75,53 @@ def _fit(candidates, width):
|
||||
return candidates[-1][:width]
|
||||
|
||||
|
||||
def format_ra(value, width=None, high_precision=None):
|
||||
"""Rektaszension formatieren, immer mit Sekunden (HHhMMmSSs).
|
||||
def _to_tenths(h, m, s):
|
||||
"""(h, m, s) in (Stunden, Minuten, Zehntelminuten) umrechnen.
|
||||
|
||||
Bei Niedrigpraezision liefert die Montierung nur Zehntel-Minuten; die
|
||||
Sekunden kommen dann in 6-Sekunden-Schritten. Das Format ist dasselbe wie
|
||||
bei Hochpraezision -- der Unterschied steckt allein in der Aufloesung der
|
||||
Rohdaten, nicht in der Darstellung. high_precision wird daher fuer RA nicht
|
||||
ausgewertet (nur bei DEC relevant, siehe format_dec).
|
||||
Ganzzahlig gerechnet, damit es unter MicroPython keine Float-Ueberraschungen
|
||||
gibt. Der Uebertrag ist wichtig: 18:36:57 sind 36,95 Minuten und werden zu
|
||||
18:37.0, nicht zu 18:36.10.
|
||||
"""
|
||||
zehntel = ((h * 3600 + m * 60 + s) * 10 + 30) // 60 # kaufmaennisch runden
|
||||
minuten, t = divmod(zehntel, 10)
|
||||
h, m = divmod(minuten, 60)
|
||||
return h % 24, m, t
|
||||
|
||||
|
||||
def format_ra(value, width=None, high_precision=None):
|
||||
"""Rektaszension formatieren.
|
||||
|
||||
Die Darstellung folgt dem, was die Montierung wirklich liefert:
|
||||
|
||||
Niedrigpraezision (Vorgabe): **HHhMM.T** mit Zehntelminuten -- genau die
|
||||
Aufloesung, die die GM4000 in diesem Modus sendet. Sieben Zeichen, das laesst
|
||||
Platz fuer die Beschriftung "RA:" auf der Anzeige (siehe
|
||||
displayloop.format_coordinates). Das "h" statt eines Doppelpunkts ist
|
||||
Nutzerwunsch -- der Doppelpunkt wirkt in der Sperrschrift zu wuchtig.
|
||||
|
||||
Hochpraezision: HHhMMmSSs mit echten Sekunden.
|
||||
|
||||
Bis 2026-07-27 wurde auch bei Niedrigpraezision mit Sekunden angezeigt. Die
|
||||
sprangen dort in 6-Sekunden-Schritten, weil die Rohdaten sie gar nicht
|
||||
hergeben -- die Zehntelform ist ehrlicher und kuerzer.
|
||||
"""
|
||||
width = config.CHARS_PER_LINE if width is None else width
|
||||
high = config.HIGH_PRECISION if high_precision is None else high_precision
|
||||
h, m, s = parse_ra(value)
|
||||
return _fit([
|
||||
"%02dh%02dm%02ds" % (h, m, s),
|
||||
"%02d:%02d:%02d" % (h, m, s),
|
||||
"%02dh%02dm" % (h, m),
|
||||
], width)
|
||||
if high:
|
||||
candidates = [
|
||||
"%02dh%02dm%02ds" % (h, m, s),
|
||||
"%02d:%02d:%02d" % (h, m, s),
|
||||
"%02dh%02dm" % (h, m),
|
||||
]
|
||||
else:
|
||||
h, m, t = _to_tenths(h, m, s)
|
||||
candidates = [
|
||||
"%02dh%02d.%dm" % (h, m, t), # 8: 18h36.9m
|
||||
"%02dh%02d.%d" % (h, m, t), # 7: ohne Minutenzeichen
|
||||
"%02dh%02dm" % (h, m), # 6: ohne Zehntel
|
||||
]
|
||||
return _fit(candidates, width)
|
||||
|
||||
|
||||
def format_dec(value, width=None, high_precision=None):
|
||||
|
||||
@@ -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._checksum = checksum
|
||||
self._response = response
|
||||
# Was gerade auf welcher Zeile steht. Grundlage fuer den Vergleich in
|
||||
# show_lines: geschrieben wird nur, was sich geaendert hat.
|
||||
self._gezeigt = {}
|
||||
|
||||
def send(self, data_unit):
|
||||
"""Data-Unit rahmen, senden und ggf. auf das Antworttelegramm warten."""
|
||||
@@ -44,6 +47,10 @@ class Display:
|
||||
|
||||
# -- Hoehere Funktionen ------------------------------------------------
|
||||
def clear(self):
|
||||
# Der gemerkte Inhalt gilt danach nicht mehr -- sonst hielte der
|
||||
# Vergleich in show_lines Zellen faelschlich fuer schon vorhanden und
|
||||
# die Anzeige bliebe nach dem Loeschen teilweise leer.
|
||||
self._gezeigt = {}
|
||||
self.send(migra.DataUnit().fill(config.BACKGROUND))
|
||||
|
||||
def set_brightness(self, percent):
|
||||
@@ -52,84 +59,271 @@ class Display:
|
||||
def show_lines(self, *lines, clear=False):
|
||||
"""Zeilen ab Zeile 1 auf die Anzeige schreiben.
|
||||
|
||||
Baut ein einziges Telegramm: pro Zeile Cursor setzen und den Text
|
||||
schreiben. Das Gradzeichen (config.DEGREE_MARKER) wird ueber einen kurzen
|
||||
Zeichensatzwechsel eingefuegt.
|
||||
Geschrieben wird nur, was sich seit dem letzten Mal geaendert hat --
|
||||
Zelle fuer Zelle. Das ist der entscheidende Punkt fuer eine ruhige
|
||||
Anzeige:
|
||||
|
||||
Jede Zeile wird mit Leerzeichen auf volle Breite (CHARS_PER_LINE)
|
||||
aufgefuellt und damit *an Ort und Stelle* ueberschrieben -- ohne die
|
||||
Anzeige vorher komplett zu loeschen. Das vermeidet das sichtbare Blitzen
|
||||
bei zyklischen Updates und haelt das Telegramm kurz (sonst kommt das
|
||||
naechste zu frueh und das letzte Zeichen kann verloren gehen).
|
||||
clear=True erzwingt zusaetzlich ein Vollbild-Loeschen (einmal beim Start).
|
||||
Seit jedes Zeichen einzeln positioniert wird (10 statt 9 Zeichen, siehe
|
||||
_emit_line), kostet eine volle Zeile rund 100 Bytes statt 20. Bei 19200
|
||||
Baud mit 8E1 (11 Bit je Byte) dauerte ein kompletter Bildwechsel damit
|
||||
rund 300 ms. Die Anzeige stellt schon waehrend des Empfangs dar, also
|
||||
war der Aufbau als Flackern zu sehen -- bei Aktualisierung im
|
||||
Sekundentakt ein Drittel der Zeit. (Am Geraet gemessen 2026-07-27.)
|
||||
|
||||
Reihenfolge: Die Zeilen werden von unten nach oben gezeichnet, damit die
|
||||
oberste Zeile zuletzt kommt. Grund: Ein Zeichensatzwechsel mitten im
|
||||
Telegramm beschaedigt die Home-Zelle (0,0) -- die oberste Zeile muss also
|
||||
nach allen Wechseln geschrieben werden, damit sie diese Zelle ueberschreibt.
|
||||
(Am Geraet verifiziert 2026-07-15.)
|
||||
Von einer Sekunde zur naechsten aendert sich aber fast nichts:
|
||||
"18h36m56s" -> "18h36m57s" ist ein einziges Zeichen, die Deklination
|
||||
steht meist voellig still. Mit dem Vergleich gegen den zuletzt
|
||||
dargestellten Inhalt schrumpft der Bildwechsel auf ein bis zwei Zellen,
|
||||
und eine unveraenderte Zeile erzeugt gar kein Telegramm mehr.
|
||||
|
||||
clear=True loescht das Vollbild und schreibt beide Zeilen komplett neu
|
||||
(einmal beim Start und nach jedem Verbindungsabriss).
|
||||
|
||||
Reihenfolge: Die Zeilen werden von unten nach oben geschrieben, damit die
|
||||
oberste zuletzt kommt. Grund: Ein Zeichensatzwechsel beschaedigt die
|
||||
Home-Zelle (0,0), also den Anfang der obersten Zeile. Findet ein solcher
|
||||
Wechsel statt (Gradzeichen), wird die oberste Zeile deshalb vollstaendig
|
||||
nachgezogen -- siehe _zeile_schreiben. (Am Geraet verifiziert 2026-07-15.)
|
||||
"""
|
||||
if len(lines) > len(config.LINE_Y):
|
||||
raise ValueError("Anzeige hat nur %d Zeilen" % len(config.LINE_Y))
|
||||
|
||||
du = migra.DataUnit()
|
||||
if clear:
|
||||
du.fill(config.BACKGROUND)
|
||||
du.attributes(fg=config.COLOR, bg=config.BACKGROUND, blink=migra.STATIC)
|
||||
self.clear()
|
||||
self._gezeigt = {}
|
||||
|
||||
wechsel = False
|
||||
for i in reversed(range(len(lines))):
|
||||
self._emit_line(du, config.LINE_Y[i], lines[i])
|
||||
self.send(du)
|
||||
# Die oberste Zeile (i == 0) komplett neu, wenn weiter unten ein
|
||||
# Zeichensatzwechsel war: der hat ihren Anfang zerstoert.
|
||||
voll = wechsel and i == 0
|
||||
if self._zeile_schreiben(i, lines[i], voll=voll):
|
||||
wechsel = True
|
||||
|
||||
def show_line(self, index, text, clear=False):
|
||||
"""Nur eine einzelne Zeile aktualisieren (eigenes, kurzes Telegramm).
|
||||
"""Nur eine einzelne Zeile aktualisieren.
|
||||
|
||||
Fuer zyklische Updates: die Zeile ohne Gradzeichen (also ohne
|
||||
Zeichensatzwechsel) laesst sich beliebig oft neu schreiben, ohne dass die
|
||||
Anzeige Bytes verschluckt. Zeilen *mit* Gradzeichen sollten dagegen selten
|
||||
und moeglichst per show_lines (Vollbild) gezeichnet werden.
|
||||
Schreibt ebenfalls nur die geaenderten Zellen. Achtung: Enthaelt die
|
||||
Zeile ein *neues* Gradzeichen, wird dafuer der Zeichensatz gewechselt und
|
||||
damit die Home-Zelle beschaedigt -- die oberste Zeile muesste danach
|
||||
nachgezogen werden. Wer beide Zeilen hat, nimmt darum show_lines.
|
||||
"""
|
||||
if not 0 <= index < len(config.LINE_Y):
|
||||
raise ValueError("ungueltiger Zeilenindex %d" % index)
|
||||
du = migra.DataUnit()
|
||||
if clear:
|
||||
du.fill(config.BACKGROUND)
|
||||
self.clear()
|
||||
self._gezeigt = {}
|
||||
self._zeile_schreiben(index, text)
|
||||
|
||||
@staticmethod
|
||||
def _breiten(text):
|
||||
"""Breite jeder einzelnen Zelle in Pixeln.
|
||||
|
||||
Zeichen aus config.CELL_NARROW brauchen weniger Platz. Fuer Leerzeichen
|
||||
gilt das aber nur *innerhalb* des Textes -- dort trennen sie Beschriftung
|
||||
und Wert ("RA 18h36.9m"). Die Leerzeichen, mit denen die Zeile rechts
|
||||
aufgefuellt wird, behalten die volle Breite: Sie sollen den Rest der
|
||||
Zeile ueberschreiben, und je schmaler sie waeren, desto mehr Zellen
|
||||
muesste man dafuer schreiben.
|
||||
"""
|
||||
ohne_fuellung = len(text.rstrip())
|
||||
return [config.CELL_NARROW.get(ch, config.CELL_WIDTH)
|
||||
if i < ohne_fuellung else config.CELL_WIDTH
|
||||
for i, ch in enumerate(text)]
|
||||
|
||||
@staticmethod
|
||||
def _breite(text):
|
||||
"""Pixelbreite eines Textes."""
|
||||
return sum(Display._breiten(text))
|
||||
|
||||
@staticmethod
|
||||
def _aufbereiten(text):
|
||||
"""Auf die Zeilenbreite bringen: abschneiden und auffuellen.
|
||||
|
||||
Massgeblich sind die **Pixel**, nicht die Zeichenzahl: Seit schmale
|
||||
Zeichen weniger Platz brauchen (config.CELL_NARROW), passen mal 10 und
|
||||
mal 11 Zeichen in dieselbe Zeile. Ein Text wird deshalb so lange
|
||||
gekuerzt, bis er in DISPLAY_WIDTH passt, und nur so weit aufgefuellt, wie
|
||||
eine weitere Zelle noch hineingeht. Ohne das ragte die letzte Zelle ueber
|
||||
den Rand und die Anzeige quittierte mit Fehlercode 4.
|
||||
"""
|
||||
# Ohne str.ljust/center: die kennt MicroPython nicht (am Geraet aufgefallen).
|
||||
platz = config.DISPLAY_WIDTH - config.LINE_X
|
||||
text = text[:config.CHARS_PER_LINE]
|
||||
while text and Display._breite(text) > platz:
|
||||
text = text[:-1]
|
||||
# Beim Auffuellen bewusst mit der *vollen* Zellenbreite rechnen, obwohl
|
||||
# das Leerzeichen schmal gesetzt wird: Die Anzeige bemalt je Zeichen
|
||||
# GLYPH_WIDTH Pixel, ein Leerzeichen am rechten Rand reichte sonst
|
||||
# darueber hinaus.
|
||||
while (len(text) < config.CHARS_PER_LINE
|
||||
and Display._breite(text) + config.CELL_WIDTH <= platz):
|
||||
text += " "
|
||||
return text
|
||||
|
||||
def _zeile_schreiben(self, index, text, voll=False):
|
||||
"""Eine Zeile nachfuehren. Liefert True, wenn der Zeichensatz gewechselt wurde.
|
||||
|
||||
voll=True schreibt alle Zellen, auch die unveraenderten.
|
||||
"""
|
||||
neu = self._aufbereiten(text)
|
||||
pos = self._positionen(neu)
|
||||
alt, alt_pos = self._gezeigt.get(index, (None, None))
|
||||
if voll:
|
||||
alt = None
|
||||
elif alt_pos != pos:
|
||||
# Die Zeile hat eine andere Form als vorher (z.B. ein Dezimalpunkt
|
||||
# kommt dazu, der weniger Platz braucht). Dann sitzen alle Zellen
|
||||
# woanders und der Vergleich waere sinnlos -- vollstaendig neu.
|
||||
alt = None
|
||||
elif alt == neu:
|
||||
return False # nichts zu tun, kein Telegramm
|
||||
|
||||
du = migra.DataUnit()
|
||||
du.attributes(fg=config.COLOR, bg=config.BACKGROUND, blink=migra.STATIC)
|
||||
self._emit_line(du, config.LINE_Y[index], text)
|
||||
self.send(du)
|
||||
if alt is None:
|
||||
# Erste Ausgabe dieser Zeile: Zeichensatz sicherheitshalber setzen.
|
||||
# Danach behaelt die Anzeige ihn, und _emit_fremd schaltet nach
|
||||
# jedem Sonderzeichen wieder zurueck -- ein Wechsel je Telegramm
|
||||
# waere nur Ballast und wuerde die Home-Zelle beschaedigen.
|
||||
du.charset(config.CHARSET, spaced=config.CHARSET_SPACED)
|
||||
wechsel = self._emit_line(du, config.LINE_Y[index], neu, alt, pos)
|
||||
try:
|
||||
self.send(du)
|
||||
except Exception:
|
||||
# Ungewiss, wie viel die Anzeige bekommen hat. Den gemerkten Inhalt
|
||||
# verwerfen, damit die Zeile beim naechsten Mal vollstaendig neu
|
||||
# geschrieben wird -- sonst blieben Zellen stehen, die wir
|
||||
# faelschlich fuer aktuell hielten.
|
||||
self._gezeigt.pop(index, None)
|
||||
raise
|
||||
self._gezeigt[index] = (neu, pos)
|
||||
return wechsel or alt is None
|
||||
|
||||
def _emit_line(self, du, y, text):
|
||||
"""Eine Zeile bei Pixelzeile y ausgeben, auf volle Breite aufgefuellt."""
|
||||
text = text[:config.CHARS_PER_LINE].ljust(config.CHARS_PER_LINE)
|
||||
du.charset(config.CHARSET, spaced=config.CHARSET_SPACED)
|
||||
du.cursor(0, y)
|
||||
for i, part in enumerate(text.split(config.DEGREE_MARKER)):
|
||||
if i > 0:
|
||||
self._emit_degree(du)
|
||||
if part:
|
||||
du.text(part)
|
||||
@staticmethod
|
||||
def _positionen(text):
|
||||
"""x-Position jeder Zelle einer Zeile.
|
||||
|
||||
def _emit_degree(self, du):
|
||||
"""Das Gradzeichen einfuegen -- ueber Zeichensatzwechsel oder als Fallback."""
|
||||
if config.DEGREE_CHARSET is None:
|
||||
du.text(config.DEGREE_FALLBACK)
|
||||
return
|
||||
du.charset(config.DEGREE_CHARSET, spaced=config.CHARSET_SPACED)
|
||||
du.text(config.DEGREE_CHAR)
|
||||
du.charset(config.CHARSET, spaced=config.CHARSET_SPACED)
|
||||
Normalerweise ein festes Raster aus CELL_WIDTH. Zeichen aus
|
||||
config.CELL_NARROW bekommen weniger Platz -- der Dezimalpunkt der
|
||||
Zehntelminuten stuende sonst sehr luftig zwischen den Ziffern.
|
||||
"""
|
||||
breiten = Display._breiten(text)
|
||||
x = config.LINE_X
|
||||
aus = []
|
||||
for b in breiten:
|
||||
aus.append(x)
|
||||
x += b
|
||||
return aus
|
||||
|
||||
@staticmethod
|
||||
def _zelle_faellig(neu, alt, i, pos):
|
||||
"""Muss Zelle i neu geschrieben werden?
|
||||
|
||||
Ja, wenn sie sich selbst geaendert hat -- oder wenn ihr **linker**
|
||||
Nachbar neu geschrieben wird und bis in diese Zelle hineinragt: Die
|
||||
Anzeige bemalt je Zeichen GLYPH_WIDTH Pixel, mehr als eine Zelle breit
|
||||
ist, und loescht damit die erste Pixelspalte hier (siehe _emit_line).
|
||||
"""
|
||||
def anders(k):
|
||||
return k >= len(alt) or alt[k] != neu[k]
|
||||
|
||||
if anders(i):
|
||||
return True
|
||||
return (i > 0 and anders(i - 1)
|
||||
and pos[i] < pos[i - 1] + config.GLYPH_WIDTH)
|
||||
|
||||
def _emit_line(self, du, y, text, alt=None, pos=None):
|
||||
"""Zellen einer Zeile ausgeben. Liefert True bei Zeichensatzwechsel.
|
||||
|
||||
Jedes Zeichen wird einzeln auf x = LINE_X + i*CELL_WIDTH gesetzt, statt
|
||||
den Auto-Vorschub der Anzeige laufen zu lassen. Der Vorschub der
|
||||
Sperrschrift ist 7 px und liesse nur 9 Zeichen zu; die Zeichenmatrix ist
|
||||
aber nur 6 px breit, sodass 10 Zeichen sauber nebeneinander passen (am
|
||||
Geraet gemessen 2026-07-27, siehe config.CHARS_PER_LINE).
|
||||
|
||||
alt = zuletzt dargestellter Inhalt derselben Zeile. Ist er gegeben,
|
||||
werden nur die Zellen geschrieben, die sich unterscheiden -- das haelt
|
||||
den Bildwechsel kurz und die Anzeige ruhig (siehe show_lines).
|
||||
|
||||
**Der rechte Nachbar muss dabei mit.** Die Anzeige malt je Zeichen 7 px
|
||||
breit (daher auch der 7-px-Auto-Vorschub), wir setzen die Zeichen aber
|
||||
auf 6-px-Raster. Ein neu geschriebenes Zeichen loescht damit die erste
|
||||
Pixelspalte des Zeichens rechts daneben. Beim vollstaendigen Neuaufbau
|
||||
fiel das nie auf, weil der Nachbar gleich danach ohnehin neu gemalt
|
||||
wurde; beim Schreiben einzelner Zellen bliebe er beschaedigt stehen.
|
||||
Am Geraet aufgefallen (2026-07-27): Beim Sekundenwechsel fehlten dem "s"
|
||||
die linken Pixel, beim Minutenwechsel dem "m".
|
||||
"""
|
||||
if pos is None:
|
||||
pos = self._positionen(text)
|
||||
wechsel = False
|
||||
for i in range(len(text)):
|
||||
if alt is not None and not self._zelle_faellig(text, alt, i, pos):
|
||||
continue
|
||||
du.cursor(pos[i], y)
|
||||
if self._emit_zeichen(du, text[i]):
|
||||
wechsel = True
|
||||
return wechsel
|
||||
|
||||
def _emit_zeichen(self, du, ch):
|
||||
"""Ein Zeichen ausgeben. Liefert True, wenn der Zeichensatz wechseln musste.
|
||||
|
||||
Nur das Gradzeichen kommt aus einem anderen Zeichensatz -- in
|
||||
Zeichensatz 0 gibt es keines.
|
||||
|
||||
Ein Zeichensatzwechsel beschaedigt die Home-Zelle (0,0), also den Anfang
|
||||
der obersten Zeile. Solange alle Wechsel in der *unteren* Zeile
|
||||
stattfinden, genuegt die Reihenfolge in show_lines (unten vor oben).
|
||||
|
||||
**Ein Wechsel mitten in der obersten Zeile laesst sich dagegen nicht
|
||||
sauber reparieren.** Das erste Zeichen danach noch einmal zu schreiben,
|
||||
wurde am Geraet ausprobiert (2026-07-27) und machte es schlimmer: Das
|
||||
nachgezogene Zeichen bemalt GLYPH_WIDTH Pixel und frisst dabei die linke
|
||||
Spalte seines rechten Nachbarn. Deshalb steht in der obersten Zeile
|
||||
bewusst kein Sonderzeichen -- die Beschriftung nutzt ein schmales
|
||||
Leerzeichen statt eines Doppelpunkts aus Zeichensatz 1.
|
||||
"""
|
||||
if ch == config.DEGREE_MARKER:
|
||||
if config.DEGREE_CHARSET is None:
|
||||
du.text(config.DEGREE_FALLBACK)
|
||||
return False
|
||||
du.charset(config.DEGREE_CHARSET, spaced=config.CHARSET_SPACED)
|
||||
du.text(config.DEGREE_CHAR)
|
||||
du.charset(config.CHARSET, spaced=config.CHARSET_SPACED)
|
||||
return True
|
||||
du.text(ch)
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
self._t.close()
|
||||
|
||||
|
||||
def open_display(port=config.PORT, fake=False, response=config.WANT_RESPONSE):
|
||||
"""Transport passend zur Plattform oeffnen und Display-Objekt liefern."""
|
||||
def open_display(port=None, fake=False, response=config.WANT_RESPONSE):
|
||||
"""Transport passend zur Plattform oeffnen und Display-Objekt liefern.
|
||||
|
||||
Auf dem ESP32-C3 wird machine.UART genommen (Pins aus config), auf dem Mac
|
||||
pyserial mit config.PORT. fake=True haengt stattdessen den Mitschreiber ein.
|
||||
"""
|
||||
if fake:
|
||||
from transport import FakeTransport
|
||||
t = FakeTransport(src=config.HOST_ADDR, display_addr=config.DISPLAY_ADDR)
|
||||
elif config.MICROPYTHON:
|
||||
from transport import UartTransport
|
||||
t = UartTransport(
|
||||
uart_id=config.UART_ID,
|
||||
baudrate=config.BAUDRATE,
|
||||
parity=config.PARITY,
|
||||
bytesize=config.BYTESIZE,
|
||||
stopbits=config.STOPBITS,
|
||||
timeout=config.RESPONSE_TIMEOUT,
|
||||
tx=config.UART_TX,
|
||||
rx=config.UART_RX,
|
||||
)
|
||||
else:
|
||||
from transport import SerialTransport
|
||||
t = SerialTransport(
|
||||
port=port,
|
||||
port=config.PORT if port is None else port,
|
||||
baudrate=config.BAUDRATE,
|
||||
parity=config.PARITY,
|
||||
bytesize=config.BYTESIZE,
|
||||
|
||||
+162
@@ -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."""
|
||||
|
||||
|
||||
def _hex(data):
|
||||
"""Bytes als Hexstring, ohne sich auf bytes.hex() zu verlassen.
|
||||
|
||||
MicroPython 1.28 hat bytes.hex() (am Geraet nachgeprueft), aber nicht jede
|
||||
Firmware ist damit gebaut. Wird nur in Fehlermeldungen gebraucht -- ein
|
||||
AttributeError ausgerechnet dort wuerde den eigentlichen Fehler verdecken.
|
||||
"""
|
||||
return "".join("%02x" % b for b in data)
|
||||
|
||||
|
||||
def encode_text(s):
|
||||
"""Text in Anzeige-Bytes wandeln.
|
||||
|
||||
@@ -56,10 +66,16 @@ def encode_text(s):
|
||||
|
||||
|
||||
def _digits(value, places):
|
||||
"""Zahl als ASCII-Ziffern fester Laenge (Handbuch 3.7: alle Werte ASCII-codiert)."""
|
||||
"""Zahl als ASCII-Ziffern fester Laenge (Handbuch 3.7: alle Werte ASCII-codiert).
|
||||
|
||||
Von Hand aufgefuellt statt mit "%0*d". MicroPython 1.28 beherrscht die
|
||||
dynamische Feldbreite zwar (am Geraet nachgeprueft), sie stand aber lange
|
||||
als nicht unterstuetzt in den Docs -- so bleibt es unabhaengig davon.
|
||||
"""
|
||||
if value < 0 or value >= 10 ** places:
|
||||
raise MigraError("Wert %d passt nicht in %d Stellen" % (value, places))
|
||||
return ("%0*d" % (places, value)).encode("ascii")
|
||||
s = "%d" % value
|
||||
return ("0" * (places - len(s)) + s).encode("ascii")
|
||||
|
||||
|
||||
class DataUnit:
|
||||
@@ -175,9 +191,9 @@ def parse_response(frame, src=0):
|
||||
if not frame:
|
||||
raise MigraError("keine Antwort von der Anzeige (Timeout)")
|
||||
if len(frame) < 6 or frame[0] != STX or frame[-1] != ETX:
|
||||
raise MigraError("unplausibles Antworttelegramm: %s" % frame.hex())
|
||||
raise MigraError("unplausibles Antworttelegramm: %s" % _hex(frame))
|
||||
if frame[1] != (0x80 | src):
|
||||
raise MigraError("Antwort nicht an uns adressiert: %s" % frame.hex())
|
||||
raise MigraError("Antwort nicht an uns adressiert: %s" % _hex(frame))
|
||||
|
||||
code = chr(frame[4])
|
||||
if code != "0":
|
||||
|
||||
+22
-5
@@ -29,10 +29,18 @@ class MountClient:
|
||||
|
||||
def connect(self):
|
||||
self.close()
|
||||
# Ueber getaddrinfo statt direkt mit (host, port): MicroPython kann ein
|
||||
# Adresstupel mit Hostnamen nicht selbst aufloesen, mit der aufgeloesten
|
||||
# Adresse laeuft connect() auf beiden Seiten gleich.
|
||||
try:
|
||||
addr = socket.getaddrinfo(self.host, self.port)[0][-1]
|
||||
except (OSError, IndexError) as e:
|
||||
raise MountError("Adresse %s:%d nicht aufloesbar: %s"
|
||||
% (self.host, self.port, e))
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(self.timeout)
|
||||
try:
|
||||
sock.connect((self.host, self.port))
|
||||
sock.connect(addr)
|
||||
except OSError as e:
|
||||
sock.close()
|
||||
raise MountError("Verbindung zu %s:%d fehlgeschlagen: %s"
|
||||
@@ -60,10 +68,19 @@ class MountClient:
|
||||
"""Ein gerahmtes Kommando senden und die Antwort ohne '#' zurueckgeben."""
|
||||
if not self.connected:
|
||||
raise MountError("nicht verbunden")
|
||||
try:
|
||||
self._sock.sendall(framed_cmd.encode("latin-1"))
|
||||
except OSError as e:
|
||||
raise MountError("Senden gestoert: %s" % e)
|
||||
# Von Hand schleifen statt sendall(): MicroPython laesst dessen Verhalten
|
||||
# auf Sockets mit Timeout ausdruecklich offen. Die Kommandos sind vier
|
||||
# Bytes lang, die Schleife laeuft also praktisch immer nur einmal.
|
||||
data = framed_cmd.encode("latin-1")
|
||||
sent = 0
|
||||
while sent < len(data):
|
||||
try:
|
||||
n = self._sock.send(data[sent:])
|
||||
except OSError as e:
|
||||
raise MountError("Senden gestoert: %s" % e)
|
||||
if not n:
|
||||
raise MountError("Verbindung von der Montierung geschlossen")
|
||||
sent += n
|
||||
raw = self._recv_until_terminator().decode("latin-1")
|
||||
return lx200.strip_terminator(raw)
|
||||
|
||||
|
||||
+74
-14
@@ -8,8 +8,13 @@ Client nicht haengt.
|
||||
python3 mount_mock.py # feste Koordinaten, Port 3490
|
||||
python3 mount_mock.py --ra 18:36:56 --dec +38:47:01
|
||||
python3 mount_mock.py --move # RA laeuft (Sekunden ticken hoch)
|
||||
python3 mount_mock.py --lan --move # auch vom ESP32 aus erreichbar
|
||||
|
||||
Nur fuer die Entwicklung (CPython). Auf dem ESP32 laeuft spaeter nur der Client.
|
||||
Vorgabe ist 127.0.0.1, also nur lokal. Damit der ESP32 den Mock uebers WLAN
|
||||
erreicht, braucht es --lan (bindet auf 0.0.0.0); die Adresse, die dann auf dem
|
||||
ESP32 einzutragen ist, wird beim Start ausgegeben.
|
||||
|
||||
Nur fuer die Entwicklung (CPython). Auf dem ESP32 laeuft nur der Client.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -53,31 +58,54 @@ def _numbers(text):
|
||||
return [int(x) for x in out]
|
||||
|
||||
|
||||
def _fmt_ra(total_s):
|
||||
def _fmt_ra(total_s, high=True):
|
||||
"""Rektaszension wie die GM4000.
|
||||
|
||||
high=True -> HH:MM:SS (Hochpraezision, nach ":U#")
|
||||
high=False -> HH:MM.T (Zehntelminuten -- so liefert die Montierung im
|
||||
Auslieferzustand, verifiziert 2026-07-18)
|
||||
"""
|
||||
total_s %= 86400
|
||||
h, rest = divmod(total_s, 3600)
|
||||
m, s = divmod(rest, 60)
|
||||
return "%02d:%02d:%02d" % (h, m, s)
|
||||
if high:
|
||||
return "%02d:%02d:%02d" % (h, m, s)
|
||||
zehntel = (total_s * 10 + 30) // 60 # in Zehntelminuten, gerundet
|
||||
minuten, t = divmod(zehntel, 10)
|
||||
h, m = divmod(minuten, 60)
|
||||
return "%02d:%02d.%d" % (h % 24, m, t)
|
||||
|
||||
|
||||
def _fmt_dec(total_arcsec):
|
||||
def _fmt_dec(total_arcsec, high=True):
|
||||
"""Deklination wie die GM4000.
|
||||
|
||||
high=True -> sDD*MM:SS (Hochpraezision)
|
||||
high=False -> sDD*MM (ganze Bogenminuten, Auslieferzustand)
|
||||
|
||||
Gradzeichen als '*', Minuten/Sekunden mit ':' -- der coords-Parser kommt mit
|
||||
beiden Trennzeichen klar. (Die echte Montierung sendet 0xDF als Gradzeichen.)
|
||||
"""
|
||||
sign = "-" if total_arcsec < 0 else "+"
|
||||
total_arcsec = abs(total_arcsec)
|
||||
d, rest = divmod(total_arcsec, 3600)
|
||||
m, s = divmod(rest, 60)
|
||||
# Gradzeichen als '*', Minuten/Sekunden mit ':' -- der coords-Parser
|
||||
# kommt mit beiden Trennzeichen klar.
|
||||
return "%s%02d*%02d:%02d" % (sign, d, m, s)
|
||||
if high:
|
||||
return "%s%02d*%02d:%02d" % (sign, d, m, s)
|
||||
return "%s%02d*%02d" % (sign, d, m)
|
||||
|
||||
|
||||
class MountMock:
|
||||
"""Simuliert die Montierung auf TCP-Ebene."""
|
||||
|
||||
def __init__(self, host="127.0.0.1", port=3490,
|
||||
ra="18:36:56", dec="+38:47:01", move=False):
|
||||
ra="18:36:56", dec="+38:47:01", move=False, high=True):
|
||||
self._ra0 = _parse_ra(ra)
|
||||
self._dec = _parse_dec(dec)
|
||||
self._move = move
|
||||
# high=False bildet den Auslieferzustand der GM4000 nach: keine Sekunden,
|
||||
# RA als Zehntelminuten. Vorgabe bleibt True, damit bestehende Tests mit
|
||||
# ihren festen Erwartungen unveraendert durchlaufen.
|
||||
self._high = high
|
||||
self._tick = 0 # zaehlt bei --move pro RA-Abfrage hoch
|
||||
self._dectick = 0 # zaehlt bei --move pro DEC-Abfrage hoch
|
||||
self._lock = threading.Lock()
|
||||
@@ -111,9 +139,9 @@ class MountMock:
|
||||
def _reply_for(self, cmd):
|
||||
"""Antwort (inkl. '#') fuer ein Kommando ohne ':' und ohne '#'."""
|
||||
if cmd == "GR":
|
||||
return _fmt_ra(self._current_ra()) + "#"
|
||||
return _fmt_ra(self._current_ra(), self._high) + "#"
|
||||
if cmd == "GD":
|
||||
return _fmt_dec(self._current_dec()) + "#"
|
||||
return _fmt_dec(self._current_dec(), self._high) + "#"
|
||||
if cmd == "GVP":
|
||||
return PRODUCT + "#"
|
||||
if cmd == "GVN":
|
||||
@@ -172,20 +200,52 @@ class MountMock:
|
||||
self.stop()
|
||||
|
||||
|
||||
def lan_address():
|
||||
"""Die eigene LAN-Adresse ermitteln -- die, die der ESP32 ansprechen muss.
|
||||
|
||||
Ueber einen UDP-Socket ans Gateway: der verschickt nichts, verraet aber,
|
||||
welche lokale Adresse das Betriebssystem fuer den Weg nach draussen nimmt.
|
||||
"""
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(("192.168.1.1", 1))
|
||||
return s.getsockname()[0]
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--host", default="127.0.0.1")
|
||||
p.add_argument("--lan", action="store_true",
|
||||
help="auf allen Schnittstellen lauschen (0.0.0.0), damit der "
|
||||
"ESP32 den Mock uebers WLAN erreicht")
|
||||
p.add_argument("--port", type=int, default=3490)
|
||||
p.add_argument("--ra", default="18:36:56")
|
||||
p.add_argument("--dec", default="+38:47:01")
|
||||
p.add_argument("--move", action="store_true", help="RA laeuft mit der Zeit hoch")
|
||||
p.add_argument("--low", action="store_true",
|
||||
help="Niedrigpraezision wie im Auslieferzustand der GM4000: "
|
||||
"RA als HH:MM.T, DEC ohne Bogensekunden")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
mock = MountMock(host=args.host, port=args.port, ra=args.ra, dec=args.dec,
|
||||
move=args.move)
|
||||
print("Mock-Montierung auf %s:%d (RA=%s DEC=%s%s)"
|
||||
% (mock.host, mock.port, args.ra, args.dec, ", move" if args.move else ""))
|
||||
host = "0.0.0.0" if args.lan else args.host
|
||||
mock = MountMock(host=host, port=args.port, ra=args.ra, dec=args.dec,
|
||||
move=args.move, high=not args.low)
|
||||
print("Mock-Montierung auf %s:%d (RA=%s DEC=%s%s, %s)"
|
||||
% (mock.host, mock.port, args.ra, args.dec,
|
||||
", move" if args.move else "",
|
||||
"Niedrigpraezision" if args.low else "Hochpraezision"))
|
||||
if host == "0.0.0.0":
|
||||
ip = lan_address()
|
||||
if ip:
|
||||
print("Vom ESP32 aus erreichbar unter: %s:%d" % (ip, mock.port))
|
||||
print(" run_esp32.main(host=\"%s\", fake_display=True)" % ip)
|
||||
print("Falls keine Verbindung zustande kommt: macOS fragt beim ersten Mal,")
|
||||
print("ob python3 eingehende Verbindungen annehmen darf -- das muss erlaubt sein.")
|
||||
print("Beenden mit Ctrl-C.")
|
||||
mock.serve_forever()
|
||||
return 0
|
||||
|
||||
@@ -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.
|
||||
|
||||
python3 probe.py ruler Ziffernlineal 12345678901234... -> Zeichen zaehlen
|
||||
python3 probe.py pitch 6 Zellenbreite messen: Auto-Vorschub gegen 6 px
|
||||
python3 probe.py pitch 7 dasselbe gegen 7 px
|
||||
python3 probe.py lines Zeile 1 und Zeile 2 an den Positionen aus config.py
|
||||
python3 probe.py scan-y dieselbe Zeile schrittweise nach unten schieben
|
||||
python3 probe.py corners Punkte in die vier Ecken -> Pixelaufloesung pruefen
|
||||
@@ -37,6 +39,51 @@ def ruler(d):
|
||||
print("Das ist CHARS_PER_LINE (aktuell in config.py: %d)." % config.CHARS_PER_LINE)
|
||||
|
||||
|
||||
def pitch(d, cell=6):
|
||||
"""Zellenbreite direkt messen, statt Zeichen zu zaehlen.
|
||||
|
||||
Der Cursor arbeitet auf *Pixel*positionen -- das laesst sich ausnutzen:
|
||||
|
||||
Zeile 1: zehn Ziffern als ein Text ab x=0. Die Anzeige rueckt selbst vor,
|
||||
mit der echten Zellenbreite.
|
||||
Zeile 2: dieselben Ziffern einzeln auf x = i*cell gesetzt.
|
||||
|
||||
Stehen beide Zeilen exakt untereinander, ist `cell` die Zellenbreite. Ein
|
||||
Unterschied von nur 1 px summiert sich ueber zehn Zeichen auf 9 px auf und
|
||||
ist nicht zu uebersehen -- viel eindeutiger als das Abzaehlen am Umbruch.
|
||||
|
||||
5x7-Matrix mit 1 px Luecke waere cell=6 (dann passen 10 Zeichen in 64 px),
|
||||
Sperrschrift mit 2 px Luecke waere cell=7 (dann 9 Zeichen).
|
||||
"""
|
||||
ziffern = "0123456789"
|
||||
|
||||
du = migra.DataUnit()
|
||||
du.fill(config.BACKGROUND)
|
||||
du.attributes(fg=config.COLOR, bg=config.BACKGROUND)
|
||||
du.charset(config.CHARSET, spaced=config.CHARSET_SPACED)
|
||||
|
||||
# Untere Zeile zuerst -- der Zeichensatzwechsel wuerde sonst die Home-Zelle
|
||||
# der oberen Zeile beschaedigen (siehe display.show_lines).
|
||||
for i, ch in enumerate(ziffern):
|
||||
x = i * cell
|
||||
if x + cell > 64:
|
||||
break
|
||||
du.cursor(x, config.LINE_Y[1])
|
||||
du.text(ch)
|
||||
|
||||
du.cursor(0, config.LINE_Y[0])
|
||||
du.text(ziffern)
|
||||
d.send(du)
|
||||
|
||||
print("Obere Zeile: Anzeige rueckt selbst vor.")
|
||||
print("Untere Zeile: Ziffern fest auf x = i*%d gesetzt." % cell)
|
||||
print("")
|
||||
print("Stehen sie exakt untereinander? -> Zellenbreite ist %d px," % cell)
|
||||
print("also CHARS_PER_LINE = %d bei 64 px Breite." % (64 // cell))
|
||||
print("Laeuft die obere Zeile nach rechts weg? -> Zelle ist breiter als %d px." % cell)
|
||||
print("Dann mit einem groesseren Wert wiederholen: probe.py pitch %d" % (cell + 1))
|
||||
|
||||
|
||||
def lines(d):
|
||||
d.show_lines("Zeile 1", "Zeile 2")
|
||||
print("Stehen beide Zeilen sauber untereinander? Sonst LINE_Y anpassen.")
|
||||
@@ -71,18 +118,28 @@ def corners(d):
|
||||
print("Punkt %3d/%-3d -> %s" % (x, y, e))
|
||||
|
||||
|
||||
MODES = {"ruler": ruler, "lines": lines, "scan-y": scan_y, "corners": corners}
|
||||
MODES = {"ruler": ruler, "pitch": pitch, "lines": lines, "scan-y": scan_y,
|
||||
"corners": corners}
|
||||
|
||||
|
||||
def main():
|
||||
args = [a for a in sys.argv[1:] if not a.startswith("-")]
|
||||
if len(args) != 1 or args[0] not in MODES:
|
||||
if not args or args[0] not in MODES:
|
||||
print(__doc__)
|
||||
return 2
|
||||
|
||||
extra = []
|
||||
if args[0] == "pitch" and len(args) > 1:
|
||||
try:
|
||||
extra = [int(args[1])]
|
||||
except ValueError:
|
||||
print("pitch braucht eine Pixelzahl, z.B.: probe.py pitch 6")
|
||||
return 2
|
||||
|
||||
response = config.WANT_RESPONSE and "--no-response" not in sys.argv
|
||||
d = open_display(response=response)
|
||||
try:
|
||||
MODES[args[0]](d)
|
||||
MODES[args[0]](d, *extra)
|
||||
finally:
|
||||
d.close()
|
||||
return 0
|
||||
|
||||
+13
-54
@@ -13,19 +13,12 @@ Hintergrund erneut verbunden.
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
|
||||
import config
|
||||
import coords
|
||||
import migra
|
||||
import displayloop
|
||||
from brightness import BrightnessController
|
||||
from display import open_display
|
||||
from mount_client import MountClient, MountError
|
||||
|
||||
|
||||
def _format(ra_raw, dec_raw):
|
||||
"""Rohstrings der Montierung fuer die Anzeige aufbereiten."""
|
||||
return coords.format_ra(ra_raw), coords.format_dec(dec_raw)
|
||||
from mount_client import MountClient
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
@@ -56,54 +49,20 @@ def main(argv=None):
|
||||
|
||||
d = open_display(fake=args.dry_run)
|
||||
client = MountClient(host, port, timeout=config.MOUNT_TIMEOUT)
|
||||
last_shown = None
|
||||
|
||||
# Helligkeitsregelung: derzeit ein fester LDR-Wert von der Kommandozeile;
|
||||
# spaeter (ESP32) wird hier pro Durchlauf der ADC gelesen.
|
||||
dimmer = BrightnessController() if args.ldr is not None else None
|
||||
# Helligkeitsregelung: hier ein fester LDR-Wert von der Kommandozeile; auf
|
||||
# dem ESP32 liest run_esp32.py stattdessen den ADC.
|
||||
dimmer = read_ldr = None
|
||||
if args.ldr is not None:
|
||||
dimmer = BrightnessController()
|
||||
read_ldr = lambda: args.ldr
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
if not client.connected:
|
||||
client.connect()
|
||||
|
||||
# Helligkeit nachfuehren (nur senden, wenn sich die Stufe aendert)
|
||||
if dimmer is not None:
|
||||
level, percent, changed = dimmer.update(args.ldr)
|
||||
if changed:
|
||||
d.set_brightness(percent)
|
||||
print("LDR %d -> Helligkeit Stufe %d = %d %%"
|
||||
% (args.ldr, level, percent))
|
||||
|
||||
ra_raw, dec_raw = client.get_coordinates()
|
||||
ra_line, dec_line = _format(ra_raw, dec_raw)
|
||||
|
||||
if (ra_line, dec_line) != last_shown:
|
||||
# Bei jeder Aenderung beide Zeilen neu zeichnen. Kein Vollbild-
|
||||
# Loeschen (kein Flimmern); die umgekehrte Zeilenreihenfolge in
|
||||
# show_lines schuetzt die Home-Zelle vor dem Gradzeichen-
|
||||
# Zeichensatzwechsel, auch bei staendiger Aktualisierung.
|
||||
first = last_shown is None or last_shown == config.NOLINK_LINES
|
||||
d.show_lines(ra_line, dec_line, clear=first)
|
||||
last_shown = (ra_line, dec_line)
|
||||
print("RA %-11s DEC %-12s -> %s | %s"
|
||||
% (ra_raw, dec_raw, ra_line, dec_line))
|
||||
except migra.MigraError as e:
|
||||
# Anzeige-/Antwortfehler (z.B. gestoerte Antwort): nicht abstuerzen,
|
||||
# nur melden und beim naechsten Durchlauf neu zeichnen.
|
||||
print("Anzeige: %s" % e, file=sys.stderr)
|
||||
last_shown = None
|
||||
except (MountError, ValueError) as e:
|
||||
print("Montierung: %s" % e, file=sys.stderr)
|
||||
client.close()
|
||||
if config.NOLINK_LINES != last_shown:
|
||||
d.show_lines(*config.NOLINK_LINES, clear=True)
|
||||
last_shown = config.NOLINK_LINES
|
||||
|
||||
if args.once:
|
||||
break
|
||||
time.sleep(args.interval)
|
||||
displayloop.poll_loop(d, client,
|
||||
interval=args.interval,
|
||||
once=args.once,
|
||||
read_ldr=read_ldr,
|
||||
dimmer=dimmer)
|
||||
except KeyboardInterrupt:
|
||||
print("\nbeendet")
|
||||
finally:
|
||||
|
||||
+200
@@ -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
|
||||
"""Tests fuer die Helligkeitsregelung (verallgemeinert aus DoDimmen_V).
|
||||
"""Tests fuer die Helligkeitsumschaltung (zwei Stufen, ein Umschaltpunkt).
|
||||
|
||||
python3 -m unittest -v test_brightness
|
||||
|
||||
Grenzen: Mitten (300, 2500, 3500) mit Hysterese (30, 150, 150)
|
||||
-> hoch bei 330 / 2650 / 3650, runter bei 270 / 2350 / 3350.
|
||||
Die Tests geben ihre Werte selbst vor, statt settings.json zu lesen -- so
|
||||
haengen sie nicht daran, was gerade auf einem Geraet konfiguriert ist.
|
||||
Vorgabewerte: Schwelle 1800 +/- 150 -> hoch ab 1950, runter unter 1650.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
import brightness
|
||||
from brightness import BrightnessController
|
||||
from brightness import BrightnessController, DUNKEL, HELL
|
||||
|
||||
WERTE = {"dunkel_prozent": 5, "hell_prozent": 50,
|
||||
"schwelle": 1800, "hysterese": 150}
|
||||
|
||||
|
||||
class TestStufen(unittest.TestCase):
|
||||
def controller(**abweichend):
|
||||
werte = dict(WERTE)
|
||||
werte.update(abweichend)
|
||||
return BrightnessController(werte=werte)
|
||||
|
||||
|
||||
class TestUmschalten(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.c = BrightnessController()
|
||||
self.c = controller()
|
||||
|
||||
def test_erststart_schaltet_von_0_hoch(self):
|
||||
self.assertEqual(self.c._next_level(329, None), 0)
|
||||
self.assertEqual(self.c._next_level(330, None), 1)
|
||||
self.assertEqual(self.c._next_level(2649, None), 1)
|
||||
self.assertEqual(self.c._next_level(2650, None), 2)
|
||||
self.assertEqual(self.c._next_level(3649, None), 2)
|
||||
self.assertEqual(self.c._next_level(3650, None), 3) # 100 % erreichbar
|
||||
def test_erststart_entscheidet_an_der_blanken_schwelle(self):
|
||||
"""Ohne bisherige Stufe zaehlt die Schwelle selbst, nicht ihre Kante.
|
||||
|
||||
def test_hysterese_von_stufe_1(self):
|
||||
self.assertEqual(self.c._next_level(270, 1), 1)
|
||||
self.assertEqual(self.c._next_level(269, 1), 0)
|
||||
self.assertEqual(self.c._next_level(2649, 1), 1)
|
||||
self.assertEqual(self.c._next_level(2650, 1), 2)
|
||||
Sonst startete die Anzeige im Bereich zwischen Schwelle und Kante
|
||||
faelschlich dunkel und wachte erst spaeter auf.
|
||||
"""
|
||||
self.assertEqual(self.c._next_level(1799, None), DUNKEL)
|
||||
self.assertEqual(self.c._next_level(1800, None), HELL)
|
||||
|
||||
def test_hysterese_von_stufe_2(self):
|
||||
self.assertEqual(self.c._next_level(2350, 2), 2)
|
||||
self.assertEqual(self.c._next_level(2349, 2), 1)
|
||||
self.assertEqual(self.c._next_level(3649, 2), 2)
|
||||
self.assertEqual(self.c._next_level(3650, 2), 3)
|
||||
def test_hochschalten_erst_ueber_der_oberen_kante(self):
|
||||
self.assertEqual(self.c._next_level(1949, DUNKEL), DUNKEL)
|
||||
self.assertEqual(self.c._next_level(1950, DUNKEL), HELL)
|
||||
|
||||
def test_hysterese_von_stufe_3(self):
|
||||
self.assertEqual(self.c._next_level(3350, 3), 3)
|
||||
self.assertEqual(self.c._next_level(3349, 3), 2)
|
||||
def test_herunterschalten_erst_unter_der_unteren_kante(self):
|
||||
self.assertEqual(self.c._next_level(1650, HELL), HELL)
|
||||
self.assertEqual(self.c._next_level(1649, HELL), DUNKEL)
|
||||
|
||||
def test_prozente(self):
|
||||
self.assertEqual(brightness.PERCENTS, (5, 15, 50, 100))
|
||||
def test_im_hysteresefenster_bleibt_die_stufe_stehen(self):
|
||||
"""Der Kern der Sache: dazwischen aendert sich nichts, egal von wo."""
|
||||
for wert in (1650, 1800, 1949):
|
||||
self.assertEqual(self.c._next_level(wert, DUNKEL), DUNKEL)
|
||||
self.assertEqual(self.c._next_level(wert, HELL), HELL)
|
||||
|
||||
def test_prozente_kommen_aus_den_einstellungen(self):
|
||||
c = controller(dunkel_prozent=7, hell_prozent=80)
|
||||
self.assertEqual(c.percent(DUNKEL), 7)
|
||||
self.assertEqual(c.percent(HELL), 80)
|
||||
|
||||
def test_gemessene_lichtlagen(self):
|
||||
"""Die am Geraet gemessenen Lagen (2026-07-27) in Stufen uebersetzt."""
|
||||
self.assertEqual(self.c._next_level(0, None), DUNKEL) # abgedeckt
|
||||
self.assertEqual(self.c._next_level(2453, None), HELL) # Raumlicht
|
||||
self.assertEqual(self.c._next_level(3552, None), HELL) # angeleuchtet
|
||||
|
||||
|
||||
class TestUpdate(unittest.TestCase):
|
||||
|
||||
def test_erste_aenderung_meldet_changed(self):
|
||||
c = BrightnessController()
|
||||
self.assertEqual(c.update(100), (0, 5, True))
|
||||
def test_erste_meldung_gilt_als_aenderung(self):
|
||||
c = controller()
|
||||
level, prozent, changed = c.update(100)
|
||||
self.assertEqual((level, prozent), (DUNKEL, 5))
|
||||
self.assertTrue(changed)
|
||||
|
||||
def test_gleicher_wert_keine_aenderung(self):
|
||||
c = BrightnessController()
|
||||
def test_gleicher_wert_meldet_keine_aenderung(self):
|
||||
c = controller()
|
||||
c.update(100)
|
||||
level, percent, changed = c.update(100)
|
||||
_, prozent, changed = c.update(100)
|
||||
self.assertFalse(changed)
|
||||
self.assertEqual(percent, 5)
|
||||
self.assertEqual(prozent, 5)
|
||||
|
||||
def test_dunkel_zu_hell_erreicht_100(self):
|
||||
c = BrightnessController()
|
||||
c.update(100)
|
||||
level = percent = None
|
||||
for _ in range(brightness.AVERAGE_WINDOW + 2):
|
||||
level, percent, _ = c.update(4000)
|
||||
self.assertEqual(level, 3)
|
||||
self.assertEqual(percent, 100)
|
||||
def test_dunkel_nach_hell_und_zurueck(self):
|
||||
c = controller()
|
||||
c.update(0)
|
||||
self.assertEqual(c.level, DUNKEL)
|
||||
for _ in range(brightness.AVERAGE_WINDOW):
|
||||
c.update(3500)
|
||||
self.assertEqual(c.level, HELL)
|
||||
self.assertEqual(c.percent(c.level), 50)
|
||||
for _ in range(brightness.AVERAGE_WINDOW):
|
||||
c.update(0)
|
||||
self.assertEqual(c.level, DUNKEL)
|
||||
|
||||
def test_gleitender_mittelwert_fenster(self):
|
||||
c = BrightnessController()
|
||||
def test_rauschen_um_die_schwelle_schaltet_nicht(self):
|
||||
"""Gemessen wurden rund +/-32 Streuung nach der Mittelung.
|
||||
|
||||
Genau dagegen ist die Hysterese da -- ohne sie wuerde die Anzeige an der
|
||||
Schwelle zwischen den beiden Helligkeiten springen.
|
||||
"""
|
||||
c = controller()
|
||||
for _ in range(brightness.AVERAGE_WINDOW):
|
||||
c.update(1800)
|
||||
stufe = c.level
|
||||
for delta in (+60, -60, +55, -50, +45, -60, +60, -55):
|
||||
neu, _, changed = c.update(1800 + delta)
|
||||
self.assertFalse(changed, "Rauschen hat umgeschaltet")
|
||||
self.assertEqual(neu, stufe)
|
||||
|
||||
def test_gleitender_mittelwert_begrenzt_das_fenster(self):
|
||||
c = controller()
|
||||
for _ in range(20):
|
||||
c.update(1000)
|
||||
self.assertLessEqual(len(c._buf), brightness.AVERAGE_WINDOW)
|
||||
|
||||
def test_mittelwert_daempft_einen_ausreisser(self):
|
||||
"""Ein einzelner heller Messwert darf die Anzeige nicht aufblitzen lassen."""
|
||||
c = controller()
|
||||
for _ in range(brightness.AVERAGE_WINDOW):
|
||||
c.update(0)
|
||||
_, _, changed = c.update(4095)
|
||||
self.assertFalse(changed)
|
||||
self.assertEqual(c.level, DUNKEL)
|
||||
|
||||
|
||||
class TestReload(unittest.TestCase):
|
||||
"""Geaenderte Einstellungen uebernehmen -- der Weg, den MQTT spaeter geht."""
|
||||
|
||||
def test_reload_liest_die_datei_neu(self):
|
||||
import os
|
||||
import settings
|
||||
pfad = "test_settings_tmp.json"
|
||||
try:
|
||||
settings.save({"schwelle": 1000, "hell_prozent": 90}, pfad)
|
||||
c = BrightnessController(pfad=pfad)
|
||||
self.assertEqual(c.settings["schwelle"], 1000)
|
||||
self.assertEqual(c.percent(HELL), 90)
|
||||
|
||||
settings.save({"schwelle": 3000, "hell_prozent": 20}, pfad)
|
||||
c.reload()
|
||||
self.assertEqual(c.settings["schwelle"], 3000)
|
||||
self.assertEqual(c.percent(HELL), 20)
|
||||
finally:
|
||||
try:
|
||||
os.remove(pfad)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class TestRawToLdr(unittest.TestCase):
|
||||
|
||||
def test_invertiert_spiegelt_den_rohwert(self):
|
||||
self.assertEqual(brightness.raw_to_ldr(4095, inverted=True, full_scale=4095), 0)
|
||||
self.assertEqual(brightness.raw_to_ldr(0, inverted=True, full_scale=4095), 4095)
|
||||
|
||||
def test_nicht_invertiert_laesst_den_rohwert_stehen(self):
|
||||
self.assertEqual(brightness.raw_to_ldr(1234, inverted=False, full_scale=4095), 1234)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
+418
@@ -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):
|
||||
ra_raw, dec_raw = self.client.get_coordinates()
|
||||
self.assertEqual(coords.format_ra(ra_raw, high_precision=True), "18h36m56s")
|
||||
self.assertEqual(coords.format_dec(dec_raw, high_precision=True), "+38°47'01")
|
||||
# Seit CHARS_PER_LINE = 10 passt die volle Form mit abschliessendem ".
|
||||
self.assertEqual(coords.format_dec(dec_raw, high_precision=True), "+38°47'01\"")
|
||||
|
||||
def test_produktname(self):
|
||||
self.assertEqual(self.client.query(lx200.GET_PRODUCT), mount_mock.PRODUCT)
|
||||
|
||||
+180
@@ -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 config
|
||||
import coords
|
||||
import migra
|
||||
from display import Display
|
||||
@@ -135,41 +136,80 @@ class TestKoordinaten(unittest.TestCase):
|
||||
self.assertEqual(coords.format_dec("+45d12m30s", width=7, **hp), "+45°12'")
|
||||
|
||||
def test_formatierung_niedrigpraezision(self):
|
||||
"""RA in Zehntelminuten -- so, wie die Montierung sie liefert.
|
||||
|
||||
Bis 2026-07-27 wurde hier in Sekunden umgerechnet (11h36m54s). Die
|
||||
sprangen aber in 6-Sekunden-Schritten, weil die Rohdaten sie nicht
|
||||
hergeben. Die Zehntelform ist ehrlicher und laesst mit sieben Zeichen
|
||||
Platz fuer die Beschriftung "RA:".
|
||||
"""
|
||||
lp = dict(high_precision=False)
|
||||
# RA: Zehntel-Minute (0.9 min) kommt als Sekunden (54 s) heraus
|
||||
self.assertEqual(coords.format_ra("11:36.9", width=9, **lp), "11h36m54s")
|
||||
# Breiteste passende Form: mit Minutenzeichen am Ende, sonst ohne.
|
||||
self.assertEqual(coords.format_ra("11:36.9", width=9, **lp), "11h36.9m")
|
||||
self.assertEqual(coords.format_ra("11:36.9", width=8, **lp), "11h36.9m")
|
||||
self.assertEqual(coords.format_ra("11:36.9", width=7, **lp), "11h36.9")
|
||||
self.assertEqual(coords.format_ra("11:36.9", width=6, **lp), "11h36m")
|
||||
# DEC: ohne Sekunden
|
||||
self.assertEqual(coords.format_dec("-00°55", width=9, **lp), "-00°55'")
|
||||
self.assertEqual(coords.format_dec("-00°55", width=6, **lp), "-00:55")
|
||||
|
||||
def test_zehntelminuten_mit_uebertrag(self):
|
||||
"""18:36:57 sind 36,95 Minuten -- das muss auf 18:37.0 aufgehen."""
|
||||
lp = dict(high_precision=False, width=7)
|
||||
self.assertEqual(coords.format_ra("18:36:57", **lp), "18h37.0")
|
||||
self.assertEqual(coords.format_ra("18:36:00", **lp), "18h36.0")
|
||||
self.assertEqual(coords.format_ra("18:36:54", **lp), "18h36.9")
|
||||
# Auch ueber die Stunde und ueber Mitternacht hinweg.
|
||||
self.assertEqual(coords.format_ra("18:59:58", **lp), "19h00.0")
|
||||
self.assertEqual(coords.format_ra("23:59:58", **lp), "00h00.0")
|
||||
|
||||
def test_hochpraezision_behaelt_die_sekundenform(self):
|
||||
hp = dict(high_precision=True, width=10)
|
||||
self.assertEqual(coords.format_ra("18:36:56", **hp), "18h36m56s")
|
||||
self.assertEqual(coords.format_dec("+38*47:01", **hp), "+38°47'01\"")
|
||||
|
||||
|
||||
class TestDisplay(unittest.TestCase):
|
||||
|
||||
def test_zeilen_ergeben_ein_telegramm(self):
|
||||
def test_je_zeile_ein_telegramm(self):
|
||||
"""Eine Zeile je Telegramm, jeweils sauber gerahmt.
|
||||
|
||||
Frueher war beides ein Telegramm. Seit jedes Zeichen einzeln
|
||||
positioniert wird (10 statt 9 Zeichen), waeren beide Zeilen zusammen zu
|
||||
dicht an migra.MAX_DATA_UNIT -- siehe display.show_lines.
|
||||
"""
|
||||
t = FakeTransport(echo=False)
|
||||
Display(t, response=True).show_lines("12:34:56", "+45:12:30", clear=True)
|
||||
|
||||
self.assertEqual(len(t.frames), 1)
|
||||
frame = t.frames[0]
|
||||
self.assertEqual(frame[:4], bytes([0x02, 0x81, 0x80, 0x81]))
|
||||
self.assertEqual(frame[-1], 0x03)
|
||||
# clear=True ist ein eigenes, sehr kurzes Telegramm, danach je Zeile eins.
|
||||
self.assertEqual(len(t.frames), 3)
|
||||
for frame in t.frames:
|
||||
self.assertEqual(frame[:4], bytes([0x02, 0x81, 0x80, 0x81]))
|
||||
self.assertEqual(frame[-1], 0x03)
|
||||
self.assertLessEqual(len(frame), migra.MAX_DATA_UNIT)
|
||||
|
||||
data = frame[4:-1]
|
||||
self.assertIn(b"\x1bF0", data) # mit clear=True: vorher loeschen
|
||||
self.assertIn(b"\x1bC000000\x1f12:34:56", data) # Zeile 1 bei y=0
|
||||
self.assertIn(b"\x1bC000009\x1f+45:12:30", data) # Zeile 2 bei y=9
|
||||
loeschen, unten, oben = t.frames # untere Zeile zuerst
|
||||
self.assertIn(b"\x1bF0", loeschen)
|
||||
self.assertNotIn(b"\x1bF0", unten)
|
||||
self.assertNotIn(b"\x1bF0", oben)
|
||||
|
||||
# Jedes Zeichen sitzt auf seiner eigenen Zelle: x = LINE_X + i*CELL_WIDTH.
|
||||
def zelle(i, y):
|
||||
return b"\x1bC%03d%03d" % (config.LINE_X + i * config.CELL_WIDTH, y)
|
||||
|
||||
# Linksbuendig (config.CENTER_LINES): beide Zeilen beginnen bei Zelle 0.
|
||||
self.assertIn(zelle(0, 0) + b"\x1f1", oben)
|
||||
self.assertIn(zelle(1, 0) + b"\x1f2", oben)
|
||||
self.assertIn(zelle(0, 9) + b"\x1f+", unten)
|
||||
|
||||
def test_ohne_clear_kein_fill_aber_aufgefuellt(self):
|
||||
"""Standard (clear=False): kein Vollbild-Loeschen, Zeilen auf volle Breite."""
|
||||
from test_display import zeile, aufbereitet
|
||||
t = FakeTransport(echo=False)
|
||||
Display(t, response=True).show_lines("12:34:56", clear=False)
|
||||
data = t.frames[0][4:-1]
|
||||
self.assertNotIn(b"\x1bF0", data)
|
||||
# auf CHARS_PER_LINE mit Leerzeichen aufgefuellt
|
||||
import config
|
||||
padded = "12:34:56".ljust(config.CHARS_PER_LINE).encode()
|
||||
self.assertIn(padded, data)
|
||||
self.assertNotIn(b"\x1bF0", t.frames[0])
|
||||
# auf CHARS_PER_LINE mit Leerzeichen aufgefuellt (mittig, siehe CENTER_LINES)
|
||||
self.assertEqual(zeile(t.frames, 0), aufbereitet("12:34:56"))
|
||||
|
||||
def test_gradzeichen_via_zeichensatz(self):
|
||||
"""Gradzeichen wird durch kurzen Wechsel auf Zeichensatz 1 erzeugt."""
|
||||
@@ -180,11 +220,20 @@ class TestDisplay(unittest.TestCase):
|
||||
self.assertIn(b"\x1bz01\x1f/\x1bz00", data)
|
||||
|
||||
def test_oberste_zeile_wird_zuletzt_gezeichnet(self):
|
||||
"""Wegen der Home-Zellen-Stoerung muss y=0 nach y=9 im Telegramm stehen."""
|
||||
"""Wegen der Home-Zellen-Stoerung muss y=0 nach y=9 gesendet werden.
|
||||
|
||||
Seit es ein Telegramm je Zeile gibt, entscheidet die Reihenfolge der
|
||||
Telegramme statt der Reihenfolge innerhalb eines Telegramms.
|
||||
"""
|
||||
t = FakeTransport(echo=False)
|
||||
Display(t, response=True).show_lines("12h34m56s", "+45°12'30\"")
|
||||
data = t.frames[0][4:-1]
|
||||
self.assertLess(data.index(b"C000009"), data.index(b"C000000"))
|
||||
from test_display import zellen
|
||||
self.assertEqual(len(t.frames), 2)
|
||||
# Auf die y-Koordinaten schauen, nicht auf feste Cursor-Bytes.
|
||||
y_zuerst = set(y for _, y in zellen(t.frames[:1]))
|
||||
y_zuletzt = set(y for _, y in zellen(t.frames[1:]))
|
||||
self.assertEqual(y_zuerst, {config.LINE_Y[1]}) # untere Zeile zuerst
|
||||
self.assertEqual(y_zuletzt, {config.LINE_Y[0]}) # oberste Zeile zuletzt
|
||||
|
||||
def test_gradzeichen_fallback_ohne_zeichensatz(self):
|
||||
"""Ist DEGREE_CHARSET None, wird der Fallbacktext statt der Umschaltung genutzt."""
|
||||
@@ -208,12 +257,12 @@ class TestDisplay(unittest.TestCase):
|
||||
|
||||
def test_show_line_nur_eine_zeile(self):
|
||||
"""show_line schreibt nur die angegebene Zeile, ohne Vollbild-Loeschen."""
|
||||
from test_display import zellen
|
||||
t = FakeTransport(echo=False)
|
||||
Display(t, response=True).show_line(0, "18h36m56s")
|
||||
data = t.frames[0][4:-1]
|
||||
self.assertNotIn(b"\x1bF0", data) # kein Loeschen
|
||||
self.assertIn(b"\x1bC000000", data) # Cursor auf Zeile 1 (y=0)
|
||||
self.assertNotIn(b"C000009", data) # Zeile 2 unberuehrt
|
||||
self.assertNotIn(b"\x1bF0", t.frames[0]) # kein Loeschen
|
||||
y_werte = set(y for _, y in zellen(t.frames))
|
||||
self.assertEqual(y_werte, {config.LINE_Y[0]}) # nur Zeile 1, Zeile 2 unberuehrt
|
||||
|
||||
def test_show_line_ungueltiger_index(self):
|
||||
t = FakeTransport(echo=False)
|
||||
|
||||
+406
@@ -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
|
||||
|
||||
try: # MicroPython
|
||||
from machine import UART
|
||||
from machine import UART, Pin
|
||||
except ImportError:
|
||||
UART = None
|
||||
Pin = None
|
||||
|
||||
try:
|
||||
import time
|
||||
_monotonic = time.monotonic
|
||||
except AttributeError: # MicroPython kennt kein time.monotonic
|
||||
_monotonic = lambda: time.ticks_ms() / 1000.0
|
||||
from ticks import deadline as _deadline, expired as _expired
|
||||
|
||||
|
||||
def _truncate_at_etx(buf):
|
||||
"""Alles ab dem ersten ETX abschneiden (einschliesslich).
|
||||
|
||||
Nach ETX ist das Antworttelegramm zu Ende; alles danach waere Stoerung und
|
||||
wuerde die Plausibilitaetspruefung in migra.parse_response scheitern lassen.
|
||||
"""
|
||||
idx = buf.find(b"\x03")
|
||||
return bytes(buf if idx < 0 else buf[:idx + 1])
|
||||
|
||||
|
||||
class SerialTransport:
|
||||
@@ -49,9 +56,9 @@ class SerialTransport:
|
||||
|
||||
def read_response(self):
|
||||
"""Bytes bis einschliesslich ETX lesen, oder bis der Timeout greift."""
|
||||
deadline = _monotonic() + self._timeout
|
||||
deadline = _deadline(self._timeout)
|
||||
buf = bytearray()
|
||||
while _monotonic() < deadline:
|
||||
while not _expired(deadline):
|
||||
chunk = self._port.read(1)
|
||||
if not chunk:
|
||||
continue
|
||||
@@ -65,7 +72,12 @@ class SerialTransport:
|
||||
|
||||
|
||||
class UartTransport:
|
||||
"""Serieller Port ueber machine.UART (MicroPython / ESP32-C3)."""
|
||||
"""Serieller Port ueber machine.UART (MicroPython / ESP32-C3).
|
||||
|
||||
Auf dem XIAO ESP32-C3 haengt die Anzeige ueber einen MAX3232 an UART1:
|
||||
TX = GPIO21 (Pad 7 / D6) -> T2IN, RX = GPIO20 (Pad 8 / D7) <- R2OUT.
|
||||
Die Pins stehen in config.py, weil UART1 auf dem C3 frei zuordenbar ist.
|
||||
"""
|
||||
|
||||
def __init__(self, uart_id, baudrate, parity, bytesize, stopbits, timeout,
|
||||
tx=None, rx=None):
|
||||
@@ -80,27 +92,42 @@ class UartTransport:
|
||||
stop=stopbits,
|
||||
timeout=int(timeout * 1000),
|
||||
)
|
||||
# Die Pins als Pin-Objekt uebergeben -- die esp32-Portierung akzeptiert
|
||||
# zwar meist auch die nackte GPIO-Nummer, Pin() ist aber ueberall gueltig.
|
||||
if tx is not None:
|
||||
kwargs["tx"] = tx
|
||||
kwargs["tx"] = Pin(tx) if Pin is not None else tx
|
||||
if rx is not None:
|
||||
kwargs["rx"] = rx
|
||||
kwargs["rx"] = Pin(rx) if Pin is not None else rx
|
||||
self._uart = UART(uart_id, **kwargs)
|
||||
self._timeout = timeout
|
||||
|
||||
def _drain(self):
|
||||
"""Empfangspuffer leeren (wie SerialTransport.reset_input_buffer)."""
|
||||
n = self._uart.any()
|
||||
while n:
|
||||
self._uart.read(n)
|
||||
n = self._uart.any()
|
||||
|
||||
def write(self, data):
|
||||
# Wie bei pyserial: vor dem Senden den Empfangspuffer leeren, damit
|
||||
# read_response nur die frische Antwort auf DIESES Telegramm sieht.
|
||||
self._drain()
|
||||
self._uart.write(data)
|
||||
|
||||
def read_response(self):
|
||||
deadline = _monotonic() + self._timeout
|
||||
deadline = _deadline(self._timeout)
|
||||
buf = bytearray()
|
||||
while _monotonic() < deadline:
|
||||
chunk = self._uart.read(1)
|
||||
while not _expired(deadline):
|
||||
n = self._uart.any()
|
||||
if not n:
|
||||
continue
|
||||
chunk = self._uart.read(n)
|
||||
if not chunk:
|
||||
continue
|
||||
buf.extend(chunk)
|
||||
if chunk[0] == migra.ETX:
|
||||
if migra.ETX in chunk:
|
||||
break
|
||||
return bytes(buf)
|
||||
return _truncate_at_etx(buf)
|
||||
|
||||
def close(self):
|
||||
self._uart.deinit()
|
||||
|
||||
@@ -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