diff --git a/.gitignore b/.gitignore index 6686b65..122ff48 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,9 @@ venv/ Hardware/ # WLAN-Zugangsdaten des ESP32 (Vorlage: wifi_config_example.py) -wifi_config.py \ No newline at end of file +wifi_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 \ No newline at end of file diff --git a/README.md b/README.md index a1b6e3f..20f5739 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ python3 run_display.py --mock --move --ldr 2000 # zusätzlich mit Helligkeitsre python3 mount_mock.py --move # Mock separat starten # 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 test_display test_esp32 +python3 -m unittest discover -p 'test_*.py' # alle Tests python3 probe.py ruler # Geometrie der Anzeige ausmessen ``` @@ -56,9 +56,10 @@ beiden Startprogrammen. | `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, Helligkeit, WLAN — alles an einer Stelle. | ✓ | +| `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` | Helligkeitsregelung: LDR-Wert → Stufe/Prozent. | ✓ | +| `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). | ✓ | @@ -69,7 +70,7 @@ beiden Startprogrammen. | `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_migra.py` / `test_lx200.py` / `test_brightness.py` / `test_display.py` / `test_esp32.py` | Tests. | | +| `test_*.py` | Tests (migra, lx200, brightness, settings, display, esp32). | | ## Protokoll (Kurzfassung) @@ -247,11 +248,14 @@ Mac: ## Offene Punkte -- **LDR-Schwellen in der Sternwarte gegenprüfen.** Die Grenzen sind am realen - Aufbau gemessen (2026-07-27, siehe *Helligkeitsregelung*), aber am Schreibtisch. - Vor Ort bei Tag und Nacht die Rohwerte ablesen und `HELL_LOW`/`HELL_HIGH`/ - `HELL_MAX` bestätigen — besonders die Grenzlage bei 2400. Bis dahin läuft die - Anzeige mit fester Helligkeit (`run_esp32.main()` ohne `with_ldr`). +- **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-Anbindung.** Die Einstellungen liegen bereits zur Laufzeit änderbar in + `settings.json` (`settings.update(...)`, geprüft auf Typ und Bereich); der + MQTT-Client, der sie von außen setzt, fehlt noch. - **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. @@ -343,51 +347,75 @@ zerlegt (Trennzeichen `:`, `*`, `0xDF`, `'` sind alle egal). ## Helligkeitsregelung -Portiert aus dem alten MSP430-Programm (`DoDimmen_V`). 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 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 % | < 1050 | -| 1 | 15 % | ≥ 1350 / < 1050 | -| 2 | 50 % | ≥ 2550 / < 2250 | -| 3 | 100 % | ≥ 3350 / < 3050 | +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 `1200 / 2400 / 3200`, Hysterese einheitlich `±150`. +| 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 | 0 → 5 % | -| Raumlicht | 1642 | 2453 | 1 → 15 % | -| angeleuchtet (Taschenlampe) | 543 | 3552 | 3 → 100 % | +| abgedeckt | 4095 (ADC-Anschlag) | 0 | dunkel → 5 % | +| Raumlicht | 1642 | 2453 | hell → 50 % | +| angeleuchtet (Taschenlampe) | 543 | 3552 | hell → 50 % | -Die alten Werte `300 / 2500 / 3500` stammten vom MSP430-Aufbau und passten nicht: -Stufe 3 begann bei 3650 und war **unerreichbar** — selbst eine Taschenlampe direkt -auf dem Sensor kam nur auf 3552. Die Aufteilung ist jetzt so gewählt, dass der -5-%-Bereich bis 1200 reicht („nachts länger dunkel", schont die Dunkeladaption). +Die Schwelle 1800 liegt mit Abstand zwischen dem Dunkelwert und dem Raumlicht. -Auch die **Hysterese war zu klein**: Der Rohwert streut bei Raumlicht um ±270, -nach der Mittelung bleiben rund ±32 (1σ). Die früheren `±30` am untersten Übergang -lagen darunter — die unterste Stufe hätte geflattert. Jetzt einheitlich ±150 (≈4,7σ) -und damit weit unter dem Stufenabstand von 800. - -Ein Helligkeitstelegramm wird nur bei **Stufenwechsel** gesendet (`ESC H 2 `), -wie im Original. Am Gerät bestätigt: Abdecken schaltet herunter, Freigeben wieder -hoch, ohne Flattern. - -> **Bekannte Grenzlage:** Der gemessene Raumlichtwert 2453 liegt nur 53 Zähler über -> `HELL_HIGH` (2400) und damit innerhalb der Hysterese — Raumlicht landet deshalb auf -> Stufe 1 (15 %), nicht auf 50 %. Ob das so gewollt ist, entscheidet sich erst an den -> Lichtverhältnissen in der Sternwarte. `test_brightness.py` hält das Verhalten fest. +**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 `), wie im Original. **Im Dunkeln steht der ADC am Anschlag** (4095). Der Teiler treibt A0 über den -Messbereich, „Dämmerung" und „stockdunkel" sind daher nicht unterscheidbar. Für die -Regelung unkritisch — nachts ist ohnehin die dunkelste Stufe gewollt. Eine feinere +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 diff --git a/brightness.py b/brightness.py index d518744..bc25089 100644 --- a/brightness.py +++ b/brightness.py @@ -1,20 +1,29 @@ #!/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): -gleitender Mittelwert, Helligkeitsstufen mit Hysterese, Prozentwerte aus -hellTafel_ub. Das alte Programm hatte 3 Stufen (bis 50 %), hier sind es 4 mit -100 % als oberster. +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. -Der Fotowiderstand (LDR) liefert einen Rohwert (12-bit-ADC, 0..4095). Die -Schwellen rechnen in der Richtung "grosser Wert = helle Umgebung"; auf der -ESP32-Platine ist die Kennlinie umgekehrt, deshalb laeuft der Rohwert zuerst -durch raw_to_ldr(). Die Grenzen sind am realen Aufbau gemessen (siehe unten), -nicht mehr vom MSP430 uebernommen. +Die Werte stehen nicht hier, sondern in settings.json (siehe settings.py), damit +sie sich im Betrieb aendern lassen -- spaeter ueber MQTT. -Reines Python, laeuft auch unter MicroPython (dort spaeter vom ADC gespeist). +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. - python3 brightness.py --ldr 1500 # Stufe/Prozent berechnen und senden +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 """ @@ -22,41 +31,11 @@ Reines Python, laeuft auch unter MicroPython (dort spaeter vom ADC gespeist). # CLI-Teil ganz unten gebraucht und darum erst dort importiert. So laesst sich # dieses Modul auf dem ESP32 einfach importieren. -# -- Stufengrenzen, am realen Aufbau gemessen (2026-07-27) ---------------- -# Gemessen wurde am XIAO ESP32-C3 mit dem LDR an GPIO2 (200 Messwerte je Lage, -# Median), Werte nach raw_to_ldr, also schon in Zaehlrichtung "gross = hell": -# -# abgedeckt roh 4095 (ADC-Anschlag) -> 0 -# Raumlicht roh 1642 -> 2453 -# angeleuchtet roh 543 -> 3552 -# -# Die alten Grenzen (300/2500/3500) stammten vom MSP430-Aufbau und passten -# nicht: Stufe 3 begann bei 3650 und war damit unerreichbar -- selbst eine -# Taschenlampe direkt auf dem Sensor kam nur auf 3552. -# -# Aufteilung nach Nutzerwunsch "nachts laenger dunkel": der 5-%-Bereich reicht -# bis 1200, damit schwaches Licht die Anzeige noch nicht aufhellt und die -# Dunkeladaption der Beobachter geschont wird. -HELL_LOW = 1200 # Stufengrenze 0<->1 (5 % bis hierher) -HELL_HIGH = 2400 # Stufengrenze 1<->2 -HELL_MAX = 3200 # Stufengrenze 2<->3 (100 % ab hier, jetzt erreichbar) - -# Hysterese: Der Rohwert streut bei Raumlicht um +/-270; nach dem gleitenden -# Mittel ueber AVERAGE_WINDOW bleiben rund +/-32 (1 Sigma). 150 liegt sicher -# darueber (~4,7 Sigma) und zugleich weit unter dem Stufenabstand von 800. -# Frueher standen hier 30 fuer den untersten Uebergang (und 5*30 fuer die -# oberen) -- 30 lag unter dem Rauschen, die unterste Stufe haette geflattert. -HELL_HYST = 150 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) - -# Mittelpunkte der Stufengrenzen und deren Hysterese (Stufe i <-> i+1). -# Jetzt einheitlich: die gemessene Streuung ist an allen Grenzen dieselbe, es -# gibt keinen Grund mehr fuer die gestaffelten Breiten des alten Programms. -_CENTERS = (HELL_LOW, HELL_HIGH, HELL_MAX) -_HYSTS = (HELL_HYST, HELL_HYST, HELL_HYST) +# Die beiden Helligkeitsstufen. DUNKEL gilt unterhalb der Schwelle, HELL darueber. +DUNKEL = 0 +HELL = 1 def raw_to_ldr(raw, inverted=None, full_scale=None): @@ -76,39 +55,68 @@ def raw_to_ldr(raw, inverted=None, full_scale=None): 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) @@ -117,7 +125,7 @@ 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): @@ -131,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 diff --git a/deploy.sh b/deploy.sh index 2d17f4c..8e97e6a 100755 --- a/deploy.sh +++ b/deploy.sh @@ -23,10 +23,16 @@ PORT="${1:-auto}" # 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. FILES=" ticks.py migra.py config.py +settings.py transport.py display.py coords.py diff --git a/displayloop.py b/displayloop.py index 9c75c0d..451dda6 100644 --- a/displayloop.py +++ b/displayloop.py @@ -64,7 +64,8 @@ def poll_loop(display, client, interval=None, once=False, level, percent, changed = dimmer.update(raw) if changed: display.set_brightness(percent) - log("LDR %d -> Helligkeit Stufe %d = %d %%" % (raw, level, percent)) + log("LDR %d -> %s = %d %%" + % (raw, "HELL" if level else "DUNKEL", percent)) ra_raw, dec_raw = client.get_coordinates() ra_line, dec_line = format_coordinates(ra_raw, dec_raw) diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..a54890a --- /dev/null +++ b/settings.py @@ -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) diff --git a/test_brightness.py b/test_brightness.py index 2aed7b3..36253ef 100644 --- a/test_brightness.py +++ b/test_brightness.py @@ -1,127 +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 am Geraet gemessen (2026-07-27): Mitten (1200, 2400, 3200) mit -einheitlicher Hysterese 150 -> hoch bei 1350 / 2550 / 3350, -runter bei 1050 / 2250 / 3050. - -Die Zahlen stehen bewusst ausgeschrieben statt aus brightness.* gerechnet: -So faellt eine versehentliche Aenderung der Schwellen im Test auf, statt -stillschweigend mitzuwandern. +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(1349, None), 0) - self.assertEqual(self.c._next_level(1350, None), 1) - self.assertEqual(self.c._next_level(2549, None), 1) - self.assertEqual(self.c._next_level(2550, None), 2) - self.assertEqual(self.c._next_level(3349, None), 2) - self.assertEqual(self.c._next_level(3350, 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(1050, 1), 1) - self.assertEqual(self.c._next_level(1049, 1), 0) - self.assertEqual(self.c._next_level(2549, 1), 1) - self.assertEqual(self.c._next_level(2550, 1), 2) - - def test_hysterese_von_stufe_2(self): - self.assertEqual(self.c._next_level(2250, 2), 2) - self.assertEqual(self.c._next_level(2249, 2), 1) - self.assertEqual(self.c._next_level(3349, 2), 2) - self.assertEqual(self.c._next_level(3350, 2), 3) - - def test_hysterese_von_stufe_3(self): - self.assertEqual(self.c._next_level(3050, 3), 3) - self.assertEqual(self.c._next_level(3049, 3), 2) - - def test_prozente(self): - self.assertEqual(brightness.PERCENTS, (5, 15, 50, 100)) - - def test_gemessene_lagen_ergeben_die_gewollten_stufen(self): - """Die am Geraet gemessenen Lichtlagen, in Stufen uebersetzt. - - Das ist der eigentliche Zweck der Kalibrierung: abgedeckt muss auf die - dunkelste Stufe fallen, angeleuchtet auf die hellste. Beides ging mit - den alten MSP430-Schwellen daneben (100 % war unerreichbar). + Sonst startete die Anzeige im Bereich zwischen Schwelle und Kante + faelschlich dunkel und wachte erst spaeter auf. """ - self.assertEqual(self.c._next_level(0, None), 0) # abgedeckt -> 5 % - self.assertEqual(self.c._next_level(3552, None), 3) # Lampe -> 100 % + self.assertEqual(self.c._next_level(1799, None), DUNKEL) + self.assertEqual(self.c._next_level(1800, None), HELL) - def test_raumlicht_liegt_dicht_an_der_grenze(self): - """Bekannte Grenzlage, absichtlich festgehalten. + 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) - Der gemessene Raumlichtwert 2453 liegt nur 53 Zaehler ueber HELL_HIGH - (2400) und damit unter der Hysterese: Hochgeschaltet wird erst ab 2550, - also bleibt Raumlicht auf Stufe 1 (15 %). Eine Stufengrenze so dicht an - einem realen Betriebspunkt ist keine gute Stelle -- welche Stufe dort - gewollt ist, entscheidet sich erst an den Lichtverhaeltnissen in der - Sternwarte. Bis dahin haelt dieser Test das Verhalten schriftlich fest, - damit es niemanden ueberrascht. - """ - self.assertEqual(self.c._next_level(2453, None), 1) - self.assertLess(abs(2453 - brightness.HELL_HIGH), brightness.HELL_HYST) + 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_hysterese_ueberdeckt_das_gemessene_rauschen(self): - """Nach dem gleitenden Mittel bleiben rund 32 Zaehler Streuung (1 Sigma). + 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) - Die Hysterese muss deutlich darueber liegen, sonst springt die Stufe an - einer Grenze hin und her -- genau das waere mit den alten 30 passiert. - """ - self.assertGreaterEqual(brightness.HELL_HYST, 100) + 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_kein_stufensprung_bei_rauschen_um_eine_grenze(self): - """Werte, die um eine Stufengrenze streuen, duerfen die Stufe halten.""" - c = BrightnessController() - for _ in range(brightness.AVERAGE_WINDOW): - c.update(1200) # genau auf der Grenze einschwingen - stufe_vorher = c.level - for delta in (+60, -60, +55, -50, +45, -60, +60, -55): - stufe, _, changed = c.update(1200 + delta) - self.assertFalse(changed, "Rauschen um die Grenze hat umgeschaltet") - self.assertEqual(stufe, stufe_vorher) + 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) diff --git a/test_esp32.py b/test_esp32.py index 409ec30..4e053f9 100644 --- a/test_esp32.py +++ b/test_esp32.py @@ -328,15 +328,19 @@ class TestLdrKennlinie(unittest.TestCase): self.assertEqual(brightness.raw_to_ldr(0), config.LDR_MAX if config.LDR_INVERTED else 0) - def test_helle_umgebung_ergibt_hohe_stufe(self): + 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() - self.assertEqual(c.update(hell)[0], len(brightness.PERCENTS) - 1) - c = brightness.BrightnessController() - self.assertEqual(c.update(dunkel)[0], 0) + 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 TestLdrLeser(unittest.TestCase): @@ -434,8 +438,8 @@ class TestWifi(unittest.TestCase): # Die Dateien, die auf dem Geraet landen -- muss zu deploy.sh passen. DEPLOYED = ( - "ticks.py", "migra.py", "config.py", "transport.py", "display.py", - "coords.py", "lx200.py", "mount_client.py", "brightness.py", + "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", "run_esp32.py", "probe.py", ) diff --git a/test_settings.py b/test_settings.py new file mode 100644 index 0000000..dbf07e0 --- /dev/null +++ b/test_settings.py @@ -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)