Helligkeit: zwei Werte und eine Schwelle, zur Laufzeit aenderbar

Die vier abgestuften Helligkeiten des alten MSP430-Programms (5/15/50/100 %)
entfallen. Fuer die Sternwarte genuegt der Wechsel Tag/Nacht: unterhalb der
Schwelle laeuft die Anzeige mit dunkel_prozent, oberhalb mit hell_prozent.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 16:34:47 +02:00
parent e8a5b5d5e3
commit 0a893d0684
9 changed files with 549 additions and 210 deletions
+6 -1
View File
@@ -13,4 +13,9 @@ venv/
Hardware/
# WLAN-Zugangsdaten des ESP32 (Vorlage: wifi_config_example.py)
wifi_config.py
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
+71 -43
View File
@@ -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, 04095) 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, 04095) wird über `raw_to_ldr` in die Zählrichtung
„groß = hell" gebracht, über einen gleitenden Mittelwert (8 Werte) geglättet und
gegen die Schwelle gehalten:
Schwellen-Mitten `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 0100, Schwelle 04095). Das ist der Grund, warum die Prüfung überhaupt
existiert: Sobald MQTT dazukommt, kommen die Werte von außen, und ein
Helligkeitswert von 500 % oder eine Schwelle von 1 würde die Anzeige sonst
unbrauchbar machen. Fehlt die Datei oder ist sie beschädigt, gelten die Vorgaben
aus `settings.DEFAULTS` — ein Gerät in der Sternwarte soll sich nicht an einer
kaputten Konfigurationsdatei aufhängen. Ein einzelner unbrauchbarer Wert verwirft
nur diesen, nicht die ganze Datei.
`settings.json` wird von `deploy.sh` **nicht** mitkopiert und steht in `.gitignore`:
Es ist Laufzeitzustand des Geräts, kein Quellcode. Würde es mitkopiert, wären die
eingestellten Werte nach jedem Deployen weg.
### Die Schwelle stammt aus einer Messung
**Am realen Aufbau gemessen (2026-07-27)**, XIAO ESP32-C3 mit LDR an GPIO2,
je 200 Messwerte, Median — Werte nach `raw_to_ldr`:
| Lichtsituation | roh | nach `raw_to_ldr` | Stufe |
|---|---|---|---|
| abgedeckt | 4095 (ADC-Anschlag) | 0 | 0 → 5 % |
| Raumlicht | 1642 | 2453 | 115 % |
| angeleuchtet (Taschenlampe) | 543 | 3552 | 3100 % |
| abgedeckt | 4095 (ADC-Anschlag) | 0 | dunkel → 5 % |
| Raumlicht | 1642 | 2453 | hell → 50 % |
| angeleuchtet (Taschenlampe) | 543 | 3552 | hell50 % |
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 <ppp>`),
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 <ppp>`), wie im Original.
**Im Dunkeln steht der ADC am Anschlag** (4095). Der Teiler treibt A0 über den
Messbereich, „Dämmerung" und „stockdunkel" sind daher nicht unterscheidbar. 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,51 MΩ im Dunkeln) liefe das auf 160330 kΩ hinaus — dann
+81 -68
View File
@@ -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
+6
View File
@@ -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
+2 -1
View File
@@ -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)
+117
View File
@@ -0,0 +1,117 @@
"""Einstellungen, die sich im Betrieb aendern lassen -- als JSON auf dem Geraet.
config.py ist Programmcode: Pinbelegung, Protokoll, Geometrie. Das aendert sich
nur beim Deployen. Werte, die im laufenden Betrieb verstellt werden sollen --
spaeter ueber MQTT -- gehoeren dagegen hierher. Sie liegen als JSON im
Dateisystem (settings.json) und lassen sich lesen und zurueckschreiben, ohne das
Programm neu zu spielen.
import settings
w = settings.load() # mit Vorgaben aufgefuellt
settings.update({"hell_prozent": 60}) # aendern und speichern
Fehlt die Datei oder ist sie beschaedigt, gelten die Vorgaben aus DEFAULTS --
die Anzeige laeuft dann weiter, statt beim Start auszufallen. Das ist Absicht:
ein Geraet in der Sternwarte soll sich nicht an einer kaputten Konfigurationsdatei
aufhaengen.
Alle Werte werden beim Laden UND beim Schreiben geprueft (siehe _pruefe). Das ist
wichtig, sobald MQTT dazukommt: Von dort kommen fremde Eingaben, und ein
Helligkeitswert von 500 % oder eine Schwelle von -1 wuerde die Anzeige sonst in
einen unbrauchbaren Zustand bringen.
"""
import json
PFAD = "settings.json"
# Vorgaben. Die Helligkeiten und die Schwelle stammen aus der LDR-Messung vom
# 2026-07-27 (siehe README, Abschnitt Helligkeitsregelung): abgedeckt ergab 0,
# Raumlicht 2453, angeleuchtet 3552 -- 1800 liegt mit Abstand zwischen dunkel
# und Raumlicht. Die Hysterese deckt das gemessene ADC-Rauschen ab (~32 nach
# der Mittelung, 1 Sigma).
DEFAULTS = {
"dunkel_prozent": 5, # Helligkeit unterhalb der Schwelle
"hell_prozent": 50, # Helligkeit oberhalb der Schwelle
"schwelle": 1800, # LDR-Wert (nach raw_to_ldr) fuer den Umschaltpunkt
"hysterese": 150, # Breite um die Schwelle, gegen Flattern
}
# Erlaubte Bereiche: (minimum, maximum). Die Prozente gehen an ESC H, das nur
# 0..100 kennt; die LDR-Werte kommen aus einem 12-Bit-ADC.
_GRENZEN = {
"dunkel_prozent": (0, 100),
"hell_prozent": (0, 100),
"schwelle": (0, 4095),
"hysterese": (0, 2000),
}
class SettingsError(ValueError):
"""Ein Wert liegt ausserhalb des erlaubten Bereichs oder ist unbekannt."""
def _pruefe(werte):
"""Werte auf Typ und Bereich pruefen. Liefert ein geprueftes neues dict."""
geprueft = {}
for name, wert in werte.items():
if name not in DEFAULTS:
raise SettingsError("unbekannte Einstellung %r" % (name,))
# bool ist in Python ein int -- hier aber sicher ein Irrtum.
if isinstance(wert, bool) or not isinstance(wert, int):
raise SettingsError("%s: %r ist keine ganze Zahl" % (name, wert))
low, high = _GRENZEN[name]
if not low <= wert <= high:
raise SettingsError("%s: %d liegt ausserhalb %d..%d"
% (name, wert, low, high))
geprueft[name] = wert
return geprueft
def load(pfad=None, log=None):
"""Einstellungen lesen, fehlende Werte aus DEFAULTS ergaenzen.
Wirft nicht: Fehlt die Datei, ist sie unlesbar oder steht Unsinn darin, gibt
es die Vorgaben. Ein einzelner unbrauchbarer Wert verwirft nur diesen, nicht
die ganze Datei.
"""
pfad = PFAD if pfad is None else pfad
werte = dict(DEFAULTS)
try:
with open(pfad) as f:
gelesen = json.load(f)
except (OSError, ValueError):
return werte # keine Datei oder kaputtes JSON
if not isinstance(gelesen, dict):
return werte
for name, wert in gelesen.items():
try:
werte.update(_pruefe({name: wert}))
except SettingsError as e:
if log:
log("settings: %s -- Vorgabe %r bleibt"
% (e, DEFAULTS.get(name)))
return werte
def save(werte, pfad=None):
"""Gepruefte Einstellungen schreiben. Liefert die gespeicherten Werte."""
pfad = PFAD if pfad is None else pfad
vollstaendig = dict(DEFAULTS)
vollstaendig.update(_pruefe(werte))
with open(pfad, "w") as f:
json.dump(vollstaendig, f)
return vollstaendig
def update(aenderungen, pfad=None, log=None):
"""Einzelne Werte aendern und speichern -- der Weg fuer MQTT.
Erst pruefen, dann mit dem Bestand mischen, dann schreiben. Ist etwas
ungueltig, bleibt die Datei unveraendert.
"""
geprueft = _pruefe(aenderungen)
werte = load(pfad, log=log)
werte.update(geprueft)
return save(werte, pfad)
+122 -90
View File
@@ -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)
+11 -7
View File
@@ -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",
)
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Tests fuer die zur Laufzeit aenderbaren Einstellungen (settings.json).
python3 -m unittest -v test_settings
Schwerpunkt ist die Pruefung der Werte: Sobald MQTT dazukommt, kommen sie von
aussen. Ein Helligkeitswert von 500 % oder eine kaputte Datei darf die Anzeige
nicht lahmlegen.
"""
import json
import os
import unittest
import settings
class Tempdatei(unittest.TestCase):
def setUp(self):
self.pfad = "test_settings_tmp.json"
self._weg()
def tearDown(self):
self._weg()
def _weg(self):
try:
os.remove(self.pfad)
except OSError:
pass
def _schreibe_roh(self, inhalt):
with open(self.pfad, "w") as f:
f.write(inhalt)
class TestLaden(Tempdatei):
def test_ohne_datei_gelten_die_vorgaben(self):
self.assertEqual(settings.load(self.pfad), settings.DEFAULTS)
def test_fehlende_werte_werden_ergaenzt(self):
self._schreibe_roh(json.dumps({"schwelle": 1234}))
w = settings.load(self.pfad)
self.assertEqual(w["schwelle"], 1234)
self.assertEqual(w["hell_prozent"], settings.DEFAULTS["hell_prozent"])
def test_kaputtes_json_faellt_auf_die_vorgaben_zurueck(self):
"""Ein Geraet in der Sternwarte darf daran nicht haengenbleiben."""
self._schreibe_roh("{das ist kein json")
self.assertEqual(settings.load(self.pfad), settings.DEFAULTS)
def test_json_das_kein_objekt_ist(self):
self._schreibe_roh("[1, 2, 3]")
self.assertEqual(settings.load(self.pfad), settings.DEFAULTS)
def test_einzelner_unbrauchbarer_wert_verwirft_nur_diesen(self):
self._schreibe_roh(json.dumps({"schwelle": 99999, "hell_prozent": 42}))
meldungen = []
w = settings.load(self.pfad, log=meldungen.append)
self.assertEqual(w["schwelle"], settings.DEFAULTS["schwelle"]) # verworfen
self.assertEqual(w["hell_prozent"], 42) # uebernommen
self.assertTrue(any("schwelle" in m for m in meldungen))
def test_unbekannter_schluessel_wird_ignoriert(self):
self._schreibe_roh(json.dumps({"voellig_unbekannt": 1}))
w = settings.load(self.pfad)
self.assertEqual(w, settings.DEFAULTS)
self.assertNotIn("voellig_unbekannt", w)
class TestSpeichern(Tempdatei):
def test_speichern_und_wieder_lesen(self):
settings.save({"schwelle": 2000, "dunkel_prozent": 3}, self.pfad)
w = settings.load(self.pfad)
self.assertEqual(w["schwelle"], 2000)
self.assertEqual(w["dunkel_prozent"], 3)
def test_gespeichert_wird_immer_vollstaendig(self):
settings.save({"schwelle": 2000}, self.pfad)
with open(self.pfad) as f:
roh = json.load(f)
self.assertEqual(set(roh), set(settings.DEFAULTS))
def test_update_aendert_nur_das_angegebene(self):
settings.save({"schwelle": 1000, "hell_prozent": 30}, self.pfad)
settings.update({"hell_prozent": 70}, self.pfad)
w = settings.load(self.pfad)
self.assertEqual(w["hell_prozent"], 70)
self.assertEqual(w["schwelle"], 1000) # unveraendert
def test_ungueltiges_update_laesst_die_datei_unberuehrt(self):
settings.save({"schwelle": 1000}, self.pfad)
with self.assertRaises(settings.SettingsError):
settings.update({"hell_prozent": 500}, self.pfad)
self.assertEqual(settings.load(self.pfad)["schwelle"], 1000)
class TestPruefung(unittest.TestCase):
"""Die Grenzen, an denen spaeter MQTT-Eingaben haengenbleiben sollen."""
def test_prozent_ueber_100_wird_abgelehnt(self):
with self.assertRaises(settings.SettingsError):
settings.save({"hell_prozent": 101}, "/dev/null")
def test_negative_werte_werden_abgelehnt(self):
with self.assertRaises(settings.SettingsError):
settings.save({"schwelle": -1}, "/dev/null")
def test_schwelle_ueber_adc_bereich_wird_abgelehnt(self):
with self.assertRaises(settings.SettingsError):
settings.save({"schwelle": 4096}, "/dev/null")
def test_unbekannter_name_wird_abgelehnt(self):
with self.assertRaises(settings.SettingsError):
settings.save({"gibtsnicht": 1}, "/dev/null")
def test_nichtganzzahlige_werte_werden_abgelehnt(self):
for wert in (1.5, "50", None, True):
with self.assertRaises(settings.SettingsError):
settings.save({"hell_prozent": wert}, "/dev/null")
def test_die_raender_sind_erlaubt(self):
geprueft = settings._pruefe({"hell_prozent": 100, "dunkel_prozent": 0,
"schwelle": 4095, "hysterese": 0})
self.assertEqual(geprueft["hell_prozent"], 100)
self.assertEqual(geprueft["schwelle"], 4095)
if __name__ == "__main__":
unittest.main(verbosity=2)