Compare commits

...
177 Commits
Author SHA1 Message Date
adminandClaude Sonnet 5 b5fdbcaf65 Editor: Datumsbedingungen jetzt bewertet statt dauerhaft "offen"
Der Punkt neben einer Datumsbedingung ("Zeitpunkt" -> "Datum") stand bisher
immer gestrichelt, nie gruen - der Editor zaehlte den Typ "date" bewusst zu
den ZEITARTEN, die er nicht selbst bewertet, um keine zweite Wahrheit neben
dem Runner zu pflegen. Fuer Uhrzeit, Sonnenstand und Verkettung ist das
richtig: die haengen an Nachholfenster, Haltezeit oder echtem Zeitabstand -
Zustand, den nur der Runner fuehrt.

Das Datum ist die Ausnahme: seit autoaction_runner.py nur noch Monat und Tag
vergleicht (Jahr wird ignoriert), ist die Regel ein zustandsloser Vergleich
zweier Zahlenpaare - dieselbe Zeile laesst sich im Editor gefahrlos ein
zweites Mal schreiben, siehe datumErfuellt(). Ausdruecklich nur fuer das
gerechnete Geraet "Zeitpunkt" (device.url === "Logic"), damit ein
kuenftiges, echt gemessenes "date" nicht denselben, dann falschen Vergleich
bekaeme.

Wirkung: die Kopfzeile "gerade X von Y" erreicht jetzt "alles erfuellt",
wenn es wirklich zutrifft, statt an einer Datumsbedingung dauerhaft zu
scheitern - live an der Frostwarnung geprueft (22.09., 3,6 Grad): "alles
erfuellt" mit gruenem Punkt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 07:36:58 +02:00
adminandClaude Opus 5 b6ff7c6ad9 Doku: das Meteogramm beschrieben, falsche Quellenangabe richtiggestellt
doku/meteogramm.md: der Weg der Daten, welche Groessen links vom Jetzt-Strich
gemessen und welche vorhergesagt sind, die vier Tabellen samt der Regel, dass
nichts geloescht wird, das Tupel-Schema des Endpunkts, das Katalog-Vokabular
mit dem Dreizeiler "so kommt eine Spur dazu", und die beiden Entscheidungen
beim Zeichnen (ein SVG ueber alle Felder, Wolkenband als Farbverlauf statt
als Raster).

Dazu zwei Fallen, die sonst niemand wiederfindet: die Archiv-Schnittstelle
von Open-Meteo rechnet alle Zeiten mit dem heute gueltigen Zeitzonenversatz
um, und die Spaltenlage des Endpunkts steht an zwei Stellen und muss
zueinander passen.

Richtiggestellt: doku/datenbank.md und README.md nannten Open-Meteo als
Quelle von weatherHours/weatherDays. Das war nie so - es war OpenWeatherMap,
und seit dem 27.10.2024 gar nichts mehr. Beide Zeilen warfen ausserdem
Messung und Vorhersage in einen Topf; sie sind jetzt getrennt, mit dem
richtigen Schreiber je Tabelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 14:07:07 +02:00
adminandClaude Opus 5 2c48d78880 Eigenes Meteogramm statt des eingebetteten von meteoblue
Das Widget von meteoblue war ein fremdes Dokument im iframe: es passte
farblich nicht, liess sich nicht aendern, wurde auf schmalen Schirmen als
Ganzes heruntergerechnet und konnte die eigenen Messwerte nicht zeigen.
Genau das kann das neue Modul - links vom Jetzt-Strich steht, was die
Wetterstation gemessen hat, rechts die Vorhersage.

Aufgebaut wie die uebrigen Anzeige-Module: meteogrammKatalog.js sagt, WAS
gezeigt wird, meteogramm.js nur, WIE. Der Renderer kennt kein Feld und keine
Spur beim Namen. Eine weitere Spur ist damit ein Objekt im Katalog und keine
Codeaenderung.

Ein einziges SVG ueber alle drei Felder, damit Zeitachse, Nachtstreifen,
Jetzt-Linie und Zeiger nur einmal existieren und ueberall gleich sitzen.

Zwei Entscheidungen, die man sonst sucht:

Das Wolkenband zeichnet je Schicht ein Rechteck mit waagerechtem
Farbverlauf. Ein Rechteck je Stunde war der erste Versuch und sah aus wie
Fernsehrauschen - zweihundert harte Kanten nebeneinander. Open-Meteo kennt
ohnehin nur drei Schichten statt eines Hoehenprofils.

Die Strahlung teilt sich das Feld mit der Temperatur, statt ein viertes zu
bekommen: gleicher Tagesrhythmus, gleiche Nacht-Schattierung, und ein
viertes Feld machte das Ganze am Handy hoeher als das externe Widget.

Die alte Einbindung bleibt vorerst und ist umschaltbar. mountMeteogram()
laeuft jetzt erst beim Umschalten - wer meteoblue nie ansieht, laedt es auch
nicht mehr.

Die Karte heisst "Wetter" statt "Forecast": darunter steht bereits eine
zweite Karte dieses Namens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:54:55 +02:00
adminandClaude Opus 5 a4b25ba279 Doku: die Schnittstelle zur BYD-Batterie beschrieben
doku/byd.md: warum der Umweg über die BMU überhaupt nötig ist (der Gen24
kennt weder Zellen noch SOH noch die Zähler), der Ablauf der sechs Anfragen
in einer einzigen Verbindung, und vor allem die Eigenheit, aus der sich der
ganze Aufbau des Sammlers ergibt — das Netzwerkmodul startet alle ~102
Sekunden neu und bedient danach nur die ersten ein, zwei Verbindungen.

Dazu die Byte-Lagen samt der einen Abweichung vom ioBroker-Adapter
(wortvertauschte Gesamtzähler), die Aufteilung der 128 Zellen auf vier
Antworten, was unter solarManager/byd/… herauskommt, die beiden Tabellen in
solarLog und wie speicher.js daraus die Ansicht baut.

Dabei aufgefallen und vermerkt: ajax/speicher.php?was=gesundheit hat keinen
Aufrufer. Die Tageswerte für ein Alterungsdiagramm liegen also schon bereit,
es fehlt nur die Anzeige.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 12:34:50 +02:00
adminandClaude Opus 5 95a61709d7 Haltezeit als Zeitfeld: Stunden und Minuten statt einer Minutenzahl
Dasselbe <input type="time"> wie von/bis daneben, nur als Dauer gelesen -
"03:30" tippt sich leichter als "210" und steht in derselben Zeile wie die
beiden Uhrzeiten darueber. Dass es keine Uhrzeit meint, sagt der Text
ringsum ("erst nach ... am Stueck"); die Kopfzeile des Rahmens schreibt es
beim Tippen aus.

Die Obergrenze ist damit 23:59 statt 24 Stunden - genau das, was ein
Zeitfeld hergibt.

Dabei aufgefallen: haltezeitKurz() rundete auf Zehntelstunden und machte aus
23:59 die Auskunft "24 Std.". Eine Haltezeit, die laenger aussieht als sie
ist, ist genau die falsche Auskunft - krumme Werte stehen jetzt als
"23 Std. 59 Min." da, halbe behalten ihr Komma.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 09:26:00 +02:00
adminandClaude Opus 5 229910bcb8 Haltezeit frei in Minuten eingeben statt aus Stufen waehlen
Wonach man sucht, haengt am Messwert: eine offene Tuer faellt nach zwei
Minuten auf, ein laufender Wasserhahn erst nach Stunden. Eine Stufenliste
traf damit immer nur die Haelfte der Faelle.

Das Feld nimmt Minuten, das Modell rechnet in Sekunden weiter. Die Kopfzeile
des Rahmens rechnet beim Tippen mit - aus "210" wird dort "erst nach 3,5
Std.", also steht die lesbare Fassung neben der eingebbaren.

haltezeitPruefen() ersetzt die Whitelist: nicht unter null, nicht ueber
einen Tag, auf ganze Minuten gerundet. Sekunden gewinnen hier nichts, die
Messwerte kommen viel seltener.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 09:21:24 +02:00
adminandClaude Opus 5 e3ac9bad37 Haltezeit: Stufe 3,5 Std. und lesbare Angabe in Stunden
Die 3,5 Stunden fallen aus der Reihe, weil sie aus einem konkreten Fall
stammen: dauerhafter Wasserverbrauch. Kuerzer waere Fehlalarm (eine lange
Dusche, eine Waschmaschine), laenger liefe ein offener Hahn eine halbe
Nacht. Die 3 Std. weichen dafuer - niemand hat sie benutzt.

Dazu haltezeitKurz(): ab einer Stunde in Stunden statt in Minuten. "210
Min." muss man umrechnen, "3,5 Std." nicht. Die Beschriftungen der
Auswahlliste kommen jetzt aus derselben Funktion wie die Uebersichtszeile,
das JavaScript hat eine gleichlautende.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 09:10:55 +02:00
adminandClaude Opus 5 01d04e8f56 Haltezeit im Rahmen: "Auslösen erst nach X ununterbrochen"
Der Gegenspieler zur Sperre, direkt daneben: die bremst die Wiederholung,
die Haltezeit den ersten Lauf. Damit lassen sich Dauerzustände abfragen,
die in einem einzelnen Messwert nicht zu sehen sind - "der Wasserzähler
läuft seit einer halben Stunde ohne Pause".

Gespeichert wird in automations.hold_secs (haltezeit.sql im SolarManager),
gezählt wird im Runner. Angeboten werden feste Stufen, wie bei der Sperre:
eine weitere ist eine Zeile in haltezeitChoices().

0 ist die Vorgabe und heißt "sofort" - vorhandene Automatiken ändern sich
nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 08:57:27 +02:00
adminandClaude Opus 5 d3ff959574 Doku: Benachrichtigungen beschrieben
Web Push ohne App, E-Mail, das gerechnete Geraet und der zweite Weg ueber
MQTT - samt der Stolpersteine (sw.js gehoert ins Wurzelverzeichnis, iPhone
erst ab Home-Bildschirm, Endpoint ist das Geheimnis, Schluessel nie neu
erzeugen solange Abos daran haengen).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 08:38:44 +02:00
adminandClaude Opus 5 df48432133 Benachrichtigungen: Geraet, Abos und der Reiter Meldungen
Der Geraetesuchlauf legt "Benachrichtigungen" an (benachrichtigung_module.py),
ein Kommando je Kanal. Damit kann jede Automatik melden, ohne dass Editor,
Zeitleiste oder Datenbank etwas davon wissen muessten.

Fuer die Meldung aufs Handy braucht es keine App: Der Browser meldet sich
unter Einstellungen -> Meldungen selbst an (sw.js, ajax/push.php), das Abo
steht in homeMesh.push_abos, verschickt wird vom Runner. Auf dem iPhone geht
es erst ab der Ablage auf dem Home-Bildschirm - der Reiter sagt das auch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 08:37:31 +02:00
adminandClaude Opus 5 061dfa3fc5 Doku: Solar-Uebersicht beschrieben
Katalog und Renderer, die Elternbeziehung als Struktur, und wie aus Watt
eine Zahl, ein gestauchter Ring, ein Fluss mit Punktgroesse und Tempo, ein
Fuellstand und Plaketten werden. Dazu quer/hochkant, der Sekundentakt mit
seinen drei Sparmassnahmen und der Bearbeitungsmodus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 08:11:39 +02:00
adminandClaude Opus 5 132d8916b1 Doku: Rollup-Job mit Pfad und Betriebsarten nachgetragen
Liegt in ~/Backup-scripts/solarlog-rollup.sh. Dazu die Eigenschaften der
Rohdaten, die man beim Rechnen kennen muss (Momentanleistungen, gemischte
Einheiten und Vorzeichen).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 08:03:45 +02:00
adminandClaude Opus 5 93ec545aaa Doku: Datenbanken und Einstellungsseite ergaenzt
datenbank.md: die drei Schemata, wer was schreibt, die drei Stufen der
Verdichtung und welche Tabelle fuer welche Auswertung taugt.
einstellungen.md: was jeder Reiter speichert, welche Regeln das Modell
durchsetzt (feste Etagenkuerzel, Loeschsperren, Schluessel gehen nie zurueck)
und was die Seite bewusst nicht kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 07:57:35 +02:00
adminandClaude Opus 5 a0a8ad04de Doku: Automatiken und Zeitleiste ausfuehrlich beschrieben
Neues Verzeichnis doku/ mit zwei Tiefenbohrungen: das Regelwerk der
Automatiken (Datenmodell, Editor, Runner, Auswertung, Verkettung, Sperren)
und die Zeitleiste (Serverrechnung, Zeichnen, Stapeln, Ketten, Karte).
Mit Diagrammen und je einem Bild; die Haupt-README verweist darauf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 07:39:43 +02:00
adminandClaude Opus 5 149cdb46e1 Skoda: Ladefenster im neuen Stil, Ladeziel einstellbar
Laden oeffnet jetzt ein Fenster wie auf der Solarseite: Ring mit Ladestand
und Ladeziel, ein Knopf zum Starten oder Stoppen, das Ladeziel in Stufen von
50 bis 100 Prozent. skodaCmd.php kennt dafuer den Befehl ladeziel (PUT
charging/limit) und prueft die Stufe, bevor ein Befehl aus dem Kontingent
draufgeht. Gesendet wird erst, wenn eine Weile keine andere Stufe gewaehlt
wurde; bis das Fahrzeug das Ziel meldet, steht die eigene Wahl im Fenster.
Ohne Kabel ist der Start gesperrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:11:32 +02:00
adminandClaude Opus 5 05db809140 Uebersicht: keine Verbindungslinie mehr, wo nichts fliesst
Die duenne dunkle Linie gibt es nur noch im Bearbeitungsmodus, wo sie beim
Schieben zeigt, was mit was verbunden ist. In der Ansicht tragen die
laufenden Punkte den Weg allein.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:49:37 +02:00
adminandClaude Opus 5 f83f8fa2fd Heizfenster: eingestellte Leistung bleibt stehen
Das Geraet meldet nur die Leistung, die es gerade zieht. Nach acht Sekunden
fiel die Anzeige deshalb auf "gemessen/100, sonst 30" zurueck - aus fest
eingestellten 0,1 kW wurden 3 kW, sobald der Heizstab nicht zog. Die eigene
Wahl gilt jetzt, solange das Fenster offen ist; die Automatik meldet das
Geraet weiterhin selbst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:40:49 +02:00
adminandClaude Opus 5 a50905bb6c Heizstab kann ganz aus
Dritte Betriebsart neben Automatik und Fest: Aus schickt eine feste Leistung
von null (setMode=-1). In der Automatik liefe der Heizstab beim naechsten
Ueberschuss wieder an. Erkannt wird der Zustand daran, dass das Geraet
feste Betriebsart, aber keine Leistung meldet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 10:08:13 +02:00
adminandClaude Opus 5 357c73cf8a Uebersicht: Ladestaende deutlicher, dunkle Bahn unter den Punkten weg
Die Fuellung war mit 14 Prozent Deckung kaum zu sehen; jetzt 22 Prozent,
beim Laden 32, dazu die Wasserlinie als eigene Kante. Ohne gemeldeten Stand
bleibt die Fuellung ganz weg - eine Wallbox ohne Auto zeigte sonst eine
Linie am unteren Rand. Das Bett ist nur noch eine duenne Linie und
verschwindet, sobald Punkte laufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 10:01:17 +02:00
adminandClaude Opus 5 fe12e57d88 Uebersicht: gestauchte Ringskala und staerker gespreizte Flusspunkte
Der Ring wuchs linear bis zur Skala - bei 15 kW Vollausschlag war ein
Geschoss mit 300 W ein Strich von zwei Grad. Jetzt bekommt das untere
Zehntel knapp ein Drittel des Rings, der volle Ausschlag bleibt bei skala.
Bei mehreren Segmenten wird ueber die Summe gerechnet, sonst waere die
Skala nicht additiv. Die Punkte laufen jetzt von 2,4 bis 13 statt 3,5 bis 10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 09:43:32 +02:00
adminandClaude Opus 5 993a4fe6f8 Uebersicht hochkant: UG und Heizstab tauschen die Plaetze
Hochkant wurden bisher nur x und y getauscht. Der Katalog kann jetzt mit
hochX/hochY eine eigene Lage fuer diese Ansicht nennen; UG steht damit unter
dem Verbrauch bei den anderen Geschossen, der Heizstab rechts daneben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 09:05:38 +02:00
adminandClaude Opus 5 d36989ae0d Eigene Auswahllisten ueberall: dunkel und ueber der Seite statt im Fluss
Chrome zeichnet die Liste eines <select> hell auf grau und laesst sich davon
nicht abbringen. js/solar/auswahl.js ergaenzt jedes <select> um einen eigenen
Knopf; die Liste haengt an <body> und liegt ueber der Seite, damit ein Modal
nicht hoeher wird und der Inhalt nicht springt. Ab neun Eintraegen mit
Suchfeld. Der eigene Picker im Raumfenster faellt dafuer weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 07:55:17 +02:00
adminandClaude Opus 5 fe8630eb4f Ueberall dasselbe Beschattungs-Symbol (bi-beschattung)
Raum-Modal, Zeitleiste und Automatik-Editor zeigten drei verschiedene
Zeichen; bi-blinds gibt es in der Icon-Schrift gar nicht. Das neue Symbol
liegt als CSS-Maske in solar.css und verhaelt sich wie ein Icon aus der
Schrift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 07:37:42 +02:00
adminandClaude Opus 5 7caa3850d5 README wiederhergestellt: ein sed-Aufruf hatte |temp| als Regex-Alternative ersetzt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 22:28:56 +02:00
adminandClaude Opus 5 e7d8bcef06 README: Zeile zu room.php repariert
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 22:27:52 +02:00
adminandClaude Opus 5 0eb01403c5 Raum-Modal Licht: Preset als Chips, Effekt als durchsuchbarer Picker, WLED-Zustand live
Die native Auswahlliste zeichnete Chrome hell auf hell. Welcher Effekt und
welches Preset gerade laufen, liest room.php?action=wled direkt von der Lampe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 22:27:40 +02:00
adminandClaude Opus 5 d5d2d8f336 Raum-Modal im neuen Stil: Reiter-Kacheln, Beschattung mit my, Heizring, Licht
- Beschattung: Auf/Stop/my/Zu, Hoehe und Lamellen in 25-%-Stufen, Ziehen im
  Fensterbild, neue Behang-Grafik ueber die ganze Kartenhoehe, Alle im Raum
- my-Position: aktuellen Stand per setMemorized1Position/-Orientation in den
  Motor schreiben (room.php?action=my), gespeicherte Werte werden angezeigt
- Heizung: Ring mit Ist-Temperatur und ziehbarem Soll-Punkt, -/+ und Schnellwahl
- Licht: Birne als Schalter, Helligkeit, Farbfelder, Effekt/Preset eingeklappt
- Logik aus homeMQTT.js nach js/solar/raumfenster.js; bindModalSlider entfaellt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 22:21:19 +02:00
adminandClaude Opus 5 fed0d35e03 Zeitleiste: Karte vor dem Messen oben links ablegen
Ohne Lage stand sie beim Messen unter allen Bahnen, ragte aus main.app-main,
und Chrome liess dort eine zweite Bildlaufleiste stehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:38:53 +02:00
adminandClaude Opus 5 50ebbcdf69 Zeitleiste: Karte bleibt innerhalb von main, keine zweite Bildlaufleiste
Die Karte klappte nur nach oben, wenn sie aus dem Fenster ragte. main.app-main
hat bei AdminLTE overflow:auto bei Inhaltshoehe - ragte die Karte unten aus
main, bekam main eine eigene Bildlaufleiste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:34:40 +02:00
adminandClaude Opus 5 3aae289a8c Bewaesserung: Ablaeufe zuerst; Zeitleiste: offene Karte uebersteht Neuzeichnen
Am Rechner liess die Karte die Bildlaufleiste erscheinen, die Zeitleiste
wurde schmaler und der ResizeObserver zeichnete sie samt Karte neu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:30:31 +02:00
adminandClaude Opus 5 6e805678d4 Heizstab- und Bewaesserungsfenster im neuen Look, Bewaesserungszeilen enger
Beide Fenster lesen den Zustand live aus MQTT und uebernehmen jede Wahl
sofort (bedienfenster.js). submitFormAjax entfaellt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:26:16 +02:00
adminandClaude Opus 5 bf5fdfefa4 Uebersicht: Wasserwelle und Flusspunkte laufen ohne Spruenge
Welle: gezeichnet mit dem Ringradius, verschoben um den Radius der inneren
Scheibe - am Ende jeder Runde sprang sie. Jetzt beides mit dem inneren
Radius.

Punkte: jede Leistungsaenderung setzte eine neue Animationsdauer, und der
Browser rechnete die Position neu. Die Dauer ist jetzt fest 1 s, das Tempo
kommt ueber updatePlaybackRate() und behaelt die Position.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:15:01 +02:00
adminandClaude Opus 5 5dfaf27591 Uebersicht: Symbol und Wert wieder an alter Stelle, nur die kleine Zeile naeher
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:11:14 +02:00
adminandClaude Opus 5 64f423da78 Schmales Leerzeichen vor Einheiten in Uebersicht, Anlage, Speicher, Ladefenster
Vor W, kW, kWh, V, mV, A, % und °C steht jetzt ueberall U+2009 wie in der
Uebersicht von Hand begonnen (65 Stellen, nur in Anzeigetexten; CSS-Werte
und freistehende Achseneinheiten bleiben). Dazu "Wirkungsgrad 96 %" mit
Leerzeichen und die Heizstab-Temperaturen mit " / " wie Sonne und Netz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:08:14 +02:00
adminandClaude Opus 5 1eeda335d1 Uebersicht: schmale Leerzeichen und / als Trenner in den Nebenzeilen
Sonne und Netz trennen ihre Werte mit schmalem Leerzeichen und Schraegstrich
statt Mittelpunkt; die Batterie setzt ein schmales Leerzeichen vor %.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:05:02 +02:00
adminandClaude Opus 5 44c4a7dd13 Uebersicht: Wert und Zeile im Kreis hoeher, damit die Zeile Platz hat
Symbol etwas kleiner und hoeher, Wert und Nebenzeile nach oben - dort ist
die Scheibe breiter, und Zeilen wie "89 % entlaedt" stossen nicht mehr an
den Rand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:02:31 +02:00
adminandClaude Opus 5 896f0fac03 Uebersicht: eigene Symbole fuer die Bewaesserungszonen
Troege als Pflanzkuebel (statt Einkaufskorb), Vorn als Baum im Gras,
Beete als Hochbeet - gezeichnet im Stil der uebrigen Symbole (ZONENBILDER
in energiefluss.js). Eine Zone kann weiter ein Bootstrap-Icon nutzen
(zeichen statt bild).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:51:34 +02:00
adminandClaude Opus 5 5f42309410 Ladefenster: eigene Abfahrtszeit lesbar und als ganzer Knopf antippbar
Das Zeitfeld im Chip war zu schmal (von 16:00 blieb die 1) und oeffnete
den Waehler nur ueber das Uhrsymbol von Chromium. Jetzt zeigt der Knopf die
Zeit und oeffnet den Waehler per showPicker(); das Feld liegt unsichtbar
darunter. Car-Zustand Complete heisst beendet, nicht voll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:44:58 +02:00
adminandClaude Opus 5 b0bac3d3ba Ladefenster neu: Zustand zuerst, Modus als Karten, Aenderungen sofort
Nach dem abgestimmten Entwurf: Ladestand als Ring mit Ziel, Leistung,
Sonnenanteil und fertig um; ein grosser Knopf, der sich nach der gemeldeten
Freigabe (frc) richtet - "Laden stoppen", "Ladung freigeben" (zurueck zum
Modus statt dauerhaft gesperrt), "Jetzt laden", ohne Auto ausgegraut.
Modus als Karten (Sofort, Sonne, Geplant), Planung nur bei "Geplant": mit
Ladestand des Autos als Ziel-Ladestand, sonst als zusaetzliche Prozent;
Abfahrt per Schnellwahl. Ladestrom als Segmente mit kW.

Jede Aenderung geht sofort als JSON an carSteuerung.php (frc, modus, strom,
plan) statt ueber "Save changes"; die Logik steht in js/solar/ladefenster.js.
Am 800er-Display zweispaltig ohne Scrollen.

Nebenbei: die Uebersicht las die Restladezeit aus carRemChrg, das der
Manager nicht mehr schickt - jetzt evRemChrgTime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:40:29 +02:00
adminandClaude Opus 5 2e66bdeff5 Anzeige fuer den Pi ist jetzt die Solar-Seite im Anzeige-Modus
Statt einer eigenen Vorlage mit nachgebauter Verdrahtung: ?action=anzeige
ist der Eintrag solar mit "anzeige" => true. header.php laesst Kopfleiste
und Menue weg und setzt die schmale Leiste (neu: Diagramme), footer.php
den Fuss; die Realtime-Karte fuellt einen Bildschirm, Diagramme folgen
darunter. Damit gehen am Display auch die Bedienfenster (Heizstab,
Wallboxen, Bewaesserung) und die Diagramme - alles aus solarMQTT.js.

anzeige.js bleibt fuer Leerlauf, Verbindungspunkt, Uhr und Neuladen;
restricted/anzeige.php, "eigen" in index.php und klickFilter entfallen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:22:35 +02:00
adminandClaude Opus 5 b8b2e66fd0 Vollbildanzeige fuer den Raspberry Pi (800 x 480)
index.php?action=anzeige: Uebersicht, Anlage und Speicher ohne Kopf und
Seitenleiste, schmale Leiste links, immer quer. Nach zwei Minuten ohne
Beruehrung zurueck zur Uebersicht, MQTT verbindet still neu (Punkt in der
Leiste), alle sechs Stunden neu laden. Standard "ruhig" fuer den Pi 3B+
(nur Fluesse, grobe Stufen, kein Mauszeiger); &animation=1 und &maus=1.

Dafuer:
- Seitentabelle kennt "eigen" (Vorlage ist eine ganze Seite)
- Energiefluss: Optionen hochkantUnter und klickFilter; Anlage: hochkantUnter
- Bewaesserungszustand aus solarMQTT.js nach bewaesserungAnzeige.js, von
  Solar-Seite und Anzeige gemeinsam genutzt
- Speicher: "voll/leer" mit Tag ("morgen 21:52", "in 3 Tagen")

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:15:46 +02:00
adminandClaude Opus 5 377aa03637 Uebersicht: Heizstab im Verbrauchsring nicht doppelt zaehlen
Gemein enthaelt den Heizstab. Der Text zeigt weiter die volle Zahl, das
Ringsegment Allgemeinstrom aber Gemein minus Heizstab - der hat sein
eigenes Segment. Ist der Heizstab-Kreis aus, faellt sein Anteil wie
bisher ins letzte Segment zurueck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:17:27 +02:00
adminandClaude Opus 5 ba5622f9b2 Solar: Speicher-Ansicht mit Modulen und Zellen der BYD-Batterie
Dritte Ansicht der Realtime-Karte (auch per Klick auf die Batterie):
Kennzahlen (Ladestand mit kWh und voll/leer um, Leistung, SOH,
Zellspreizung, Temperaturen, Spannung, Durchsatz, Zustand), der Turm aus
BMU und acht Modulen mit je 16 Zellbalken, das gewaehlte Modul mit
Zellspannungen und Temperaturfuehlern, dazu Verlaeufe fuer Ladestand und
Leistung (EnergyFlow) sowie Spreizung und Temperatur (byd) mit Zeitraum-
wahl und Anzeige beim Ueberfahren. Neu: ajax/speicher.php.

Nebenbei: Icon-Zeichen in energiefluss.js und im Katalog stehen als
Escape (\uF...) statt als unsichtbare Zeichen im Quelltext.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:11:22 +02:00
adminandClaude Opus 5 de2892f7a7 Solar: Anlage-Ansicht neu, im Stil der Energiefluss-Uebersicht
Der alte Plan (pvplan.svg) ist ersetzt durch einen Katalog der Anlage
(js/solar/anlageKatalog.js: Flaechen, Wechselrichter, echte Lage jeder
Platte) und einen Renderer (js/solar/anlage.js):

- Flaechen mit Leistung und Anteil als Balken
- Lageplan: Farbe stufenlos nach Leistung im Vergleich zu den staerksten
  Platten der Flaeche, Warnzeichen nur bei genug Licht unter 70 % des
  Medians, "ohne Daten" wenn der Wechselrichter nichts meldet;
  Auftritt, Lichtwelle bei Sonne, pulsierende Warnung; hochkant gedreht
  auf schmalen Schirmen
- Detail zu Platte, String oder Wechselrichter (Eingaenge als Balken,
  Auslastung, Temperatur), Verlauf je Platte ab Seitenaufruf
- Wechselrichterliste mit Zustand ok / hochgerechnet / keine Daten

Die Flaechen im Sonnenring der Uebersicht kommen jetzt aus demselben
Katalog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:56:37 +02:00
adminandClaude Opus 5 a59277e33e Solar: neue Energiefluss-Uebersicht, anpassbar je Haus
Das feste realtime.svg ist ersetzt durch einen Katalog
(js/solar/energieflussKatalog.js: jeder Kreis einmal, samt Werten aus MQTT)
und einen Renderer (js/solar/energiefluss.js): Ringe mit Segmenten,
Fluesse als Boegen mit laufenden Punkten, Fuellstandswelle fuer Batterie
und Auto, animierte Symbole, Plaketten, Hochkant-Ansicht auf schmalen
Schirmen. Hierarchie und Lage wie bisher: Quellen -> Wechselrichter ->
Verbrauch -> OG/EG/UG -> Wallboxen, Heizstab.

Einstellungen -> Uebersicht: Kreise ein/aus (Kinder ruecken eine Ebene
hoeher), Namen, Lage per Ziehen in der Vorschau, PV-Flaechen. Gespeichert
werden nur Abweichungen in homeMesh.energiefluss.

Der PV-Lageplan hinter der Sonne liegt unveraendert als assets/img/pvplan.svg
und ist ueber den Umschalter Uebersicht/Anlage erreichbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:42:52 +02:00
adminandClaude Opus 5 5ed3a0796e Tabellen byd und byd_zellen fuer den BYD-Speicher
Verlauf aus gatherBYDData.py (SolarManager): alle 5 Minuten Kennwerte,
alle 15 Minuten alle Zellspannungen und Temperaturen als JSON.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:16:08 +02:00
adminandClaude Opus 5 fcd0b92785 Heizstab: keine PHP-Warnung beim Einstellen, 6,0 kW wirksam, Ausfall gemeldet
- setMode kam aus POST oder GET, addiert wurden beide - der fehlende gab
  "Undefined array key", und die Warnung stand als Meldung im Browser.
  Jetzt wird der vorhandene genommen.
- Der Regler reicht bis 60 (6,0 kW), die Pruefung liess nur < 60 zu; ganz
  rechts geschah still nichts.
- Ist der Heizstab nicht erreichbar, kommt "Der Heizstab ist nicht
  erreichbar." statt weiterer PHP-Fehler aus fwrite auf eine gescheiterte
  Verbindung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:44:15 +02:00
adminandClaude Opus 5 865dd61535 Einfuehrung fuer Einsteiger: Folien mit echten Aufnahmen und Markierungen
Neue Seite "Einfuehrung" im Menue: 31 Folien in zwei Teilen - Bedienen im
Alltag (Grundriss, Raum, Rollos, Heizung, Licht, Zeitleiste, Solar, Auto,
Heizstab und Wasser, Statistik, Historie, Heizung, Wetter, Fahrzeug, Hilfe)
und Einrichten und Verwalten (Preise, Grundriss, Kacheln, Geraete,
Automatik anlegen in Wenn/Wann/Dann, Ketten, Fahrzeugschluessel,
Protokolle).

- Bilder sind echte Aufnahmen der Oberflaeche mit Livewerten, Handy- und
  Desktopgroesse. tools/einfuehrung/aufnehmen.ps1 steuert Edge ohne
  Fenster ueber das DevTools-Protokoll nach plan.json und schreibt zu jedem
  Bild die Lage der markierten Elemente; daraus sitzen die nummerierten
  Punkte. Wetterort und Schluesselendungen werden vor der Aufnahme
  abgedeckt.
- Blaettern per Knopf, Pfeiltaste oder Wischen; Kapitelsprung; die Folie
  steht in der Adresse (#folie-7). Erklaerung und Punkt heben sich
  gegenseitig hervor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:32:14 +02:00
adminandClaude Opus 5 05029889b9 Zeitleiste: "nicht dran" als kleiner blasser Punkt statt gestricheltem Kreis
Der gestrichelte Kreis neben dem Etikett sagte dasselbe wie das
Kalender-Symbol darin, nur doppelt - und stand bei links umgeschlagenen
Etiketten abgesetzt daneben wie ein eigenes Zeichen. Jetzt ein kleiner
blasser Punkt dicht am Etikett. Die Marke behaelt ihre Groesse, damit
Zeitlage, Stapeln und Kettenlinien unveraendert bleiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:54:20 +02:00
adminandClaude Opus 5 2aadb667fb Zeitleiste: Karte je Eintrag mit Bearbeiten, Pausieren/Fortsetzen, Loeschen
Ein Klick auf einen Eintrag oeffnete bisher gleich den Editor - pausieren
und loeschen ging nur in der Liste. Jetzt oeffnet sich eine kleine Karte:
was die Automatik tut, wann, wie sie heute steht, die Bedingung, und die
Knoepfe Bearbeiten, Pausieren bzw. Fortsetzen und Loeschen. Auch die
Eintraege in den Baendern "Jederzeit" und "Pausiert" oeffnen sie.

Pausieren und Loeschen gehen ueber toggleAutomation und deleteAutomation
aus autoActionFuncs.js - dieselben Wege wie in der Liste, samt Rueckfrage
bei abhaengigen Automatiken. Die Karte schliesst per Kreuz, Esc oder Klick
daneben; solange sie offen ist, leuchtet die Kette des Eintrags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:43:11 +02:00
adminandClaude Opus 5 d291f063f5 Zeitleiste: eigenes Zeichen fuer "nicht dran", keine Marke unter dem Etikett
Der gestrichelte Kreis im Etikett von "Sichtschutz Kinderzimmer" war nicht
das Zeichen fuer "an diesem Tag nicht dran", sondern die Marke des
fruehesten Zeitpunkts im Fenster (Sonnenuntergang + 30), die unter dem
Etikett lag und nur gestrichelt aussah, weil der Eintrag nicht dran war.
Bei "Rollos morgens Wochenende" faellt dieser Zeitpunkt auf den
Fensterbeginn - dort gab es keine Marke und damit gar kein Zeichen.

Jetzt traegt jedes Etikett, das an diesem Tag nicht dran ist, vorn ein
Kalender-Symbol, bei Punkt und Fenster gleich und gleich der Legende. Die
Marke des fruehesten Zeitpunkts entfaellt; er steht im Hinweis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:38:47 +02:00
adminandClaude Opus 5 6aace0d9a2 Zeitleiste: Fenster-Etikett nie vor dem Balken, Kettenlinie lotrecht
Passte ein Name hinter einem spaeten Fenster nicht mehr in die Spur, stand
das Etikett rechtsbuendig am Balkenende und hing weit vor dessen Beginn -
"Hitzeschutz West zurueck" (15:00-18:31) sah aus, als finge es um halb elf
an. Jetzt beginnt ein Fenster-Etikett immer am Balken; reicht der Platz
nicht, wird der Name gekuerzt, und nur wenn nicht einmal die Uhrzeit Platz
hat, rueckt es so wenig wie noetig nach links.

Die Kettenlinie verlaesst den Ausloeser lotrecht unter seiner Marke (bzw.
unter Laufmarke oder Balkenanfang), laeuft gerade hinunter und biegt mit
einer kleinen Rundung von links in den Nachfolger ein. Liegt der Nachfolger
links davon, faehrt sie ihn von oben an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:33:30 +02:00
adminandClaude Opus 5 48dc2b896a Zeitleiste: Ketten stehen unter ihrem Ausloeser, Linien deutlicher
Gestapelt wurde nur nach Uhrzeit in die erste freie Zeile - ein Nachfolger
wie "Hitzeschutz Ost zurueck" landete irgendwo, wo gerade Platz war. Jetzt
wird familienweise eingesetzt: der Nachfolger kommt in die erste freie
Zeile direkt unter seinem Ausloeser. Je Zeile werden dafuer die belegten
Strecken gefuehrt statt nur ihr Ende.

Die Kettenlinie fehlte bei Nachfolgern mit Zeitfenster ganz und ging sonst
im Stundenraster unter. Sie hat jetzt einen dunklen Grund, eine hellere
Farbe, einen Endpunkt, laeuft erst nach unten und trifft den Nachfolger
von links. Beim Ueberfahren eines Eintrags leuchtet seine Kette auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:26:45 +02:00
adminandClaude Opus 5 93c34bf069 Automatismen als Zeitleiste des Tages, Liste bleibt umschaltbar
Die Uebersicht auf der Startseite zeigt die Automatiken jetzt als
Zeitleiste: eine Bahn je Geraeteart, die Spur ist der Tag. Der Umschalter
"Zeitleiste | Liste" oben in der Karte fuehrt zur bisherigen Tabelle
zurueck und wird im Browser gemerkt.

Jede Automatik erscheint genau einmal, nach festen Regeln
(restricted/zeitleiste.php), auch kuenftige:
- feste Uhrzeit oder Sonnenstand: Punkt; Zeitfenster mit Messwert: Balken;
  Kette: hinter ihrem Ausloeser, als gestrichelter Bogen verbunden
- ohne jede Zeit: Band "Jederzeit"; pausiert: Band "Pausiert"
- an diesem Tag nicht dran (Wochentag, Ferien, Feiertag, Vorabend, wie im
  Runner gerechnet): gestrichelt mit Grund, nie ausgeblendet
- Bahn aus den geschalteten Geraeten ueber bedienform(), Mehrheit gewinnt;
  Bewaesserung am Geraetetyp
- Zaehler "x von y" im Kopf

Dazu: Tag vor/zurueck, Etagen einzeln einblendbar (gemerkt), Jetzt-Linie
und Nachtschatten aus solarLog.daylight (spaetere Tage vom selben
Kalendertag eines Vorjahres), gelaufene Eintraege mit Uhrzeit aus
automation_log, Klick oeffnet den Editor. Schmal nur Symbole in den Bahnen
und Start bei der aktuellen Uhrzeit.

Auf einer Probeseite mit echten Daten geprueft: nichts ragt ueber, keine
ueberlappenden Etiketten, alle 22 Automatiken genau einmal, Filter,
Tageswechsel und Umschalter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:12:15 +02:00
adminandClaude Opus 5 8d626abc6d Einstellungen -> Grundriss: Etagen, Raeume und Kacheln am Plan pflegen
Neuer Reiter mit Plan-Editor: Etagen anlegen, umbenennen, sortieren,
loeschen und als Standard markieren; Grundrissbild hochladen; Raeume mit
Name, Kuerzel und Thermostat-Zweig anlegen und bearbeiten; Kacheln im Plan
ziehen oder einen Raum ohne Kachel mit "Platzieren" und einem Klick setzen.
Loest tools/kachelpositionen.php ab - Raster, Geisterkachel und die
Umrechnung Mitte -> linke obere Ecke sind von dort uebernommen.

- restricted/grundriss.php: alle Pruefungen und Schreibzugriffe. Eine
  Etage mit Raeumen oder Automatiken laesst sich nicht loeschen, die
  Ablehnung nennt die Namen. Raeume loeschen laesst ihre Geraete ohne Raum.
- Grundrissbilder nach tiles/grundriss/ (beschreibbar, nicht im Git),
  nur PNG/JPEG/WebP, am Inhalt geprueft; SVG bewusst nicht. Prüfsumme im
  Dateinamen, alte hochgeladene Bilder werden weggeraeumt.
- ajax/settings.php: ?action=grundriss und sieben POST-Aktionen, jede
  antwortet mit dem ganzen neuen Stand.
- "Werte" springt in den Reiter Home-Kacheln und oeffnet die Kachel;
  Home-Kacheln und Geraete laden nach einer Grundriss-Aenderung nach.
- Startseite ohne Etage mit platziertem Raum: Hinweis auf die
  Einstellungen statt einer leeren Zeichenflaeche.
- README: Grundriss als Inhalt statt Code, neue Dateien und Endpunkte,
  Reihenfolge der Schema-Dateien fuer ein neues Haus.

Backend mit einer Probe-Etage durchgespielt (auch Fehlerfaelle, Upload,
Aufraeumen); Oberflaeche auf einer Probeseite mit echten Daten und
Zeiger-Ereignissen geprueft: Ziehen, Antippen, Platzieren, neuer Raum.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 11:11:10 +02:00
adminandClaude Opus 5 62a8af3fc6 Raeume ueber ihre feste Nummer: actors.room_id, Raum-Modal, Thermostat-Zweig
Geraete hingen ueber den Text "Etage/Raum" an ihrem Raum, das Raum-Modal
zerlegte "EG_Bad" am Unterstrich, und das Thermostat-Topic wurde aus Etage
und Name zusammengesetzt. Ein umbenannter Raum haette still alle Geraete,
seinen Temperaturregler und sein Heizsymbol verloren.

- actors.room (Text) -> actors.room_id, Fremdschluessel auf rooms, beim
  Loeschen eines Raums SET NULL. Alle 59 Zuordnungen uebernommen.
- automations.floor: ENUM(UG,EG,OG,AG) -> Verweis auf floors.
  Eine neue Etage kann damit auch Automatiken tragen.
- room.php nimmt ?room=<Nummer>; Solltemperatur geht an
  rooms.thermostat + /changeSetTemp.
- homeMQTT.js: Heizsymbol, Regler und Ist-Werte ueber mqttZweig(thermostat)
  statt mqttData.Raumtemp[Etage][Name].
- Raumvorschlaege in den Einstellungen: Messwerte unter dem Thermostat-
  Zweig eines Raums statt fest Raumtemp/<Etage>/<Name>.
- Schluessel in raumListe(), Katalog und Maske ist die Raumnummer als Text.

Geprueft gegen den Stand davor: Startseite gleich bis auf das Klickziel,
jede der 25 Kacheln oeffnet ihren eigenen Raum; Geraeteliste, Vorschlaege,
Editor-Katalog und die eigenen Geraete je Kachel gleich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:58:30 +02:00
adminandClaude Opus 5 3636a134d6 Grundriss aus der Datenbank: Etagen und Raeume in homeMesh
Etagen, Raeume, Kachelpositionen und Kachelwerte standen als Tabelle in
restricted/rooms.php, die Werte zusaetzlich ueberschreibbar in
solarLog.kachelWerte. Fuer ein anderes Haus haette man den Quelltext
umschreiben muessen.

Jetzt liegen sie in homeMesh.floors und homeMesh.rooms
(homeMesh_grundriss.sql). rooms.php liest sie und gibt sie in der
bisherigen Form weiter; Startseite, Menue, Editor und Einstellungen sind
unveraendert - vorher und nachher als HTML verglichen, gleich bis auf
Livewerte und die zwei neuen Felder nr und thermostat in homeRooms.

- Vorgabe einer Kachel ist jetzt allein das Thermostat (rooms.thermostat):
  Soll, Ist, Feuchte - sonst nichts. Was bisher im Code als Vorgabe stand,
  gilt damit als selbst gesetzt.
- Grundrissbild je Etage (floors.bild) statt assets/img/<code>.png.
- Standard-Etage (floors.standard) statt "OG" an vier Stellen.
- meshDb() in eigener Datei, sonst Ring zwischen rooms.php und
  automations.php.
- solarLog_kacheln.sql entfaellt; die Tabelle kachelWerte liest niemand mehr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:29:58 +02:00
adminandClaude Opus 5 483f08888e auto_watering.py entfernt - die Bewaesserung laeuft ueber die Automatiken
Die drei Automatiken (Troege, Garten vorn, Hochbeete) sind seit dem
Umzug aktiv; das stuendliche Skript im DSM-Aufgabenplaner ist abgeschaltet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:02:04 +02:00
adminandClaude Opus 5 b2102d4752 README auf den Stand gebracht: Fahrzeug-Reiter, Vorabend, skoda_ladepunkte
- skodaKeys.php in Verzeichnisbaum, Modelltabelle und Include-Graph
- settings.js hat vier Reiter, ajax/settings.php die skoda-keys-Aktionen
- Kachelformat leistung_kw, carForm.php in der Endpunkttabelle
- solarLog.skoda_ladepunkte, Schalter "Am Vorabend" bei den Automatiken
- SolarManager-Repo ohne Wecker

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:53:09 +02:00
adminandClaude Opus 5 59d2a4a55e Automatiken: Rahmen am Vorabend fuer den folgenden Tag
Neuer Schalter "Am Vorabend" im Wann-Band (Spalte next_day). Wochentage,
Ferien und Feiertage gelten dann fuer morgen: "Kinderrollos zu, wenn morgen
Schule ist" ist Mo-Fr, Ferien nie, Feiertage nie - wie der Wecker. Mit dem
heutigen Tag ging das am letzten Ferientag, am Abend vor einem Feiertag und
am Abend eines Feiertags daneben.

Editor-Satz, Kurzfassung und Uebersicht nennen den Vorabend vorne, damit
auch "nicht an Feiertagen" als Folgetag gelesen wird. Die Spalte legt
vorabend.sql im SolarManager an, ausgewertet wird sie im Runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:33:59 +02:00
adminandClaude Opus 5 deb81045d5 Skoda: Schluessel in den Einstellungen, Ladungen am Stueck, Wallbox in kW
- neuer Reiter "Fahrzeug": API-Schluessel eintragen und pruefen, ohne dass
  ein Wert je an den Browser zurueckgeht (restricted/skodaKeys.php)
- ajax/skoda.php trennt Ladungen am Zustand statt an 30 Minuten Pause und
  rahmt sie mit der Zeile davor und danach ein
- solarLog_skoda_ladepunkte.sql: Wallbox-Verlauf waehrend einer Ladung
- Kachelformat "leistung_kw" fuer Quellen, die schon Kilowatt melden
- Steckersymbol versteht evPlug als 1/0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:30:18 +02:00
adminandClaude Opus 5 b01e938905 Automatik-Editor als drei Baender, mit laufenden Werten
Das Akkordeon schloss beim Oeffnen die anderen Faecher - Ausloeser und
Aktion waren also nie gleichzeitig zu sehen, ausgerechnet bei einer
Regel, die genau daraus besteht. Und das mittlere Fach hiess
"Bedingungen", enthielt aber die Rahmenbedingungen; die echten standen
im ersten. Jetzt drei sichtbare Baender: Wenn, Wann, Dann, gebaut aus
.card mit farbigem Rand und .card-header.

Die Bedingungszeile magert von fuenf gleich schweren Kaesten auf zwei
ab. Das Geraet steht klein und matt ueber dem Messwert - es beantwortet
"woher", nicht "was" -, der Messwert ist Text mit gepunkteter Linie,
und nur Vergleich und Wert sehen noch wie Eingabefelder aus. Dort
steckt die Aussage der Bedingung, dort tippt man auch. Die Einheit
sitzt im Wertfeld statt in einem sechsten Kasten, der Papierkorb
erscheint erst beim Ueberfahren.

Neben jeder Bedingung laeuft ihr aktueller Wert mit. ?action=werte
liefert alle acht Sekunden {state_id: {value, unit}} aus actor_states -
bewusst von dort und nicht ueber MQTT, weil dort nur ein Teil der
Geraete auftaucht: die Jalousien haengen an der Tahoma-Box, die
gerechneten Werte an gar nichts. Das ersetzt den Satz, der frueher am
Ende stand und die Regel Wort fuer Wort nacherzaehlte: statt zu
wiederholen, was darueber steht, beantwortet die Zeile die Frage, die
man wirklich hat - warum laeuft die Automatik gerade nicht?

Bei time, date, datetime, deltatime und elapsed faellt der Browser
bewusst kein Urteil. Ob "Uhrzeit um 07:30" zutrifft, haengt am
Nachholfenster, am Tagesrand und bei elapsed am echten Abstand seit der
letzten Ausloesung - das steht im Runner. Es hier nachzubauen hiesse,
eine zweite Wahrheit zu pflegen, die auseinanderlaeuft. Diese Zeilen
bekommen einen gestrichelten Punkt statt einer Aussage.

Der Takt malt nur nach, statt neu zu bauen: renderConditions() ersetzt
den ganzen Block, damit waeren alle acht Sekunden eine offene
Geraeteliste weg und der Cursor aus einem Wertfeld, in das man gerade
tippt.

Der Rahmen steht zusammengefaltet da, solange nichts von der Vorgabe
abweicht - und das ist der Normalfall; vorher nahm er die meiste
Flaeche fuer den seltensten Inhalt. Aufgeklappt tauschen die drei
Vorlagen (taeglich, werktags, Wochenende) mit der Kurzfassung den
Platz. Sie stehen ausserhalb des Elements mit data-bs-toggle, und das
ist kein Zufall: Bootstrap haelt seinen Umschalter am Dokument in der
Capture-Phase, ein stopPropagation() im Knopf kaeme grundsaetzlich zu
spaet.

Getragen wird das von vier Variablen in solar.css, damit der Rest der
Seite mitzieht statt hinterherzuhinken: --ton-radius auf .875rem,
--ton-radius-klein neu auf .5rem (beide an Bootstraps
--bs-border-radius und -sm), --font-anzeige auf Poppins fuer alle
Ueberschriften. Poppins lag seit jeher unter assets/fonts und trug
bisher die vier Etagenknoepfe im Aussenplan - eine ganze Schrift fuer
vier Buchstaben. Fliesstext und Zahlenkolonnen bleiben beim Theme, weil
Poppins keine Tabellenziffern mitbringt.

Drei neue Klassen stehen absichtlich in solar.css und nicht im Editor.
.feld-als-text ist ein Auswahlfeld, das wie Text aussieht, bis man es
anfaehrt - dieselbe Not haben das Raum-Modal und die
Kachel-Einstellungen. .punkt ist an oder aus. .wert-jetzt ist die eine
echte Ausnahme und als einzelne Klasse auch als solche erkennbar. Der
Satz im Wann-Band braucht gar keine: .callout gibt es in AdminLTE, und
seine Toene kommen aus Bootstraps *-bg-subtle-Variablen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:08:50 +02:00
adminandClaude Opus 5 38e6c3f96b Wetterstation erkennen, Einheiten nicht mehr ueberschreiben
Die Station haengt laengst am Websocket und wsMQTTbridge.py legt ihre
Felder unter weatherStation/# ab - nur wusste die Automatikdatenbank
nichts davon. Das neue Modul macht daraus ein Geraet mit elf
Messwerten: Aussentemperatur, Taupunkt, Feuchte, Druck, Wind, Boe und
die beiden Richtungen. Wie das Gartenwasser- und das
SolarManager-Modul sucht es nichts, sondern schreibt ein festes Geraet
hin; die Station meldet sich nicht per Home-Assistant-Discovery.

Gebraucht wird sie fuer den Hitzeschutz: dafuer ist die
Aussentemperatur die bessere Bedingung als die Raumtemperatur - ist es
drinnen schon warm, ist es zum Verschatten zu spaet. Und Wind gab es
hier bisher ueberhaupt nicht; ohne ihn laesst sich fuer Markise und
Sonnensegel kein Sturmschutz bauen.

Nicht dabei sind zwei Topics. IP ist keine Messung. Und QNH steht bei
uns gut hundert Hektopascal neben qff (1133 gegen 1023) und ist
offenbar nicht auf die Stationshoehe eingestellt - zwei Druecke
nebeneinander, von denen einer falsch ist, waeren im Editor eine Falle.

Dabei aufgefallen: der UPSERT schrieb unit = VALUES(unit)
bedingungslos. Ein Modul, das keine Einheit mitliefert, meint aber
"weiss ich nicht" und nicht "loesch die vorhandene" - am 10.09. hat ein
Suchlauf den drei Sonnensensoren ihr "Lux" genommen, weil das
Tahoma-Modul keine Einheiten kennt. Jetzt COALESCE, wie bei
current_value schon laenger.

config.ini liegt nicht im Git; der Abschnitt [wetterstation] muss dort
von Hand stehen, sonst bleibt das Modul aus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:08:10 +02:00
adminandClaude Opus 5 b607d4a064 Automatiken als Ausloeser: Editor, Uebersicht, Loeschen
Gegenstueck zur Runner-Seite. Der Editor selbst braucht nichts: das
gerechnete Geraet "Automatiken" fuehrt jede Automatik als Messwert, und
der Geraetewaehler listet ohnehin Geraete und deren Messwerte. Neu sind
nur die Operatoren des Datentyps `elapsed` ("+", "ab +", "vor +") und
sein Eingabefeld.

Drei Stellen wissen trotzdem davon:

pruefeKreis() lehnt beim Speichern ab, was sich mittelbar selbst
ausloesen wuerde. Im Betrieb waere ein Kreis kaum zu bemerken - die
Sperrzeit begrenzt ihn auf eine Ausloesung je lockout_secs, und im
Protokoll sieht das aus wie eine Automatik, die halt oft laeuft.

deleteAutomation() fragt vorher. fk_cond_state steht auf ON DELETE
CASCADE, mit dem Messwert verschwaende also still die Bedingung des
Nachfolgers: aus "zehn Minuten nach dem Wecker UND es ist hell" wuerde
ein blosses "es ist hell", und der Rollladen fuehre ab morgen jeden Tag
bei Sonnenaufgang hoch. Bei der letzten Bedingung faengt der Runner das
ab, bei einer von zweien niemand. Zur Wahl stehen Abhaengen (Bedingung
raus, Nachfolger pausiert) und Mitloeschen; automatisch mitzuloeschen
waere die falsche Vorgabe.

Die Uebersicht rueckt Nachfolger unter ihren Ausloeser ein und zeigt sie
eingeklappt. Das ist nur die Darstellung - jeder Nachfolger bleibt eine
vollwertige Automatik mit eigener Pause, eigenen Rahmenbedingungen und
eigener Zeile im Protokoll. Sobald "gruppiert" auch "gehoert dazu"
hiesse, waere zu klaeren, wem die Pause gehoert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 22:51:42 +02:00
adminandClaude Opus 5 c6a879b424 Spoiler ueber die Heckscheibe, Reifen wieder schwarz
Der Spoiler steht jetzt hinter der Heckklappe im Markup und liegt damit
ueber deren Scheibe - am Auto sitzt er auf der Dachkante und damit vor
dem Glas. Die Reling bleibt beim Dach, wo sie hingehoert.

Die Reifen sind wieder schwarz. Unter zwei getoenten Flaechen bleibt
davon wenig uebrig, vorn unter der Haube fast nichts - das ist so
gewollt: von oben saehe man sie ohnehin nicht, sie stehen im Bild,
damit die Achsen einen Ort haben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:55:41 +02:00
adminandClaude Opus 5 01bcbfffc1 Reling, Spoiler, Spiegel, Seitenscheiben und Kupplung nachgezogen
Die Relingholme enden hinten bei 3,79 m statt 3,98 m: am Auto laufen
sie ueber dem Radlauf aus, der Spoiler sitzt ein Stueck dahinter. Der
Spoiler selbst ist an den Enden stumpf abgeschnitten statt spitz - die
Spitzen gaben ihm die Form eines Blattes, am Auto hoert er schlicht auf.

Die Spiegel ruecken von 1,81 m auf 1,62 m, also an den Fuss der
A-Saeule, und die vorderen Seitenscheiben laufen bis 1,66 m nach vorn
mit. Vorher stand dort ein Stueck Blech, das es nicht gibt.

Die Anhaengerkupplung steht wieder hinten heraus - die Kugel rund neun
Zentimeter hinter der Heckkante, so wie am Auto. Dass das nicht wie ein
Loch im Umriss aussieht, liegt jetzt an der Farbe: ganz schwarz, damit
erkennbar ein Anbauteil und kein fehlendes Blech.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:53:45 +02:00
adminandClaude Opus 5 551b727256 Windschutzscheibe vor die Vordertuer, Fahrzeug in Lagen sortiert
Die Scheibe beginnt jetzt bei 1,37 m und damit vor der vorderen
Tuerfuge (1,46 m) - am Auto setzt sie schon ueber dem Kotfluegel an,
die A-Saeule erst weiter hinten. Die Motorhaube endet entsprechend bei
1,33 m statt 1,58 m, die Schnauze wird um einen Vierteilmeter kuerzer.
Der Klimaknopf rueckt auf die neue Scheibenmitte mit.

Das Markup ist ausserdem durchgehend als Schichtung geordnet, von
unten nach oben: Fahrwerk, Blech, Glas und Licht, Akku - die
Antriebsbatterie liegt jetzt ueber allem. Das geht auf, weil sie nur
ueber dem Radstand und nur von x 46 bis 178 liegt; Lichter, Fenster,
Tueren und Heckklappe liegen alle daneben.

Reifen und Anhaengerkupplung sind dadurch die unterste Lage und waren
zunaechst unsichtbar: fast schwarz unter zwei getoenten Flaechen. Sie
sind deshalb heller gezeichnet und tragen eine helle Kontur - dieselbe
Sprache wie die Akkuplatte, die auch nur als Umriss durch den Boden
scheint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:49:10 +02:00
adminandClaude Opus 5 8f040dec19 Fahrzeug auf die Werksmasse gebracht
Alle Masse nachgerechnet und gegen die Werksangaben gestellt. Massstab sind
104,97 Einheiten je Meter (4649 mm auf 488 Einheiten).

                     Zeichnung   Fahrzeug   Abweichung
  Laenge                  4649       4649        0 mm
  Breite Karosserie       1877       1879       -2
  Spiegelbreite           2147       2147        0
  Radstand                2763       2765       -2
  Spurweite               1581       1585       -4
  Reifendurchmesser        743        744       -1
  Ueberhang vorn           905        903       +2
  Ueberhang hinten         981        981        0

Der grosse Fehler war die Spur: die Raeder standen 1820 mm auseinander statt
1585 und ragten damit acht Einheiten ueber die Flanke hinaus. Sie stehen
jetzt innerhalb des Umrisses, wie am Auto - dort ist die Karosserie 1879 mm
breit und Spur plus Reifenbreite 1820 mm.

Damit sie trotzdem zu sehen sind, stehen sie im Markup hinter der Karosserie
statt davor: das Blech ist durchscheinend gezeichnet. Von oben saehe man die
Raeder in Wirklichkeit gar nicht - hier gilt dieselbe Freiheit wie bei der
Akkuplatte, die auch durch den Boden zu sehen ist.

Kleiner dazu: Vorderachse vier Einheiten nach hinten (903 statt 871 mm
Ueberhang), Karosserie um 26 mm schmaler, Spiegel um 32 mm breiter, und die
Bananenschale liegt weiter in der linken Radspur, die jetzt woanders ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:21:36 +02:00
adminandClaude Opus 5 b180e3ef98 Windschutzscheibe endet auf halber Vordertuer
Die A-Saeule stoesst im Foto etwa auf halber Laenge der Vordertuer ans Dach.
Gezeichnet reichte die Scheibe bis fast ans Tuerende - das Dach begann
dadurch einen guten Drittelmeter zu weit hinten und war entsprechend kurz.

Jetzt: Scheibenfuss 1,6 m, Dachanfang 2,15 m (y 232 statt 268), Dachende
unveraendert 4,0 m. Das Dach ist damit 1,87 m lang statt 1,52 m. Reling und
Aussenspiegel sind mitgezogen - die Spiegel sitzen am Fuss der A-Saeule, und
der wandert mit ihr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:14:07 +02:00
adminandClaude Opus 5 1451c5e42b Tueren beginnen dicht hinter dem Vorderrad
Zwischen Rad und Tuer stand eine halbe Handbreit Blech, die es am Auto nicht
gibt. Nachgemessen an der Seitenansicht: die vordere Tuerfuge liegt bei
1,52 m, das Vorderrad endet bei 1,24 m - ein knapper Vierteilmeter dazwischen.
Gezeichnet war die Fuge bei 1,75 m.

Damit verschiebt sich die ganze Staffelung: vordere Tuer 1,52 bis 2,59 m (die
Fuge an der B-Saeule), hintere von dort bis zum Radlauf. Am Auto reicht die
hintere Tuer noch ueber den Radlauf hinaus bis etwa 3,5 m; gezeichnet endet
sie davor, denn weiter hinten laege ihr Blech im Bild auf dem Rad. Das steht
so auch im Quelltext.

Die Seitenfenster sind mitgezogen: vorn von der A-Saeule bis zur B-Saeule,
hinten bis zum Ende der Tuer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:09:10 +02:00
adminandClaude Opus 5 df55a77d97 Reifen kantiger, Luft zwischen Klimaknopf und Ladestand
Die Raeder hatten fast halbrunde Enden (rx 11 bei 25 Einheiten Breite) und
sahen dadurch aus wie Kapseln. Von oben ist eine Lauffläche ein Rechteck mit
leicht gebrochenen Ecken - rx 5.

Der Klimaknopf sass in der Mitte der Windschutzscheibe und damit dicht ueber
der Prozentzahl des Ladestands; beide lasen sich als ein Block. Er sitzt
jetzt im oberen Drittel der Scheibe (cy 206 statt 220). Die Zahl bleibt, wo
sie ist: sie steht auf dem Goldenen Schnitt der Akkuplatte (108 + 270 * 0,618
= 275), und das war Absicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:05:28 +02:00
adminandClaude Opus 5 29742bf77c Raeder in echter Groesse, Tueren gestaffelt, Heckklappe mit Scheibe
Drei Sachen, alle aus dem Vergleich mit der Seitenansicht:

Die Raeder waren ein Viertel zu klein: gezeichnet 58 auf 21 Einheiten, am
Fahrzeug sind es 235/50 R20, also 744 mm Durchmesser auf 235 mm Breite oder
78 auf 25 Einheiten. Das Rad ist im Bild der einzige Gegenstand mit bekanntem
Mass - ist es zu klein, wirkt alles andere zu gross, allen voran die
Schnauze. Die ist naemlich richtig: bis zum Scheibenfuss sind es 1,6 m von
4,65 m Gesamtlaenge, am Auto knapp 1,8 m.

Die Tueren waren gleich lang. Sind sie nicht: die Trennfuge liegt bei 2,60 m,
die hintere Fuge bei 3,35 m, macht 85 zu 75 Zentimeter (89 zu 79 Einheiten).
Vorher waren beide 104 Einheiten lang und zusammen einen halben Meter zu
lang.

Heckscheibe und Heckklappe sind eine Schaltflaeche - die Scheibe sitzt in der
Klappe und geht mit ihr auf. Sie behaelt nur ihr Aussehen (.teil > .scheibe
im Stylesheet, und beim Oeffnen faerbt sie sich durchsichtiger als das
Blech). Erst dadurch konnte das Dach dorthin, wo es hingehoert: es endet
jetzt bei 4,0 m statt 3,8 m, am Auto sind es 4,15 m. Als getrennte Flaeche
waere die Klappe dabei auf drei Zentimeter geschrumpft und nicht mehr zu
treffen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:00:59 +02:00
adminandClaude Opus 5 5210ea242d Glashaus neu vermessen: Dach, Reling und Spoiler sassen zu weit vorn
Die Frage war berechtigt. Nachgemessen an der Seitenansicht, umgerechnet mit
105 Einheiten je Meter (der Wagen ist 4,65 m lang und 488 Einheiten):

                        vorher          jetzt       Fahrzeug
  Scheibenfuss          1,39 m          1,62 m      ~1,75 m
  Dachanfang            1,85 m          2,37 m      ~2,55 m
  Dachende              3,14 m          3,81 m      ~4,15 m
  Dachlaenge            1,30 m          1,44 m      ~1,55 m

Das ganze Glashaus sass also gut einen halben Meter zu weit vorn und war zu
kurz - und mit ihm Reling und Spoiler. Die Reling lief ausserdem nur ueber
zwei Drittel des Daches; sie geht jetzt von 262 bis 402, also fast von der
Windschutzscheibe bis zum Spoiler, wie am Auto.

Mitgezogen sind alle Teile, die am Glashaus haengen: Motorhaube, beide
Scheiben, die Tueren (jetzt 104 statt 74 und 66 Einheiten lang, nach den
echten Tuerbreiten), die Seitenfenster, die Aussenspiegel an den Fuss der
A-Saeule und der Klimaknopf in die Mitte der Windschutzscheibe.

Ganz massstaeblich ist es weiterhin nicht: das Dach endet am Auto erst bei
4,15 m. Dahinter blieben fuer Heckscheibe, Klappe und Stossfaenger zusammen
fuenf Zentimeter Bild, und die Heckklappe waere als Schaltflaeche nicht mehr
zu treffen. Die letzten vierzig Zentimeter sind deshalb gedehnt - eine
bewusste Abweichung, die im Quelltext auch so dasteht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:47:57 +02:00
adminandClaude Opus 5 a57ffe41cc Front stumpf, Kupplung innerhalb der Kante, Dachteile nach oben
Drei Dinge aus dem Abgleich mit dem Foto:

Die Front lief spitz zu wie das Heck vorher. Der Enyaq ist auch vorn stumpf:
eine Kante von 70 bis 154, davor die Tech-Deck-Blende als Band statt als
Bogen, die acht Leuchtelemente flacher und weiter aussen, die Motorhaube an
die neue Bugkante gezogen.

Die Anhaengerkupplung stand ueber die Heckkante hinaus und las sich wie eine
Unterbrechung des Umrisses. Sie sitzt jetzt vollstaendig innerhalb.

Dachreling und Heckspoiler lagen im Markup vor der Akkuplatte - also unter
ihr, und die Platte ist halbdurchsichtig: von den Holmen war fast nichts mehr
zu sehen. Sie stehen jetzt dahinter, wo sie hingehoeren (Reling und Spoiler
liegen auf dem Dach, die Batterie im Boden), und sind schwarz statt in
Blechfarbe - an diesem Fahrzeug sind sie es auch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:39:16 +02:00
adminandClaude Opus 5 b53e4fe7e3 Heck vom Fliessheck zum SUV
Das Fahrzeug lief hinten spiegelbildlich zur Front spitz zusammen - von oben
genau die Form eines Coupes. Der Enyaq ist aber der SUV:

  Karosserie   Die Flanken bleiben bis kurz vor den Schluss auf voller Breite
               (212 bei y=250, noch 206 bei y=452), dann runden zwei Ecken auf
               eine gerade Heckkante von 56 bis 168.
  Heckscheibe  steiler (336 bis 376 statt 382) und nach unten schmaler statt
               breiter - das Auslaufen nach hinten war das Fliessheck-Merkmal.
  Heckklappe   folgt der neuen Kontur, innen abgesetzt wie am Auto.
  Rueckleuchten sitzen in den Ecken und greifen von der Klappe auf die Flanke
               ueber, so wie das Band am Original um die Ecke laeuft. Weiter
               innen sahen sie aus wie zwei Griffe in der Klappe.

Dazu die Anhaengerkupplung, die dieses Fahrzeug hat: Hals und Kugel, das
einzige Teil, das ueber die Heckkante hinaussteht - Platz dafuer war im
viewBox unten schon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:31:00 +02:00
adminandClaude Opus 5 ea98d60435 Breitere Fahrbahn, Schale in der Radspur
Drei Anpassungen am Fahrbild:

Die Zeichenflaeche ist 15 % breiter (viewBox 294,4 statt 256, mittig um den
Wagen), und die max-width waechst mit auf 297 px - das Fahrzeug bleibt also
gleich gross, es kommt nur Platz daneben dazu. Die beiden Markierungen
ruecken um dieselben 15 % nach aussen (x -23,7 und 247,7), damit zwischen Rad
und Fahrbahnrand Luft fuer den Fahrtwind bleibt.

Die Markierung ist 4,2 statt 3,15 breit und etwas heller. Massstaeblich waere
3,15; so hebt sie sich aber deutlich vom Fahrtwind ab, der mit 2,5 daneben
laeuft - und darum ging es.

Die Bananenschale liegt jetzt in der linken Radspur (x 16, die Raeder stehen
zwischen 5 und 26) statt frei auf der Fahrbahn. Unter dem Reifen hat sie ihre
Wirkung, und dass der Wagen danach nach rechts ausbricht, passt zur Seite, auf
der er den Halt verliert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:23:29 +02:00
adminandClaude Opus 5 9cfc0107df Ostergruss: eine Bananenschale auf der Fahrbahn
Ganz selten liegt waehrend der Fahrt eine Bananenschale auf der Strasse, und
der Wagen kommt kurz ins Schleudern - erst bricht er nach rechts aus, dann
faengt ihn das Gegenlenken, dann trudelt er aus.

Der Vorlauf der zweiten Bewegung ist gerechnet, nicht geschaetzt: die Schale
startet 40 Einheiten ueber dem Bild, die Vorderachse liegt bei 97, macht 137
Einheiten - bei 437 Einheiten je Sekunde sind das 0,31 s. Genau dann setzt
das Schleudern ein.

Damit das Fahrzeug sich drehen kann, ohne die Strasse mitzunehmen, liegt es
jetzt in einer eigenen Gruppe (#wagen); die Schale sitzt im Markup davor und
verschwindet unter der Karosserie, sobald der Wagen darueberrollt.

Ausgeloest wird alle 20 Sekunden mit zwei Prozent Wahrscheinlichkeit und nur
waehrend der Fahrt - im Mittel einmal in gut sechzehn Minuten. Selten genug,
dass man es nicht erwartet; haeufig genug, dass es irgendwann jemand sieht.
Wer Bewegungen abbestellt hat, bekommt den Gruss gar nicht erst: ein
schleuderndes Auto ist genau das, was damit gemeint ist. Haelt der Wagen
mitten im Ausrutscher an, wird die Schale eingesammelt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:13:45 +02:00
adminandClaude Opus 5 62c9b49073 Fahrtwind schmaler und weg von der Fahrbahnmarkierung
Die seitlichen Streifen liefen genau auf der Linie der Markierung und sahen
dort aus wie eine zweite, gestrichelte Begrenzung statt wie Luft. Sie sitzen
jetzt zwischen Rad und Markierung (x 0 und 224 statt -6 und 230), und alle
vier sind schmaler: 2,5 statt 4 neben dem Wagen, 2 statt 3 darueber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:07:29 +02:00
adminandClaude Opus 5 e525731264 Strassenmarkierung und Tempo noch einmal halbiert
Die Striche waren immer noch zu praesent. Jetzt liegt die Strasse im Viertel
des Fahrzeugmassstabs: Leitlinie 157,5 auf 315 Luecke, 3,15 breit, rechts
weiterhin durchgezogen. Das Verhaeltnis 1:2 bleibt, die Markierung ist
Beiwerk statt Hauptsache.

Das Tempo ist mit halbiert - 437 statt 875 Einheiten je Sekunde. Weil auch
die Strichlaenge halbiert ist, ziehen die Striche im selben Takt vorbei wie
zuvor; die Bewegung selbst ist ruhiger. Der Fahrtwind laeuft weiterhin genau
so schnell wie die Fahrbahn (nachgemessen: 437 gegen 436 Einheiten je
Sekunde).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:05:02 +02:00
adminandClaude Opus 5 592a134a1e Strassenmarkierung im halben Massstab
Massstaeblich waren die Striche zu gross fuers Bild: 12,6 Einheiten breit und
630 lang, das fuellte die Raender und zog den Blick vom Fahrzeug weg.

Jetzt ist die Strasse im halben Massstab des Wagens gezeichnet - Leitlinie
315 auf 630 Luecke, 6,3 breit. Das Verhaeltnis 1:2 bleibt, die Markierung
liest sich weiter als Leitlinie, und das Fahrzeug ist wieder das Groesste im
Bild. Rechts bleibt die Fahrbahnbegrenzung durchgezogen.

Die Geschwindigkeit bleibt bei 875 Einheiten je Sekunde. Im halben Massstab
sind das gut 60 km/h statt der 30 zuvor - fuer eine Landstrasse die
passendere Zahl, und der Takt der vorbeiziehenden Striche bleibt derselbe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:01:34 +02:00
adminandClaude Opus 5 b7c2bc82c7 Strassenmarkierung massstaeblich statt Baumreihe
Die Baumkronen neben der Fahrbahn sahen aus wie Erbsen und nicht wie eine
Allee - sie sind raus. Stattdessen ist die Markierung jetzt richtig.

Massstab: der Wagen ist 4,65 m lang und misst im Bild 488 Einheiten, eine
Einheit ist also knapp ein Zentimeter (105 je Meter). Danach richten sich
alle Masse:

  rechts  Fahrbahnbegrenzung, durchgezogen
  links   Leitlinie, 6 m Strich auf 12 m Luecke (630 auf 1260 Einheiten)
  beide   0,12 m breit, also 12,6 Einheiten

Der Strich ist damit laenger als das Bild hoch ist, und zwischen zwei
Strichen liegt mehr als eine Bildhoehe - genau so sieht eine Landstrasse aus
dem Auto auch aus. Sichtbar ist eine Markierung dadurch etwa zwei Drittel der
Zeit.

Die Geschwindigkeit ist ebenfalls abgeleitet und nicht geraten: 875 Einheiten
je Sekunde sind gut 30 km/h. Der Fahrtwind laeuft jetzt genauso schnell - ein
Streifen, der die Fahrbahnmarkierung ueberholt, faellt sofort auf.

Nicht massstaeblich bleibt die Breite der Fahrbahn: neben dem Wagen sind
zwanzig Zentimeter Platz statt der drei Viertel Meter einer echten Spur.
Dafuer muesste das Fahrzeug halb so gross gezeichnet werden, und darum geht
es auf dieser Kachel nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:57:55 +02:00
adminandClaude Opus 5 b0596721fa Fahrzeugbild zeigt die Fahrt: Strasse, Baeume, etwas Fahrtwind
Ob das Auto gerade rollt, stand bisher nur klein unter der Karte
("unterwegs - zuletzt: ..."). Im Bild selbst, wo man zuerst hinsieht, war es
nicht zu erkennen.

Waehrend der Fahrt liegt jetzt eine Strasse unter dem Wagen: dunkler Belag,
gestrichelte Markierungen in den Randstreifen neben den Raedern, zwei Reihen
Baumkronen, die aussen vorbeiziehen. Dazu vier Windstreifen - zwei neben dem
Wagen, zwei ueber ihn hinweg, heller und duenner, damit sie wie Luft wirken
und nicht wie ein Riss im Lack.

Beide Schleifen treffen sich selbst: die Striche wandern um eine
Strich-Lueckenlaenge (46), die Baumreihen um ihren Abstand (160) - danach
steht das Muster wieder wie zuvor, und man sieht keinen Sprung. Markierungen
und Baeume laufen mit derselben Geschwindigkeit, der Wind etwa anderthalbmal
so schnell; groessere Unterschiede lassen die Bewegungen auseinanderfallen.

Dazu ein Abzeichen "unterwegs" im Kopf der Kachel - fuer Vorleseprogramme und
fuer alle, die Bewegungen abgeschaltet haben; dann steht die Landschaft still
(prefers-reduced-motion).

Der Zustand kommt aus park_state der juengsten Zeile; der Broker kennt ihn
nicht, unter solarManager/ev* stehen nur Ladestand, Reichweite und Ladung.
Unterwegs fragt der Sammler alle vier Minuten, die Seite laedt jede Minute
nach - die Anzeige hinkt also um wenige Minuten hinterher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:50:13 +02:00
adminandClaude Opus 5 20e084bbc8 Shelly: nur Komponenten ueber MQTT, die auch senden
"status_ntf" am Geraet heisst nur, dass gesendet werden darf - nicht, dass
jede Komponente es tut. Der erste Suchlauf hat deshalb auch die
Temperaturkomponente der beiden Pro 3EM auf ein Topic gelegt
(Power_EG/status/temperature:0), und dort kam nie etwas an: unter Power_EG/#
sendet das Geraet ausschliesslich em:0, emdata:0 und online. Die beiden
Temperaturmesswerte waeren still auf ihrem letzten Wert stehengeblieben.

Jetzt entscheidet die Liste MQTT_KOMPONENTEN, welche Komponente ein Topic
bekommt - heute em und switch. Alles andere bleibt beim Abfragen: langsamer,
aber richtig. Nachgetragen wird per Hand, nachdem man mitgehoert hat; eine
automatische Erkennung waere unzuverlaessig, weil eine Komponente, die nur
bei Aenderung sendet, waehrend eines kurzen Lauschens nichts sagt.

Nach dem erneuten Suchlauf: die beiden EM-Zaehler kommen ueber den Broker,
die beiden Temperaturen und der Gen1-Handtuchtrockner werden abgefragt -
HTTP: 3 Messwerte an 3 Endpunkten statt vorher 35.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:15:16 +02:00
adminandClaude Opus 5 0fd73499d9 README: Aufbau und Datenwege des Dashboards
Das Repo hatte keine. Wer hier etwas suchte, musste sich von index.php aus
durchhangeln - und die Frage "welches Skript laedt eigentlich welche Daten"
war nur zu beantworten, indem man alle elf durchsah.

Die Datei beschreibt den Weg einer Anfrage (Router, Anmeldung, Vorlage,
Skripte), die Trennung der beiden Datenwege (laufende Werte ueber den Broker,
alles mit Verlauf ueber ajax/), die Seitenkarte mit Skripten, Endpunkten und
Topics je Seite, die JavaScript- und PHP-Schicht mit ihren Zustaendigkeiten,
die drei Datenbanken und vier Bausteine im Detail: Home-Kacheln, Raum-Modal,
Automatiken, Geraete-Erkennung. Dazu sieben Mermaid-Diagramme - Ablauf,
Datenwege, Abhaengigkeiten, Include-Graph, Tabellen, Kachelaufloesung,
Kommandoweg.

Am Ende zwei Tabellen fuer den taeglichen Gebrauch: "wo fange ich an, wenn
ich X aendern will" und die Pruefbefehle (php82 -l, node --check, readDB).

Gegenstueck ist die README im SolarManager-Repo; beide verweisen aufeinander,
damit man von jeder Seite aus zur anderen findet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:28:42 +02:00
adminandClaude Opus 5 060336c891 Kachelwerte koennen das Vorzeichen umdrehen
Ein Zaehler zaehlt in der Richtung, in der er eingebaut ist - einer der
beiden Shelly-Zaehler meldet deshalb mit umgekehrtem Vorzeichen, und auf der
Kachel stand der Verbrauch als Minusleistung.

Neu gibt es dafuer den Schalter "negativ" an jedem Kachelwert. Er sitzt in
der Anzeige und nicht am Zaehler: Automatiken und Statistik lesen dieselbe
Nachricht und rechnen mit dem Vorzeichen, das dort steht - wer es an der
Quelle drehte, verschoebe die Rechnung an einer Stelle, an der niemand
danach sucht.

Gedreht wird vor dem Formatieren, also auch vor der Skalierung zwischen W
und kW und vor den Nachkommastellen. Aus -0 wird wieder 0. Was keine Zahl
ist, bleibt unangetastet und die Kachel zeigt weiter ihren Strich.

In der eingeklappten Kurzfassung steht das Minus vor dem Namen, damit man
ohne Aufklappen sieht, welcher Wert gedreht ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:41:18 +02:00
adminandClaude Opus 5 cd398bc800 Kachelsymbole: jedes Bootstrap-Icon statt einer Handvoll
Fuer die Symbole vor den Zahlen standen drei Codepunkte von Hand in
rooms.php, und die Maske bot genau diese drei an. Jedes weitere Symbol waere
eine Codeaenderung gewesen.

Die Codepunkte stehen aber schon im Haus: css/bootstrap-icons.min.css fuehrt
sie unter .bi-<name>::before - dieselbe Quelle, aus der auch der Browser
liest. bootstrapIcons() liest sie von dort (2078 Stueck, einmal je Anfrage),
kachelIcon() schlaegt darin nach. Damit ist jedes Symbol moeglich, und ein
Versionswechsel der Icons zieht von selbst mit.

In der Maske steht deshalb kein Auswahlfeld mehr, sondern der Name mit einer
Vorschau davor - "moisture" sagt nichts, das Symbol daneben schon. Die
Vorschlagsliste beginnt mit gut dreissig ueblichen Symbolen unter deutscher
Beschriftung (Temperatur, Feuchte, Leistung, Wallbox, Regen ...), danach folgt
der ganze Rest alphabetisch. Ein Name, den es nicht gibt, zeigt sofort ein
rotes Fragezeichen und wird beim Speichern mit Begruendung abgelehnt.

In der eingeklappten Kurzfassung einer Kachel steht das Symbol jetzt mit -
es ist das schnellste Erkennungszeichen dafuer, welcher der drei Werte
gemeint ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:24:14 +02:00
adminandClaude Opus 5 eb1aa8c760 Geraetetafel ohne eigenen Rollbalken
Die Tafel stand in einem Kasten mit fester Hoehe und eigenem Balken - in einer
Karte, die selbst in einer scrollenden Seite steht. Beim Wischen wusste man
nie, welcher der beiden Balken gerade dran ist. Jetzt ist die Tafel so lang,
wie sie ist, und die Seite scrollt als Ganzes.

Der Tabellenkopf klebt weiterhin oben: gescrollt wird bei AdminLTE in
.app-main, dort sitzt oben die Navigation, und daran haelt er sich fest. Dafuer
muss die Tafel selbst frei von overflow bleiben - sonst waere sie wieder ein
eigener Scroll-Kasten, und der Kopf hinge an ihr statt an der Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:56:19 +02:00
adminandClaude Opus 5 07bca5104e Einstellungsseite: Reiter, Kachelwerte und Geraeteliste
Die Einstellungen bestanden aus einer einzigen langen Spalte mit den
Preistabellen. Sie bekommt Reiter, und darin zwei neue Flaechen: was die
Kacheln der Startseite anzeigen, und welche Geraete es ueberhaupt gibt.

Reiter gab es vorher dreimal in drei Ausfuehrungen - die Etagen ueber den
Automatismen mit eigenem switchTab(), die Geraetegruppen im Raum-Modal mit
eigener CSS-Klasse, und die Einstellungsseite noch einmal anders. Jetzt baut
restricted/reiter.php alle drei; umgeschaltet wird von Bootstrap, den offenen
Reiter merkt reiterMerken() in der Adresse.

Kachelwerte: was auf einer Kachel steht, stand fest in rooms.php. Die neue
Tabelle kachelWerte (solarLog_kacheln.sql) ueberschreibt das je Raum, die
Maske waehlt die Messwerte aus demselben Katalog wie der Automatik-Editor -
nach Etage sortiert, die Geraete des eigenen Raums oben. Fehlt die Tabelle,
zeigt jede Kachel weiter ihre Vorgabe.

Geraete: die Zuordnung Geraet -> Raum war ein Modal, erreichbar ueber ein
Zahnrad neben den Automatismen. Sie ist eine Einstellung und steht jetzt dort,
zusammen mit dem, was fehlte: seit wann ein Geraet nichts mehr meldet, in
welchen Automatiken es steckt, und ein Loeschknopf. Letzterer ist noetig, weil
der Suchlauf nichts loescht - er legt an und frischt auf, damit die Automatiken
ihre Zustands-Ids behalten; ein abgebautes Geraet bliebe sonst fuer immer
stehen. Geloescht wird nur, was in keiner Automatik vorkommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:45:24 +02:00
adminandClaude Opus 5 1b58579c22 Neue Shellys ueber MQTT statt ueber HTTP erkennen
Die Shelly-Erkennung schrieb bisher fuer jedes Geraet eine HTTP-Adresse in
actor_states.url; der Automatik-Runner musste die Werte deshalb im Takt
abfragen, obwohl die Gen2-Geraete ihren Zustand ohnehin von sich aus an den
Broker melden. Sichtbar wurde das an den beiden Stromzaehlern: ihre Messwerte
standen nirgends als Topic, weshalb sie weder in den Kachelwerten noch im
Automatik-Editor auftauchten.

Gen2-Geraete werden jetzt nach ihrer MQTT-Einrichtung gefragt
(/rpc/Mqtt.GetConfig). Ist sie eingeschaltet, meldet das Geraet Statusaenderungen
und hat ein Topic-Praefix, so steht in url das Topic
(<praefix>/status/<komponente>:<index>) und in value_path der Schluessel in der
Nachricht. Fehlt eines davon - und bei allen Gen1-Geraeten, die kein MQTT
koennen - bleibt alles beim HTTP-Weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:45:06 +02:00
adminandClaude Opus 5 cca452806f Vorschaubild-Ordner der Synology ignorieren
Sie legt @eaDir neben jedem Bild an, in assets/img also bei jedem Symbol.
Das sind ihre Miniaturen und kein Inhalt des Projekts - sie tauchten bei
jedem git status als unversionierte Ordner auf. @SynoResource kommt aus
derselben Ecke und ist gleich mit aufgenommen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:11:07 +02:00
adminandClaude Opus 5 0cb0d4c3ae Ladestand ins Fahrzeugbild, Jahresstatistik als Vergleichstabelle
Drei Teile, die zusammengehoeren: die Fahrzeugkachel zeigt den Ladestand
jetzt dort, wo er im Auto auch sitzt, die Jahresstatistik wird zur
Tabelle, und beide holen ihr Aussehen aus einer gemeinsamen Farbwelt.

Fahrzeugkachel. Die Kachel "Batterie und Ladung" ist aufgeloest; ihr
Inhalt steckt in der Draufsicht. Die Akkuplatte im Fahrzeugboden fuellt
sich von hinten nach vorn, traegt die Zielmarke und nennt Prozent und
Reichweite in ihrer Mitte - auf dem Goldenen Schnitt der Plattenhoehe,
damit sie nicht am Klimaknopf klebt. Beim Laden wandern Streifen im
gefuellten Teil nach vorn, und vom Anschluss fliesst eine Linie in die
Platte. Was sich nicht zeichnen laesst - Ladeleistung, Restzeit,
Kilometerstand, Klima, Herkunft des Ladestroms - steht als Kennzahl
daneben, dazu die gefahrenen Kilometer aus Woche, Monat und Jahr
(?was=strecke, gerechnet aus dem Zaehlerstand statt aus erkannten
Fahrten - faellt eine Zeile aus, fehlt die Fahrt in der Liste, im
Zaehlerstand aber nicht).

Am Fahrzeug selbst: kein Panoramadach, das hat unseres nicht. Reling und
Spoiler neu gezeichnet, weil sie mit der Dachplatte bisher ein
geschlossenes Rechteck bildeten und das wie ein Fensterrahmen aussah. Die
Blende am Bug ist schmaler und heller - von oben sieht man von einer
senkrechten Flaeche nur einen Streifen, und fast schwarz las sie sich wie
ein Loch. Die Ladeklappe ist gebaut wie der Klimaknopf; den Ladezustand
traegt jetzt die Farbe statt einer zweiten Form.

Jahresvergleich. getStats.php liefert kein fertiges HTML mehr, sondern
JSON, das sich selbst beschreibt: Einheit, Nachkommastellen, Richtung und
Zugehoerigkeit stehen als Metadaten je Kennzahl, nicht mehr als zwei
Arrays, die ueber den Spaltenindex mit der Abfrage synchron gehalten
werden mussten. Zwei Abfragen holen alles - Monatswerte, daraus die
ganzen Jahre und die Summe seit Aufzeichnungsbeginn, dazu der gleiche
Zeitraum jedes Jahres bis zum heutigen Kalendertag. Verhaeltniszahlen
sind auf jeder Ebene neu aus den Summen gerechnet; ein Jahresmittel ist
nicht das Mittel der Monatsmittel.

Gezeichnet wird das von einem Baustein, den sich Historien- und
Solarseite teilen: dort drei Jahresspalten mit verschiebbarem Fenster bis
zurueck zum ersten Jahr, hier eine Spalte ohne Werkzeuge. Zeilen, die
sich aufteilen, stehen als "davon" darunter; was bereits in einer anderen
Zeile steckt, als "darin" samt Namen der Zeile - der Beitrag der Batterie
und die Solarladung des Autos duerfen nicht zum Ertrag addiert werden.
Zusammengefasste Zeilen sind zunaechst zu, damit die Tabelle mit zehn
Zeilen aufmacht statt mit siebzehn.

Farbwelt. Statt Regel fuer Regel zu ueberschreiben, ist die Palette
ausgetauscht: ein paar Variablen am Wurzelelement, an die Bootstrap und
AdminLTE ohnehin gehen. Anthrazit als Grund, eine Spur hellere Flaeche
fuer Karten, Haarlinien statt Rahmen und Schatten, Bernstein als einzige
Akzentfarbe - auf dieser Seite die Farbe der eigenen Anlage. Gruen, Rot
und Gelb bleiben den Zustaenden vorbehalten.

Erklaerungen, die bisher nur im title standen, sind antippbar: am Finger
gibt es kein Ueberfahren, und ein title erscheint dort nie.

Die beiden neuen Abfragen sind gegen solarLog gelaufen, die Monatswerte
decken sich mit einer unabhaengigen Zerlegung. grundpreisImJahr() ruft
jetzt grundpreisImZeitraum() auf - dieselbe Rechnung, nur auch fuer einen
Teil des Jahres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:08:51 +02:00
adminandClaude Opus 5 22ecc1b50f Discovery-Modul fuer die Messwerte des SolarManagers
Der SolarManager legt gut 130 Topics auf den Broker, aber er meldet sich
nicht per Home-Assistant-Discovery. Das MQTT-Modul findet ihn deshalb
nicht, und eine Automatik konnte weder auf den Netzbezug noch auf den
Ladestand der Batterie oder die Temperatur im Puffer reagieren.

Vier feste Geraete mit neun Messwerten, ausgewaehlt nach dem, was das
Realtime-SVG zeigt - von der PV nur die Summe. Alles anzulegen haette die
Auswahl im Editor unbrauchbar gemacht: jeder Wechselrichter einzeln, jeder
Phasenstrom, jede Vorlauftemperatur.

  Netzanschluss              Bezug, Einspeisung
  Stromzaehler Obergeschoss  Leistung
  Solaranlage                PV-Leistung, Batterie-Ladestand
  Pufferspeicher             drei Temperaturen, Heizstab-Leistung

Die beiden Modbus-Zaehler am Gen24 (meter0 am Hausanschluss, meter1 fuer
das Obergeschoss) gibt es nur ueber diesen Weg - sie sprechen sonst mit
niemandem. Die beiden Shelly EM3 bleiben dagegen aussen vor: die stehen
ueber die Home-Assistant-Discovery laengst als Power_EG_EM_0 und
Power_UG_EM_0 in der Tabelle. solarManager/eg waere ohnehin nicht
dasselbe, dort ist die Wallbox im Carport eingerechnet.

Beim Netzanschluss stehen Bezug und Einspeisung statt P_Grid: eine Zahl
mit Vorzeichen liest sich in einer Bedingung falsch herum. Beim
Obergeschoss geht das nicht, der Zaehler meldet Verbrauch negativ - das
steht als Warnung im Modul.

Alles nur zum Lesen. Geschaltet wird am SolarManager nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 15:06:31 +02:00
adminandClaude Opus 5 fe8602a79f Ende der Fahrt abwarten, Umweg an die Hoehe binden
Zwei Sachen, beide an der Anlage aufgefallen.

Abgebrochen wurde auf "core:MovingState ist false". Das ist nicht
dasselbe wie "steht": die Box meldet das Ende der Fahrt, bevor Hoehe und
Neigung darauf nachgezogen haben, und der festgehaltene Wert war dann der
von kurz davor. Statt einer festen Wartezeit zaehlt jetzt Stabilitaet -
fertig ist es, wenn zwei Abfragen hintereinander dasselbe sagen. Damit
faellt auch die Bestaetigungsrunde weg, sie war genau dieser Gedanke in
schlechter.

Der Umweg der Kugelschreiber-Mechanik hing am Neigungswert (ueber 30 %).
Er gehoert an die Hoehe: eine Hoehenfahrt rastet die Lamellen um, danach
muss die Neigung ueber 0 % wieder angefahren werden - bei jedem Winkel,
nicht erst bei grossen. Wird dagegen nur die Neigung verstellt, steht die
Mechanik schon richtig und es geht direkt. NEIGUNG_DIREKT_MAX faellt
damit weg.

Der Hoehenregler im Raum-Modal schickt deshalb jetzt das kombinierte
Kommando mit der Stellung des Neigungsreglers daneben - ohne
Neigungsziel gaebe es nichts, was der Umweg anfahren koennte.

Geprueft an der Wozi-Schiebetuer: Hoehe von 48 auf 60 bei Neigung 76, und
die 76 standen danach wieder da. Vorher htte die Mechanik sie auf einen
beliebigen Wert umgerastet. Dazu elf Faelle im Browser und sieben gegen
transports.py, darunter "Position+Neigung 10" (Umweg) gegen "nur Neigung
80" (direkt).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 13:35:38 +02:00
adminandClaude Opus 5 fe73517c17 Raum-Modal fragt die Jalousien beim Oeffnen an der Box nach
Wer eine Jalousie faehrt, das Modal zumacht und gleich wieder aufmacht,
sah weiter den alten Stand - und zwar dauerhaft. Zwei Gruende, die sich
uebereinandergelegt haben: die Box meldet die neue Hoehe erst am Ende der
Fahrt (gemessen rund zwanzig Sekunden), und das Modal zeichnet sich nur
beim Oeffnen. Was in diesen zwanzig Sekunden aufgemacht wurde, blieb
falsch stehen, bis jemand es noch einmal aufmachte.

Beim Oeffnen wird jetzt jede Jalousie des Raums einmal an der Box
nachgefragt. Faehrt sie gerade, bleibt die Verfolgung dran, bis sie steht.
Ohne Auftrag gibt es keinen Vorlauf abzuwarten und nichts nachzureichen,
deshalb genau eine Abfrage je Jalousie - drei fuer das Wohnzimmer.

Damit stimmt die Anzeige in beiden Richtungen: der Server schreibt nach
jedem Fahrbefehl nach (67c2a77), das Modal holt sich beim Aufmachen den
Stand.

Geprueft an der Wozi-Schiebetuer, genau der gemeldete Ablauf: Regler auf
10, Modal nach 1,2 s zu, nach weiteren 0,6 s wieder auf. Es zeigte 60,
verfolgte weiter und sprang nach 28 Sekunden auf 10. Ein ruhiges Oeffnen
kostet drei Abfragen fuer drei Jalousien. Dazu dreizehn Faelle gegen die
echte Datei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 13:13:02 +02:00
adminandClaude Opus 5 67c2a774ac Nach dem Fahrbefehl liest der Server nach, nicht der Browser
Wer das Raum-Modal gleich nach einem Fahrbefehl zumachte, nahm den
einzigen Zuschauer mit: die Verfolgung im Browser endet mit dem Modal,
und niemand hielt fest, wo die Jalousie stehen blieb. In actor_states
stand bis zum naechsten Poll des Runners der alte Wert - genau die fuenf
Minuten, die eigentlich weg sein sollten.

ajax/room.php schickt die Antwort auf ein Tahoma-Kommando jetzt ab,
schliesst die Verbindung und wartet danach selbst auf das Ende der Fahrt
(fastcgi_finish_request, dieselbe Bauart wie in ajax/tahoma.php). Ob der
Browser noch da ist, spielt keine Rolle mehr; ignore_user_abort deckt
auch den Fall ab, dass er mitten im Aufruf verschwindet.

Die Verfolgung im Modal bleibt, sie ist jetzt aber nur noch fuer die
Anzeige zustaendig, solange jemand hinsieht. Das Aufschreiben haengt
nicht mehr daran.

Geprueft an der Wozi-Schiebetuer: Kommando auf Position 70, 0,7 Sekunden
spaeter die Seite neu geladen. 48 Sekunden danach zeigte das frisch
geoeffnete Modal 70 - und eine Neigung von 59 statt 62, die sich beim
Fahren von selbst verstellt hatte und die nur die Box kannte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 12:55:45 +02:00
adminandClaude Opus 5 60c4ad989b Jalousie-Verfolgung an der Anlage nachgezogen
Am echten Geraet ausprobiert - drei Sachen stimmten nicht.

Die PHP-Warnung. Nicht jeder Tahoma-Zustand ist eine Zahl oder ein Wort,
manche sind Listen. strval() darauf ist eine Warnung, und die stand mitten
in der JSON-Antwort. response.json() scheiterte, raumRequest() lieferte
{error}, die Verfolgung brach still ab - genau das Bild, das der Bericht
"kommt noch nicht" beschreibt. Nicht-Skalare werden jetzt uebergangen, und
ein Fehler wird gemeldet statt verschluckt.

Das Mitziehen waehrend der Fahrt. Gemessen: die Hoehe steht die ganzen
zwanzig Sekunden auf dem alten Wert und springt erst am Ende um. Wer das
anzeigte, holte den Regler vom eingestellten Ziel auf den alten Stand
zurueck, um ihn danach wieder vorzuschieben. Angezeigt wird deshalb erst,
was am Ende dasteht.

Das vorzeitige Aufhoeren. Zwischen den beiden Befehlen der
Kugelschreiber-Mechanik steht die Jalousie still und meldet die
Zwischenstellung. Die Verfolgung hielt das fuer das Ende und schrieb
"Neigung 0" fest, wo 62 gemeint war. Sie laeuft jetzt mindestens, bis der
Auftrag zurueck ist, und danach noch vierzehn Sekunden - so lange dauert
eine Neigungsfahrt von Anschlag zu Anschlag.

Gegengeprueft an der Wozi-Schiebetuer: Position 49 -> 20 -> 100 -> 49,
Neigung 62 -> 80 -> 62. Die Neigung wanderte beim Herunterfahren von
selbst auf 100 und stand sofort richtig im Modal. Waehrend des zehn
Sekunden langen Kugelschreiber-Auftrags liefen drei Verfolgungsanfragen
durch - die Sitzungssperre ist also wirklich weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 12:46:10 +02:00
adminandClaude Opus 5 8914133d01 Jalousien waehrend der Fahrt nachfuehren
actor_states.current_value schrieb bisher nur der AutoAction-Runner fort,
und der fragt die Tahoma-Box alle fuenf Minuten ab (poll_tahoma). Nach
einem Fahrbefehl stand im Raum-Modal deshalb bis zu fuenf Minuten lang
der Stand von vor der Fahrt.

Das Modal verfolgt die Jalousie jetzt selbst: alle zwei Sekunden fragt es
ueber ajax/room.php?action=jalousie genau dieses eine Geraet, zieht die
beiden Regler und das Lamellenbild nach und hoert auf, sobald die Box
keinen Fahrbefehl mehr meldet. Der gelesene Stand landet dabei gleich in
actor_states, damit auch das naechste Oeffnen und der Automatik-Editor
ihn haben. Wer gerade selbst am Schieber zieht, behaelt ihn.

Drei Dinge mussten dafuer aus dem Weg:

- Die Sitzung. helper.php startet sie und schliesst sie nie, PHP sperrt
  ihre Datei bis zum Ende der Anfrage. Ein "Zu" wartet ueber eine Minute
  auf das Ende der Fahrt - solange stand jede weitere Anfrage desselben
  Browsers still. ajax/room.php gibt sie jetzt frei, sobald checkLogin()
  durch ist; danach wird $_SESSION nur noch gelesen.
- Drei Abfragen je Warterunde. warteAufJalousie() holte MovingState,
  Neigung und Position einzeln, obwohl eine Abfrage die ganze Liste
  liefert. Jetzt eine.
- Kein Ort, an dem der frische Stand haengenbleibt. Dafuer
  tahomaZustaende(), tahomaZustaendeSchreiben() und tahomaAktorUrl().

Wahrheitswerte werden dabei als "True"/"False" geschrieben - so macht es
der Runner mit str(), und zwei Schreibweisen fuer denselben Zustand
waeren schlimmer als eine schraege.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 12:18:04 +02:00
adminandClaude Opus 5 958408eba9 Etagenwechsel im Automatik-Editor sah wie eine Kopie aus
Wer im Editor die Etage aenderte, fand die Automatik danach unter beiden
Reitern. In der Datenbank stand sie nur einmal - saveAutomation() macht
bei bekannter id ein UPDATE samt floor, einen INSERT gibt es auf dem Weg
gar nicht. Nachgeladen wurde nach dem Speichern aber nur die Liste der
neuen Etage; die alte behielt ihre Zeile, bis jemand die Seite neu lud.

Der Editor merkt sich jetzt die Etage, mit der er aufgemacht wurde, und
holt bei einem Wechsel beide Listen neu. Ohne Wechsel bleibt es bei der
einen Anfrage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 12:02:20 +02:00
adminandClaude Opus 5 97b5c78fda Bewaesserungsanzeige loest sich vom alten Regenskript
Die Anzeigen im Realtime-SVG und im Bewaesserungs-Modal lasen ihre
Vorschau aus Gartenwasser/AutoWatering/<zone>/RainStatus, das
auto_watering.py stuendlich veroeffentlicht hat. Solange das so blieb,
konnte das Skript nicht abgeschaltet werden, obwohl die Entscheidung
laengst in den Automatiken des Runners steht - und dessen Schwellen
haetten dann neben denen im Skript gestanden, ohne dass ein Unterschied
auffaellt.

Statt des Plans zeigen die Anzeigen jetzt, ob die Zone heute schon
gelaufen ist. Die Regenautomatik loest zum Sonnenaufgang aus; wer
spaeter auf das Dashboard schaut, will ohnehin wissen, was passiert ist,
nicht was vorgesehen war. Die Angabe kommt aus dem retained
WateringToday der Ventilsteuerung, das die Karte schon fuer "Heute"
auswertet - eine Bewaesserung von Hand zaehlt damit genauso, was hier
richtig ist.

Im SVG steht dafuer ein Haekchen statt der Stoppuhr, das Schild im
Modal sagt "heute bewaessert" statt "heute geplant", und die
Planungszeilen in der Automatik-Karte entfallen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 11:33:15 +02:00
adminandClaude Opus 5 7c646c5aee Leistungen auf den Kacheln skalieren mit: unter 1 kW in W, darueber in kW
Nicht nachgebaut, sondern powerToString() aus common.js benutzt. Sie macht
genau das schon, und die Solarseite formatiert dieselben Zahlen damit - eine
eigene Regel hier haette bedeutet, dass dieselbe Wallbox an zwei Stellen
verschieden gerundet dasteht. common.js wird im Fuss vor den seitenweisen
Skripten geladen, steht homeMQTT.js also zur Verfuegung.

Ersetzt wird nur das Trennzeichen: auf der Kachel steht ein schmales
Leerzeichen zwischen Zahl und Einheit, im Rest der Anwendung ein
gewoehnliches.

Betroffen sind Garage, Carport OG und Carport EG. Der Regen im Garten bleibt
bei Einheit und Nachkommastelle - Millimeter skalieren nicht.

Nebenbei: mein Einschub von wechselrichterWert() hatte den Kommentarblock
von kachelWert() von seiner Funktion getrennt, er stand seitdem ueber der
falschen. Zurueckgesetzt und um die beiden neuen Schluessel ergaenzt.

Geprueft mit dem echten Code im Browser: 0 -> "0 W", 999 -> "999 W",
1234 -> "1.23 kW", 12340 -> "12.3 kW"; die Summe der vier Carport-Hoymiles
ergibt 1.23 kW und laesst einen fuenften Wechselrichter richtigerweise
draussen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:04:27 +02:00
adminandClaude Opus 5 3073c5afdb Etagenschalter neu gestalten, Aussengelaende ruecken lassen
Das Rendering des Grundstuecks fuellt seinen Rahmen aus, die drei
Geschossbilder haben dagegen breiten schwarzen Rand - deshalb lagen die
Etagenschalter nur beim Aussengelaende auf dem Bild. Der Rand gehoert ins
Bild und nicht in home.php: dort einzurechnen haette auch OG, EG und UG
verschoben, und deren Kacheln sind auf die heutige Lage eingemessen. AG.png
bekommt links 114 durchsichtige Punkte, die sieben Kacheln sind mit
derselben Abbildung umgerechnet.

Die Schalter selbst waren ein duenner Ring in einem Amber (#aa7713), das in
css/solar.css nirgends vorkommt - und alle vier sahen immer gleich aus, die
aktive Etage war an ihnen nicht zu erkennen. Jetzt tragen sie dieselbe
Scheibe wie die Kacheln im Grundriss, die aktive bekommt die Akzentfarbe der
Schieberegler (#00788F), und switchFloor() zieht den Zustand mit. Dort
classList und nicht die Helfer addClass/removeClass weiter oben: deren
"el.className +=" wirkt auf SVG-Elementen nicht, className ist dort
schreibgeschuetzt.

Ausserdem sind sie kleiner. Mit halber Groesse beherrschten sie die linke
Seite, und das Bild musste umso weiter einruecken, um sie freizuhalten - bei
0,38 statt 0,50 bleibt mehr Plan uebrig. Die Groesse steht als eine Zahl in
home.php, Abstand und Bildrand folgen daraus.

Die Beschriftung sitzt jetzt ueber text-anchor und dominant-baseline im
Kreismittelpunkt. Vorher stand je Etage ein von Hand gesuchter Versatz im
Markup (62, 63, 64), und jede neue Etage haette einen neuen gebraucht.

Weggefallen: die Definition #floorBtn, die nur einmal benutzt wurde, und die
Animationen showOG/showEG/showUG, die ein bereits sichtbares Element von 0
auf 1 blendeten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:54:29 +02:00
adminandClaude Opus 5 0196a52454 Veranda-Kacheln ausblenden
Ihre beiden Kacheln lagen auf der Westfassade des Hauses so dicht
beieinander, dass sie mehr gestiftet als erklaert haben. Ohne x/y bleiben
Veranda EG und OG Raeume wie zuvor - Geraete sind ihnen zugeordnet und
Automatiken laufen darauf, das haengt allein daran, dass sie in dieser
Tabelle stehen.

Die Wechselrichter der Veranda OG stehen als Kommentar daneben. Sie waren
muehsam genug zu finden, und ohne Kachel gaebe es keinen Ort mehr, an dem
die Namen ueberlebt haetten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:48:07 +02:00
adminandClaude Opus 5 fad6d5ab34 Wechselrichter ueber ihren Namen, Symbole auf den Kacheln
Carport EG und Veranda OG zeigen jetzt ihre PV-Leistung. Der SolarManager
legt die Hoymiles unter solarManager/inverters0, /inverters1 und so fort ab
- die Nummer ist eine Position in einer Liste und sagt nichts darueber,
welches Dach gemeint ist. Ein fest eingetragenes "inverters3" waere
stillschweigend falsch, sobald sich die Reihenfolge aendert.

Deshalb sucht wechselrichterWert() ueber invertersN/name und summiert die
genannten auf. Das ist eine dritte Art, an einen Wert zu kommen - neben
Topic und JSON-Pfad -, aber sie bleibt auf einen Schluessel und eine
Funktion beschraenkt, und der gewoehnliche Weg ist unberuehrt. Findet sie
keinen der Namen, schreibt sie die vorhandenen in die Konsole; sonst stuende
nur ein Strich auf der Kachel und niemand wuesste, woran es liegt.

tileTopics() unterschied bisher nicht und machte aus dem fehlenden Topic ein
"/#" - eine Anmeldung auf einen leeren Zweig.

Dazu Symbole vor den Zahlen. "0 W" allein sagt nicht, ob geladen, erzeugt
oder verbraucht wird, und die Einheit beantwortet das nicht. Die Icons
kommen als Schrift und nicht als Pfad: css/bootstrap-icons.min.css ist
ohnehin eingebunden, und SVG-Text darf dieselbe Schriftfamilie benutzen wie
HTML. Die Codepunkte stehen in kachelIcon() mit dem Namen daneben,
abgeschrieben aus genau dieser CSS.

Wegen font-display:block bleiben die Symbole bis zum Laden der Schriftdatei
unsichtbar - die Werte stehen so lange ohnehin auf einem Strich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:41:21 +02:00
adminandClaude Opus 5 a5920ac7cc Raum-Modal fuer Raeume mit Leerzeichen, Messwerte im Aussengelaende
Das Raum-Modal fand die Geraete von "Carport EG" und den fuenf anderen
AG-Raeumen mit Leerzeichen nicht - es meldete stattdessen, dem Raum sei
nichts zugeordnet. ajax/room.php bereinigte den Schluessel mit
[^A-Za-z0-9_] und warf dabei das Leerzeichen weg: aus "AG_Carport EG" wurde
"AG_CarportEG", der Vergleich gegen die gespeicherte Zuordnung
"AG/Carport EG" ging ins Leere. Aufgefallen ist es erst jetzt, weil sechs
von neun AG-Raeumen ein Leerzeichen im Namen haben und keiner der achtzehn
auf den anderen Etagen. Durchgespielt: vorher 6 von 27 Raeumen kaputt,
danach 0.

Messwerte auf den AG-Kacheln, wo es etwas zu messen gibt:

  Garage      solarManager/evPower    - die Wattpilot der EG-Partei
  Carport OG  solarManager/evPowerOG  - der go-e Charger
  Garten      Wetter/Regen [tage1]    - Regen des laufenden Tages

Veranda, Terrasse und Pergola behalten ihren Namen: dort haengen Licht und
Rollladen, und die zeigt das Raum-Modal. Carport EG bleibt vorerst ohne -
die Hoymiles liegen auf solarManager/invertersN, positionsnummeriert, und
welcher Strang der Carport ist, steht nirgends im Code. Geraten wird das
nicht.

tileTopics() zieht Wetter/# von selbst nach, das Abonnement folgt aus dem
Topic.

Ausserdem der weisse Saum am freigestellten Rendering. Die feste Schwelle
liess den ein bis zwei Punkte breiten Uebergang stehen - gemessen 247, 250,
241, 204, 145, und die 241 blieb knapp haengen und leuchtete vor dem
schwarzen Grund. Die Flutfuellung frisst jetzt entlang der Kante in immer
dunklere Punkte nach und legt zuletzt einen Punkt mit halber Deckung an,
damit die Kante nicht treppig wird.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:22:02 +02:00
adminandClaude Opus 5 93fb054b66 Zeilenenden vereinheitlichen: LF ueberall ausser im Fremdcode
Bisher entschied jede Arbeitsstation fuer sich, welche Zeilenenden ein
Commit bekommt: Git fuer Windows setzt core.autocrlf=input in seiner
System-Konfiguration, die NAS setzt nichts. Weil der Bestand ausserdem in
sich gemischt war - 34 PHP-Dateien mit LF, 30 mit CRLF -, gab es gar kein
richtiges Zeilenende, an das ein Werkzeug sich haette halten koennen. Jede
Ergaenzung mit LF in einer CRLF-Datei ergab eine gemischte Datei; helper.php
und js/solar/homeMQTT.js waren bereits so entstanden.

.gitattributes macht die Zeilenenden zur Eigenschaft des Repositorys. LF,
weil dieses Verzeichnis der Web-Ordner der NAS ist und von Linux
ausgeliefert wird - und weil Git fuer Windows mit "input" ohnehin schon
dorthin zeigt.

restricted/WebAuthn bleibt ausgenommen und damit byteweise so, wie die
Bibliothek geliefert wurde; darunter liegen 292 Zertifikate.

Von den 47 geaenderten Dateien wurde nachgerechnet keine einzige im Inhalt
angefasst - der Vergleich HEAD-ohne-CR gegen Index ist ueberall gleich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:04:00 +02:00
adminandClaude Opus 5 abdf6880eb Aussengelaende als vierte Etage einhaengen
Das Rendering des Grundstuecks tritt an die Stelle des Vektorplans. Es kommt
aus derselben Art Quelle wie die drei Geschossbilder und passt damit besser
zu ihnen, als der Plan es je koennte; der hat seinen Zweck erfuellt, indem
er die Geometrie sortiert hat.

Freigestellt wird mit einer Flutfuellung vom Rand her, nicht mit einem
Farbtausch: Weiss kommt auch im Bild selbst vor - Hauswand,
Grundstuecksmauer -, und nur was vom Rand aus zusammenhaengend erreichbar
ist, ist wirklich Hintergrund. Aufgefuellt statt beschnitten auf 4:3, weil
1024x792 etwas hoeher ist und Wegschneiden das Bild selbst treffen wuerde.

Etagenbilder und Schalter in home.php kommen jetzt aus einer Schleife.
Ausgeschrieben waren die Blenden bei drei Etagen neun Zeilen und bei vier
schon sechzehn, und jede neue Etage haette alle anderen mit angefasst. Die
Schalter ruecken ab der vierten Etage von 100 auf 78 Einheiten zusammen,
sonst faellt der unterste aus der 300 hohen viewBox.

Die Kachelpositionen sind ein Startwert, kein Endstand: Richtung aus dem
Modell gerechnet - Westen liegt links unten, Sueden rechts unten -, Lage
per Auge im Raster. Der erste Wurf hatte die Terrasse an der falschen
Fassade; im Modell liegt sie an der Suedwestecke.

Lange Raumnamen brechen jetzt um. "Terrasse OG" stand einzeilig ueber die
24 Einheiten breite Kachel hinaus - sichtbar wurde das erst hier, weil im
Aussengelaende jeder Raumname aus zwei Woertern besteht.

Nebenbei: xcursor="pointer" am OG-Schalter war ein Tippfehler, der Kreis
hatte als einziger keinen Zeiger. Das tote border-Attribut am OG-Bild ist
mit weggefallen, SVG kennt es nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:53:46 +02:00
adminandClaude Opus 5 182b5b66b4 Aussenplan an die Farbgebung der Geschoss-Renderings angleichen
Aus OG.png gemessen, nicht geschaetzt: die hellste Stufe ist genau neutral
und liegt bei 200 - heller wird dort nichts -, und je dunkler eine Flaeche
wird, desto blauer wird sie. Beides steckt jetzt in aussenSchatten(), das
bisher farbtreu gegen Schwarz gerechnet hat.

Damit allein fehlte dem Plan aber immer noch das obere Drittel der
Helligkeitsskala, das die Renderings haben. Grund war, dass jede Aussenwand
denselben Faktor bekam, egal wohin sie zeigte - der Plan hatte gar kein
gerichtetes Licht. aussenBeleuchtung() liefert es jetzt aus einer Sonne im
Westsuedwesten und ersetzt nebenbei die grobe Zweistufung am Dach.

Waagerechte Flaechen bleiben knapp unter der besonnten Wand. Mit vollem
Licht waren Boden und Terrassen das Hellste im Bild; im Vorbild sind es die
Waende.

Die Deckflaeche lief bisher an aussenSchatten() vorbei, sonst haette der
Deckel die flachen Flaechen nie erreicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:33:20 +02:00
adminandClaude Opus 5 f29ed5abfa Wandflaechen von hinten nach vorn malen, nicht in Kantenreihenfolge
Der OG-Carport sah aus wie ein Tisch auf vier Beinen: seine Westwand fehlte
scheinbar, man schaute an ihrer Stelle in den Innenraum. Gezeichnet wurde sie
die ganze Zeit - die Kennungen der Flaechen zeigen sie als "Wand W aussen".

Der Fehler lag in der Reihenfolge. Die Flaechen einer Wand entstehen in
Kantenreihenfolge, und die Nordwand ist Kante 3, die Westwand Kante 2. Die
Innenseite der hinteren Nordwand wurde damit nach der Aussenseite der
vorderen Westwand gemalt und uebermalte sie. Bei einem geschlossenen Koerper
faellt das nie auf, weil dort keine Innenseiten sichtbar sind; erst mit
Oeffnungen und Wandstaerke kam es heraus.

Jetzt werden die Wandflaechen eines Koerpers gesammelt, nach Tiefe sortiert
und dann gemalt. Das Tiefenmass ist die Projektion des Flaechenmittelpunkts
auf die Blickrichtung - bei dieser Projektion faellt genau eine Raumrichtung
auf einen Bildpunkt zusammen, ihre Komponenten sind sin(Winkel), cos(Winkel)
und Stauchung/Ueberhoehung. Die projizierte Y-Koordinate taugt dafuer nicht:
in ihr steckt auch die Hoehe, ein hoher Punkt saehe damit weiter hinten aus.

Boden und Dach bleiben ausserhalb der Sortierung - der eine liegt immer
unten, das andere immer oben.

Dazu tragen die Flaechen jetzt eine Kennung ("Carport OG: Wand W aussen").
aussenSvg() liest sie nicht, sie kostet also nichts und macht die Fehlersuche
moeglich, ohne den Zeichner umzubauen - ohne sie waere dieser Fehler
Farbraten geblieben.

Die Schnittkanten sind nebenbei von 0,84 auf 0,66 abgedunkelt. Heller als die
Wand liessen sie die Ecken wie Pfosten aussehen, was den Tischeindruck noch
verstaerkt hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:09:48 +02:00
adminandClaude Opus 5 bcc685dcfa Der Keil zwischen Garage und Haus ist Garten
Der Gartenumriss laeuft als Schluesselloch um Haus und Veranda herum. In
seiner westlichen Ausbuchtung sprang er von [22.7, 24.2] gleich weiter nach
[19.9, 25.6] und liess damit den Keil zwischen der Ostkante der Garage, der
Westseite der Veranda und der Nordkante des Hofs aus. Dort lag ueberhaupt
keine Flaeche, und der schwarze Hintergrund schien durch.

Vier Punkte fuehren den Rand jetzt um diesen Keil herum: an der Veranda
hinauf, ueber die Nordkante der Garage und an deren Ostkante wieder herunter
zum Hof. Der Umriss bleibt dabei ueberschneidungsfrei - nachgerechnet ueber
alle 17 Kantenpaare, denn ein Schluesselloch, das sich selbst kreuzt, wuerde
die Fuellregel an unerwarteter Stelle aussparen und den Fehler nur
verschieben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:01:28 +02:00
adminandClaude Opus 5 1c80ec588e PV aufs Haus, Dachdicke fuer die Carports, Sonnensegel auf zwei Meter
Auf dem OG-Carport liegt keine Anlage - die Module gehoeren aufs Hausdach.
Dafuer kann der Zeichner jetzt auch Module auf einer Dachschraege und nicht
nur auf einer waagrechten Deckflaeche: "pv" nimmt eine Himmelsrichtung. Auf
dem Haus ist es die Suedschraege, und weil die Wiederkehr danach gezeichnet
wird, deckt sie die Mitte ab - genau wie auf dem Luftbild, wo die Module
links und rechts von ihr liegen. Eingezogen wird dort im Bild und nicht in
Metern: die Schraege ist schon projiziert, und auf ihrer Ebene bleibt ein
Einzug auch nach der Projektion ein Einzug.

Die Carportdaecher haben eine Dicke, mit 30 cm etwas mehr als die 20 cm der
Waende. Das brauchte zwingend einen Ueberstand: buendig aufliegend sind
Stirnflaeche und Wandflaeche deckungsgleich, die Dicke waere gerechnet und
trotzdem unsichtbar. Genau daher kam auch der Eindruck, dem OG-Carport fehle
die Westwand - sie war die ganze Zeit da und wurde auch gezeichnet, sie hatte
nur keine Kante, an der sie aufgehoert haette. Mit 25 cm Ueberstand steht sie
jetzt sichtbar unter dem Dach.

Die Pergola ist ein Sonnensegel: eine Bahn Tuch auf zwei Metern, heller und
ohne Bauwerk darunter. Dass sie zu schweben scheint, ist richtig so - die
Masten waeren bei diesem Massstab duenner als ein Strich.

Der Hof laeuft vor dem EG-Carport gerade bis an den Garten weiter; der
Carport steht damit auf ihm. Seine suedliche Kante sind dieselben beiden
Punkte, die auch im Gartenumriss stehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 20:57:55 +02:00
adminandClaude Opus 5 8119645123 Waende bekommen Dicke, Carportdaecher werden flacher
Vier Korrekturen am Aussenplan:

Die Waende sind Kaesten und keine Flaechen mehr. An einer Oeffnung stand
sonst ein Blatt Papier im Raum - Innenseite, Oberkante und die beiden
Schnittkanten fehlten, und genau das sah seltsam aus. Die Dicke wird nur
gerechnet, wo ein Koerper ueberhaupt offen ist; bei einer geschlossenen Wand
sieht man sie nie.

Der First der Garage lief vorher schon richtig, Nord-Sued. Die Drehung um 90
Grad war ein Missverstaendnis und ist zurueckgenommen - jetzt steht die Achse
ausgeschrieben da, damit bei diesem fast quadratischen Grundriss nicht wieder
die zweite Nachkommastelle entscheidet.

Die halb offenen Nord- und Suedwaende des EG-Carports sind wieder zu. Die
Faehigkeit bleibt im Zeichner, sie wird hier nur nicht mehr gebraucht.

Beide Pultdaecher sind auf ein Drittel abgeflacht: 0,27 m statt 0,8 beim
OG-Carport, 0,23 statt 0,7 beim EG-Carport - rund vier Prozent Neigung. Beim
OG-Carport ist die Wandhoehe dafuer auf 2,23 m gesetzt, damit die hohe Seite
bei 2,50 m herauskommt und dort an die Traufe des Garagendachs anschliesst.
Nachgerechnet: beide bei 2,50 m.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 20:49:56 +02:00
adminandClaude Opus 5 d30f4ac0ab Aussenplan bekommt Daecher, Oeffnungen und die Farbgebung der Renderings
Der Zeichner konnte bisher nur Klotz mit Satteldach. Jetzt:

  * Pultdaecher mit Fallrichtung. Die Richtung kommt aus der Aussennormalen
    der genannten Kante und nicht aus einem festen Nord-Sued-Vektor - das
    Grundstueck steht zehn Grad schief, und eine Traufe soll parallel zum
    Gebaeude laufen und nicht fast parallel.
  * Offene und halb offene Seiten. Genannt werden Himmelsrichtungen, die
    Kanten sagen selbst, welche gemeint ist. Bei einer halb offenen Seite
    bleibt die Haelfte stehen, die von der Oeffnung weiter weg liegt.
  * Ein Tor, mittig in seiner Wand.
  * Module, die plan auf der Dachflaeche liegen.
  * Rueckflaechen fallen weg. Bei positivem Umlauf der Grundflaeche haben die
    zugewandten Seiten eine positive projizierte Flaeche - damit verschwinden
    die Kanten, die man sonst durch einen Koerper hindurch gesehen haette,
    ohne sie einzeln benennen zu muessen.
  * Der First laeuft nicht mehr an festen Eckennummern entlang, sondern
    zwischen den Mitten zweier benannter Kanten. "achse" ueberschreibt das -
    bei der fast quadratischen Garage entschiede sonst die zweite
    Nachkommastelle, welche Seite die laengere ist.

Damit steht am Grundstueck: das Haus bekommt seine Wiederkehr auf der
Suedseite (als eigener Koerper auf derselben Grundflaeche - sie liegt
suedlich und damit weiter vorn, deckt das Hauptdach also von selbst richtig
ab; Breite und Tiefe sind vom Luftbild geschaetzt). Die Garage hat ein
begruentes Dach, ihr First liegt quer und ihr Tor nach Sueden. Der OG-Carport
ist nach Sueden offen und faellt zur Strasse hin ab, der EG-Carport ist nach
Westen offen, seine Nord- und Suedwand stehen nur hinten, und er faellt nach
Sueden ab. Auf beiden liegen die Module.

Die Farben folgen den Innenansichten: Weiss und Grau, dazu ein blaeulicher
Grauton fuer die Boeden von Veranda und Terrasse, OG etwas heller als EG.
Rot war ohnehin falsch, seit das Garagendach begruent ist.

Drei Fehler kamen beim Zeichnen heraus:

  * Die Dachflaechen nahmen die Firstenden in fester Reihenfolge. Eine der
    beiden wurde damit zur Schleife und zeichnete einen Zacken quer ueber das
    Haus. Jetzt bekommt jedes Traufende den Firstpunkt, der bei ihm liegt.
  * Die Wiederkehr zog ihre Ost- und Westwand mitten durch das Haus. Sie hat
    jetzt gar keine Waende: sie sitzt als reines Dach auf sechs Metern.
  * Wo eine Seite offen ist, schaute man durch den Koerper auf den schwarzen
    Hintergrund - ein Loch, kein Unterstand. Solche Koerper bekommen jetzt
    einen Boden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 20:40:05 +02:00
adminandClaude Opus 5 ff74ec3c68 Der Lageplan des Aussengelaendes ist gezeichnet
Die elf Flaechen sind am Luftbild abgeklickt und danach gerichtet worden:
von Hand trifft man keine rechten Winkel, und Ecken, die derselbe Punkt sein
sollten, lagen ein paar Dezimeter auseinander.

Gerichtet wurde im Bezugssystem des Grundstuecks. Dessen Ausrichtung steckt
in den Hauskanten - laengengewichtet gemittelt ueber den vierfachen Winkel,
weil vier Kanten in vier Richtungen desselben Rasters zeigen und erst der
vierfache Winkel sie zusammenfallen laesst. Ergebnis: -9,77 Grad.

In diesem System sind die Gebaeude achsparallel. Sie durch ihr umschliessendes
Rechteck zu ersetzen macht sie damit genau rechtwinklig, und das anschliessende
Zusammenschnappen der Koordinaten - u und v getrennt - laesst sie Rechtecke
bleiben. Andersherum waere das nicht gegangen: schnappt man vorher, stehen die
Winkel wieder schief.

Hof, Garten und Pergola bleiben, wie sie abgeklickt wurden. Die ersten beiden
sind gewachsene Formen, und am dritten haengt ein Sonnensegel - die sind
selten rechteckig. Ihre Ecken schnappen trotzdem mit, sonst klafften Luecken
zu den Gebaeuden.

Ergebnis, alle acht Gebaeude auf 90,0 Grad:

  Haus         15,3 x 14,2 m      Veranda EG/OG   2,4 x 11,5 m
  Garage        6,5 x  6,4 m      Terrasse EG/OG  2,6 x  5,0 m
  Carport OG    4,0 x  6,4 m      Carport EG      9,0 x  4,3 m

Die groesste Verschiebung einer Ecke betrug 0,92 m, die meisten lagen unter
einem halben Meter.

Dazu zwei Fehler am Dach, die erst die Zeichnung zeigte: die Giebel standen
offen, sodass an den Schmalseiten die Wand durchschien, und der First lag
fest an den Ecken 0/3 und 1/2 - bei einem quer stehenden Gebaeude waere er
damit ueber die kurze Achse gelaufen. Jetzt schliessen zwei Giebeldreiecke
das Dach, und der First richtet sich nach der laengeren Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 20:24:07 +02:00
adminandClaude Opus 5 e2b97c049e Aussengelaende in Schraegsicht von Suedwesten
Veranda und Terrasse gibt es zweimal, je Wohnpartei eine. In der Draufsicht
laege das obere Paar genau auf dem unteren: nicht auseinanderzuhalten und
schon gar nicht getrennt anzuklicken. Deshalb wird AG nicht flach von oben
gezeichnet, sondern schraeg von Suedwesten - von dort sieht man den Hof, auf
den die Veranden zeigen, und die Ostseite des Hauses traegt nichts, was
vorkommen muesste.

restricted/aussenplan.php haelt die Projektion und die Geometrie. Gezeichnet
wird in Militaerprojektion, nicht in echter Perspektive:

    X = x·cos φ − y·sin φ
    Y = (x·sin φ + y·cos φ)·STAUCHUNG − z·UEBERHOEHUNG

Der Grundriss bleibt dabei masshaltig - Geraden bleiben gerade, Rechtecke
Rechtecke. Genau deshalb diese und keine perspektivische: die Flaechen lassen
sich am Luftbild von oben abklicken, und dieselben Zahlen ergeben ohne Umweg
die Schraegsicht. Eine echte Perspektive muesste man neu vermessen.

Das Vorzeichen des Winkels bestimmt die Himmelsrichtung. Bei +35° zeigt der
Y-Gradient nach Osten und Sueden, die naechste Ecke waere die suedoestliche;
fuer Suedwesten muss er negativ sein. Was die Paare trennt, ist ohnehin nicht
die Drehung, sondern Stauchung und Ueberhoehung - bei 1,4 stehen Terrasse EG
und Terrasse OG rund 34 Einheiten auseinander, eine Kachel ist 19 hoch.

Hoehen: Traufe 6 m, First 9 m, Carport 2,5 m. Die OG-Ebene ist mit 3 m
angenommen - die halbe Traufhoehe bei zwei Geschossen.

tools/lageplan.php nimmt dafuer jetzt je Flaeche eine Hoehe entgegen und
zeigt die Schraegsicht live neben dem Luftbild: nur dort sieht man beim
Abklicken, ob die Paare wirklich auseinandergehen. Die Vorschau rechnet
dieselben Formeln im Browser nach; massgeblich bleibt der PHP-Code, von dem
spaeter die gezeichnete Etage und die Kachelpositionen kommen.

Dazu die Raeume: aus "Veranda" und "Terrasse" werden je zwei, eine pro
Partei. Welche der vier Geraete zu welcher gehoeren, sagt kein Name - die
beiden Wechselrichter tragen immerhin "OG" ("Veranda OG2-4", "Veranda
UG/OG1"), Licht und Rollladen der Terrasse gar nichts. Sie stehen deshalb
vorlaeufig auf Veranda OG und Terrasse EG.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 20:04:46 +02:00
adminandClaude Opus 5 3c67303acf Zwei Carports, einer je Wohnpartei
Es gibt nicht einen Carport, sondern zwei - einen fuer die EG-Partei und
einen fuer die OG-Partei. Damit heissen sie "Carport EG" und "Carport OG"
und benutzen dieselben Kuerzel wie die Etagen; das Haus hat dieses Vokabular
ohnehin ueberall.

Der go-e Charger steht am OG-Carport: restricted/config.php haelt fest, dass
"og" der go-e ist und "eg" die Wattpilot - und die steht in der Garage, nicht
am Carport.

Welcher der fuenf Wechselrichter auf welchem Carport sitzt, sagt weder sein
Name ("Carport 1-6", "Carport 7-12" ...) noch die OpenDTU, aus der die Namen
stammen. Sie stehen deshalb vorlaeufig zusammen auf dem EG-Carport - ein
benannter Raum ist besser als ein Schluessel, den es nicht mehr gibt. Die
Aufteilung ist ein Klick im Zuordnungsdialog, sobald sie feststeht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:44:20 +02:00
adminandClaude Opus 5 762af074e8 Messblatt fuer den Lageplan des Aussengelaendes
OG, EG und UG haben je ein Rendering in assets/img, Puppenhausschnitt von
schraeg oben. Fuer AG gibt es so ein Bild nicht: die Planungssoftware kennt
nur das einzelne Gebaeude, nicht das Grundstueck. Der Plan fuer das
Aussengelaende entsteht deshalb als Vektor direkt im SVG von home.php.

Diese Seite liefert die Koordinaten dafuer. Als Hintergrund liegt das
amtliche Luftbild - DOP20 mit 20 cm Bodenaufloesung, live vom WMS der
Bayerischen Vermessungsverwaltung, CC BY 4.0. Es liegt also keine Kopie im
Repository, und weil es ein True Orthophoto ist, sitzen die Dachkanten ueber
dem Grundriss statt perspektivisch daneben - bei Google zeichnet man sonst
den Dachueberstand ab.

Abgeklickt wird Flaeche fuer Flaeche; unten fallen die fertigen
<polygon>-Zeilen fuer home.php heraus und dazu die x/y-Zeilen fuer
rooms.php, deren Kachelmitte aus den Ecken gerechnet wird. Welche Flaechen
es gibt, sagt die Raumtabelle - ein neuer AG-Raum taucht hier von selbst
auf. Das Haus steht davor, obwohl es kein Raum ist: es ist der Umriss, an
dem sich beim Zeichnen alles andere ausrichtet, und bekommt folgerichtig
keine Kachelzeile.

Der Ausschnitt laesst sich ueber ?lat=, ?lon= und ?breite= verschieben, ohne
die Datei anzufassen. Die Umrechnung nach UTM32 steckt in der Seite, weil
ein quadratischer Ausschnitt in Laengen- und Breitengraden keiner waere: ein
Grad Laenge ist auf dieser Breite nur zwei Drittel so lang wie ein Grad
Breite.

Bauart und Aussehen wie tools/kachelpositionen.php, und aus demselben Grund
kein Eintrag im Menue: die Seite wird einmal gebraucht und danach nie wieder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:15:55 +02:00
adminandClaude Opus 5 d3c1cb4682 Aussengelaende wird die vierte Etage AG
Was draussen haengt, stand als Pseudo-Raum "aussen" in automations.php:
"Geraete, die zu keinem Raum der Wohnung gehoeren - Carport, Garage,
Regensensor". Das waren inzwischen sechzehn Geraete in einem einzigen Topf,
der groesste ueberhaupt - in der Geraeteauswahl des Editors standen die fuenf
Carport-Wechselrichter, das Sonnensegel und das Garagentor unsortiert
nebeneinander.

Jetzt ist das Aussengelaende eine Etage wie OG, EG und UG, mit eigenen
Raeumen: Carport, Veranda, Pergola, Terrasse, Garage und Garten. Die Namen
stammen von den Geraeten selbst - die Hoymiles heissen seit jeher "Carport
1-6" und "Veranda OG2-4". Die achtzehn Geraete sind verteilt (die beiden
Ventilsteuerungen hatten noch gar keinen Raum und stehen jetzt im Garten,
zusammen mit dem Regensensor, der ihre Automatiken ausloest); "Ohne Raum"
faellt damit von acht auf sechs, und die sind es zu Recht.

AG ist eine Etage ohne Grundriss. Ihre Raeume haben kein x/y, es gibt ja
kein Bild, auf das eine Kachel zeigen koennte - ein Raum ohne Position war
schon immer vorgesehen. Das Menue und das SVG richten sich deshalb nach der
neuen floorsWithPlan(), die Zuordnung und die Automatiken nach $floors.
Bekommt der erste AG-Raum eine Kachel, erscheint die Etage von selbst auch
im Menue; es gibt keine zweite Liste, die man nachziehen muesste.

Die Etagen standen bisher an sechs Stellen noch einmal im Code. Jetzt gibt
es genau eine Liste, $floors in rooms.php, und alles andere leitet sich ab:

  * automations.php prueft gegen $floors statt gegen eine eigene Whitelist
  * ajax/AutoAction.php baut seine Etagen-Auswahl daraus
  * home.php baut Reiter und Inhaltsbereiche in einer Schleife statt
    dreimal fast dasselbe HTML
  * header.php baut die Menuepunkte daraus
  * switchTab() in homeMQTT.js liest die Reiter aus der Seite, statt OG, EG
    und UG sechsmal aufzuzaehlen
  * refreshAutomations() ebenso - eine feste Liste haette die vierte Etage
    still ausgelassen, die Tabelle waere ohne Fehlermeldung leer geblieben

Dazu die ausgeschriebenen Namen in $floorLabels: das Kuerzel steht in URLs,
SVG-Ids und im MQTT-Baum, "Aussengelaende" nur als Titel am Menuepunkt und
am Reiter.

In der Datenbank ist das Enum um 'AG' erweitert; header.php laedt rooms.php
jetzt selbst, weil es vor home.php eingebunden wird.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:55:11 +02:00
adminandClaude Opus 5 71f2f844c8 gartenwasser_module.py mit CRLF wie seine Nachbarn
Git fuer Windows setzt core.autocrlf=input in seiner System-Konfiguration,
die Synology-Seite hat das nicht. Dieselbe Arbeitskopie liegt auf beiden
Systemen - unter /volume1/web/smart und als Z:\ -, und die Datei war deshalb
im Arbeitsverzeichnis CRLF, im Repository aber LF: Windows sah sie sauber,
die NAS dauerhaft geaendert. Ein "git pull" dort waere daran haengengeblieben.

Jetzt liegt sie so im Repository, wie sie im Arbeitsverzeichnis steht - und
wie die uebrigen Discovery-Module, die ebenfalls mit CRLF eingecheckt sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:30:22 +02:00
adminandClaude Opus 5 7b9c9362a6 Discovery loescht keine laufenden Messwerte mehr
Die Upserts schrieben current_value = VALUES(current_value). Ein Modul, das
keinen Startwert kennt - das Gartenwasser-Modul zum Beispiel, dessen Werte
ausschliesslich per MQTT hereinkommen -, liefert dort NULL, und damit
ueberschrieb jeder Discovery-Lauf den laufenden Wert mit NULL.

Der Runner holte das nicht zurueck. Er merkt sich in `geschrieben`, was er
zuletzt in die Datenbank geschrieben hat, und `regelwerk_laden` uebernimmt
diesen Merker beim Neuladen per setdefault - der naechste hereinkommende
Wert war damit "unveraendert" und wurde nicht erneut geschrieben. Im Editor
stand danach "Regen heute (= )" statt "(= 0,0 mm)", und zwar bis sich der
Wert zufaellig einmal aenderte. Bei "Bewaessert heute" koennen das Tage
sein.

Jetzt COALESCE: ein NULL aus dem Modul heisst "kein Startwert" und laesst
stehen, was da ist. Module, die beim Suchen wirklich einen Wert ablesen -
Tahoma, Shelly, WLED -, schreiben ihn weiterhin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:01:54 +02:00
adminandClaude Opus 5 03f823ae39 Bewaesserung meldet retained, ob sie gerade laeuft
Die Firmware der Ventilsteuerungen liefert seit heute ein retained
Gartenwasser/<x>/Running mit "running", "running<Ventil>" und "mode", dazu
das schon vorbereitete "daysSinceWatering<Ventil>" in LastWatering.

Der Messwert "Laufende Automatik" las diese Angabe bisher aus Timers und
weicht deshalb. Timers ist nicht retained und wird nur im Sekundentakt
gesendet, solange etwas laeuft: der Runner sah im Ruhezustand gar nichts
und behielt nach einem Lauf den zuletzt gesehenen Wert - also genau das
Gegenteil von verlaesslich. Running ist retained und faellt ueber eine
Last-Will-Nachricht auch dann auf false, wenn die Steuerung mitten im
Giessen wegbricht.

Neu sind damit je Steuerung "Bewaesserung laeuft" und "Laufender Modus",
dazu je Zone ein eigener Laufmerker - aber nur, wo eine Steuerung mehr als
eine Zone bedient. Bei "hinten" waere er derselbe Wert wie der Merker der
Steuerung, und ein Messwert, der nie etwas anderes sagt, macht die Auswahl
nur laenger.

"Bewaesserung laeuft = NEIN" ist der Waechter fuer jede Regel, die hier
etwas anstossen will: die Zonen einer Steuerung haengen an derselben
Leitung, zwei gleichzeitig gibt es nicht.

Die beiden alten Zeilen "Laufende Automatik" muessen von Hand aus
actor_states verschwinden - das Discovery schreibt nur mit ON DUPLICATE KEY
UPDATE und loescht nie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:58:42 +02:00
adminandClaude Opus 5 128acae48e Bewaesserung und Regen werden Geraete im Automatik-Editor
Erster Teil der Ablosung von restricted/gartenbewaesserung/auto_watering.py.
Das Skript holt heute stuendlich den Regen, vergleicht ihn mit Schwellen
aus einer ZONES-Tabelle im Quelltext und schaltet selbst. Kuenftig
entscheidet der AutoAction-Runner, und die Schwellen stehen im Editor.

Dafuer legt ein neues Discovery-Modul drei Geraete an. Es sucht nichts,
sondern schreibt sie fest hin - wie das Logic-Modul auch:

  * Die beiden ESP32-Ventilsteuerungen sprechen zwar MQTT, aber ohne
    Home-Assistant-Discovery; das MQTT-Modul findet sie deshalb nicht.
  * "Regen" ist gar kein Geraet, sondern das, was gatherRainData.py im
    SolarManager von Open-Meteo holt und nach Wetter/Regen legt.

Je Steuerung entsteht ein Kommando "Automatik" mit dem Modus als
Parameter - ein Kommando ohne Parameter schickt im Runner eine leere
Nutzlast, der Klartext ("Hoch", "Trog", "Vorn") muss also der Parameter
sein. Dazu je Zone zwei Messwerte:

  Bewaessert heute       ersetzt die Zwoelf-Stunden-Sperre des Skripts;
                         "heute noch nicht" heisst hier "= 0". Die
                         Steuerung setzt den Wert um Mitternacht zurueck,
                         der Runner kann den mitgelieferten "wateringDay"
                         naemlich nicht pruefen.
  Tage seit Bewaesserung ersetzt max_dry_days. Ein Zeitstempel nuetzte
                         nichts: der Runner vergleicht Datumsangaben nur
                         gegen einen festen Wert, "laenger als fuenf Tage
                         her" ist damit nicht formulierbar. Die Firmware
                         liefert deshalb gleich die Anzahl Tage.

Die Regensummen kommen als tage1 bis tage7 herein; welche davon als
Messwert auftauchen, entscheidet allein dieses Modul. Der Sammler
veroeffentlicht immer alle sieben, damit sich die beiden Repositorien
nicht ueber eine Fensterliste einigen muessen.

Die Logseite kennt rainOutput.log jetzt ebenfalls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:37:43 +02:00
adminandClaude Opus 5 37962224c1 Geraeteauswahl im Automatik-Editor gruppiert nach Etage
Bisher stand ueber jedem Raum eine eigene Ueberschrift - bei neunzehn
Raeumen aus rooms.php also neunzehn Stueck, jede mit der Etage davor:
"OG - Bad", "EG - Bad", "UG - Bad". In einer Liste, die fuenfzehn Zeilen
hoch ist, sah man dadurch mehr Ueberschrift als Geraet.

Jetzt gruppiert nur die Etage, und der Raum steht klein und rechtsbuendig
an jeder Zeile. Sortiert wird innerhalb der Etage nach Raum (Reihenfolge
wie in rooms.php), sodass die Geraete eines Raums trotzdem beisammen
stehen; aus zwanzig Ueberschriften werden fuenf. Ein Raum, den es in
rooms.php nicht mehr gibt, bekommt eine eigene Ueberschrift, statt
stillschweigend unter den Unzugeordneten zu verschwinden.

Die Suche laeuft zusaetzlich ueber den Raum: "bad" findet damit auch den
Handtuchtrockner, der im Bad haengt, ohne es im Namen zu tragen. In der
Trefferliste steht keine Ueberschrift darueber, deshalb traegt dort jede
Zeile die volle Angabe "OG - Bad".

Dafuer nennt raumListe() Etage und Raum jetzt auch einzeln, und der
Geraetekatalog reicht beides durch - der Schluessel "Etage/Raum" muss so
nicht im JavaScript wieder auseinandergenommen werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:37:25 +02:00
adminandClaude Opus 5 77651a860a Verwaistes wattpilot.py heisst jetzt wattpilot_unused.py
Seit der Umstellung auf MQTT ruft es niemand mehr auf - kein PHP, kein
Startskript, kein Aufgabenplaner. Der neue Name macht das sichtbar, bevor
die Datei irgendwann geloescht wird; das Wallbox-Passwort steht darin
weiterhin im Klartext.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:06:37 +02:00
adminandClaude Opus 5 4bbc7ee7a7 Enyaq mit 77 kWh statt 79
Von der Skoda-Website abgelesen. Datenbanken im Netz nennen fuer den RS
des Modelljahrs 2026 teils 79 kWh nutzbar - der Hersteller ist die
verlaesslichere Quelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:03:29 +02:00
adminandClaude Opus 5 2ef946c732 Akkugroessen der beiden Autos stimmen und stehen in der Konfiguration
Der Schieberegler "Zusaetzlich benoetigte Ladung" rechnet Prozent in
Wattstunden um. Dafuer standen bisher drei verschiedene Zahlen im Code:
140 Wh/% beim Schreiben der EG-Wallbox, 90 Wh/% beim go-e und feste
14000 Wh beim Zurueckrechnen im Browser - fuer beide Autos dieselbe. Eine
Einstellung von 50 % am go-e schrieb 4500 Wh und las sich beim naechsten
Oeffnen als 32 % zurueck.

Jetzt kommt die Zahl aus einer Quelle: restricted/config.php nennt Auto
und nutzbare Akkukapazitaet je Wallbox, carForm.php gibt die Wattstunden
je Prozent als data-Attribut an den Schieberegler weiter, und das
JavaScript rechnet damit zurueck statt mit einer festen Konstanten.

EG: Skoda Enyaq RS, Modelljahr 2026, 84 kWh brutto / 79 kWh nutzbar
OG: Jeep Compass 4xe, 11,4 kWh

Das Lademodal zeigt Auto und Kapazitaet jetzt unter dem Regler an - damit
sichtbar ist, worauf sich die Prozente beziehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 08:53:03 +02:00
adminandClaude Opus 5 61ea98db1d Beide Wallboxen werden gleich bedient
carEG.php und carOG.php waren fast identisch und unterschieden sich nur im
Transportweg: der go-e bekam seine Kommandos per HTTP, die Wattpilot ueber
einen eigenen Python-Prozess je Klick, der sich einen Websocket aufbaute,
anmeldete, ein Feld setzte und wieder starb.

Beide haengen laengst am selben MQTT-Broker - der go-e mit eigenem Client
(mce=true, mcr=false), die Wattpilot ueber wattpilot_bruecke.py. Beide
nehmen Kommandos auf <praefix><schluessel>/set entgegen. Die Steuerung
steht deshalb jetzt einmal in carSteuerung.php; was die Wallboxen wirklich
trennt - Topic-Praefix, die Namen ate/att gegen ftt/fte und die
Wattstunden je Prozent - steht als Tabelle in restricted/wallboxen.php.

Nebenbei faellt damit /volume1/homes/wagner/wattpilot.py weg, das das
Passwort der Wallbox im Klartext enthaelt und ausserhalb jedes Repos liegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 08:36:13 +02:00
adminandClaude Opus 5 b4b5e59cb4 Tooltip der MQTT-Bruecke sagt, was sie tatsaechlich tut
"Reicht die Messwerte an den Browser weiter" verschwieg die Quelle und
den Weg. wsMQTTbridge.py haengt am Websocket der Wetterstation
(ws://192.168.179.42/ws) und veroeffentlicht die Werte unter dem
MQTT-Topic weatherStation; der Browser holt sie erst von dort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:34:40 +02:00
adminandClaude Opus 5 cbc9353f0a Protokollseite: die Logs der Hintergrundprozesse im Dashboard
Bisher kam man an die Ausgabe von solarManager.py, dem Runner und den
uebrigen Dauerprozessen nur per SSH heran. Die neue Seite unter
"Protokolle" zeigt sie: links die fuenf Dateien mit Groesse und Alter -
eine, die seit Tagen nicht gewachsen ist, faellt so auf, ohne dass man sie
oeffnet -, rechts die Zeilen mit Volltextsuche, Level-Filter und
optionalem Nachladen.

Nicht in die Datenbank geschrieben, obwohl das naheliegt: ein Log muss
genau dann noch funktionieren, wenn die Datenbank es nicht tut. Ein
Handler, der nach MySQL schreibt, verschluckt ausgerechnet die Meldung
"Datenbank nicht erreichbar" - also die, wegen der man nachsieht. Was
strukturiert ausgewertet werden soll, steht ohnehin in automation_log.

Gelesen wird rueckwaerts in Bloecken: 5000 Zeilen aus der 329 MB grossen
Datei der Wallbox kosten 46 ms, weil nur das Ende angefasst wird. Sucht
man ins Leere, bricht der Server nach 4 MB ab und sagt es auch.

Die beiden Prozesse formatieren unterschiedlich - der eine stellt das
Level voran, der andere den Zeitstempel -, deshalb wird beides gesucht
statt an fester Stelle erwartet. Zeilen ohne eigenes Level erben das der
Zeile darueber, sonst filterte "nur Fehler" die Tracebacks weg, die den
Fehler erklaeren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:26:56 +02:00
adminandClaude Opus 5 abfa84dff8 Geaenderte Bedingungen erreichen den Runner wieder
Die Seite speichert eine Automatik, indem sie deren Bedingungen loescht und
neu einfuegt. Wer nur die Uhrzeit verstellt, aendert damit weder die
Zeilenzahl noch automations.changed - ON UPDATE greift nur, wenn sich in
automations wirklich eine Spalte aendert, und Name, Stockwerk und Fenster
stehen ja noch genauso da. Der Runner prueft aber genau daran, ob er sein
Regelwerk neu laden muss, und lief deshalb bis zum naechsten Neustart mit
der alten Uhrzeit weiter.

Aufgefallen ist es an einer Automatik, die auf 18:16 gestellt war und um
18:24 ausloeste - zur Zeit, die vor der Aenderung dort stand.

changed wird jetzt ausdruecklich mitgeschrieben. Der Runner bekommt
zusaetzlich einen Fingerabdruck ueber den Inhalt, damit er nicht darauf
angewiesen ist, dass die Oberflaeche mitdenkt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:26:40 +02:00
adminandClaude Opus 5 d285ec27a0 Einstellungsseite, Skoda-Seite und Karte
Sammelstand der offenen Arbeit im Dashboard.

Einstellungen: eine Eingabemaske fuer die Energiepreise unter
restricted/settings.php, dazu costs.php als Modell und ajax/settings.php
als Endpunkt. Die Preise stehen jetzt in gridCosts, gasCosts und fuelCosts
statt fest im Quelltext von getStats.php; die Jahresstatistik rechnet ueber
LEAD() den jeweils gueltigen Zeitraum aus und schlaegt den anteiligen
Grundpreis auf. Eingegeben wird in gewohnten Einheiten - l/100 km,
kWh/100 km, Euro je Liter -, umgerechnet wird beim Speichern.
solarLog_costs.sql beschreibt die Umstellung der Tabellen.

Skoda: eigene Seite mit Live-Werten ueber MQTT und Historie ueber
ajax/skoda.php, Kommandos ueber ajax/skodaCmd.php.

Karte: Leaflet mit einem eigenen Kachel-Zwischenspeicher (ajax/tile.php),
dessen Ablage unter tiles/ nicht ins Repository gehoert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:21:27 +02:00
adminandClaude Opus 5 c9a9e16b21 Verweise auf den Runner nachgezogen
Die Pfade in commands.php und homeMesh_automations.sql zeigten noch ins
Web-Verzeichnis. Dazu die Beschreibung der Zeit-Operatoren im Kopf von
automations.php: "um 16:30" gilt jetzt ab dieser Minute und noch
catchup_minutes lang, damit ein Neustart oder ein langsamer Durchlauf den
Termin nicht mehr verschluckt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:21:14 +02:00
adminandClaude Opus 5 cbddd664e3 Runner zieht zum SolarManager um
Der AutoAction-Runner ist ein Hintergrundprozess und gehoert damit zu den
anderen, nicht ins Web-Verzeichnis. Er liegt jetzt unter
/volume1/homes/wagner/SolarManager/autoActions und wird von dort zusammen
mit dem Manager gestartet; versioniert ist er im Repository SolarManager.

Browser und Runner reden ohnehin nur ueber die Datenbank homeMesh
miteinander - das Web-UI kennt den Pfad nicht und muss ihn nicht kennen.
Die Verweise in commands.php und homeMesh_automations.sql zeigen bereits
auf den neuen Ort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 20:56:08 +02:00
adminandClaude Opus 5 79a55aeafd Kacheln zeigen frei gewaehlte Messwerte, Heizung nur wo eine haengt
Drei Sachen an den Kacheln und am Raum-Modal.

Die Platzhalter sind nur noch Striche. Vorher stand dort "---- hPa",
gefuellt wurde die Zeile aber mit der Solltemperatur - eine Einheit, die
nie kam, an einer Stelle, die etwas anderes zeigt.

Der Heizungs-Reiter erscheint nur noch, wo auch ein Thermostat haengt.
Frueher entschied das "hat der Raum eine Kachel" - das stimmte, solange
nur Raeume mit Thermostat eine bekamen. Seit auch Kueche und Technik eine
haben, bekamen sie einen Regler fuer eine Heizung, die es dort nicht gibt.
Jetzt zaehlt, ob im Raum ein Geraet der Bedienform "heizung" steht.

Und eine Kachel zeigt jetzt ein bis drei frei gewaehlte Messwerte. In
restricted/rooms.php steht je Raum unter "werte", welche das sind: topic,
optional pfad (Schluessel in einer JSON-Nachricht), einheit, stellen. Ohne
Angabe bleibt es bei den drei Thermostatwerten samt Heizsymbol, also genau
wie bisher - nur diese Raeume zeichnen die Symbole ueberhaupt noch.

Alles kommt live ueber MQTT, auch die beiden Shelly-Zaehler: sie schicken
ihre sechzehn Werte als eine JSON-Nachricht auf Power_EG/status/em:0, aus
der "pfad" den gesuchten Schluessel greift. Der Browser meldet nicht mehr
fest "Raumtemp/#" an, sondern die Zweige, die die Raumtabelle nennt -
derzeit vier statt vierzig einzelner Topics.

Damit steht auf EG Technik die Leistung (gemessen 293 W), auf UG Technik
Leistung und Wasserzaehler (78 W, 400,4 m3). Raeume ohne Messwert tragen
ihren Namen und oeffnen den Raum; ein leeres Rechteck saehe aus wie ein
Fehler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:55:58 +02:00
adminandClaude Opus 5 9f52b095b3 Kachelpositionen: Messblatt unter tools/, fuenf neue Kacheln
Wo eine Kachel im Grundriss hingehoert, liess sich bisher nur schaetzen -
und der Bezugspunkt ist auch noch die linke obere Ecke, nicht die Mitte,
die man im Auge hat. tools/kachelpositionen.php nimmt beides ab: Grundriss
mit Raster, die vorhandenen Kacheln in Originalgroesse samt einem Punkt an
ihrem Bezugspunkt, eine Geisterkachel am Zeiger und darunter die Zeile,
die in rooms.php gehoert. Ein Klick friert sie ein.

Als PHP-Seite im Webverzeichnis, nicht als erzeugte HTML-Datei: so liest
sie ihre Raeume live aus restricted/rooms.php und veraltet nicht, wenn ein
Raum dazukommt. Geprueft an bekannten Werten - auf die Mitte einer
vorhandenen Kachel gezeigt kommt genau ihre gespeicherte Koordinate
heraus.

Damit haben jetzt alle Raeume eine Kachel: EG Kueche und Technik, UG
Technik, dazu OG Kueche und Technik als neue Raeume. Sie zeigen vorerst
die Platzhalter der Temperaturvorlage - was auf ihnen stehen soll
(Leistung, Wasserverbrauch), ist der naechste Schritt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:38:10 +02:00
adminandClaude Opus 5 08e8e68a61 Raum-Modal: Solltemperatur wirkt sofort, kein Speichern-Knopf mehr
Im Raum-Modal wirkt jedes Bedienelement sofort - der Rollladen faehrt, der
Schalter schaltet, die Lampe leuchtet. Nur die Solltemperatur wartete auf
"Save changes". Sie schickt jetzt beim Loslassen des Reglers, wie die
Rollladenregler daneben, und der Knopf ist im Raum-Modal weg. Einer, der
nur fuer einen der Reiter gilt, waere in allen anderen eine Falle.

Das Modal bleibt nach dem Schicken offen; frueher schloss es sich, weil
der Knopf das Ende der Bedienung war. Wer dreimal nachjustiert, will nicht
dreimal neu aufmachen. Die Heizungskarte traegt dafuer dieselbe Sperre wie
die Geraetekarten - waehrend das Kommando unterwegs ist, ist sie gesperrt.

Dabei einen Fehler mitgenommen: der Speichern-Knopf gehoert allen Modals
gemeinsam, und wer ihn versteckt, versteckt ihn fuer alle. Ein Raum ohne
Thermostat liess ihn schon bisher verschwinden - danach hatten der
AutoAction-Editor und die Raumzuordnung bis zum Neuladen keinen
Speichern-Knopf mehr. Beide holen ihn jetzt wieder hervor. Nachgemessen:
im Raum-Modal versteckt, im Editor und in der Raumzuordnung sichtbar.

Die Fahrknoepfe sind von 34 auf 50 Pixel gewachsen - sie sind das, was man
im Vorbeigehen antippt, die Regler daneben braucht man seltener.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:20:15 +02:00
adminandClaude Opus 5 e8612bebb8 Raum-Modal: Heizung in einen eigenen Reiter, Spalten buendig
Die Heizung stand ueber den Tabs und war in jedem Raum sichtbar, auch wenn
man wegen der Rollladen gekommen war. Sie ist jetzt eine Gruppe wie die
anderen - nur baut sie sich nicht aus einer Geraeteliste, sondern ist ein
Regler fuer den Raum. zeichneGruppen() nimmt dafuer fertige Inhalte
entgegen. Bei einem Raum mit nur einem Thermostat bleibt die Leiste weg,
dort sieht es aus wie vorher.

Aus den Pillen sind Reiter geworden - sie sehen jetzt aus wie das, was sie
sind, und nicht wie eine Knopfreihe.

Position und Neigung standen um 0,35 rem versetzt: die Regel fuer
gestapelte Regler (Helligkeit ueber Farbe) griff auch auf die zweite
Spalte, die aber danebensteht. Sie gilt jetzt nur noch fuer Regler, die
keine Spalte sind - nachgemessen stehen beide Bahnen auf 181/104.

Das Fensterbild ist von "so breit wie moeglich" auf 5 rem zurueckgenommen;
der uebrige Platz verteilt sich auf die Zwischenraeume, statt das Bild
breitzuziehen. Zwischen ihm und den Bedienelementen liegt jetzt Luft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:11:42 +02:00
adminandClaude Opus 5 18557021b0 Raum-Modal: Fensterbild zeigt Hoehe und Neigung, Regler nach rechts
Die Regler standen neben den Fahrknoepfen - beim Antippen von "Auf" fand
der Daumen leicht den Schieber daneben. Jetzt stehen sie rechts aussen,
zwischen ihnen und den Knoepfen liegt das Fensterbild.

Das Bild zeigt jetzt beides. Die Hoehe schneidet ab: gezeichnet wird nur
der Teil des Fensters, den die Jalousie bedeckt, mit der Endschiene an
seiner Unterkante; darunter sieht man hinaus. Die Neigung bestimmt wie
bisher, wie dick eine Lamelle von vorn erscheint. Ein Rollladen ohne
Neigung bekommt volle Lamellenhoehe - er ist ja ein geschlossener Panzer.

Die Prozentzahl unter den Reglern ist weg; die Bahn ist dafuer von 5 auf
6,5 rem gewachsen. Was der Regler bewirkt, zeigt das Bild ohnehin besser
als eine Zahl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:59:52 +02:00
adminandClaude Opus 5 e7fd7715ef Raum-Modal: Regler gedreht statt writing-mode, eigenes Aussehen
Der senkrechte Positionsregler liess sich am Handy kaum anfassen. Gebaut
war er mit writing-mode: vertical-rl - dem neueren Weg, aber einem, bei
dem der Browser das Ziehen als Wischen liest und die Seite blaettert,
statt den Griff mitzunehmen.

Jetzt liegt ein gewoehnlicher waagerechter Regler um 90 Grad gedreht in
seinem Kasten. Er bewegt sich in seinen eigenen Koordinaten waagerecht,
wird von jedem Browser unterstuetzt, traegt dieselbe Gestaltung wie die
liegenden Regler, und touch-action: none haelt die Seite still, solange
der Finger auf ihm liegt. Die Trefferflaeche ist 1,6 rem breit statt der
sichtbaren 0,375 rem Bahn. Nachgemessen: oben angeklickt 8 %, unten 93 %.

Die Neigung steht jetzt ebenfalls senkrecht, daneben das Lamellenbild -
Regler und Bild bilden ein Paar, das Bild sagt, was der Regler bedeutet.
Beide Spalten sind mit Namen und den Worten an ihren Enden beschriftet
("oben"/"unten", "auf"/"zu"); die Zahl darunter ist das, was sich beim
Ziehen aendert.

Die Regler im Raum-Modal haben ein eigenes Aussehen bekommen: Bootstraps
weisse Bahn mit blauem Griff stach auf den dunklen Karten heraus, und in
einem Raum mit drei Jalousien waren das sechs helle Balken. Bahn und Griff
tragen jetzt dasselbe Blaugruen wie der Temperaturregler, der ungefuellte
Teil ein Grau, das in beiden Themes traegt. Die Regler in den Modals fuer
Autoladung und Heizstab bleiben unberuehrt.

Die Regel fuer die Bahn steht zweimal da, einmal fuer -webkit- und einmal
fuer -moz-: als Liste notiert wirft der Browser beim unbekannten Selektor
die ganze Regel weg, und dann bliebe die Bahn ungestaltet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:53:49 +02:00
adminandClaude Opus 5 f6e0414866 Raum-Modal: Gruppen als Tabs, Jalousien senkrecht bedienen
Die Gruppenueberschriften sind Tabs geworden, Beschattung zuerst - das ist
es, was man in einem Raum am haeufigsten anfasst. Vorher scrollte man an
drei Rollladen vorbei, um zum Licht zu kommen. Bei nur einer Gruppe bleibt
die Leiste weg; auf schmalen Schirmen scrollt sie seitwaerts, statt die
Namen umzubrechen.

Bei den Jalousien stehen Auf, Stop und Zu jetzt untereinander - so zeigen
die Knoepfe in die Richtung, in die sie fahren. Die Hoehe daneben ist ein
senkrechter Regler mit "oben" und "unten" an den Enden: der Griff steht
dort, wo der Rollladen steht, und niemand muss "100 %" uebersetzen.

Die Neigung hat ein Bild dazubekommen. "30 %" oder "70 %" sagt nicht, ob
man noch hinaussieht; vier Streifen sagen es. Gezeichnet wird die Hoehe,
die eine gekippte Lamelle von vorn zeigt (w * sin(Winkel)) - bei "auf"
sieht man nur ihre Kante und viel Luft dazwischen, bei "zu" deckt sie den
Abstand zur naechsten vollstaendig ab. Genau das, was das Licht abhaelt.

Die Regler faerben ihre Bahn jetzt mit, wie es der Temperaturregler seit
jeher tut - dieselbe Eigenschaft --background-size, nur beim senkrechten
von oben nach unten.

css/solar.css hatte gemischte Zeilenenden und ist dabei durchgehend auf LF
gebracht worden; das erklaert den grossen Diff bei kleiner Aenderung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:38:09 +02:00
adminandClaude Opus 5 17f972d081 Raum-Modal: Bedienelemente statt Kommandoliste
Das Modal zeigte jedes Kommando so, wie es in der Datenbank steht: eine
Jalousie kam mit acht Rohbefehlen daher, darunter "Winken", die Farbe einer
Lampe als drei Zahlenfelder 0-255, ein Stromzaehler als abgeschnittene
Textzeile. Die Solltemperatur stand doppelt da - einmal als Schieberegler,
einmal als Zahlenfeld der Thermostat-Karte.

Jetzt entscheidet restricted/roomControls.php, welches Geraet welches
Bedienelement bekommt, und zwar an seinen Faehigkeiten statt am Typnamen:
hat es ein "setClosure"? ein An/Aus-Paar? drei Parameter rot/gruen/blau?
Damit bekommt auch ein neu gefundenes Geraet sein passendes Element, ohne
dass jemand einen Typnamen nachtraegt.

  Beschattung   Auf/Stop/Zu, darunter Position und Neigung als Schieber
                auf dem zuletzt gemeldeten Stand; "Winken" und
                "my-Position" im Mehr-Menue
  Licht         Kippschalter, Helligkeit, Farbwaehler des Browsers,
                Effekt und Preset als Auswahl
  Schalten      ein Kippschalter statt dreier Knoepfe
  Messwerte     Wertegitter, die sechs wichtigsten sichtbar - beim
                Wasserzaehler standen vorher MAC-Adresse und freier
                Speicher vorn und der Verbrauch hinter "12 weitere"
  Heizung       nur noch der Schieberegler, daneben Ist-Temperatur und
                Feuchte live aus dem MQTT-Strom

Ab zwei Geraeten einer Art ordnet eine Ueberschrift; darunter waere sie
nur Aufwand. Schieberegler schicken beim Loslassen, nicht beim Ziehen -
sonst bekaeme eine Jalousie bei jedem Pixel ein neues Ziel. Waehrend ein
Kommando unterwegs ist, sperrt sich die Karte sichtbar: ein "Zu" dauert
ueber eine Minute, und ohne Sperre tippt man in der Zeit nach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:18:46 +02:00
adminandClaude Opus 5 131d7cc46d Jalousien: Zu heisst zweifach schwenken, Versand laeuft im Hintergrund
Die Kugelschreiber-Mechanik betrifft jedes Kommando, nicht nur
setOrientation, und sie betrifft auch das Schliessen: ein "Zu" faehrt die
Jalousie zwar herunter, die Lamellen bleiben aber bei etwa 30 % offen
stehen. Dicht wird sie erst durch das zweifache Schwenken.

Die Regel lautet jetzt einheitlich: derselbe Befehl zweimal - erst mit
Neigung 0, dann warten, bis die Fahrt steht, dann mit dem gewuenschten Wert.
Die Position bleibt dabei erhalten, die Jalousie faehrt also nur einmal.

    Zu                     -> [100,0] warten [100,100]
    Neigung 20             -> direkt, unter der Schwelle
    Neigung 80             -> [0] warten [80]
    Position 40 Neigung 50 -> [40,0] warten [40,50]

"Zu" wird dabei nur umgeschrieben, wenn das Geraet Position und Neigung
zusammen setzen kann - der einfache Rollladen "Terasse" hat keine Lamellen
und bekommt weiter das rohe down.

Gewartet wird auf zwei Auskuenfte zusammen, weil einzeln keine traegt:
core:MovingState trug die lange Fahrt (gemessen 61 s), wird bei kurzen
Neigungsfahrten aber nie gesetzt; die Zustandswerte sind die Wahrheit,
zeigen direkt nach dem Kommando aber noch den alten Stand. Fertig heisst:
nichts faehrt mehr, die Ziele stimmen, und es wurde entweder ein "faehrt"
gesehen oder der Vorlauf von acht Sekunden ist um.

Und weil ein "Zu" damit ueber eine Minute dauert, schickt der Runner nicht
mehr selbst: der neue Versand nimmt die Kommandos entgegen und arbeitet sie
in eigenen Faeden ab - je Geraet der Reihe nach, ueber Geraete hinweg
nebeneinander. Sonst haette eine einzige Jalousie die ganze Auswertung fuer
eine Minute angehalten: keine Zeit-Ausloeser, keine Messwerte, und mehrere
Rollladen in einer Automatik haetten sich aufaddiert. Die Datenbank bleibt
dabei im Hauptfaden - die Faeden melden nur ihr Ergebnis zurueck, eine
pymysql-Verbindung ist nicht fuer mehrere Faeden gedacht.

Nachgemessen: Einreihen kehrt sofort zurueck, zwei Kommandos an dasselbe
Geraet laufen nacheinander (6 s, dann 12 s), drei an verschiedene Geraete
gleichzeitig, Fehler kommen zurueck, und die Schleife lief in 20 Sekunden
20 Takte durch.

Die Bedienung im Modal wartet weiterhin - dort sitzt ein Mensch vor einem
Fortschritt und klickt einmal. ajax/room.php hebt dafuer die Zeitgrenze auf
180 Sekunden an, wie es ajax/tahoma.php an derselben Stelle auch tut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:29:40 +02:00
adminandClaude Opus 5 d557a1651f Neigungs-Umweg gilt fuer jedes Kommando, nicht nur setOrientation
Die Kugelschreiber-Mechanik haengt nicht am Kommando, sondern an der
Neigung selbst - "Position+Neigung" braucht den Umweg ueber 0 % genauso wie
"Neigung". Erkannt wird er deshalb am Parameter und nicht am Kommandonamen:
beide Kommandos nennen ihren Neigungsparameter "Neigung", das
Geraetemodell liefert die Zuordnung also mit.

Geprueft im Probelauf in beiden Versendern: Position+Neigung mit 20 % geht
direkt raus, mit 90 % mit vorgeschaltetem Umweg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:14:05 +02:00
adminandClaude Opus 5 1b9bbc9857 Jalousien: Neigung ueber 30 % nur mit Umweg ueber 0 %
Die Aussenjalousien haben eine Kugelschreiber-Mechanik: beim Herunterfahren
stehen die Lamellen bei etwa 30 %. In Richtung 0 % laesst sich von jeder
Stellung aus direkt neigen; darueber hinaus rastet die Mechanik nur um, wenn
sie vorher einmal auf 0 % war. Ein "Neigung 80 %" ohne diesen Umweg bleibt
wirkungslos.

Beide Versender wissen das jetzt: restricted/commands.php fuer die
Bedienung im Raum-Modal, transports.py fuer den Runner. Bei setOrientation
mit einem Ziel ueber 30 % geht erst eine 0 raus, dann wird gewartet, bis die
Box diese 0 auch meldet, und erst danach der eigentliche Wert. Der Umweg
gehoert dorthin und nicht in die Bedienung - sonst muesste ihn jeder kennen,
der eine Jalousie anspricht, auch beim Anlegen einer Automatik.

Gewartet wird auf core:SlateOrientationState und nicht auf
core:MovingState: bei Neigungsfahrten meldet die Box waehrend der ganzen
Bewegung "faehrt nicht". Genau darauf war ich vorher hereingefallen.

Zwei Messungen aus der Erprobung stecken in den Konstanten: von 100 % auf
0 % braucht eine Jalousie gut fuenfzehn Sekunden, deshalb die Grenze von
25 s. Im Versuch ging ein "Neigung 100 %" von 79 % aus nach 14 s durch -
echtes Warten, nicht die Notbremse.

Dabei ein Fehler, den ausgerechnet das Ziel 0 verdeckt haette: die
PHP-Leseroutine liefert null, wenn die Abfrage fehlschlaegt, und
intval(null) ist 0. Die Warteschleife hielt einen fehlgeschlagenen
Lesevorgang damit fuer "Ziel erreicht" und brach sofort ab - der Umweg fand
also gar nicht statt, es sah nur so aus. In der Python-Fassung ist
dieselbe Stelle jetzt ebenfalls ausdruecklich gegen ein fehlendes Feld
abgesichert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 19:18:55 +02:00
adminandClaude Opus 5 98a1da6cd5 Raum-Modal: Heizung, Rollladen und Licht auf einer Seite
Der Grundriss zeigt Raeume, ein Klick darauf zeigte aber nur den
Temperaturregler. Die Rollladen im selben Zimmer waren von dort gar nicht
erreichbar, obwohl die Karte genau sagt, wo sie haengen.

Das Modal zeigt jetzt alles, was dem Raum zugeordnet ist: die
Solltemperatur wie bisher, dazu je Geraet seine Kommandos. Kommandos ohne
Parameter werden Knoepfe nebeneinander - "Auf", "Zu", "Stop" will man
antippen und nicht erst einstellen -, Kommandos mit Parametern bekommen
ihre Eingabefelder und einen Ausloeser. Geraete ohne Kommandos zeigen ihre
Messwerte. Die Reihenfolge der Knoepfe kommt nicht mehr aus dem
Geraete-Suchlauf: die haeufig gebrauchten stehen vorn.

Ausgefuehrt wird ueber restricted/commands.php - das Gegenstueck zu
transports.py, nur fuer den Browser. Dieselbe Zuordnung "welche URL gehoert
zu welchem Weg", nur eben in PHP:

    mqtt://   Nutzlast auf das Topic des Parameters
    wled://   JSON-Vorlage mit Platzhaltern an /json/state
    http://   Abfrageargumente an die Geraete-URL
    Tahoma    exec/apply, erkannt an der Box-Kennung in der URL

Zwei Umsetzungen derselben Sache sind nicht schoen. Die Alternative waere,
den Runner um eine HTTP-Schnittstelle zu erweitern - dann haengt die
Bedienung daran, dass er laeuft - oder das Web-UI Python aufrufen zu
lassen. Beides waere teurer als diese Datei; der Kommentar sagt das an
beiden Stellen.

Der Endpunkt kennt einen Probelauf: mit "probe" beschreibt er, was er
schicken wuerde, ohne es zu schicken. Damit sind alle vier Wege geprueft,
ohne einen Rollladen in Bewegung zu setzen - Tahoma mit und ohne Parameter,
WLED mit gefuellter Vorlage, Shelly Gen1 und MQTT.

ajax/roomtemp.php entfaellt, es ging darin ausschliesslich um den Regler,
den das Raum-Modal jetzt enthaelt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:43:21 +02:00
adminandClaude Opus 5 bed1884b04 Raeume ohne Kachel: Kueche EG und die beiden Technikraeume
Die Zuordnung der restlichen Geraete lief gegen eine Luecke im Raummodell:
rooms.php kannte nur Raeume, die eine Kachel im Grundriss haben - also
Raeume mit Thermostat. Die Kueche im EG hat keines, und die Stromzaehler
und der Wasserzaehler sitzen in Technikraeumen, die dort ebenfalls fehlten.

Ein Raum ohne x/y ist jetzt trotzdem ein Raum: Geraete lassen sich ihm
zuordnen, er erscheint nur nicht im Grundriss. roomsOnFloor() liefert
weiterhin nur Raeume mit Kachel - alles, was zeichnet oder aktualisiert,
geht darueber -, allRooms() liefert fuer die Zuordnung alle. Die
Aktualisierungsschleife im Browser bekommt ausdruecklich nur die
Kachel-Raeume; ein Raum ohne Position haette dort keine Elemente zu fuellen.

Neu eingetragen: EG/Kueche, EG/Technik, UG/Technik.

Damit sind 49 der 54 Geraete einem Raum zugeordnet. Offen bleiben mit
Absicht: Alarm (hausweit), Zeitpunkt (gerechnet), Thermostat EG
Unkonfiguriert (laut eigenem Namen) und die beiden Dachfenster, deren Raum
noch niemand genannt hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:33:34 +02:00
adminandClaude Opus 5 4b47372015 Geraete den Raeumen zuordnen
Beim Anlegen einer Automatik denkt man in Raeumen ("im Bad soll ..."), nicht
in Geraetearten. Ein Raummodell gibt es schon - restricted/rooms.php, die
einzige Quelle fuer die Home-Ansicht -, nur fehlte die Zuordnung Geraet ->
Raum.

Die neue Spalte actors.room haelt sie als "Etage/Raum" (OG/Bad), dazu
"aussen" fuer Carport, Garage und Regensensor. Etage und Raum zusammen,
weil "Bad" allein auf drei Etagen vorkommt. Discovery fasst die Spalte nicht
an - der Upsert setzt nur type und name -, die Zuordnung ueberlebt also
jeden Suchlauf.

Zugeordnet wird ueber das Haus-Symbol in der Karte "Automatismen". Wo der
Raum aus dem MQTT-Topic hervorgeht ("Raumtemp/OG/Bad/Temp[degC]"), schlaegt
die Ansicht ihn vor und uebernimmt ihn auf Knopfdruck - das sind die
dreizehn Thermostate, ohne jedes Raten. Bei allen anderen wird bewusst nicht
geraten: "Bad Links" sagt nichts darueber, auf welcher der drei Etagen
dieses Bad liegt. Noch nicht zugeordnete Geraete stehen in der Tabelle oben.

Die Geraeteauswahl im Editor gruppiert danach nach Raum, in der Reihenfolge
aus rooms.php. Solange niemand zugeordnet hat, bleibt es bei der Gruppierung
nach Geraeteart - eine einzige Gruppe "Nicht zugeordnet" waere ein
Rueckschritt. Und weil auch nach der ersten Zuordnung noch viel ohne Raum
ist, wird dieser Rest weiter nach Geraeteart unterteilt ("Ohne Raum ·
Aussenjalousie (11)").

Anders als bei den Gerätearten behalten Raeume mit nur einem Geraet ihre
Ueberschrift: "OG · Bad" ist auch mit einem einzigen Thermostat genau die
Zeile, die man sucht - und mehr als eines haengt selten in einem Raum.

Die dreizehn Thermostate sind bereits zugeordnet, weil das ihre eigenen
Topics hergeben; alles andere steht auf "-" und will von Hand vergeben
werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:16:48 +02:00
adminandClaude Opus 5 2ac504ca29 Geraeteauswahl: Suchfeld statt Auswahlliste
Die Gruppierung nach Geraeteart hat die Liste geordnet, aber nicht kuerzer
gemacht: elf Jalousien bleiben elf Zeilen. Statt der Auswahlliste steht
jetzt ein Knopf in der Zeile, der eine durchsuchbare Liste ueber die volle
Breite aufklappt.

Gesucht wird ueber Beschriftung UND Geraeteart zugleich, also quer zur
Gruppierung: "bad" findet die drei Thermostate im Bad und die zwei
Jalousien "Bad Links"/"Bad Rechts", "jalousie" alle elf auf einmal. Ohne
Suchtext bleiben die Zwischenueberschriften stehen; sobald gefiltert wird,
faellt die Gruppierung weg, weil die Treffer dann quer ueber alle Arten
stehen und die Art ohnehin an jeder Zeile mitlaeuft.

Die aufgeklappte Liste haengt unter der ganzen Zeile und nicht in ihr - in
einer input-group stuende sie sonst als weitere Spalte daneben. Escape
schliesst, Enter nimmt den ersten Treffer, ein Klick daneben schliesst
ebenfalls. Es ist immer hoechstens eine offen.

Dabei zwei Sachen mitgenommen:

Eine neue Zeile beginnt jetzt ohne Geraet ("Gerät wählen …"). Bisher stand
dort das erste Geraet mit seinem ersten Messwert - nach dem
Geraete-Discovery also "Bad Links: Gesperrt = ", eine Bedingung, die
niemand gemeint hat und die man erst wegklicken musste. Der Satz darunter
sagt in dem Fall "Noch kein Ausloeser", und der Server lehnt eine
unvollstaendige Bedingung ohnehin ab.

Auf dem Handy quetschten sich Geraet, Messwert, Vergleich, Wert, Einheit
und Papierkorb in eine Zeile - uebrig blieben "Therm OG Bad" und "te".
Unter 576 px bekommen Geraet und Messwert jetzt je eine eigene Zeile. Die
Regel braucht die input-group-Klasse im Selektor: solar.css wird vor
AdminLTE geladen, und Bootstraps ".input-group > .form-select" haette sonst
dasselbe Gewicht und wuerde als spaeteres Blatt gewinnen.

Geprueft bei 375 px und am Rechner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:10:59 +02:00
adminandClaude Opus 5 00f5944a7a Geraeteauswahl: nach Art gruppiert, Namen geputzt
Nach dem Discovery-Lauf stehen 51 Geraete in der Ausloeser-Liste und 40 in
der Aktions-Liste, flach und alphabetisch. Dreizehn Thermostate und elf
Jalousien sind darin vierundzwanzig gleich aussehende Zeilen.

Die Liste ist jetzt nach Geraeteart gruppiert (optgroup, das koennen auch
die Handy-Browser nativ). Arten mit nur einem Geraet bekommen keine eigene
Ueberschrift - fuenfzehn Gruppen mit je einem Eintrag waeren
unuebersichtlicher als die flache Liste -, sie sammeln sich am Ende unter
"Einzelne".

Dazu ein Namensputz fuer die Anzeige, ohne die Datenbank anzufassen: das
u_-Praefix aus den Tahoma-Beschriftungen faellt weg, ebenso eine
angehaengte einstellige Kanalnummer, und Unterstriche werden zu Leerzeichen.
Aus "u_Wozi Terrassentuer" wird "Wozi Terrassentuer", aus "Power_EG_EM_0"
wird "Power EG EM". Bewusst nur die einstellige Endung - "go-eCharger_270003"
traegt seine Seriennummer und behaelt sie.

Beschriftungen, die danach doppelt waeren, bekommen die Geraeteart dazu: die
Tahoma-Box nennt sowohl den Rollladen als auch den Sonnenenergie-Sensor
"Terasse", in der Liste untereinander waren sie nicht zu unterscheiden.
Daraus wird "Terasse (Rollladen)" und "Terasse (Licht)".

Die Beschriftung entsteht an einer Stelle und wird von Editor und
Uebersicht geteilt - sonst stuende in der Auswahlliste "Wozi Terrassentuer"
und im Satz darunter weiter "u_Wozi Terrassentuer".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:02:49 +02:00
adminandClaude Opus 5 9f8c9016c2 Gartenbewaesserung: Morgenfenster, Trockenheits-Notlauf, Regen von heute
Vier Aenderungen an auto_watering.py:

Zeitfenster morgens statt nur abends. Ueber --schedule laesst sich
"morning", "evening" oder "both" waehlen; morgens reicht das Fenster vom
Sonnenaufgang bis SUNRISE_FOLLOW_MINUTES danach, abends wie bisher von
WATERING_LEAD_MINUTES vor Sonnenuntergang bis zum Sonnenuntergang. Im Modus
"both" haelt die 12-Stunden-Sperre aus RECENT_WATERING_LOOKBACK_HOURS das
Ganze selbstregulierend: zweimal am Tag wird nur bei langen Sommertagen
wirklich bewaessert.

Eigener Ausloesezeitpunkt je Fenster. Die Fenster sind mit 70 Minuten
breiter als das stuendliche Cron-Raster, es fallen also je nach Sonnenzeit
zwei Laeufe hinein. Auf das LastWatering-Topic der Ventilsteuerung ist als
Schutz kein Verlass - es wird unter Umstaenden erst spaeter aktualisiert,
und zu kurze Laeufe werden bewusst ignoriert. Das Skript merkt sich seinen
Auslusezeitpunkt deshalb selbst im retained RainStatus der Zone und loest
pro Fenster und Kalendertag nur einmal aus. Nachrichten der Vorversion, die
nur ein "last_trigger" kannten, werden konservativ fuer beide Fenster
uebernommen, damit am Umstellungstag nicht doppelt bewaessert wird.

Der heutige Regen zaehlt mit. Bisher wurden nur abgeschlossene Tage
summiert - hatte es seit dem Morgen geschuettet, half das der Entscheidung
am Abend nicht. Jetzt kommt der bereits gefallene Regen des laufenden Tages
aus den stuendlichen Werten dazu, ohne die Vorhersage fuer die restlichen
Stunden. Das Fenster verschiebt sich entsprechend: threshold_days zaehlt
heute als letzten Tag mit.

Trockenheits-Notlauf. Unabhaengig vom Regen wird bewaessert, wenn die
letzte Bewaesserung laenger als max_dry_days zurueckliegt - fuer die
Hochbeete auf fuenf Tage gesetzt, fuer Troege und Garten vorn aus. Ist kein
Zeitpunkt bekannt, wird nicht ausgeloest: sonst erzwaenge jeder
Broker-Neustart eine Bewaesserung.

Geprueft mit --dry-run in beiden Modi: die Fenster werden richtig gerechnet
(06:36-07:46 und 18:52-20:02), und die Zonen entscheiden wie erwartet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:49:09 +02:00
adminandClaude Opus 5 17678da824 Zahlencodes als Klartext
Manche Geraete schicken eine Zahl und meinen einen Zustand - der
go-eCharger etwa 2 fuer "Charging". Welche Zahl welchen Namen hat, steht im
value_template der Home-Assistant-Discovery als Werttabelle:

    {{ ['Unknown','Idle','Charging','WaitCar','Complete','Error'][value_json|int] }}

Das ist kein Pfad in die Nutzlast, sondern eine Uebersetzung, und sie wurde
bisher verworfen. Jetzt landet sie in possible_values - in derselben
Schreibweise, die WLED fuer seine Effektliste schon benutzt, naemlich einer
Liste aus {Wert: Bezeichnung}. Damit versteht der Editor sie ohne
Zusatzarbeit und macht eine Auswahlliste daraus. Ein Versatz im Ausdruck
wandert in die Schluessel: aus [value_json|int-3] wird {"3":"Default"}.

Der Runner uebersetzt beim Lesen, eine Bedingung vergleicht also den
Klartext. Steht die Zahl nicht in der Tabelle, bleibt sie stehen - ein
erfundener Name waere schlimmer als ein roher Wert.

Dabei ist ein Folgefehler aufgefallen: Auswahlwerte in dieser Schreibweise
kamen im Editor als "[object Object]" an. Die alte addOptions kannte die
Form, der neue Modell-Aufbau nicht - aufgefallen ist es nie, weil WLED beim
Umbau abgeschaltet war. Der Katalog liefert Auswahlwerte jetzt einheitlich
als {value, label}, und zwar seitenrichtig:

    Messwert   angezeigt Charging, gespeichert Charging
               (der Runner hat schon uebersetzt)
    Parameter  angezeigt Blink,    gespeichert 1
               (das Geraet will die Zahl)

Die Uebersicht loest die Bezeichnung ebenfalls auf: "Effekt (Blink)" statt
"Effekt (1)".

Nachgemessen: alle fuenf Werttabellen auf dem Broker richtig erkannt,
einschliesslich des Versatzes, kein Fehltreffer unter den 35 uebrigen
Vorlagen. Live liefert der go-eCharger jetzt Idle, Neutral, Auto, Eco und
None statt 1, 0, 0, 4, 0. Ein weiterer Discovery-Lauf aendert keine Zeile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:44:39 +02:00
adminandClaude Opus 5 56b2ab1648 Mehrere Messwerte auf einem Topic auseinanderhalten
Der go-eCharger schickt sechzehn Zahlen als JSON-Feld auf einem einzigen
Topic, die Wechselrichter und die WLAN-Felder machen es aehnlich. Welcher
Teil der Nutzlast gemeint ist, steht im value_template der
Home-Assistant-Discovery - das Modul las es bisher nur, um den Datentyp zu
raten, und warf es dann weg. Alle sechzehn Messwerte bekamen denselben
Rohtext.

actor_states hat jetzt eine Spalte value_path: ein Pfad in die Nutzlast, in
derselben Schreibweise, die WLED schon benutzt ("[4]", "ssid",
"seg[0].col[0]"). NULL heisst weiterhin: die ganze Nutzlast.

Gelesen wird aus dem Template nur der einfache Fall - ein Zugriff auf
value_json und was danach an Punkten und Klammern folgt. Von den 40
Vorlagen, die hier auf dem Broker liegen, sind 28 genau das. Die uebrigen
zwoelf sind entweder die ganze Nutzlast (dann ist NULL richtig) oder
Werttabellen wie ['Idle','Charging'][value_json|int] - das ist kein Pfad,
sondern eine Uebersetzung von Zahl nach Text, und dort bleibt es beim
bisherigen Verhalten. Kein einziger Fehltreffer: die Werttabellen liefern
sauber None statt eines erfundenen Pfades.

Die Pfad-Auswertung stand schon im WLED-Transport; sie ist jetzt eine
gemeinsame Funktion, die sich beide teilen. MQTT wertet die Nutzlast einmal
je Nachricht aus und verteilt sie danach an alle Messwerte des Topics.

Nachgemessen: die sechzehn nrg-Werte liefern jetzt sechzehn eigene Zahlen
statt sechzehnmal dasselbe, die Leseabdeckung bleibt bei 440 von 441, und
ein weiterer Discovery-Lauf aendert keine Zeile.

Die restlichen geteilten Topics sind kein Fehler: bei den Wechselrichtern
lesen "Limit Persistent" und "Limit NonPersistent" wirklich denselben Wert
und unterscheiden sich nur im Kommando-Topic, und beim Thermostat liest die
Klima-Entity dieselbe Temperatur wie der Sensor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:23:08 +02:00
adminandClaude Opus 5 631baa7333 Discovery: alle Geraete finden und stabil ablegen
Ein Lauf mit allen Modulen brachte 54 Geraete und 441 Messwerte - und fuenf
Fehler ans Licht, die vorher niemand sehen konnte, weil clear_tables die
Tabellen bei jedem Lauf geleert hat.

Vier davon sind Schluessel- und Upsert-Fehler derselben Familie:

 * command_parameters war ueber (command_id, url) eindeutig. WLED-Parameter
   haben keine URL - ihr Name ist der Platzhalter in der Kommando-Vorlage -,
   und eine NULL kollidiert in MySQL nie. ON DUPLICATE KEY UPDATE griff
   also nicht, und jeder Lauf legte dieselben Parameter erneut an: aus 18
   wurden nach drei Laeufen 54. Schluessel jetzt (command_id,
   parameter_name).

 * actor_states war ueber (actor_id, url) eindeutig. Umgekehrtes Problem:
   Messwerte, die sich ein Topic teilen, ueberschrieben einander. Der
   go-eCharger schickt sechzehn Werte als JSON-Feld auf einem Topic - von
   denen kam genau einer in der Datenbank an. Schluessel jetzt (actor_id,
   state_name), das bringt 50 verlorene Messwerte zurueck.

 * Kommandos, Messwerte und Parameter aktualisierten ihre URL beim
   Wiederholungslauf nicht - sie stand nicht im UPDATE-Teil. Eine Korrektur
   in einem Modul kam damit nie in einer bestehenden Datenbank an.

 * Der Typ eines Kommando-Parameters wurde als Text in eine int-Spalte
   geschrieben. MariaDB macht daraus stillschweigend 0, und 0 ist "bool" -
   deshalb bot der Editor fuer die WLED-Helligkeit (0..255) ein Ja/Nein an.

Der fuenfte: die Shelly-Gen1-Relais hatten weder eine Kommando-URL noch eine
URL am Zustand. Ohne die weiss niemand, was zu schicken und wo nachzusehen
ist. Gen1 schaltet ueber ?turn=on|off|toggle und meldet sich in "ison".

Damit entfaellt auch die Uebersetzungstabelle im HTTPTransport: die
Zuordnung gehoert ins Geraetemodell, nicht in den Runner.

Zwei Luecken auf der Runner-Seite, die derselbe Lauf gezeigt hat:

 * WLED liess sich gar nicht ansteuern - es gab keinen Transport dafuer.
   Der neue nutzt die JSON-Vorlage aus command_url, fuellt die Platzhalter
   und schickt sie am Stueck an /json/state.

 * Tahoma wurde am Schema "io://" erkannt. Das Schema beschreibt aber die
   Funkart: dieselbe Box liefert rts:// fuer die Dachfenster und
   internal:// fuer die Alarmanlage. Drei von 22 Geraeten fielen durch.
   Erkannt wird jetzt an der Box-Kennung in der URL.

Nachgemessen: zwei Laeufe hintereinander aendern keine einzige Zeile mehr,
die Geraete-IDs bleiben ueber Laeufe stabil (Voraussetzung fuer die
Fremdschluessel der Automatiken), alle 54 Geraete finden genau einen
Transport, und 440 der 441 Messwerte sind tatsaechlich lesbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 14:57:29 +02:00
adminandClaude Opus 5 0cb1fc635e Etage ist Pflicht
Die Auswahlliste im Editor bot ein "-" an. Eine Automatik ohne Etage taucht
aber in keinem der drei Reiter auf und ist danach nur noch in der Datenbank
zu finden - anlegen und sofort verlieren.

Der Eintrag ist weg, und der Zustand ist jetzt an drei Stellen unmoeglich
statt nur unwahrscheinlich: die Liste kennt nur noch OG, EG und UG,
saveAutomation lehnt alles andere mit einer Meldung ab statt still auf ""
zurueckzufallen, und im Enum der Spalte gibt es den leeren Wert nicht mehr.

Die Reihenfolge in der Liste ist jetzt OG, EG, UG wie bei den Reitern -
damit trifft der Rueckfall auf den ersten Eintrag dieselbe Etage wie die
Startseite. Kennt die Liste die Etage eines Datensatzes nicht, faellt sie
darauf zurueck, statt leer stehen zu bleiben und beim Speichern "" zu
schicken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:32:58 +02:00
adminandClaude Opus 5 27e5c0d331 Sperrzeit je Automatik in drei Stufen
Die steigende Flanke allein schuetzt nicht gegen einen Messwert, der um die
Schwelle pendelt: "Temperatur > 22" bei 22,1 / 21,9 / 22,1 Grad ist jedes
Mal eine echte Flanke, und ueber MQTT koennen die Werte im Sekundentakt
hereinkommen. Gemessen: fuenf Kommandos in einer halben Minute.

automations.lockout_secs sagt jetzt, wie lange nach einer Ausloesung nicht
wieder geschaltet wird. Der Editor bietet drei Stufen an - ohne, eine
Minute, eine Viertelstunde -, weil die passende Wahl am Geraet haengt und
nicht an einer Zahl: ein Rollladen soll nicht alle zwanzig Sekunden
losfahren, eine Lichtfarbe darf das. Gespeichert werden Sekunden, damit eine
vierte Stufe eine Zeile in lockoutChoices() ist und keine Wanderung durch
die Datenbank. Vorbelegt ist eine Minute.

Eine Flanke in der Sperrzeit wird verworfen, nicht aufgehoben. Ein
Rollladen, der eine Viertelstunde spaeter doch noch losfaehrt, weil vor
langer Zeit einmal eine Schwelle gestreift wurde, waere unangenehmer als
einer, der gar nicht faehrt - und der naechste echte Anlass nach Ablauf der
Sperre kommt ohnehin durch. Verworfene Flanken stehen auf DEBUG und nicht in
automation_log, sonst waere die Tabelle bei einem zappelnden Sensor voll
davon.

force_once bleibt unberuehrt: es greift nur, wenn im Fenster gar nichts
gelaufen ist - dann ist auch keine Sperre aktiv.

Nachgemessen am pendelnden Sensor: mit 60 s Sperre ein Kommando, ohne Sperre
fuenf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:26:18 +02:00
adminandClaude Opus 5 25d3c47bd3 Sonnenstand: ueber die Tagesgrenze rechnen statt abschneiden
Ein Versatz, der ueber Mitternacht hinausreicht, wurde bisher am Tagesrand
abgeschnitten. Damit liessen sich "sechs Stunden vor Sonnenaufgang" oder
"fuenf Stunden nach Sonnenuntergang" gar nicht mehr formulieren - solche
Angaben sind aber gewollt und landen eben am Vorabend bzw. nach Mitternacht.

Gerechnet wird jetzt modulo Tag. Verglichen wird die Uhrzeit innerhalb des
Tages: ein Ziel jenseits von Mitternacht gilt als diese Uhrzeit am selben
Tag. Bei "+" und "-" ist das genau der gemeinte Zeitpunkt, bei "ab" und
"vor" verschiebt sich der wahre Bereich entsprechend mit - das steht so in
der README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:10:20 +02:00
adminandClaude Opus 5 ee417f302f Zeit-Ausloeser: "um" ergaenzt, Sonnenstand vervollstaendigt
Bei Uhrzeit und Datum gab es nur "ab" und "vor". Der haeufigste Fall - eine
Aktion genau um 16:30 - liess sich damit gar nicht ausdruecken. "um" ist
jetzt der erste Eintrag und damit die Vorbelegung.

Der Hinweis von frueher bleibt trotzdem richtig und steht in der README:
"um" trifft nur eine einzige Minute, faellt der Runner ausgerechnet in
dieser Minute aus, ist die Automatik fuer den Tag verloren. "ab" holt der
naechste Takt nach. Wer beides will, hakt force_once an.

Beim Sonnenauf- und -untergang fehlte die halbe Tabelle: der Versatz kann
davor oder danach liegen, und verglichen werden kann davor, danach oder
genau. Statt zwei gibt es jetzt sechs Operatoren - "+", "-", "ab +",
"ab -", "vor +", "vor -". Das Vorzeichen steckt im Operator, weil der
Editor eine einzige Auswahlliste zeigt und nicht zwei Bedienelemente fuer
eine Angabe.

Rutscht ein Versatz rechnerisch ueber den Tagesrand (Sonnenaufgang 05:34
minus sechs Stunden), bleibt es beim Tagesrand statt auf die andere Seite
von Mitternacht zu springen - "kurz vor Sonnenaufgang" soll nicht ploetzlich
gestern abend bedeuten.

Der Editor brauchte keine Aenderung: er liest Wert und Beschriftung der
Operatoren aus dem Geraetekatalog, statt sie selbst zu kennen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 12:30:31 +02:00
adminandClaude Opus 5 5f643b9844 AutoAction-Runner: Automatiken ausfuehren
Bisher konnte man Automatiken nur anlegen - ausgefuehrt hat sie niemand.
restricted/autoActions/autoaction_runner.py holt das nach.

Dauerlaeufer statt Cronjob, aus zwei Gruenden: Schwellwert-Ausloeser sollen
greifen, wenn die MQTT-Nachricht hereinkommt, und actor_states.current_value
wird sonst von niemandem fortgeschrieben - beim Discovery einmal gesetzt und
danach nie wieder. Ein zustandsloser Lauf haette gar nichts, womit er
vergleichen koennte. Der Runner pflegt den Wert nebenbei mit, wovon auch der
Editor profitiert: er zeigt neben jedem Messwert den aktuellen Stand.

Ausgeloest wird nur auf der steigenden Flanke (automations.cond_met), sonst
wuerde "Temperatur ueber 22 Grad" bei jedem Takt erneut feuern. Aus
demselben Grund heissen Zeit-Ausloeser jetzt "ab 16:30" statt "gleich
16:30": ein Gleichheitsvergleich waere nur in einer einzigen Minute wahr,
und ein Ausfall in genau dieser Minute kostet den ganzen Tag. Dieselbe
Ueberlegung steht hinter den breiten Zeitfenstern in auto_watering.py.

Der Weg zum Geraet haengt an der URL des Aktors: mqtt:// abonniert und
publiziert, http:// pollt und haengt Parameter an, io:// spricht mit der
Tahoma-Box, Logic rechnet Uhrzeit, Datum und Sonnenzeiten. Alle vier stehen
in transports.py; eine fuenfte Geraeteart ist eine weitere Klasse mit
passt(), zustaende_lesen() und senden().

fetch_calendar.py fuellt calendar_days aus openholidaysapi.org, damit "in
den Ferien" und "an Feiertagen" eine Grundlage haben. Einmal jaehrlich per
Cron.

Die Uebersicht blendet auf schmalen Schirmen Spalten aus, statt sie
wegzuschieben - auf dem Handy waren die Knoepfe zum Pausieren und Loeschen
sonst nur per Seitwaertsscrollen erreichbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 19:21:07 +02:00
adminandClaude Opus 5 eee3a89b4e AutoAction-Editor: Speichern, Laden und Loeschen
Der Editor las seine Geraete aus homeMesh, schrieb aber nach solarLog in
Tabellen, deren Fremdschluessel auf solarLog.actors/sensors zeigten. Jedes
INSERT lief damit gegen den Fremdschluessel - autoactionsSensors und
autoactionsActors sind deshalb leer geblieben, und die Aktor-Schleife war
ohnehin nie geschrieben worden.

Die Automatiken liegen jetzt in homeMesh neben dem Geraetemodell
(homeMesh_automations.sql). Eine Bedingung verweist mit einem einzigen
Fremdschluessel auf actor_states statt auf Geraet, State-URL und ein
unklares valID; Kommandoparameter stehen zeilenweise statt in vier festen
Spalten "Wert 1" bis "Wert 4". Verknuepft wird ueber group_no: gleiche
Nummer UND, verschiedene ODER, ausgewertet als any(all(gruppe)).

Im Editor ist eine Gruppe ein gerahmter Block mit eigenem "+ Bedingung",
dazwischen steht ODER - die Klammerung ist damit gezeichnet und nicht
vereinbart, und darunter steht derselbe Satz noch einmal in Worten. Die
Uebersicht zeigt ihn in der Spalte "Ausloeser" und ersetzt die bisher fest
verdrahtete Beispielzeile.

Nebenbei behoben: die Operator-Knoepfe schickten ihr innerHTML ("≠",
"&gt;") an eine Whitelist, die "!=" erwartete - jede Bedingung "ungleich"
wurde still zu "gleich". Ausgeblendete Wertfelder sendeten ihren Inhalt
trotzdem mit. Beides entfaellt, weil der Editor jetzt JSON aus einem
Modell schickt statt durchnummerierter Formularfelder.

Die vier Endpunkte fillSensorDD, fillActorDD, sensorDetails und
actorDetails entfallen: der Geraetekatalog reist einmal mit dem Editor
mit, statt je Bedingungszeile zwei Anfragen nachzuladen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 19:07:29 +02:00
adminandClaude Opus 5 fd27fe168e Handy-Darstellung: Diagramme, Meteogramm und Abstaende
Auf dem Handy war die Seite bisher nur mit der Desktopversion des Browsers
brauchbar. Drei Ursachen, alle nachgemessen bei 375 px:

Die Diagramme standen im Hochformat, 301 x 400 px - die Zeitachse lag auf
der kurzen Seite. Die Hoehe kam nicht aus dem Container: .chart-container
hatte inline height:100% in einem Elternelement ohne Hoehe, und die Canvas
trugen height="400px;", was kein gueltiger Zahlenwert ist. Die Hoehe steht
jetzt im Stylesheet, 400 px am Rechner und 250 px unter 768 px Breite, die
Beschriftung dort 8 statt 10 px.

Das Meteogramm hat sich nicht angepasst, sondern gequetscht. meteoblue legt
sein Bild einmal beim Laden fest und zeichnet es bei einer Breitenaenderung
nicht neu; unter etwa 600 px ueberlagern sich Wochentage und Stundenachse.
Es wird jetzt in mindestens 640 px Breite geladen - auf breiten Karten
gleich in Kartenbreite, dort aendert sich nichts - und, wenn die Karte
schmaler ist, als Ganzes verkleinert. Zuschnitt und Skalierung setzt das
Skript direkt am Element, nicht ueber das Stylesheet: sonst haengt die
Darstellung an einer womoeglich veralteten solar.css im Browser-Cache, und
der breit geladene Rahmen ragt ungeschnitten aus der Karte.

Passend dazu bekommt solar.css eine Versionskennung aus filemtime(), wie sie
die Skripte in footer.php und adminlte.min.css laengst tragen. Ohne sie kam
auf schon einmal besuchten Geraeten die alte Datei aus dem Cache.

Die Statistik-Kacheln lagen in nackten .col ohne Breitenangabe; Bootstrap
laesst sie dann nach Textlaenge umbrechen, mal zwei, mal drei je Zeile und
unterschiedlich breit. Die Breite kommt jetzt aus row-cols-2/row-cols-md-4,
und die Karte fuellt die Zeilenhoehe, damit die Werte trotz ein- oder
zweizeiliger Titel auf einer Linie stehen.

Zuletzt sind die Abstaende unter 768 px enger gefasst - auf .app-content
beschraenkt, damit Seitenleiste, Kopfzeile und Modals unberuehrt bleiben:
Spaltenabstand 24 -> 8 px, Kartenabstand 24 -> 12 px, Polsterung 16 -> 8 px.
Die nutzbare Breite steigt damit von 301 auf 317 px.

Geprueft bei 375 und 1200 px auf Solar, Historie, Heizung, Home und Wetter:
kein Querlauf, am Rechner unveraenderte Werte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 23:05:33 +02:00
adminandClaude Opus 5 6aac3326a9 Bewaesserungs-Modal: Tagessummen aus WateringToday und LastWateringDay
Die Ventilsteuerung veroeffentlicht neuerdings je Steuerung zwei weitere
Topics: WateringToday mit der heute schon gelaufenen Zeit je Ventil und
LastWateringDay mit derselben Summe fuer den letzten Tag, an dem ueberhaupt
bewaessert wurde. Beide tauchen als zweite Zeile in der Zonenkarte auf:
"Heute 35m - Vortag 30m".

WateringToday wird gegen das heutige Datum geprueft. Die Nachricht ist
retained; nach einem Tageswechsel ohne Bewaesserung stuende sonst die Summe
von gestern als heutige da. Passt der Tag nicht, zeigt die Karte "--" und
nennt den Stand im Titel. Die Spalte heisst "Vortag" und nicht "Gestern",
weil LastWateringDay einen eigenen wateringDay mitfuehrt - es ist der letzte
Tag mit Bewaesserung, nicht zwangslaeufig der gestrige; das Datum steht im
Titel.

Geprueft: leeres Topic, Nutzlast mit 0 Sekunden, gefuellte Werte, veraltete
Nutzlast, fehlendes Feld, kaputtes JSON, Datumsformat - zehn Faelle, keine
Abweichung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 22:48:25 +02:00
adminandClaude Opus 5 eca5c8f041 Bewaesserungs-Modal: Zonenkarten statt Tabelle
Das Modal ist 500 px breit (modal-dialog ohne Groessenklasse). Die Tabelle
mit fuenf Spalten samt Zahlenfeld war darin schon eng; jede weitere Angabe
haette sie gesprengt. Statt der Tabelle bekommt jede Zone eine eigene Karte
mit Zustandsschild, Steuerzeile und den Zeiten darunter, die Automatik eine
eigene Karte darueber - dort sitzt auch die kombinierte Automatik, die zwei
Zonen bedient.

Die Automatik-Karte zeigt zusaetzlich, was auto_watering.py je Zone vorhat,
und begruendet es: "heute geplant - nur 0,2 mm Regen in 2 Tagen". Die Zahlen
stammen aus dem RainStatus-Topic, das die Seite ohnehin schon abonniert hat,
bisher aber nur fuer das Uhr-Symbol im SVG benutzte. Die genaue Regel steht
im Titel der Zeile, weil sich die Schwellen je Zone deutlich unterscheiden.
Die letzte Bewaesserung bleibt in der Zonenkarte, sie ist eine Eigenschaft
der Zone; die Automatik nennt sie nur als Begruendung.

Nebenbei sind die drei getrennten Zuordnungstabellen (wateringZones,
wateringIndicators, wateringHistoryRows) zu einer verschmolzen, die Ventil,
Automatik-Zone, Beschriftung und die SVG-Anzeige-Ids traegt. Damit wird aus
updateWateringStatus eine Schleife statt dreier, und die doppelt vorhandene
Knopflogik faellt weg. Alle 18 Zuordnungen und die Historienzeilen sind
gegen den vorherigen Stand geprueft, ohne Abweichung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 22:47:50 +02:00
adminandClaude Opus 5 37270acb23 .htaccess: Git-Verzeichnis auch ohne mod_rewrite und mod_alias sperren
Die Regel gegen Punkt-Pfade stand ausschliesslich in IfModule-Bloecken fuer
mod_rewrite und mod_alias. Ist keines der beiden geladen, entfaellt sie
ersatzlos und ohne Fehlermeldung, und .git waere wieder abrufbar - genau das
Fail-Open, das derselbe Commit-Satz bei restricted/.htaccess beseitigen
sollte.

Ergaenzt um eine Notbremse aus Kern-Direktiven, die immer verfuegbar sind:
FilesMatch auf die Einstiegsdateien eines Git-Verzeichnisses (HEAD, config,
index, packed-refs und weitere) sowie pack und idx in der Typenliste. Ohne
diese Dateien laesst sich das Verzeichnis nicht aufrollen, weil die
Objektdateien hexadezimale Namen tragen.

FilesMatch passt nur auf Dateinamen, nicht auf Pfadbestandteile, und kann
.git deshalb nicht vollstaendig sperren. Der Kommentar in der Datei sagt das
und benennt die belastbare Loesung: .git und restricted/ ausserhalb des
Dokumentenbaums legen.

Gegengeprueft: von den 40 Adressen, die die Seiten laden, faellt keine unter
die neuen Regeln; in .git greifen sie zusaetzlich fuer 13 Dateien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:16:26 +02:00
adminandClaude Opus 5 f17d010d5f fillElementArray(): auch den Wert-Cache verwerfen
Die Funktion soll laut eigener Beschreibung den Zwischenspeicher verwerfen,
falls das SVG neu gezeichnet wird, leerte aber nur svgElements. letzterWert
blieb gefuellt, sodass setText, setAttr und setDisplay jeden Schreibzugriff
danach als "unveraendert" ueberspringen: die Knoten werden zwar neu
nachgeschlagen, aber nie beschrieben. Das frische SVG behaelt dann seine
Platzhalter, bis sich der jeweilige Messwert zufaellig aendert - bei den
Boegen mit auf volle Grad gerundeten Winkeln kann das dauern.

Nachgestellt: nach dem Verwerfen nur der Knoten bleibt die Anzeige auf "--",
mit geleertem letzterWert wird wieder geschrieben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:15:24 +02:00
adminandClaude Opus 5 405162ed47 setFlowWidth(): negative Breite bedeutet kein Fluss, nicht Fluss rueckwaerts
Die Funktion bildete die uebergebene Breite mit Math.abs() ab. Die Aufrufer
rechnen 55*(1-exp(x)); kehrt sich das Vorzeichen der Messgroesse um, wird der
Ausdruck negativ. Frueher setzte das eine negative stroke-width und die Linie
blieb leer. Mit Math.abs() erschien stattdessen eine Animation voller Staerke
in unveraenderter Laufrichtung - das Diagramm zeigte einen Fluss, den es nicht
gab.

Beispiel: speist die EG-Etage mit 900 W zurueck, ergibt 55*(1-exp(900/2000))
den Wert -31,3. Math.abs machte daraus eine 31,3 breite laufende Linie, jetzt
sind es 0 und die Linie ruht.

Positive Breiten und die Schwelle von 0,5 bleiben unveraendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:14:54 +02:00
adminandClaude Opus 5 dc85f33e9f ipInNetwork(): Praefixlaenge pruefen statt ungeprueft uebernehmen
Die Laenge wurde mit intval() aus dem CIDR-String uebernommen. Bei einem
Tippfehler in LOCAL_NETWORKS lief die Funktion damit fail-open: "/-5" ergibt
intdiv(-5,8)=0 ganze Bytes und chr((0xFF << 13) & 0xFF)=Nullbyte, womit beide
maskierten Bytes gleich sind und jede Adresse in jedem Netz liegt. isLocal()
haette dann jede Anfrage aus dem Internet als lokal gewertet - und damit als
angemeldet, inklusive Freigabe der Passkey-Registrierung.

Die Laenge muss jetzt aus reinen Ziffern bestehen und darf die Adressbreite
nicht ueberschreiten; sonst liefert die Funktion false. "/-5", "/244", "/abc"
und "/" fallen damit durch, gueltige Angaben bleiben unveraendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:14:25 +02:00
adminandClaude Opus 5 16e236c7f8 Verzeichnisschutz haerten: Web-Root absichern, .htaccess versionsfest machen
restricted/.htaccess enthielt nur "deny from all". Das ist Apache-2.2-Syntax;
unter 2.4 funktioniert sie ausschliesslich mit geladenem mod_access_compat und
quittiert sonst mit einem Fehler. Der Schutz haengt damit an einem Modul, das
es nur aus Abwaertskompatibilitaet gibt. Jetzt stehen "Require all denied"
(2.4) und "Order allow,deny" (2.2) nebeneinander in IfModule-Bloecken.

Neu dazu ein .htaccess im Web-Root, denn restricted/ war nur ein Verzeichnis
von mehreren:

- Punkt-Pfade werden gesperrt. Das Git-Verzeichnis liegt im Web-Root; ueber
  .git liesse sich sonst der komplette Quellcode samt Historie herunterladen.
  Betrifft 65 Dateien, darunter .git/config und .claude/settings.local.json.
  .well-known bleibt frei fuer Zertifikatsausstellungen.
- Dateitypen gesperrt, die der Browser nie anfordert: ini, py, pyc, json, md,
  log, bak, example, sql, sqlite, db, tgz, gz, zip, yml, yaml. Betrifft 24
  Dateien, darunter deviceDiscovery/config.ini mit Zugangsdaten,
  tahoma_devices*.json und die Python-Skripte.

Gegengeprueft: von den 37 Adressen, die die Seiten laden, faellt keine unter
die neuen Regeln. Das Frontend holt ausschliesslich php, css, js, Bilder und
Schriften; tahoma_devices_classic.json liest PHP serverseitig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:53:30 +02:00
adminandClaude Opus 5 48017e895a isLocal() haerten: echte Adressvergleiche statt Zeichenketten
Die Pruefung zerlegte beide Adressen mit explode(":") und verglich die ersten
vier Felder. Bei IPv4 entsteht dabei nur ein Feld, die uebrigen drei sind
undefiniert und null == null ist wahr - die Bedingung schrumpfte damit auf
REMOTE_ADDR == SERVER_ADDR. Kaeme je ein Reverse Proxy auf denselben Host,
haette das jeden Zugriff aus dem Internet als lokal gelten lassen.

Neu:
- LOCAL_NETWORKS als sichtbare Liste, derzeit 192.168.179.0/24.
- ipInNetwork() vergleicht binaer ueber inet_pton, also unabhaengig von der
  Schreibweise. "2001:0db8:a:b::9" gilt jetzt zurecht als dasselbe /64 wie
  "2001:db8:a:b::9" - vorher fiel dieser Client durch.
- Das IPv6-/64 wird weiterhin aus SERVER_ADDR abgeleitet, da der Provider das
  Praefix vergibt.
- REMOTE_ADDR wird mit filter_var geprueft; "192.168.179.44 evil" galt vorher
  wegen str_starts_with als lokal.
- Auf IPv6 abgebildete IPv4-Adressen (::ffff:192.168.179.44) werden
  normalisiert. Ein Dual-Stack-Socket meldet LAN-Clients so; bisher fielen
  sie durch.
- $_SESSION["local"] wird in beiden Zweigen gesetzt, nicht nur im positiven.

Verhaltensaenderung: Anfragen, deren Absender gleich SERVER_ADDR ist - etwa
127.0.0.1 - gelten nicht mehr automatisch als lokal. Im Repo ruft nichts die
Seite ueber HTTP vom Server selbst auf. Wird das doch gebraucht, gehoert das
betreffende Netz in LOCAL_NETWORKS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:43:43 +02:00
adminandClaude Opus 5 768520cedd Geraete-Endpunkte: Anmeldung voraussetzen
actorDetails.php, sensorDetails.php, fillActorDD.php und fillSensorDD.php
fragten die homeMesh-Datenbank ab und gaben Geraetenamen samt Steuer-URLs
(command_url, command_parameters.url) aus - ohne jede Zugriffspruefung.

Alle vier pruefen jetzt checkLogin(), bevor sie etwas ausgeben. Aufgerufen
werden sie ausschliesslich aus js/solar/autoActionFuncs.js auf der Home-Seite,
also aus einer angemeldeten Sitzung; der Regeleditor funktioniert unveraendert.

Die SQL war bereits sauber (intval), es fehlte nur die Autorisierung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:37:49 +02:00
adminandClaude Opus 5 0075f803d0 tahoma.php: Anmeldung voraussetzen
Die Datei hatte keinerlei Zugriffspruefung. Damit liessen sich von beliebiger
Stelle die Somfy-Geraete auflisten (?action=devlist) sowie Rollladen abfragen
und verfahren (?action=move&device=...&pos=...&angle=...). Das Tahoma-Token
liegt serverseitig, ein Angreifer brauchte es also nicht.

checkLogin() ist jetzt vorgeschaltet, dafuer wird helper.php eingebunden. Im
Frontend gibt es derzeit keinen Aufrufer der Datei, ein bestehender Ablauf
kann also nicht brechen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:37:48 +02:00
adminandClaude Opus 5 464c08f414 carEG.php: Befehlseinschleusung ueber das Zeitfeld schliessen
$_POST["ftt"] wurde nur auf "nicht leer" geprueft und dann roh in die
exec()-Kommandozeile gehaengt. Ein Wert wie "12:00 2>&1; befehl #" fuehrte
damit beliebige Shell-Befehle als Web-Benutzer aus.

Das Formularfeld ist ein <input type="time">, liefert also HH:MM. Der Wert
wird jetzt gegen genau dieses Format geprueft; alles andere gilt als "keine
Ladeplanung" und nimmt den vorhandenen else-Zweig. Zusaetzlich gehen ftt und
setTime durch escapeshellarg().

fte und evAmp waren durch die Vergleichs-Klemmung bereits faktisch numerisch,
laufen jetzt aber ueber intval() und eigene Variablen statt ueber $_POST.
Fuer gueltige Eingaben ist die erzeugte Kommandozeile unveraendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:35:59 +02:00
adminandClaude Opus 5 49ff6b5c0b authServer.php: Passkey-Registrierung gegen fremde Aufrufe absichern
getCreateArgs und processCreate waren ohne jede Pruefung erreichbar. Der
Einmalschluessel aus addUser.php steuerte nur, ob index.php die
Registrierungsseite anzeigt - authServer.php liess sich direkt aufrufen und
umging das. Da userId und userName fest hinterlegt sind, konnte damit jeder
mit zwei Requests einen eigenen Passkey fuer das vorhandene Konto anlegen.

checkAdduser() vermerkt eine erfolgreiche Schluesselpruefung jetzt als
$_SESSION["mayRegister"]. authServer.php laesst getCreateArgs, processCreate
und clearRegistrations nur noch durch, wenn dieser Vermerk hoechstens zehn
Minuten alt ist oder der Aufruf aus dem lokalen Netz kommt.

Anmeldung und Metadatenabfrage (getGetArgs, processGet,
queryFidoMetaDataService) bleiben unveraendert offen.

authServer.php bindet dafuer helper.php ein statt nur restricted/mysql.php;
das eigene session_start() entfaellt, helper.php erledigt es.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:34:11 +02:00
adminandClaude Opus 5 64445e95fe Zugangsdaten nur noch aus restricted/mysql.php, Datei nicht mehr versioniert
authServer.php und ajax/getForecastData.php hatten Server, Benutzer, Passwort
und Datenbank noch einmal fest eingetragen und damit die Werte ueberschrieben,
die restricted/mysql.php ohnehin liefert. Beide Dateien binden die Konfiguration
bereits ein; die Doppelung ist entfallen.

getForecastData.php benutzt jetzt $mysql_solarUser/-Pass/-DB wie die uebrigen
Endpunkte, statt dieselben Werte unter den generischen Namen zu setzen.

restricted/mysql.php steht jetzt in .gitignore, restricted/mysql.php.example
dient als Vorlage fuer neue Arbeitskopien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:50:45 +02:00
adminandClaude Opus 5 6b8556567b Dashboard: Doppelungen zusammengelegt, Lade- und Renderlast gesenkt
Zusammenlegung
- Die sechs Endpunkte getConsData_/getProdData_{month,year,decade}.php sind
  ajax/energyHistory.php?series=&range= gewichen, der gemeinsame Aufbau der
  Chart-Daten steht in ajax/chartData.php. Die erzeugte SQL wurde gegen alle
  sechs Originale abgeglichen.
- Die 13 Raeume der Home-Ansicht stehen nur noch in restricted/rooms.php.
  home.php rendert die SVG-Kacheln daraus, homeMQTT.js bekommt dieselbe
  Tabelle als JSON (346 -> 222 bzw. 428 -> 197 Zeilen).
- Gemeinsame JS-Helfer in js/solar/common.js; geteilte Bausteine fuer
  Wetterkacheln, Chart-Skripte und das Formular der Ladesteuerung.
- Seitenvorlagen sind jetzt .php und werden eingebunden, statt per
  file_get_contents zusammengesetzt zu werden.

Fehlerbehebungen
- ?action=weather fuehrte auf eine leere Seite, es gibt jetzt weather.php.
- homeMQTT.js griff auf ein #meteogram zu, das home.php nie hatte, und brach
  damit den readystatechange-Handler ab.
- Die Heizungsseite abonnierte weatherStation/#, zeigte die Werte aber nie an.

Ladelast
- Chart.js, MQTT und Meteogramm werden nur noch auf den Seiten geladen, die
  sie brauchen; die Seitentabelle dafuer steht in index.php.
- mqtt.js durch die minifizierte Fassung ersetzt (859 -> 359 KB). MQTT.js
  5.14.1, byte-identisch mit dem signierten npm-Tarball.
- Rund 3,1 MB ungenutzte Vendor-Reste und AdminLTE-Demobilder entfernt.
- Solar 1796 -> 1240 KB, Home 1796 -> 873 KB, Historie 1796 -> 843 KB.

Speicher im Browser
- Chart.defaults.devicePixelRatio auf 1.5 gedeckelt. Die Zeichenpuffer der
  Diagramme waren mit 20-45 MB der groesste Posten der Seite.
- Das Meteogramm-iframe laedt erst beim Hinscrollen.

Realtime-SVG
- Filterbereiche auf das noetige Mass verkleinert und den wirkungslosen
  feOffset-Durchgang entfernt: 60 % weniger gerasterte Filterflaeche bei
  pixelgleichem Ergebnis.
- Die Fluss-Animation laeuft ueber transform statt stroke-dashoffset, mit
  Punkten statt Strichmuster; sie ruht, wo kein Fluss anliegt.
- Der Wertupdate schreibt nur noch bei echter Aenderung und benutzt
  textContent statt innerHTML: 9 statt 86 Elemente pro Update, davon keines
  mit Filter. describeArc rundet auf volle Grad.
- waterInfo auf scale(0.90) wie die uebrigen Info-Gruppen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:54:28 +02:00
adminandClaude Sonnet 5 29f752eafe Regenbasierte automatische Gartenbewässerung + homeMesh-Anbindung für AutoAction-Regeleditor
- Neues stündliches Python-Script (restricted/gartenbewaesserung/auto_watering.py): holt Niederschlags-
  und Sonnenuntergangsdaten von Open-Meteo, wertet je Zone (Hochbeete/Tröge/Garten vorn) individuelle
  Regen-Schwellenwerte und Mindest-Bewässerungsdauern aus und startet bei Bedarf per MQTT den passenden
  Automatik-Modus der Ventilsteuerung im Zeitraum vor Sonnenuntergang; veröffentlicht den Regen-Status
  zusätzlich stündlich als retained MQTT-Nachricht je Zone.
- Web-UI: neue Bewässerungs-Steuerung/-Anzeige (ajax/watering.php, js/solar/solarMQTT.js,
  assets/img/realtime.svg) inkl. Live-Status-Icons (aus/geplant/pausiert/aktiv per Farbe und Symbol).
- AutoAction-Regeleditor (ajax/actorDetails.php, sensorDetails.php, fillActorDD.php, fillSensorDD.php,
  tahoma.php, AutoAction.php) liest Actor-/Sensor-Parameter jetzt live aus der homeMesh-Datenbank statt
  aus stark vereinfachten Altfeldern, inkl. zugehöriger Anpassungen am Device-Discovery-Tooling
  (restricted/deviceDiscovery/*, neues logic_module.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 20:19:27 +02:00
admin 4de95c1a29 prevent alert if nothing is returned on modal form sent. 2026-02-14 20:36:43 +01:00
admin ae455dba20 modify filemode for linux 2026-02-14 20:08:34 +01:00
admin 0e78302640 Initial commit 2026-02-14 19:47:21 +01:00
553 changed files with 90622 additions and 983 deletions
+17
View File
@@ -0,0 +1,17 @@
# Zeilenenden gehoeren zum Repository, nicht zur Maschine.
#
# Ohne diese Datei entscheidet jede Arbeitsstation fuer sich: Git fuer
# Windows setzt core.autocrlf=input in seiner System-Konfiguration, die NAS
# setzt nichts. Dasselbe Repository, zwei Verhalten - und weil der Bestand
# selbst gemischt war (34 PHP-Dateien mit LF, 30 mit CRLF), gab es kein
# richtiges Zeilenende, an das ein Werkzeug sich haette halten koennen.
# Jede Ergaenzung mit LF in einer CRLF-Datei ergab eine gemischte Datei.
#
# LF und nicht CRLF, weil dieses Verzeichnis der Web-Ordner der NAS ist und
# von Linux ausgeliefert wird.
* text=auto eol=lf
# Eingekaufter Fremdcode bleibt byteweise so, wie er geliefert wurde - dann
# bleibt ein Update dagegen vergleichbar. Darunter liegen auch 292
# Zertifikate, die niemand anfassen sollte.
restricted/WebAuthn/** -text
+59
View File
@@ -0,0 +1,59 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
ENV/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Konfiguration (enthält Credentials!)
config.ini
restricted/mysql.php
# Logs
*.log
logs/
# Betriebssystem
.DS_Store
Thumbs.db
# Der Vorschaubild-Ordner der Synology. Sie legt ihn neben jedem Bild an,
# auch in assets/img - das sind ihre Miniaturen, kein Inhalt des Projekts.
@eaDir/
@SynoResource
# Temporäre Dateien
*.tmp
*.bak
*.backup
# Kartenkacheln des Zwischenspeichers (ajax/tile.php).
# Wachsen mit dem, was angesehen wurde - gehoeren nicht ins Repository.
tiles/
+52
View File
@@ -0,0 +1,52 @@
# Zugriffsschutz fuer den gesamten Web-Root.
#
# restricted/.htaccess schuetzt nur ein Verzeichnis. Die Regeln hier greifen
# fuer den ganzen Baum und fangen ab, was ausserhalb davon liegt - allen voran
# das Git-Verzeichnis, ueber das sich sonst der komplette Quellcode samt
# Historie herunterladen laesst.
# --- Punkt-Verzeichnisse und -Dateien: .git, .claude, .gitignore ---
# .well-known bleibt frei, das brauchen Zertifikatsausstellungen.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule (^|/)\.(?!well-known/) - [F,L]
</IfModule>
<IfModule !mod_rewrite.c>
<IfModule mod_alias.c>
RedirectMatch 404 (^|/)\.(?!well-known/)
</IfModule>
</IfModule>
# --- Dateitypen, die der Browser nie anfordert ---
# Konfiguration, Python-Skripte, Datenhaltung, Vorlagen und Archive. Die
# Seite laedt ausschliesslich .php, .css, .js, Bilder und Schriften.
<FilesMatch "\.(ini|py|pyc|json|md|log|bak|example|sql|sqlite|db|tgz|gz|zip|yml|yaml|pack|idx)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
# --- Notbremse ohne Modulabhaengigkeit ---
# Die Punkt-Regel oben braucht mod_rewrite oder mod_alias. Fehlen beide, faellt
# sie ersatzlos und ohne Fehlermeldung aus - und .git waere wieder abrufbar.
# FilesMatch gehoert zum Kern und ist immer verfuegbar. Es passt allerdings nur
# auf Dateinamen, nicht auf Pfadbestandteile, kann .git also nicht vollstaendig
# sperren. Es nimmt den ueblichen Auslesewerkzeugen aber den Einstieg: ohne
# HEAD, config, index und packed-refs laesst sich das Verzeichnis nicht
# aufrollen, weil die Objektdateien hexadezimale Namen tragen.
#
# Belastbar waere nur, .git und restricted/ ausserhalb des Dokumentenbaums zu
# legen. Diese Regeln sind Schadensbegrenzung, keine Grenze.
<FilesMatch "^(HEAD|ORIG_HEAD|FETCH_HEAD|MERGE_HEAD|COMMIT_EDITMSG|config|index|packed-refs|description|shallow|gitattributes|gitignore|gitmodules)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
+949 -204
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
<?php
require_once("restricted/mysql.php");
require_once("helper.php");
$mysql = new mysqli($mysql_server,$mysql_user,$mysql_pass,$mysql_db);
if(!mysqli_query($mysql,"DELETE FROM addUser WHERE datetime < DATE_SUB(NOW(), INTERVAL 1 MINUTE);")){
echo mysqli_error($mysql);
}
if(isLocal()){
$accessKey = strval(random_int(0,99999999));
if(!mysqli_query($mysql,"INSERT INTO addUser SET accesskey=".$accessKey.", datetime=NOW();")){
echo mysqli_error($mysql);
}
header('Location: https://nas.el-wa.org/smart?addUser='.$accessKey);
echo 'https://nas.el-wa.org/smart?addUser='.$accessKey;
}
?>
+520
View File
@@ -0,0 +1,520 @@
<?php
/**
* Endpunkt fuer die Automatiken: Editor, Uebersicht, Speichern, Loeschen.
*
* Alles laeuft ueber ?action=... , damit Formular und Uebersicht dieselbe
* Modell-Schicht (restricted/automations.php) benutzen und nicht zwei
* Wahrheiten ueber denselben Datensatz entstehen.
*
* GET ?action=editor&id=N Editor fuer eine vorhandene Automatik
* GET ?action=editor&floor=OG Editor fuer eine neue Automatik
* GET ?action=list&floor=OG Tabelle fuer die Karte "Automatismen"
* POST ?action=save JSON-Rumpf, legt an oder ueberschreibt
* GET ?action=followers&id=N JSON: wer haengt an dieser Automatik?
* GET ?action=werte&ids=1,2,3 JSON: was diese Messwerte gerade sagen
* POST ?action=delete {"id": N, "mode": "abhaengen"|"mitloeschen"}
* POST ?action=toggle {"id": N} - pausieren / fortsetzen
*/
require_once("../helper.php");
require_once("../restricted/automations.php");
if (!checkLogin()) {
http_response_code(403);
exit;
}
function jsonAntwort($data, $code = 200)
{
http_response_code($code);
header("Content-Type: application/json; charset=utf-8");
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
$action = $_GET["action"] ?? "editor";
$floor = in_array($_GET["floor"] ?? "", $floors, true) ? $_GET["floor"] : "";
// --- Schreibende Aufrufe -------------------------------------------------
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$body = json_decode(file_get_contents("php://input"), true);
if (!is_array($body)) {
jsonAntwort(["error" => "Konnte die Anfrage nicht lesen."], 400);
}
try {
switch ($action) {
case "save":
$id = saveAutomation($body);
jsonAntwort(["id" => $id]);
case "delete":
// Der Modus kommt aus der Rueckfrage im Browser. Unbekanntes
// faellt auf das schonendere "abhaengen" zurueck - lieber eine
// pausierte Automatik zu viel als eine geloeschte zu viel.
$modus = ($body["mode"] ?? "") === "mitloeschen"
? "mitloeschen" : "abhaengen";
deleteAutomation(intval($body["id"] ?? 0), $modus);
jsonAntwort(["ok" => true]);
case "toggle":
jsonAntwort(["enabled" => toggleAutomation(intval($body["id"] ?? 0))]);
default:
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
}
} catch (InvalidArgumentException $e) {
jsonAntwort(["error" => $e->getMessage()], 400);
} catch (Throwable $e) {
jsonAntwort(["error" => "Speichern fehlgeschlagen: " . $e->getMessage()], 500);
}
}
// --- Wer haengt an dieser Automatik? -------------------------------------
// Wird vor dem Loeschen gefragt, damit die Rueckfrage im Browser die Namen
// nennen kann und nicht nur eine Zahl.
if ($action === "followers") {
jsonAntwort(["followers" => automationFollowers(intval($_GET["id"] ?? 0))]);
}
// --- Was sagen diese Messwerte gerade? -----------------------------------
// Der offene Editor fragt das im Takt nach, damit neben jeder Bedingung der
// laufende Wert steht. Absichtlich ein eigener, winziger Endpunkt: den
// ganzen Geraetekatalog neu zu holen waeren je Runde ueber hundert
// Kilobyte fuer ein paar Zahlen.
if ($action === "werte") {
$ids = array_slice(explode(",", $_GET["ids"] ?? ""), 0, 200);
jsonAntwort(["werte" => stateValues($ids)]);
}
// --- Zeitleiste ----------------------------------------------------------
// Alle Automatiken eines Tages mit ihrer Lage, siehe restricted/zeitleiste.php.
if ($action === "zeitleiste") {
require_once("../restricted/zeitleiste.php");
try {
jsonAntwort(zeitleiste(strval($_GET["datum"] ?? date("Y-m-d"))));
} catch (Throwable $e) {
jsonAntwort(["error" => "Zeitleiste nicht abrufbar: " . $e->getMessage()], 500);
}
}
// --- Uebersichtstabelle --------------------------------------------------
if ($action === "list") {
$tage = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
$list = listAutomations($floor);
if (!$list) {
echo "<p class='text-body-secondary mb-2'>Für diese Etage ist noch nichts eingerichtet.</p>";
} else {
echo "<div class='table-responsive'><table class='table table-hover align-middle'>";
// Je schmaler der Schirm, desto weniger Spalten: auf dem Handy bleiben
// Name, Ausloeser und die Knoepfe. Sonst muss man seitwaerts scrollen,
// um eine Automatik ueberhaupt pausieren zu koennen.
echo "<thead><tr><th>Name</th><th>Auslöser</th>"
. "<th class='d-none d-sm-table-cell'>Aktion</th>"
. "<th class='d-none d-md-table-cell'>Wann</th>"
. "<th class='d-none d-md-table-cell'>Zuletzt</th><th></th></tr></thead><tbody>";
/*
* Eine Automatik, die von einer anderen ausgeloest wird, steht unter
* ihr statt alphabetisch irgendwo dazwischen - sonst liest man
* "Rollladen Magdalena morgens" und sieht nicht, woran es haengt.
*
* Eingeklappt, weil die Uebersicht sonst mit jeder Verkettung laenger
* wird, ohne mehr zu sagen: der Ausloeser ist das, was man sucht. Der
* Zaehler am Pfeil verraet, dass da noch etwas ist.
*
* Gruppiert wird nur die Darstellung. Jeder Nachfolger bleibt eine
* vollwertige Automatik mit eigener Pause, eigenen Rahmenbedingungen
* und eigener Zeile im Protokoll - sonst waere zu klaeren, wem die
* Pause gehoert und was das Protokoll zeigt.
*/
$nachFolgern = [];
foreach ($list as $a) {
foreach ($a["triggers"] as $davor) {
$nachFolgern[intval($davor)][] = intval($a["id"]);
}
}
$vorhanden = [];
foreach ($list as $a) {
$vorhanden[intval($a["id"])] = $a;
}
// Wessen Ausloeser nicht in dieser Etage steht, faengt selbst oben an.
$wurzeln = [];
foreach ($list as $a) {
$hatVater = false;
foreach ($a["triggers"] as $davor) {
if (isset($vorhanden[intval($davor)])) {
$hatVater = true;
}
}
if (!$hatVater) {
$wurzeln[] = intval($a["id"]);
}
}
$reihe = [];
$einordnen = function ($id, $tiefe, $vater) use (&$einordnen, &$reihe, $nachFolgern, $vorhanden) {
if (!isset($vorhanden[$id]) || isset($reihe[$id])) {
return;
}
$reihe[$id] = ["auto" => $vorhanden[$id], "tiefe" => $tiefe,
"vater" => $vater, "kinder" => 0];
foreach ($nachFolgern[$id] ?? [] as $kind) {
$einordnen($kind, $tiefe + 1, $id);
}
};
foreach ($wurzeln as $id) {
$einordnen($id, 0, 0);
}
// Was durch einen Kreis nicht erreicht wurde, kommt trotzdem mit.
foreach ($list as $a) {
$einordnen(intval($a["id"]), 0, 0);
}
// Gezaehlt wird erst jetzt, und nur was wirklich eingeordnet wurde:
// wer zwei Ausloeser hat, steht nur unter dem ersten. Aus der rohen
// Nachfolgerliste gezaehlt verspraeche der zweite Pfeil eine Zeile,
// die er nicht aufklappen kann.
foreach ($reihe as $eintrag) {
if ($eintrag["vater"]) {
$reihe[$eintrag["vater"]]["kinder"]++;
}
}
foreach ($reihe as $eintrag) {
$a = $eintrag["auto"];
$tiefe = $eintrag["tiefe"];
$kinder = $eintrag["kinder"];
$vater = $eintrag["vater"];
// Nur die aktiven Tage nennen. Sieben Haekchen untereinander
// haben die Zeile frueher unnoetig hoch gemacht.
$aktiveTage = [];
for ($i = 0; $i < 7; $i++) {
if ($a["weekdays"] & (1 << $i)) {
$aktiveTage[] = $tage[$i];
}
}
// Kalendertage auf "immer" gehoeren zur Tagesangabe, nicht in
// die Nachbemerkung: sie fuegen Tage hinzu. Ohne angehakten
// Wochentag tragen sie die Automatik sogar allein - "nur
// Feiertage" ist damit eine gueltige Einstellung.
$dazu = [];
if ($a["on_vacation"] == 2) {
$dazu[] = "Ferientage";
}
if ($a["on_holiday"] == 2) {
$dazu[] = "Feiertage";
}
if (count($aktiveTage) === 7) {
$wann = "täglich";
} elseif ($aktiveTage) {
$wann = implode(" ", $aktiveTage);
if ($dazu) {
$wann .= " + " . implode(" + ", $dazu);
}
} elseif ($dazu) {
$wann = "nur " . implode(" + ", $dazu);
} else {
$wann = "nie – kein Tag ausgewählt";
}
// Vorabend: alle Tagesangaben meinen morgen. Vorne, damit auch
// "nicht an Feiertagen" weiter hinten als Folgetag gelesen wird.
if ($a["next_day"]) {
$wann = "Vorabend von " . $wann;
}
if ($a["window_from"] !== "00:00" || $a["window_to"] !== "23:59") {
$wann .= ", " . $a["window_from"] . "–" . $a["window_to"];
}
if ($a["on_holiday"] == 0) {
$wann .= ", nicht an Feiertagen";
}
if ($a["on_vacation"] == 0) {
$wann .= ", nicht in den Ferien";
}
if ($a["once_per_day"]) {
$wann .= ", nur einmal am Tag";
}
// Vor der Sperre, weil es auch zeitlich davor liegt: erst muss
// die Bedingung stehen bleiben, dann wird ausgeloest.
if (!empty($a["hold_secs"])) {
$wann .= ", erst nach "
. haltezeitKurz($a["hold_secs"]) . " ununterbrochen";
}
if ($a["lockout_secs"] > 0) {
$wann .= ", frühestens alle "
. ($a["lockout_secs"] >= 60
? intdiv($a["lockout_secs"], 60) . " Min."
: $a["lockout_secs"] . " Sek.");
}
$zuletzt = $a["last_run"] ? date("d.m. H:i", strtotime($a["last_run"])) : "–";
$pause = $a["enabled"] ? "bi-pause-fill" : "bi-play-fill";
$titel = $a["enabled"] ? "Pausieren" : "Fortsetzen";
$id = intval($a["id"]);
$klassen = [];
if (!$a["enabled"]) {
$klassen[] = "opacity-50";
}
if ($tiefe > 0) {
// Eingeklappt starten: die Zeile gehoert dem Ausloeser, und
// der steht direkt darueber.
$klassen[] = "auto-folge";
$klassen[] = "auto-von-" . $vater;
$klassen[] = "d-none";
}
$klasse = $klassen ? " class='" . implode(" ", $klassen) . "'" : "";
// Der Pfeil klappt die Nachfolger auf. Er steht nur da, wo es
// welche gibt - ein Pfeil ohne Inhalt waere eine Einladung ins
// Leere.
$griff = "";
if ($kinder > 0) {
$griff = "<button type='button' class='btn btn-link btn-sm p-0 me-1 text-decoration-none'"
. " title='Abhängige Automatiken zeigen'"
. " data-gruppe='" . $id . "'"
. " onclick='toggleAutomationGroup(" . $id . ", this)'>"
. "<i class='bi bi-chevron-right'></i> " . $kinder . "</button>";
}
$einzug = $tiefe > 0
? "<span class='text-body-secondary me-1'>"
. str_repeat("&nbsp;&nbsp;&nbsp;", $tiefe)
. "<i class='bi bi-arrow-return-right'></i></span> "
: "";
echo "<tr data-auto='" . $id . "'" . $klasse . ">";
echo "<td class='text-nowrap'>" . $einzug . $griff
. htmlspecialchars($a["name"]) . "</td>";
echo "<td>" . htmlspecialchars($a["conditionText"]) . "</td>";
echo "<td class='d-none d-sm-table-cell'>" . htmlspecialchars($a["actionText"]) . "</td>";
echo "<td class='text-nowrap d-none d-md-table-cell'>" . htmlspecialchars($wann) . "</td>";
echo "<td class='text-nowrap d-none d-md-table-cell'>" . $zuletzt . "</td>";
echo "<td class='text-nowrap'>"
. "<button type='button' class='btn btn-info btn-sm' title='" . $titel . "'"
. " onclick='toggleAutomation(" . $id . ")'><i class='bi " . $pause . "'></i></button> "
. "<button type='button' class='btn btn-warning btn-sm' title='Bearbeiten'"
. " onclick=\"openAutoActionModal('?action=editor&id=" . $id . "')\"><i class='bi bi-pencil-square'></i></button> "
. "<button type='button' class='btn btn-danger btn-sm' title='Löschen'"
. " onclick='deleteAutomation(" . $id . ", \"" . htmlspecialchars($a["name"], ENT_QUOTES) . "\")'><i class='bi bi-trash3'></i></button>"
. "</td></tr>";
}
echo "</tbody></table></div>";
}
exit;
}
// --- Editor --------------------------------------------------------------
$id = intval($_GET["id"] ?? 0);
$auto = $id > 0 ? loadAutomation($id) : null;
if (!$auto) {
$auto = emptyAutomation($floor);
}
// Geraetekatalog und Datensatz reisen im selben Dokument mit. Frueher holte
// der Editor die Geraeteliste und je Zeile noch einmal deren Details nach -
// zwei zusaetzliche Anfragen pro Bedingung, und die Dropdowns fuellten sich
// asynchron, weshalb ein gespeicherter Wert nicht zuverlaessig ankam.
$nutzdaten = json_encode([
"automation" => $auto,
"devices" => deviceCatalog(),
"lockouts" => lockoutChoices(),
"ferien" => kalendertagChoices("Ferien", "in den Ferien"),
"feiertage" => kalendertagChoices("Feiertage", "an Feiertagen"),
"tagvorlagen" => tagVorlagen(),
// Nur fuer die Reihenfolge der Raum-Gruppen: rooms.php gibt sie vor, und
// Etage fuer Etage liest sich besser als alphabetisch.
"rooms" => raumListe(),
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
?>
<script type="application/json" id="autoActionData"><?= $nutzdaten ?></script>
<form id="autoActionForm" class="p-2">
<div class="row g-2 mb-2">
<div class="col-8">
<div class="form-floating">
<input type="text" class="form-control" id="autoName" placeholder="Name" maxlength="100">
<label for="autoName">Name der Automatik</label>
</div>
</div>
<div class="col-4">
<div class="form-floating">
<!-- Ohne Etage waere die Automatik in keinem der drei Reiter zu
sehen und nur noch in der Datenbank auffindbar. Reihenfolge wie
die Reiter, damit der Rueckfall auf den ersten Eintrag dieselbe
Etage trifft wie die Startseite. -->
<select class="form-select" id="autoFloor">
<?php foreach ($floors as $etage): ?>
<option value="<?= $etage ?>" title="<?= htmlspecialchars(floorLabel($etage)) ?>"><?= $etage ?></option>
<?php endforeach; ?>
</select>
<label for="autoFloor">Etage</label>
</div>
</div>
</div>
<!--
Drei Baender statt eines Akkordeons: Wenn - Wann - Dann.
Das Akkordeon schloss beim Oeffnen die anderen Faecher, Ausloeser und
Aktion waren also nie gleichzeitig zu sehen - ausgerechnet bei einer
Regel, die genau daraus besteht. Und das mittlere Fach hiess
"Bedingungen", enthielt aber die *Rahmen*bedingungen; die echten
Bedingungen standen im ersten.
Gebaut aus .card mit farbigem Rand und .card-header - kein eigenes
Bauteil. Die Ueberschriften tragen .card-title und bekommen darueber die
Anzeigeschrift aus solar.css.
-->
<div class="card border-primary mb-2">
<div class="card-header d-flex flex-wrap align-items-center gap-2 py-2">
<i class="bi bi-lightning-charge-fill text-primary"></i>
<span class="card-title mb-0 fs-5 text-primary">Wenn</span>
<span class="small text-body-secondary">… das alles zusammenkommt</span>
<span class="flex-grow-1"></span>
<!-- Wie viele Bedingungen gerade zutreffen. Fuellt renderConditions(). -->
<span id="condStand" class="d-flex align-items-center gap-2"></span>
</div>
<div class="card-body p-2">
<!-- Ein Block ist eine UND-Gruppe, zwischen den Bloecken steht ODER.
Die Klammerung ist damit gezeichnet und nicht bloss vereinbart. -->
<div id="condBlocks"></div>
<button class="btn btn-outline-secondary btn-sm mt-2" type="button" id="btnAddGroup">
<i class="bi bi-plus-lg"></i> oder stattdessen …
</button>
</div>
</div>
<div class="card mb-2">
<!--
Der Rahmen steht zusammengefaltet da, solange er nur Vorgaben enthaelt -
und das ist der Normalfall. Frueher nahm er die meiste Flaeche fuer den
seltensten Inhalt.
-->
<!--
Zugeklappt liest man hier, was gilt; aufgeklappt greift man zu. Die drei
Vorlagen tauschen deshalb mit der Kurzfassung den Platz, statt eine
eigene Zeile im Rumpf zu bekommen - das war eine Zeile mehr und eine
Ueberschrift ("An welchen Tagen?"), die neben dem Wort "Wann" ohnehin
nichts Neues sagte.
ACHTUNG beim Umbauen: die Vorlagen duerfen NICHT innerhalb des Elements
stehen, das data-bs-toggle traegt. Bootstrap haengt seinen Umschalter
nicht an dieses Element, sondern ans Dokument - und dort in der
Capture-Phase, die vor jedem Klick auf einen Knopf darin laeuft. Ein
stopPropagation() im Knopf kommt deshalb grundsaetzlich zu spaet, und
jede Vorlage waere zugleich ein Zuklappen. Deshalb traegt nur der linke
Teil den Umschalter, die Vorlagen stehen daneben.
-->
<div class="card-header d-flex flex-wrap align-items-center gap-2 py-2">
<div class="d-flex align-items-center gap-2 flex-grow-1" id="rahmenKopf"
role="button" data-bs-toggle="collapse" data-bs-target="#rahmenBody"
aria-expanded="false">
<i class="bi bi-calendar-week text-warning"></i>
<span class="card-title mb-0 fs-5 text-warning">Wann</span>
<span id="rahmenKurz" class="text-body-secondary"></span>
<span class="flex-grow-1"></span>
<span class="small text-body-secondary text-nowrap" id="rahmenAendern">ändern <i class="bi bi-chevron-down"></i></span>
</div>
<div class="btn-group btn-group-sm" role="group" id="tagVorlagen" hidden></div>
<i class="bi bi-chevron-up text-body-secondary" id="rahmenZu" role="button"
data-bs-toggle="collapse" data-bs-target="#rahmenBody" hidden></i>
</div>
<div id="rahmenBody" class="collapse">
<div class="card-body">
<div class="btn-group btn-group-sm flex-wrap mb-2" role="group" id="weekdayGroup"></div>
<div class="row g-2 mb-2">
<div class="col-sm-6 d-flex align-items-center gap-2">
<span class="text-body-secondary small" style="min-width:5rem">Ferien</span>
<div class="btn-group btn-group-sm" role="group" id="ferienGroup"></div>
</div>
<div class="col-sm-6 d-flex align-items-center gap-2">
<span class="text-body-secondary small" style="min-width:5rem">Feiertage</span>
<div class="btn-group btn-group-sm" role="group" id="feiertagGroup"></div>
</div>
</div>
<!-- Vorabend: fuer alles, was abends fuer morgen geschieht - "Kinder
ins Bett, wenn morgen Schule ist" ist dann Mo-Fr, Ferien nie,
Feiertage nie, wie der Wecker. Uhrzeit und Zeitfenster bleiben
beim heutigen Tag. -->
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="nextDay">
<label class="form-check-label" for="nextDay">
Am Vorabend – Wochentage, Ferien und Feiertage gelten für den folgenden Tag
</label>
</div>
<!--
.callout ist ein AdminLTE-Bauteil: farbiger Balken links, getoenter
Grund, und die Toene kommen aus Bootstraps *-bg-subtle-Variablen -
es zieht also mit dem Theme mit, ohne dass hier eine Regel steht.
Als .alert stand der Satz vorher in einem eingesunkenen Kasten mit
Rahmen ringsum und las sich wie eine Warnung; er ist aber das
Ergebnis der Schalter darueber. Die Polsterung stellen Hilfsklassen
kleiner - .callout ist auf ganze Absaetze ausgelegt, hier steht ein
Satz.
-->
<div class="callout callout-primary py-2 px-3 mt-0 mb-3" id="rahmenSummary"></div>
<div class="row g-2 mb-3">
<div class="col-sm-6">
<div class="input-group input-group-sm">
<span class="input-group-text">von</span>
<input type="time" class="form-control" id="tSpanFrom" value="00:00">
<span class="input-group-text">bis</span>
<input type="time" class="form-control" id="tSpanTo" value="23:59">
</div>
</div>
<div class="col-sm-6">
<div class="input-group input-group-sm">
<span class="input-group-text">Sperre</span>
<select class="form-select" id="lockoutSecs"></select>
</div>
</div>
<!--
Haltezeit. Sie steht neben der Sperre, weil beide dasselbe Mittel
benutzen - Zeit - und trotzdem entgegengesetzt wirken: die Sperre
bremst die Wiederholung, die Haltezeit den ersten Lauf.
Frei einstellbar statt aus einer Auswahlliste: Wonach man sucht,
hängt am Messwert - eine offene Tür fällt nach zwei Minuten auf,
ein laufender Wasserhahn erst nach Stunden.
Dasselbe Zeitfeld wie von/bis darüber, nur als Dauer gelesen. Dass
es keine Uhrzeit meint, sagt der Text ringsum ("erst nach … am
Stück"); die Zeile über dem Rahmen schreibt es beim Tippen aus
("erst nach 3,5 Std.").
-->
<div class="col-sm-6">
<div class="input-group input-group-sm">
<span class="input-group-text">Auslösen erst nach</span>
<input type="time" class="form-control" id="holdZeit" value="00:00"
title="Stunden und Minuten, die die Bedingung ohne Unterbrechung erfüllt sein muss. 00:00 = sofort.">
<span class="input-group-text">am Stück</span>
</div>
</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="oncePerDay">
<label class="form-check-label" for="oncePerDay">
Nur einmal am Tag – danach bis Mitternacht gesperrt
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="runOnce">
<label class="form-check-label" for="runOnce">
Am Ende des Zeitraums auf jeden Fall ausführen, falls der Auslöser nie zutraf
</label>
</div>
</div>
</div>
</div>
<div class="card border-success mb-0">
<div class="card-header d-flex flex-wrap align-items-center gap-2 py-2">
<i class="bi bi-play-fill text-success"></i>
<span class="card-title mb-0 fs-5 text-success">Dann</span>
<span class="small text-body-secondary">… passiert das hier</span>
</div>
<div class="card-body p-2">
<div id="actionList"></div>
<button class="btn btn-outline-success btn-sm mt-2" type="button" id="btnAddActor">
<i class="bi bi-plus-lg"></i> und noch etwas tun
</button>
</div>
</div>
</form>
+12
View File
@@ -0,0 +1,12 @@
<?php
/*
* Autoladung EG - die Wattpilot in der Garage.
*
* Die Ansteuerung ist fuer beide Wallboxen dieselbe und steht in
* carSteuerung.php; hier bleibt nur, welche gemeint ist.
*/
$wallbox = "eg";
$formId = "carEG_form";
require __DIR__ . "/carSteuerung.php";
+99
View File
@@ -0,0 +1,99 @@
<?php
/*
* Das Ladefenster, gemeinsam für beide Wallboxen (carEG.php, carOG.php über
* carSteuerung.php).
*
* Hier steht nur das Gerüst mit dem, was der Server weiß: welches Auto, wie
* groß sein Akku ist. Werte, Zustände und jede Änderung übernimmt
* js/solar/ladefenster.js - live aus den MQTT-Werten der Seite, geschrieben
* wird sofort per POST an carSteuerung.php.
*/
$auto = fahrzeuge()[$wallbox];
$box = wallboxen()[$wallbox];
?>
<div class="lade" data-wallbox="<?= htmlspecialchars($wallbox) ?>"
data-wh-pro-prozent="<?= wallboxWhProProzent($wallbox) ?>"
data-akku-kwh="<?= htmlspecialchars((string)$auto["akkuKWh"]) ?>">
<div class="lade-links">
<div class="lade-status">
<div class="lade-ring" aria-hidden="true">
<svg viewBox="0 0 120 120">
<circle class="lade-spur" cx="60" cy="60" r="50" pathLength="100"/>
<circle class="lade-ziel" cx="60" cy="60" r="50" pathLength="100"/>
<circle class="lade-ist" cx="60" cy="60" r="50" pathLength="100"/>
</svg>
<div class="lade-ring-mitte">
<b data-lade="soc">–</b>
<span data-lade="ringUnten"></span>
</div>
</div>
<div class="lade-text">
<div class="lade-auto"><?= htmlspecialchars($auto["name"]) ?> <span data-lade="stecker" class="lade-pille"></span></div>
<div class="lade-gross" data-lade="leistung">–</div>
<div class="lade-zeile" data-lade="quelle"></div>
<div class="lade-zeile" data-lade="fertig"></div>
</div>
</div>
<button type="button" class="lade-start" data-lade="start"><span class="lade-punkt"></span><span data-lade="startText">…</span></button>
<div class="lade-erklaerung" data-lade="erklaerung"></div>
</div>
<div class="lade-rechts">
<div class="lade-abschnitt">
<h3 id="lade-modus-<?= $wallbox ?>">Wie laden?</h3>
<div class="lade-modi" role="radiogroup" aria-labelledby="lade-modus-<?= $wallbox ?>">
<button type="button" class="lade-modus" role="radio" aria-checked="false" data-modus="sofort" style="--c:#6cbe58">
<span class="lade-sym"><svg viewBox="0 0 24 24"><path d="M13 3 5 14h6l-1 7 8-11h-6z"/></svg></span>
<b>Sofort</b><span>volle Leistung, auch aus dem Netz</span>
</button>
<button type="button" class="lade-modus" role="radio" aria-checked="false" data-modus="sonne" style="--c:#f7c000">
<span class="lade-sym"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg></span>
<b>Sonne</b><span>nur Überschuss der Anlage</span>
</button>
<button type="button" class="lade-modus" role="radio" aria-checked="false" data-modus="plan" style="--c:#4b9fd6">
<span class="lade-sym"><svg viewBox="0 0 24 24"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2M9 2h6"/></svg></span>
<b>Geplant</b><span>bis zur Abfahrt geladen</span>
</button>
</div>
</div>
<div class="lade-planung" data-lade="planung" hidden>
<div>
<div class="lade-plan-zeile">
<label for="lade-ziel-<?= $wallbox ?>" data-lade="zielTitel">Ladestand bei Abfahrt</label>
<output data-lade="zielAusgabe"></output>
</div>
<input type="range" id="lade-ziel-<?= $wallbox ?>" data-lade="ziel" min="0" max="100" step="5" value="80">
<div class="lade-marken"><span data-lade="markeLinks">0 %</span><span data-lade="markeMitte"></span><span>100 %</span></div>
</div>
<div>
<div class="lade-plan-zeile"><label>Abfahrt</label><output data-lade="abfahrtAusgabe"></output></div>
<div class="lade-chips" role="group" aria-label="Abfahrt">
<button type="button" class="lade-chip" aria-pressed="false" data-zeit="06:00">06:00</button>
<button type="button" class="lade-chip" aria-pressed="false" data-zeit="07:00">07:00</button>
<button type="button" class="lade-chip" aria-pressed="false" data-zeit="08:00">08:00</button>
<!-- Eigene Zeit: der Knopf zeigt sie an und öffnet den Zeitwähler;
das Feld liegt unsichtbar darunter. Direkt eingebettet war es
zu schmal, und Chromium öffnete den Wähler nur über sein
eigenes Uhrsymbol. -->
<button type="button" class="lade-chip lade-chip-eigen" aria-pressed="false" data-lade="chipEigen">
<i class="bi bi-clock"></i><span data-lade="chipEigenText">andere</span>
<input type="time" class="lade-zeitfeld" data-lade="zeitEigen" tabindex="-1" aria-label="eigene Abfahrtszeit">
</button>
</div>
</div>
</div>
<div class="lade-abschnitt">
<h3 id="lade-strom-<?= $wallbox ?>">Höchstens</h3>
<div class="lade-strom" role="radiogroup" aria-labelledby="lade-strom-<?= $wallbox ?>">
<button type="button" role="radio" aria-checked="false" data-ampere="6"><b>6 A</b><span>≈ 4,1 kW</span></button>
<button type="button" role="radio" aria-checked="false" data-ampere="10"><b>10 A</b><span>≈ 6,9 kW</span></button>
<button type="button" role="radio" aria-checked="false" data-ampere="16"><b>16 A</b><span>≈ 11 kW</span></button>
</div>
</div>
<div class="lade-meldung" data-lade="meldung" aria-live="polite">Änderungen gelten sofort · <?= htmlspecialchars($box["titel"]) ?></div>
</div>
</div>
+12
View File
@@ -0,0 +1,12 @@
<?php
/*
* Autoladung OG - der go-e Charger.
*
* Die Ansteuerung ist fuer beide Wallboxen dieselbe und steht in
* carSteuerung.php; hier bleibt nur, welche gemeint ist.
*/
$wallbox = "og";
$formId = "carOG_form";
require __DIR__ . "/carSteuerung.php";
+108
View File
@@ -0,0 +1,108 @@
<?php
/**
* Ladesteuerung beider Wallboxen.
*
* carEG.php und carOG.php setzen nur $wallbox und binden diese Datei ein.
* Was die beiden Wallboxen wirklich trennt, steht in restricted/wallboxen.php.
*
* GET das Ladefenster (carForm.php)
* POST {"aktion": "frc", "wert": 0|1|2} Freigabe: 0 Modus entscheidet, 1 gesperrt, 2 sofort
* POST {"aktion": "modus", "modus": "sofort"|"sonne"}
* POST {"aktion": "strom", "ampere": 6..16}
* POST {"aktion": "plan", "wh": 1.., "zeit": "HH:MM"} Ladeplanung: Energie bis Uhrzeit
*
* Seit September 2026 gilt jede Änderung sofort (js/solar/ladefenster.js) -
* vorher gingen Modus, Strom und Planung gemeinsam über "Save changes".
* Antwort ist JSON: {"ok": true} oder {"error": "..."}. Ob die Wallbox den
* Wert genommen hat, meldet sie über MQTT; das Fenster zeigt es beim
* nächsten Takt.
*/
require_once(__DIR__ . "/../helper.php");
require_once(__DIR__ . "/../restricted/wallboxen.php");
// lmo, der Betriebsmodus - beide Wallboxen nutzen dieselben Nummern.
const CAR_MODI = [
"sofort" => 3, // Default: volle Leistung, auch aus dem Netz
"sonne" => 4, // Eco (Awattar): Überschuss
"plan" => 5, // Next Trip (AutomaticStop): Energie bis Uhrzeit
];
function ladeAntwort($daten, $code = 200)
{
http_response_code($code);
header("Content-Type: application/json; charset=utf-8");
echo json_encode($daten, JSON_UNESCAPED_UNICODE);
exit;
}
if (!checkLogin()) {
if ($_SERVER["REQUEST_METHOD"] === "POST") {
ladeAntwort(["error" => "Nicht angemeldet."], 403);
}
exit;
}
if ($_SERVER["REQUEST_METHOD"] !== "POST") {
include __DIR__ . "/carForm.php";
exit;
}
$konf = wallboxen()[$wallbox];
$body = json_decode(file_get_contents("php://input"), true);
if (!is_array($body)) {
ladeAntwort(["error" => "Konnte die Anfrage nicht lesen."], 400);
}
switch ($body["aktion"] ?? "") {
case "frc":
$wert = intval($body["wert"] ?? -1);
if (!in_array($wert, [0, 1, 2], true)) {
ladeAntwort(["error" => "Unbekannte Freigabe."], 400);
}
$werte = ["frc" => $wert];
break;
case "modus":
$modus = CAR_MODI[$body["modus"] ?? ""] ?? null;
if ($modus === null || $modus === CAR_MODI["plan"]) {
// Die Planung braucht Energie und Uhrzeit - siehe "plan".
ladeAntwort(["error" => "Unbekannter Modus."], 400);
}
$werte = ["fup" => true, "lmo" => $modus];
break;
case "strom":
$werte = ["amp" => min(16, max(6, intval($body["ampere"] ?? 6)))];
break;
case "plan":
// Die Uhrzeit ging früher in eine Kommandozeile und ließ sich mit
// "12:00; befehl" missbrauchen - deshalb bleibt die Prüfung streng.
if (!preg_match('/^([01][0-9]|2[0-3]):([0-5][0-9])$/', (string)($body["zeit"] ?? ""), $teile)) {
ladeAntwort(["error" => "Die Uhrzeit ist ungültig."], 400);
}
$wh = intval($body["wh"] ?? 0);
$max = (int) round(fahrzeuge()[$wallbox]["akkuKWh"] * 1000);
if ($wh < 100 || $wh > $max) {
ladeAntwort(["error" => "Die Energiemenge liegt außerhalb dessen, was der Akku fasst."], 400);
}
// Reihenfolge zählt: Uhr, Ziel, dann der Modus.
$werte = [];
if (!empty($konf["uhrStellen"])) {
$werte["utc"] = gmdate("Y-m-d\TH:i:s") . ".000";
}
$werte["fte"] = $wh;
$werte["ftt"] = intval($teile[1]) * 3600 + intval($teile[2]) * 60;
$werte["fup"] = true;
$werte["lmo"] = CAR_MODI["plan"];
break;
default:
ladeAntwort(["error" => "Unbekannte Aktion."], 400);
}
if (!wallboxSetzen($wallbox, $werte)) {
ladeAntwort(["error" => "Der MQTT-Broker ist nicht erreichbar."], 502);
}
ladeAntwort(["ok" => true]);
+93
View File
@@ -0,0 +1,93 @@
<?php
/*
* Gemeinsame Bausteine für die Chart-Endpunkte in ajax/.
*
* Alle Endpunkte liefern Chart.js-Daten im gleichen Format: eine Abfrage mit
* einer Spalte "time" (Beschriftung) und je einer Spalte pro Datenreihe.
* chartDataset() legt das Styling einer Reihe fest, buildChartData() baut
* daraus das fertige {labels, datasets}-Objekt.
*/
/**
* Styling für eine Datenreihe. $filled merkt sich über die Reihen hinweg,
* ob die unterste Fläche des Stapels schon gegen die Nulllinie gefüllt wurde.
*/
function chartDataset($name, $linecolors, &$filled)
{
$color = isset($linecolors[$name]) ? $linecolors[$name] : "#888888";
$dataset = (object)[];
$dataset->label = $name;
$dataset->data = [];
$dataset->borderColor = $color;
$dataset->backgroundColor = $color . "55";
$dataset->borderWidth = 1;
$dataset->pointRadius = 0;
$dataset->pointHoverRadius = 5;
$dataset->tension = 0.2;
if ($name == "Solarleistung") {
$dataset->stack = "SolarPwr";
$dataset->fill = "none";
$dataset->yAxisID = 'y';
} else if ($name == "Ladestand") {
$dataset->stack = "Charge";
$dataset->fill = "none";
$dataset->yAxisID = 'y1';
} else {
$dataset->stack = "Consumers";
if (!$filled) {
$filled = true;
$dataset->fill = "origin";
} else {
$dataset->fill = "-1";
}
$dataset->yAxisID = 'y';
}
return $dataset;
}
/**
* Führt $query aus und baut daraus das Chart.js-Objekt.
* $labelFactor multipliziert die Zeitspalte (1 für Textbeschriftungen,
* 1000 für Unix-Zeitstempel, die Chart.js in Millisekunden erwartet).
*/
function buildChartData($mysql, $query, $linecolors, $labelFactor = 1)
{
$obj = (object)[];
$obj->labels = [];
$obj->datasets = [];
$result = mysqli_query($mysql, $query);
if (!$result) {
// Nicht ins Ergebnis schreiben - der Aufrufer liefert JSON aus.
error_log("chartData: " . mysqli_error($mysql));
return $obj;
}
if ($result->num_rows < 2) {
return $obj;
}
$column = []; // Spaltenname -> Position in $obj->datasets
$filled = false;
foreach ($result->fetch_fields() as $field) {
if ($field->name == "time") {
continue;
}
$column[$field->name] = count($obj->datasets);
$obj->datasets[] = chartDataset($field->name, $linecolors, $filled);
}
while ($row = $result->fetch_assoc()) {
foreach ($row as $name => $value) {
if ($name == "time") {
$obj->labels[] = $labelFactor == 1 ? $value : $value * $labelFactor;
} else {
$obj->datasets[$column[$name]]->data[] = $value;
}
}
}
return $obj;
}
+141
View File
@@ -0,0 +1,141 @@
<?php
/*
* Aggregierte Energiedaten für die Historie-Seite.
* Ersetzt getConsData_{month,year,decade}.php und getProdData_{month,year,decade}.php.
*
* Aufruf: energyHistory.php?series=prod|cons&range=month|year|decade
*
* Zwei Quellen, je nach Zeitraum:
*
* month EnergyFlow, also die Rohdaten. Nach Tagen gruppiert, und der
* heutige Balken soll bis zur letzten Messung reichen. Das
* Stundenarchiv wird nur einmal nachts fortgeschrieben und wäre
* für den laufenden Tag noch leer.
*
* year EnergyFlow_hourly, das Stundenarchiv. Die Rohdaten werden nach
* decade zwölf Monaten ausgedünnt (solarlog-rollup.sh thin), diese
* Diagramme fänden dort also nichts mehr. Bei Gruppierung nach
* Monat bzw. Jahr fällt der fehlende laufende Tag nicht auf.
*
* Beide Quellen liefern Wattstunden, weil die Diagramme auf der y-Achse durch
* 1.000.000 teilen und MWh beschriften. Die Rohdaten sind Momentanleistungen
* alle fünf Minuten, daher /12; im Archiv stehen bereits kWh, daher *1000.
* Gegengerechnet über einen kompletten Monat stimmen beide auf 0,003 % überein
* - der Rest ist die /12-Näherung gegenüber der echten Zeitgewichtung.
*
* pv_fehlbetrag_kwh wird zur erfassten Erzeugung addiert. Wenn Wechselrichter
* die Verbindung zur OpenDTU verlieren, fehlt ihr Beitrag in pvP bzw. pv_kwh -
* die Einspeisung, die Batterieladung und der Heizstab sind aber vollständig
* gemessen. Ohne die Korrektur wird der Direktverbrauch als Differenz negativ
* und der Balken bleibt leer, etwa am 03.08.2026 mit -0,5 statt +15,9 kWh.
* Im Rohdaten-Zweig kommt der Betrag über eine nach Tagen vorverdichtete
* abgeleitete Tabelle dazu, sonst zählte ihn jede der zwölf Zeilen je Stunde
* erneut; für den laufenden Tag gibt es ihn noch nicht, dann bleibt er 0.
*
* Die Einspeisung kommt in beiden Fällen aus gridPfeed, dem Zählerregister,
* nicht aus dem Saldo gridP. Innerhalb eines Fensters kommen Bezug und
* Einspeisung gleichzeitig vor; der Saldo löscht kurze Bezüge weg und passt
* dann nicht mehr zu dem, was abgerechnet wird.
*/
require_once("../helper.php");
require_once("./chartData.php");
// Zeitraum -> [Format der Beschriftung, Quelle]
$ranges = [
"month" => ["'%d.%b.'", "roh"],
"year" => ["'%b. %y'", "archiv"],
"decade" => ["'%Y'", "archiv"],
];
// Filter auf die Zeitspalte. ZEIT wird durch den Spaltennamen der Quelle ersetzt.
$filters = [
"month" => "DATE_FORMAT(ZEIT,'%Y%m%d') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 1 MONTH),'%Y%m%d')",
"year" => "DATE_FORMAT(ZEIT,'%Y%m') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 13 MONTH),'%Y%m')",
"decade" => "DATE_FORMAT(ZEIT,'%Y') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 10 YEAR),'%Y')",
];
// Datenreihen je Diagramm. Beide teilen sich Solarertrag und Heizstab und
// unterscheiden sich nur darin, wohin die Energie fließt (prod) bzw. woher
// sie kommt (cons).
$quellen = [
"roh" => [
"tabelle" => "EnergyFlow",
"zeit" => "datetime",
"join" => "LEFT JOIN (SELECT DATE(stunde) AS tag, SUM(pv_fehlbetrag_kwh) AS fehlt
FROM EnergyFlow_hourly GROUP BY 1) f ON f.tag = DATE(datetime)",
"reihen" => [
"prod" => "SUM(pvP/12 - gridPfeed/12 - IF(battP < 0, -battP/12,0) - heaterPwr/12)
+ IFNULL(MAX(f.fehlt),0)*1000 AS Direktverbrauch,
SUM(heaterPwr/12) AS Heizstab,
SUM(IF(battP < 0, -battP/12,0)) AS Batterieladung,
SUM(gridPfeed/12) AS Einspeisung",
"cons" => "SUM(pvP/12 - gridPfeed/12 - IF(battP < 0, -battP/12,0) - heaterPwr/12)
+ IFNULL(MAX(f.fehlt),0)*1000 AS Solarverbrauch,
SUM(heaterPwr/12) AS Heizstab,
SUM(IF(battP > 0, battP/12,0)) AS Batteriebezug,
SUM(gridPcons/12) AS Netzbezug",
],
],
"archiv" => [
"tabelle" => "EnergyFlow_hourly",
"zeit" => "stunde",
"join" => "",
"reihen" => [
"prod" => "SUM((pv_kwh + IFNULL(pv_fehlbetrag_kwh,0) - netz_einsp_kwh - batt_laden_kwh - heizstab_kwh)*1000) AS Direktverbrauch,
SUM(heizstab_kwh*1000) AS Heizstab,
SUM(batt_laden_kwh*1000) AS Batterieladung,
SUM(netz_einsp_kwh*1000) AS Einspeisung",
"cons" => "SUM((pv_kwh + IFNULL(pv_fehlbetrag_kwh,0) - netz_einsp_kwh - batt_laden_kwh - heizstab_kwh)*1000) AS Solarverbrauch,
SUM(heizstab_kwh*1000) AS Heizstab,
SUM(batt_entladen_kwh*1000) AS Batteriebezug,
SUM(netz_bezug_kwh*1000) AS Netzbezug",
],
],
];
$linecolors["Direktverbrauch"] = "#FFFF00";
$linecolors["Solarverbrauch"] = "#FFFF00";
$linecolors["UG"] = "#FFaa00";
$linecolors["OG"] = "#FF4400";
$linecolors["Auto UG"] = "#00aaFF";
$linecolors["Auto OG"] = "#0044FF";
$linecolors["Heizstab"] = "#FF6600";
$linecolors["Batterieladung"] = "#00aa00";
$linecolors["Batteriebezug"] = "#00aa00";
$linecolors["Einspeisung"] = "#FF0000";
$linecolors["Netzbezug"] = "#FF0000";
$linecolors["Ladestand"] = "#00aa00";
$linecolors["Vorhersage"] = "#2222FF";
header('Content-Type: application/json; charset=utf-8');
$rangeKey = isset($_GET["range"]) ? $_GET["range"] : "";
$seriesKey = isset($_GET["series"]) ? $_GET["series"] : "";
if (!isset($ranges[$rangeKey]) || !in_array($seriesKey, ["prod", "cons"], true)) {
http_response_code(400);
echo json_encode(["error" => "Unbekannter Zeitraum oder Datenreihe."]);
exit;
}
list($labelFormat, $quelleKey) = $ranges[$rangeKey];
$quelle = $quellen[$quelleKey];
$zeit = $quelle["zeit"];
$filter = str_replace("ZEIT", $zeit, $filters[$rangeKey]);
$obj = (object)[];
$obj->labels = [];
$obj->datasets = [];
if (checkLogin()) {
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
$query = "SELECT
DATE_FORMAT($zeit, $labelFormat) AS 'time',
" . $quelle["reihen"][$seriesKey] . "
FROM " . $quelle["tabelle"] . " " . $quelle["join"] . "
WHERE $filter
GROUP BY DATE_FORMAT($zeit, $labelFormat)
ORDER BY MIN($zeit) ; ";
$obj = buildChartData($mysql, $query, $linecolors);
}
echo json_encode($obj);
+162
View File
@@ -0,0 +1,162 @@
<?php
require_once("../helper.php");
if(!isset($_GET["TO"])){
$_GET["TO"] = 12;
}
$_GET["TO"] = intval($_GET["TO"]);
if(!isset($_GET["FROM"])){
$_GET["FROM"] = -24;
}
$_GET["FROM"] = intval($_GET["FROM"]);
//-totalConsumption-PL1_EV*1000-PL2_EV*1000-PL3_EV*1000-PL1_EVog*1000-PL2_EVog*1000-PL3_EVog*1000+PL1_OG+PL2_OG+PL3_OG-heaterPwr AS 'UG',
$consQuery = "SELECT
UNIX_TIMESTAMP(EnergyFlow.datetime) AS time,
pvP AS 'Solarleistung',
soc AS Ladestand,
-totalConsumption-PL1_EV*1000-PL2_EV*1000-PL3_EV*1000-PL1_EVog*1000-PL2_EVog*1000-PL3_EVog*1000+PL1_OG+PL2_OG+PL3_OG-heaterPwr-PL1_UG-PL2_UG-PL3_UG-PL1_EG-PL2_EG-PL3_EG AS 'Gemein',
(PL1_UG+PL2_UG+PL3_UG) AS 'UG',
(PL1_EG+PL2_EG+PL3_EG) AS 'EG',
-(PL1_OG+PL2_OG+PL3_OG) AS 'OG',
(PL1_EV+PL2_EV+PL3_EV)*1000 AS 'Auto UG',
(PL1_EVog+PL2_EVog+PL3_EVog)*1000 AS 'Auto OG',
heaterPwr AS 'Heizstab',
IF(battP<0, -battP, 0) AS Batterieladung,
gridPfeed AS Einspeisung
FROM solarLog.EnergyFlow
WHERE EnergyFlow.datetime BETWEEN DATE_ADD(NOW(),INTERVAL ".($_GET["FROM"])." HOUR) and DATE_ADD(NOW(),INTERVAL ".$_GET["TO"]." HOUR)
ORDER BY EnergyFlow.datetime";
$simQuery = "SELECT
UNIX_TIMESTAMP(simPower.period_end) AS time,
power*1000 AS 'Vorhersage'
FROM solarLog.simPower
WHERE simPower.period_end BETWEEN DATE_ADD(NOW(),INTERVAL ".($_GET["FROM"])." HOUR) and DATE_ADD(NOW(),INTERVAL ".$_GET["TO"]." HOUR)
ORDER BY simPower.period_end";
$linecolors["Solarleistung"] = "#FFFF00";
$linecolors["Gemein"] = "#FF9900";
$linecolors["UG"] = "#FF8800";
$linecolors["EG"] = "#FF6600";
$linecolors["OG"] = "#FF4400";
$linecolors["Auto UG"] = "#00aaFF";
$linecolors["Auto OG"] = "#0044FF";
$linecolors["Heizstab"] = "#FF0000";
$linecolors["Batterieladung"] = "#00aa00";
$linecolors["Einspeisung"] = "#b0b0b0";
$linecolors["Ladestand"] = "#00aa00";
$linecolors["Vorhersage"] = "#2222FF";
if (checkLogin()) {
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
$result = mysqli_query($mysql, $consQuery);
$simRes = mysqli_query($mysql,$simQuery);
if(!$result){
echo "Error:<br>".mysqli_error($mysql)."<br />";
}
$obj = (object)[]; // Cast empty array to object
$obj->labels = [];
$obj->datasets = [];
$i = 0;
$filled = 0;
if ($simRes->num_rows > 1) {
$dataset = (object)[];
$row1 = $simRes->fetch_assoc();
$dataset->borderColor = $linecolors["Vorhersage"];
$dataset->backgroundColor = $linecolors["Vorhersage"]."55";
$dataset->borderWidth=1.5;
$dataset->pointRadius= 0;
$dataset->pointHoverRadius= 5;
$dataset->tension=0.2;
$dataset->stack = "sim";
$dataset->fill = "none";
$dataset->yAxisID = 'y';
$dataset->label = "Vorhersage";
/*$pt = (object)[];
$pt->x = $row1["time"]*1000;
$pt->y = $row1["Vorhersage"];
$dataset->data[] = clone $pt;
while ($row1 = $simRes->fetch_assoc()) {
$pt = (object)[];
$pt->x = $row1["time"]*1000 + 30*60*1000;
$pt->y = $row1["Vorhersage"];
$dataset->data[] = clone $pt;
}*/
$obj->datasets[] = clone $dataset;
$rownext = $simRes->fetch_assoc();
$nextSimTimestamp = $rownext["time"]*1000 + 30*60*1000;
}
if ($result->num_rows > 1) {
$ii = 1;
$row = $result->fetch_assoc();
foreach ($row as $name => $value) {
$dataset = (object)[];
if ($name != "time") {
$dataset->borderColor = $linecolors[$name];
$dataset->backgroundColor = $linecolors[$name]."55";
$dataset->borderWidth=1;
$dataset->pointRadius= 0;
$dataset->pointHoverRadius= 5;
$dataset->tension=0.2;
if ($name == "Solarleistung") {
$dataset->stack = "SolarPwr";
$dataset->fill = "none";
$dataset->yAxisID = 'y';
} else if ($name == "Ladestand") {
$dataset->stack = "Charge";
$dataset->fill = "none";
$dataset->yAxisID = 'y1';
} else {
$dataset->stack = "Consumers";
if ($filled == 0) {
$filled = 1;
$dataset->fill = "origin";
} else {
$dataset->fill = "-1";
}
$dataset->yAxisID = 'y';
}
$dataset->label = $name;
$dataset->data[] = $value;
$obj->datasets[] = clone $dataset;
$ii++;
} else {
$obj->labels[] = $value * 1000;
}
}
while ($row = $result->fetch_assoc()) {
$ii = 1;
foreach ($row as $name => $value) {
if ($name != "time") {
$obj->datasets[$ii]->data[] = $value;
$ii++;
} else {
if(($value * 1000) < $nextSimTimestamp){
$obj->datasets[0]->data[] = $row1["Vorhersage"];
}else{
$row1 = $rownext;
$rownext = $simRes->fetch_assoc();
$nextSimTimestamp = $rownext["time"]*1000 + 30*60*1000;
$obj->datasets[0]->data[] = $row1["Vorhersage"];
}
$obj->labels[] = $value * 1000;
}
}
}
}
$obj->labels[] = $nextSimTimestamp; //Draw future forecast
$obj->datasets[0]->data[] = $rownext["Vorhersage"];
while($rownext = $simRes->fetch_assoc()){
$obj->labels[] = $rownext["time"]*1000 + 30*60*1000;
$obj->datasets[0]->data[] = $rownext["Vorhersage"];
}
}
//header('Content-Type: application/json');
echo json_encode($obj);
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
?>
+171
View File
@@ -0,0 +1,171 @@
<?php
require_once("../helper.php");
$consEstQuery = "SELECT
UNIX_TIMESTAMP(DATE_ADD(datetime, INTERVAL 28 DAY)) AS 'time',
SUM(-(totalConsumption + heaterPwr)/48) AS 'Vorraussichtl. Verbrauch'
FROM EnergyFlow
WHERE
DATE(datetime) >= DATE(DATE_SUB(NOW(),INTERVAL 28 DAY)) And DATE(datetime) != DATE(NOW())
GROUP BY WEEKDAY(datetime)
ORDER BY datetime";
$prodEstQuery = "SELECT UNIX_TIMESTAMP(CONVERT_TZ(period_End,'GMT','Europe/Berlin')) AS 'time',
SUM(power*500) AS 'Vorhersage'
FROM simPower
WHERE DATE(CONVERT_TZ(period_End,'GMT','Europe/Berlin')) >= DATE(DATE_SUB(NOW(),INTERVAL 7 DAY))
GROUP BY DAY(CONVERT_TZ(period_End,'GMT','Europe/Berlin'))
ORDER BY period_End;";
$prodRealQuery = "SELECT
UNIX_TIMESTAMP(datetime) AS 'time',
SUM(pvP/12) AS 'Tatsächliche Erzeugung',
SUM(-totalConsumption/12) AS 'Tatsächlicher Verbrauch'
FROM EnergyFlow
WHERE
DATE(datetime) >= DATE(DATE_SUB(NOW(),INTERVAL 7 DAY))
GROUP BY DAY(datetime)
ORDER BY datetime";
$linecolors["Tatsächliche Erzeugung"] = "#cccc00";
$linecolors["Tatsächlicher Verbrauch"] = "#EE9900";
$linecolors["Vorraussichtl. Verbrauch"] = "#BB4400";
$linecolors["Auto UG"] = "#00aaFF";
$linecolors["Auto OG"] = "#0044FF";
$linecolors["Heizstab"] = "#FF0000";
$linecolors["Batterieladung"] = "#00aa00";
$linecolors["Einspeisung"] = "#b0b0b0";
$linecolors["Ladestand"] = "#00aa00";
$linecolors["Vorhersage"] = "#4444FF";
if (checkLogin()) {
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
$consEst = mysqli_query($mysql, $consEstQuery);
$prodEst = mysqli_query($mysql,$prodEstQuery);
$prodReal = mysqli_query($mysql,$prodRealQuery);
if(!$consEst){
echo "Error:<br>".mysqli_error($mysql)."<br />";
}
if(!$prodEst){
echo "Error:<br>".mysqli_error($mysql)."<br />";
}
if(!$prodRealQuery){
echo "Error:<br>".mysqli_error($mysql)."<br />";
}
$obj = (object)[]; // Cast empty array to object
$obj->labels = [];
$obj->datasets = [];
$i = 0;
$filled = 0;
if($consEst->num_rows > 1){
$row = $consEst->fetch_assoc();
foreach ($row as $name => $value) {
$dataset = (object)[];
if ($name != "time") {
$dataset->borderColor = $linecolors[$name];
$dataset->backgroundColor = $linecolors[$name]."66";
$dataset->borderWidth=1;
$dataset->pointRadius= 0;
$dataset->pointHoverRadius= 5;
$dataset->tension=0.2;
$dataset->stack = $name;
//$dataset->fill = "none";
$dataset->yAxisID = 'y';
$dataset->label = $name;
for($i=0;$i<7;$i++){
$dataset->data[] = NULL;
}
$dataset->data[] = $value;
$obj->datasets[] = clone $dataset;
}
}
while ($row = $consEst->fetch_assoc()) {
$ii = 0;
foreach ($row as $name => $value) {
if ($name != "time") {
$obj->datasets[$ii]->data[] = $value;
$ii++;
}
}
}
}
if ($prodEst->num_rows > 1) {
$row = $prodEst->fetch_assoc();
foreach ($row as $name => $value) {
$dataset = (object)[];
if ($name != "time") {
$dataset->borderColor = $linecolors[$name];
$dataset->backgroundColor = $linecolors[$name]."55";
$dataset->borderWidth=1;
$dataset->pointRadius= 0;
$dataset->pointHoverRadius= 5;
$dataset->tension=0.2;
$dataset->stack = $name;
//$dataset->fill = "none";
$dataset->yAxisID = 'y';
$dataset->label = $name;
$dataset->data[] = $value;
$obj->datasets[] = clone $dataset;
} else {
$obj->labels[] = $value * 1000;
}
}
while ($row = $prodEst->fetch_assoc()) {
$ii = 1;
foreach ($row as $name => $value) {
if ($name != "time") {
$obj->datasets[$ii]->data[] = $value;
$ii++;
} else {
$obj->labels[] = $value * 1000;
}
}
}
}
if($prodReal->num_rows > 1){
$row = $prodReal->fetch_assoc();
foreach ($row as $name => $value) {
$dataset = (object)[];
if ($name != "time") {
$dataset->borderColor = $linecolors[$name];
$dataset->backgroundColor = $linecolors[$name]."55";
$dataset->borderWidth=1;
$dataset->pointRadius= 0;
$dataset->pointHoverRadius= 5;
$dataset->tension=0.2;
$dataset->stack = $name;
//$dataset->fill = "none";
$dataset->yAxisID = 'y';
$dataset->label = $name;
$dataset->data[] = $value;
$obj->datasets[] = clone $dataset;
}
}
while ($row = $prodReal->fetch_assoc()) {
$ii = 2;
foreach ($row as $name => $value) {
if ($name != "time") {
$obj->datasets[$ii]->data[] = $value;
$ii++;
}
}
}
}
}
//header('Content-Type: application/json');
echo json_encode($obj);
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
+105
View File
@@ -0,0 +1,105 @@
<?php
require_once("../helper.php");
$heatQuery = "SELECT
UNIX_TIMESTAMP(Heater.datetime) AS time,
PufferU AS 'Speicher unten',
PufferM AS 'Speicher mitte',
PufferO AS 'Speicher oben',
thermeVLfb AS 'Therme Vorlauf Fußboden',
thermeRL AS 'Therme Rücklauf',
heaterVL AS 'Heizstab Vorlauf',
heaterRL AS 'Heizstab Rücklauf',
fbVL AS 'Fußboden Vorlauf',
fbRL AS 'Fußboden Rücklauf'
FROM Heater
WHERE Heater.datetime BETWEEN DATE_SUB(NOW(),INTERVAL 24 HOUR) and NOW()
ORDER BY Heater.datetime";
$waterQuery = "SELECT UNIX_TIMESTAMP(wasser.datetime) AS time, rate AS 'Wasserverbrauch'
FROM solarLog.wasser
WHERE wasser.datetime BETWEEN DATE_SUB(NOW(),INTERVAL 24 HOUR) and NOW()
ORDER BY wasser.datetime";
$linecolors["Speicher oben"] = "#FF5500";
$linecolors["Speicher mitte"] = "#FFaa00";
$linecolors["Speicher unten"] = "#FFFF00";
$linecolors["Therme Vorlauf Fußboden"] = "#bb0000";
$linecolors["Therme Rücklauf"] = "#ee0000";
$linecolors["Heizstab Vorlauf"] = "#9900bb";
$linecolors["Heizstab Rücklauf"] = "#8800aa";
$linecolors["Fußboden Vorlauf"] = "#00FF00";
$linecolors["Fußboden Rücklauf"] = "#00aa00";
$linecolors["Wasserverbrauch"] = "#2222FF";
if (checkLogin()) {
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
$result = mysqli_query($mysql, $heatQuery);
$simRes = mysqli_query($mysql,$waterQuery);
if(!$result){
echo "Error:<br>".mysqli_error($mysql)."<br />";
}
$obj = (object)[]; // Cast empty array to object
$obj->labels = [];
$obj->datasets = [];
$i = 0;
$filled = 0;
if ($result->num_rows > 1) {
$ii = 0;
$row = $result->fetch_assoc();
foreach ($row as $name => $value) {
$dataset = (object)[];
if ($name != "time") {
$dataset->borderColor = $linecolors[$name];
$dataset->backgroundColor = $linecolors[$name]."22";
$dataset->borderWidth=2;
$dataset->pointRadius= 0;
$dataset->pointHoverRadius= 5;
$dataset->tension=0.2;
if(strpos($name,"Speicher") === false) {
$dataset->fill = "none";
} else {
// $dataset->stack = "Consumers";
if ($filled == 0) {
$filled = 1;
$dataset->fill = "origin";
} else {
$dataset->fill = "-1";
}
$dataset->yAxisID = 'y';
}
$dataset->label = $name;
$dataset->data[] = $value;
$obj->datasets[] = clone $dataset;
$ii++;
} else {
$obj->labels[] = $value * 1000;
}
}
while ($row = $result->fetch_assoc()) {
$ii = 0;
foreach ($row as $name => $value) {
if ($name != "time") {
$obj->datasets[$ii]->data[] = $value;
$ii++;
} else {
$obj->labels[] = $value * 1000;
}
}
}
}
}
//header('Content-Type: application/json');
echo json_encode($obj);
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
+165
View File
@@ -0,0 +1,165 @@
<?php
require_once("../helper.php");
if(!isset($_GET["TO"])){
$_GET["TO"] = 12;
}
$_GET["TO"] = intval($_GET["TO"]);
if(!isset($_GET["FROM"])){
$_GET["FROM"] = -24;
}
$_GET["FROM"] = intval($_GET["FROM"]);
$consQuery = "SELECT
UNIX_TIMESTAMP(EnergyFlow.datetime) AS time,
pvP AS Solarleistung,
IF(-totalConsumption - IF(gridP>0, gridP, 0) - IF(battP>0, battP, 0) > 0, -totalConsumption - IF(gridP>0, gridP, 0) - IF(battP>0, battP, 0), 0) AS Direktverbrauch,
IF(battP>0, battP, 0) AS Batteriebezug,
gridPcons AS Netzbezug,
-totalConsumption AS Verbrauch,".
//IF(battP<0, -battP, 0) AS Batterieladung,
"soc AS Ladestand
FROM solarLog.EnergyFlow
WHERE EnergyFlow.datetime BETWEEN DATE_ADD(NOW(),INTERVAL ".($_GET["FROM"])." HOUR) and DATE_ADD(NOW(),INTERVAL ".$_GET["TO"]." HOUR)
ORDER BY EnergyFlow.datetime";
$simQuery = "SELECT
UNIX_TIMESTAMP(simPower.period_end) AS time,
power*1000 AS 'Vorhersage'
FROM solarLog.simPower
WHERE simPower.period_end BETWEEN DATE_ADD(NOW(),INTERVAL ".($_GET["FROM"])." HOUR) and DATE_ADD(NOW(),INTERVAL ".$_GET["TO"]." HOUR)
ORDER BY simPower.period_end";
$linecolors["Solarleistung"] = "#FFFF00";
$linecolors["Direktverbrauch"] = "#FFcc00";
$linecolors["Verbrauch"] = "#FFaa44";
$linecolors["Auto UG"] = "#00aaFF";
$linecolors["Auto OG"] = "#0044FF";
$linecolors["Netzbezug"] = "#FF0000";
$linecolors["Batteriebezug"] = "#00aa00";
$linecolors["Batterieladung"] = "#0033aa";
$linecolors["Einspeisung"] = "#b0b0b0";
$linecolors["Ladestand"] = "#00aa00";
$linecolors["Vorhersage"] = "#2222FF";
if (checkLogin()) {
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
$result = mysqli_query($mysql, $consQuery);
$simRes = mysqli_query($mysql,$simQuery);
if(!$result){
echo "Error:<br>".mysqli_error($mysql)."<br />";
}
$obj = (object)[]; // Cast empty array to object
$obj->labels = [];
$obj->datasets = [];
$i = 0;
$filled = 0;
if ($simRes->num_rows > 1) {
$dataset = (object)[];
$row1 = $simRes->fetch_assoc();
$dataset->borderColor = $linecolors["Vorhersage"];
$dataset->backgroundColor = $linecolors["Vorhersage"]."55";
$dataset->borderWidth=1.5;
$dataset->pointRadius= 0;
$dataset->pointHoverRadius= 5;
$dataset->tension=0.2;
$dataset->stack = "sim";
$dataset->fill = "none";
$dataset->yAxisID = 'y';
$dataset->label = "Vorhersage";
/*$pt = (object)[];
$pt->x = $row["time"]*1000;
$pt->y = $row["Vorhersage"];
$dataset->data[] = clone $pt;
while ($row = $simRes->fetch_assoc()) {
$pt = (object)[];
$pt->x = $row["time"]*1000 + 30*60*1000;
$pt->y = $row["Vorhersage"];
$dataset->data[] = clone $pt;
}*/
$obj->datasets[] = clone $dataset;
$rownext = $simRes->fetch_assoc();
$nextSimTimestamp = $rownext["time"]*1000 + 30*60*1000;
}
if ($result->num_rows > 1) {
$ii = 1;
$row = $result->fetch_assoc();
foreach ($row as $name => $value) {
$dataset = (object)[];
if ($name != "time") {
$dataset->borderColor = $linecolors[$name];
$dataset->backgroundColor = $linecolors[$name]."22";
$dataset->borderWidth=1;
$dataset->pointRadius= 0;
$dataset->pointHoverRadius= 5;
$dataset->tension=0.2;
if ($name == "Solarleistung") {
$dataset->stack = "SolarPwr";
$dataset->fill = "none";
$dataset->yAxisID = 'y';
}else if ($name == "Verbrauch") {
$dataset->stack = "ConsPwr";
$dataset->fill = "1";
$dataset->yAxisID = 'y';
}else if ($name == "Batterieladung") {
$dataset->stack = "ConsPwr";
$dataset->fill = "-1";
$dataset->backgroundColor = $linecolors[$name]."77";
$dataset->yAxisID = 'y';
} else if ($name == "Ladestand") {
$dataset->stack = "Charge";
$dataset->fill = "none";
$dataset->yAxisID = 'y1';
} else {
$dataset->stack = "Consumers";
if ($filled == 0) {
$filled = 1;
$dataset->fill = "origin";
} else {
$dataset->fill = "-1";
}
$dataset->yAxisID = 'y';
}
$dataset->label = $name;
$dataset->data[] = $value;
$obj->datasets[] = clone $dataset;
$ii++;
} else {
$obj->labels[] = $value * 1000;
}
}
while ($row = $result->fetch_assoc()) {
$ii = 1;
foreach ($row as $name => $value) {
if ($name != "time") {
$obj->datasets[$ii]->data[] = $value;
$ii++;
} else {
if(($value * 1000) < $nextSimTimestamp){
$obj->datasets[0]->data[] = $row1["Vorhersage"];
}else{
$row1 = $rownext;
$rownext = $simRes->fetch_assoc();
$nextSimTimestamp = $rownext["time"]*1000 + 30*60*1000;
$obj->datasets[0]->data[] = $row1["Vorhersage"];
}
$obj->labels[] = $value * 1000;
}
}
}
}
$obj->labels[] = $nextSimTimestamp; //Draw future forecast
$obj->datasets[0]->data[] = $rownext["Vorhersage"];
while($rownext = $simRes->fetch_assoc()){
$obj->labels[] = $rownext["time"]*1000 + 30*60*1000;
$obj->datasets[0]->data[] = $rownext["Vorhersage"];
}
}
//header('Content-Type: application/json');
echo json_encode($obj);
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
?>
+369
View File
@@ -0,0 +1,369 @@
<?php
/*
* Jahresstatistik der Anlage - alle Jahre, alle Zeitraeume, als JSON.
*
* Frueher lieferte diese Datei fertiges HTML: je Aufruf ein Jahr, 18 Kacheln,
* Einheit und Richtung in zwei Arrays, die ueber den Spaltenindex mit der
* Abfrage synchron gehalten werden mussten. Wer eine Kennzahl einfuegte,
* musste an drei Stellen zaehlen.
*
* Jetzt kommt alles auf einmal und beschreibt sich selbst:
*
* kennzahlen Metadaten je Kennzahl - Name, Einheit, Nachkommastellen,
* Richtung, Gruppe, Einrueckung. Wer eine hinzufuegt, schreibt
* sie EINMAL in KENNZAHLEN und liefert ihren Wert in
* kennzahlenAus(); Anzeige und Reihenfolge ergeben sich daraus.
* werte drei Saetze: ganze Jahre, gleicher Zeitraum (1. Januar bis
* zum heutigen Stichtag) und die Summe ueber alle Jahre.
* monate Monatswerte je Jahr fuer die Verlaufsbalken.
*
* Warum drei Saetze und nicht einer: ein laufendes Jahr gegen ein volles zu
* stellen vergleicht Aepfel mit Birnen. Der Satz "gleich" schneidet jedes Jahr
* am selben Kalendertag ab und macht das laufende Jahr damit beurteilbar.
*
* Gerechnet wird auf stats_daily, den Tageswerten:
*
* 1. Die Rohdaten werden nach zwoelf Monaten ausgeduennt bzw. ins
* Stundenarchiv verschoben (solarlog-rollup.sh). Wer auf EnergyFlow
* rechnet, zeigt fuer alte Jahre zu wenig an.
* 2. Ein Jahr sind 365 Zeilen statt rund 105.000.
*
* Die Tarife bleiben unveraendert: je Zeile ein Stichtag, das Ende holt LEAD()
* beim naechsten. Weil ein Stichtag ein DATUM ist, kann ein Tag nie zwei
* Tarife haben - die Verdichtung auf Tage verliert hier also nichts.
*
* Was in stats_daily vorberechnet sein MUSS, weil es eine Bedingung je
* Messwert auswertet und sich aus Tagessummen nicht rekonstruieren laesst:
*
* eigenverbrauch_kwh pv - Einspeisung - Batterieladung - Heizstab
* + Batterieentladung. Dazu addiert wird
* pv_fehlbetrag_kwh: bei Ausfall einzelner
* Wechselrichter fehlt deren Beitrag in pv, alle
* Senken sind dagegen gemessen - die Differenz
* faellt sonst zu klein oder negativ aus.
* heizstab_solar_kwh Heizstab, anteilig nach autonomy
* auto_*_solar_kwh Wallbox, anteilig nach autonomy - dem vom Geraet
* in feiner Aufloesung gerechneten Nicht-Netz-
* Anteil der Zufuhr
*
* Die Abfragen liefern nur SUMMEN. Jede Verhaeltniszahl - Autarkie, Quoten,
* effektiver Preis - rechnet kennzahlenAus() daraus, und zwar auf jeder Ebene
* neu. Das ist keine Formsache: Durchschnitte darf man nicht addieren, ein
* Jahresmittel ist nicht das Mittel der Monatsmittel, und die Autarkie ueber
* acht Jahre schon gar nicht die Summe von acht Prozentzahlen.
*
* Der Grundpreis faellt unabhaengig vom Verbrauch an und steckt deshalb in
* keiner Summe, sondern kommt aus grundpreisImZeitraum() dazu.
*
* Achtung beim Lesen der Ertragsgruppe: Eigenverbrauch, Einspeisung und
* Heizung TEILEN den Ertrag auf, sie ergeben ihn zusammen. Der Beitrag der
* Batterie und die Solarladung des Autos stecken dagegen IM EIGENVERBRAUCH -
* der Strom, der aus der Batterie kommt oder ins Auto geht, ist ja Strom, der
* im Haus geblieben ist. Sie sind nach Herkunft bzw. Verwendung
* herausgegriffen und duerfen nicht dazuaddiert werden. Die Heizung ist davon
* unberuehrt: heizstab_solar_kwh ist aus eigenverbrauch_kwh herausgerechnet
* und gegen den Gaspreis bewertet, nicht gegen den Strompreis.
*
* Beim Beitrag der Batterie ist "darin" der Groesse nach zu lesen: gerechnet
* ist er netto, also abzueglich der Verguetung, die fuer den zum Laden
* verwendeten Strom entgangen ist. Brutto waere die Zeile zu freundlich - der
* Speicher ist nicht umsonst zu fuellen.
*/
require_once("../helper.php");
require_once("../restricted/costs.php");
header('Content-Type: application/json; charset=utf-8');
if (!checkLogin()) {
http_response_code(403);
echo json_encode(["error" => "Nicht angemeldet."]);
exit;
}
/* ------------------------------------------------------------- Kennzahlen --
*
* Die Anzeige-Beschreibung. Reihenfolge ist Anzeigereihenfolge.
*
* einheit wie sie an der Zeile steht
* dez Nachkommastellen. Jahressummen brauchen keine Cent.
* invers weniger ist besser (Verbrauch, Kosten)
* punkte Prozentwert; Veraenderungen sind Prozentpunkte, nicht Prozent
* summe Zeile, unter der sich Teilbetraege aufreihen
* stufe/teil eingerueckt: "davon" teilt die Summe darueber auf
* teilmenge "darin": steckt bereits in der genannten Zeile und darf nicht
* dazuaddiert werden. Der Wert ist deren id, damit die Anzeige
* sagen kann, WORIN es steckt - die blosse Reihenfolge liesse
* offen, ob die vorangehende oder die uebergeordnete Zeile
* gemeint ist.
* klapp Zeile mit Teilzeilen, zunaechst zusammengeklappt
* unter gehoert in die Klappgruppe dieser Zeile. Das ist etwas
* anderes als teilmenge: unter sagt, WOMIT eine Zeile
* auf- und zugeht, teilmenge, WORIN ihr Wert steckt.
* braucht Spalte in stats_daily, ohne die es die Kennzahl nicht gibt
*/
const KENNZAHLEN = [
["gruppe" => "Strom vom Netz"],
["id" => "bezug", "name" => "Netzbezug", "einheit" => "kWh", "dez" => 0, "invers" => true],
["id" => "kosten", "name" => "Netzkosten (mit Grundpreis)", "einheit" => "€", "dez" => 0, "invers" => true],
["id" => "preis", "name" => "Effektiver Strompreis", "einheit" => "ct/kWh", "dez" => 1, "invers" => true],
["gruppe" => "Was die Anlage einbringt"],
["id" => "ertrag", "name" => "Ertrag der Anlage", "einheit" => "€", "dez" => 0, "summe" => true, "klapp" => true],
["id" => "eigen", "name" => "Eigenverbrauch", "einheit" => "€", "dez" => 0, "teil" => true, "unter" => "ertrag"],
["id" => "batterie", "name" => "Beitrag der Batterie", "einheit" => "€", "dez" => 0, "teilmenge" => "eigen", "unter" => "ertrag"],
["id" => "solarauto", "name" => "Solarladung des Autos", "einheit" => "€", "dez" => 0, "teilmenge" => "eigen", "unter" => "ertrag"],
["id" => "einsp", "name" => "Einspeisung", "einheit" => "€", "dez" => 0, "teil" => true, "unter" => "ertrag"],
["id" => "heizung", "name" => "Heizung über den Heizstab", "einheit" => "€", "dez" => 0, "teil" => true, "unter" => "ertrag"],
["id" => "autarkie", "name" => "Autarkie", "einheit" => "%", "dez" => 1, "punkte" => true],
["id" => "quote", "name" => "Eigenverbrauchsquote", "einheit" => "%", "dez" => 1, "punkte" => true,
"braucht" => "pv_kwh"],
["gruppe" => "Auto"],
["id" => "geladen", "name" => "Geladen", "einheit" => "kWh", "dez" => 0, "summe" => true, "klapp" => true],
["id" => "ladenEG", "name" => "Wallbox EG", "einheit" => "kWh", "dez" => 0, "teil" => true, "unter" => "geladen"],
["id" => "ladenOG", "name" => "Wallbox OG", "einheit" => "kWh", "dez" => 0, "teil" => true, "unter" => "geladen"],
["id" => "solaranteil","name" => "Solaranteil der Ladung", "einheit" => "%", "dez" => 1, "punkte" => true],
["id" => "benzin", "name" => "Benzin gespart", "einheit" => "L", "dez" => 0],
["id" => "benzingeld", "name" => "Ersparnis gegenüber Benzin", "einheit" => "€", "dez" => 0],
];
/* Die drei Kennzahlen, die oben gross stehen. */
const LEITZAHLEN = ["ertrag", "kosten", "autarkie"];
/* ------------------------------------------------------------------ Summen --
*
* Alle Grundgroessen in einem Ausdruck. Was hier steht, ist addierbar - genau
* das ist die Bedingung dafuer, dass Monat, Jahr, Zeitraum und Gesamtsumme
* aus derselben Abfrage entstehen koennen.
*/
function summenSQL($hatPv)
{
return "
SUM(d.netz_bezug_kwh) AS bezug_kwh,
SUM(d.netz_bezug_kwh * preis.cost) AS bezug_eur,
SUM(d.netz_einsp_kwh) AS einsp_kwh,
SUM(d.netz_einsp_kwh * preis.gain) AS einsp_eur,
SUM((d.eigenverbrauch_kwh + IFNULL(d.pv_fehlbetrag_kwh,0)) * preis.cost) AS eigen_eur,
" . ($hatPv ? "SUM(d.pv_kwh + IFNULL(d.pv_fehlbetrag_kwh,0))" : "NULL") . " AS pv_kwh,
SUM(d.heizstab_solar_kwh * gas.cost / NULLIF(gas.kwhPerLitre,0)) AS heiz_eur,
SUM(d.batt_entladen_kwh * preis.cost - d.batt_laden_kwh * preis.gain) AS batt_eur,
SUM(d.autarkie_avg * d.stunden_erfasst) AS autarkie_gew,
SUM(d.stunden_erfasst) AS stunden,
SUM(d.auto_eg_kwh) AS eg_kwh,
SUM(d.auto_og_kwh) AS og_kwh,
SUM(d.auto_eg_solar_kwh) AS eg_solar_kwh,
SUM(d.auto_og_solar_kwh) AS og_solar_kwh,
SUM(d.auto_eg_solar_kwh * preis.cost) AS eg_solar_eur,
SUM(d.auto_og_solar_kwh * preis.cost) AS og_solar_eur,
SUM((d.auto_eg_kwh + d.auto_og_kwh) * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS benzin_l,
SUM((d.auto_eg_kwh + d.auto_og_kwh) * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)
* sprit.cost) AS benzin_eur";
}
/** Der gemeinsame Rumpf: Tageswerte, an jedem Tag der damals gueltige Tarif. */
function vonTarifen()
{
return "
FROM stats_daily d
JOIN preis ON d.tag >= preis.active_date AND (preis.folgt IS NULL OR d.tag < preis.folgt)
LEFT JOIN gas ON d.tag >= gas.active_date AND (gas.folgt IS NULL OR d.tag < gas.folgt)
LEFT JOIN sprit ON d.tag >= sprit.active_date AND (sprit.folgt IS NULL OR d.tag < sprit.folgt)";
}
function mitTarifen()
{
return "WITH
preis AS (SELECT active_date, cost, gain,
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gridCosts),
gas AS (SELECT active_date, cost, kwhPerLitre,
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gasCosts),
sprit AS (SELECT active_date, cost, lPer100km, kwhPer100km,
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM fuelCosts)
SELECT ";
}
/* --------------------------------------------------------------- Rechnung --
*
* Aus Summen werden Kennzahlen. Diese Funktion ist die einzige Stelle, an der
* eine Kennzahl definiert ist - sie laeuft fuer jeden Monat, jedes Jahr, jeden
* Zeitraum und die Gesamtsumme mit denselben Regeln.
*
* null heisst "nicht bestimmbar" und nicht "null": ohne Bezug gibt es keinen
* effektiven Preis, ohne Ladung keinen Solaranteil. Die Anzeige zeigt darauf
* einen Strich statt einer 0.
*/
function kennzahlenAus($s, $grundpreis)
{
$teile = function ($zaehler, $nenner, $faktor = 1) {
return ($nenner === null || $nenner <= 0 || $zaehler === null)
? null : $zaehler / $nenner * $faktor;
};
$bezug = floatval($s["bezug_kwh"]);
$kosten = floatval($s["bezug_eur"]) + $grundpreis;
$geladen = floatval($s["eg_kwh"]) + floatval($s["og_kwh"]);
$solar = floatval($s["eg_solar_kwh"]) + floatval($s["og_solar_kwh"]);
return [
"bezug" => $bezug,
"kosten" => $kosten,
"preis" => $teile($kosten, $bezug, 100),
"ertrag" => floatval($s["eigen_eur"]) + floatval($s["einsp_eur"]) + floatval($s["heiz_eur"]),
"eigen" => floatval($s["eigen_eur"]),
"einsp" => floatval($s["einsp_eur"]),
"heizung" => floatval($s["heiz_eur"]),
"batterie" => floatval($s["batt_eur"]),
"solarauto" => floatval($s["eg_solar_eur"]) + floatval($s["og_solar_eur"]),
"autarkie" => $teile($s["autarkie_gew"], $s["stunden"]),
// Eigenverbrauchsquote: der Anteil der Erzeugung, der im Haus blieb.
"quote" => $s["pv_kwh"] === null ? null
: $teile(floatval($s["pv_kwh"]) - floatval($s["einsp_kwh"]),
floatval($s["pv_kwh"]), 100),
"geladen" => $geladen,
"ladenEG" => floatval($s["eg_kwh"]),
"ladenOG" => floatval($s["og_kwh"]),
"solaranteil" => $teile($solar, $geladen, 100),
"benzin" => floatval($s["benzin_l"]),
"benzingeld" => floatval($s["benzin_eur"]),
];
}
/** Zwei Summensaetze addieren - fuer Jahre aus Monaten und die Gesamtsumme. */
function summenPlus($a, $b)
{
if ($a === null) return $b;
foreach ($b as $k => $v) {
if ($v === null) continue;
$a[$k] = ($a[$k] === null ? 0 : $a[$k]) + $v;
}
return $a;
}
/* ------------------------------------------------------------------ Abruf --*/
$db = solarDb();
// Gibt es die Erzeugung als Tageswert? Ohne sie entfaellt die
// Eigenverbrauchsquote - lieber eine Kennzahl weniger als eine Abfrage, die
// an einer fehlenden Spalte scheitert.
$hatPv = false;
if ($res = $db->query("SHOW COLUMNS FROM stats_daily LIKE 'pv_kwh'")) {
$hatPv = $res->num_rows > 0;
}
$heute = new DateTimeImmutable("today");
/*
* Monatswerte. Daraus entstehen die Verlaufsbalken, durch Addition die ganzen
* Jahre und durch nochmalige Addition die Gesamtsumme - eine Abfrage fuer
* drei Ebenen.
*/
$monate = [];
$sql = mitTarifen() . "YEAR(d.tag) AS jahr, MONTH(d.tag) AS monat, " . summenSQL($hatPv)
. vonTarifen() . " GROUP BY 1, 2 ORDER BY 1, 2";
$res = $db->query($sql);
if (!$res) {
http_response_code(500);
echo json_encode(["error" => "Statistik nicht abrufbar: " . $db->error]);
exit;
}
while ($z = $res->fetch_assoc()) {
$jahr = intval($z["jahr"]);
$monat = intval($z["monat"]);
unset($z["jahr"], $z["monat"]); // ab hier sind nur noch Summen drin
$monate[$jahr][$monat] = $z;
}
/*
* Gleicher Zeitraum: jedes Jahr vom 1. Januar bis zum heutigen Kalendertag.
* Verglichen wird nach Monat und Tag statt nach DAYOFYEAR - der 7. September
* ist in einem Schaltjahr der 251., sonst der 250. Tag, und ein Vergleich
* ueber die Tagesnummer waere in Schaltjahren um einen Tag verschoben.
*/
$gleichSummen = [];
$sql = mitTarifen() . "YEAR(d.tag) AS jahr, " . summenSQL($hatPv) . vonTarifen()
. " WHERE (MONTH(d.tag) < MONTH(CURDATE())
OR (MONTH(d.tag) = MONTH(CURDATE()) AND DAY(d.tag) <= DAY(CURDATE())))
GROUP BY 1 ORDER BY 1";
$res = $db->query($sql);
while ($res && $z = $res->fetch_assoc()) {
$jahr = intval($z["jahr"]);
unset($z["jahr"]);
$gleichSummen[$jahr] = $z;
}
$jahre = array_keys($monate);
sort($jahre);
/* ------------------------------------------------------- Antwort aufbauen --*/
$antwort = [
"stichtag" => $heute->format("Y-m-d"),
"jahre" => array_values($jahre),
"kennzahlen" => array_values(array_filter(KENNZAHLEN, function ($k) use ($hatPv) {
return !isset($k["braucht"]) || ($k["braucht"] === "pv_kwh" && $hatPv);
})),
"leitzahlen" => LEITZAHLEN,
"voll" => [],
"gleich" => [],
"monate" => [],
];
$gesamtSummen = null;
$gesamtGrund = 0.0;
foreach ($jahre as $jahr) {
$jahresSummen = null;
$grundJahr = grundpreisImJahr($jahr);
// Monatswerte. Der Grundpreis des Jahres verteilt sich nach Tagen auf die
// Monate - fuer den Verlauf genau genug, und eine Abfrage je Monat waere
// hundert Abfragen fuer eine Balkenhoehe.
$tageImJahr = intval((new DateTimeImmutable($jahr . "-12-31"))->format("z")) + 1;
for ($m = 1; $m <= 12; $m++) {
if (!isset($monate[$jahr][$m])) continue;
$tageImMonat = intval((new DateTimeImmutable(sprintf("%d-%02d-01", $jahr, $m)))->format("t"));
$werte = kennzahlenAus($monate[$jahr][$m], $grundJahr * $tageImMonat / $tageImJahr);
foreach ($werte as $id => $wert) {
$antwort["monate"][$jahr][$id][$m - 1] = $wert;
}
$jahresSummen = summenPlus($jahresSummen, $monate[$jahr][$m]);
}
// Monate ohne Daten bleiben als Luecke stehen, damit der Verlauf zwoelf
// Plaetze hat und der Januar links liegt.
foreach (array_keys($antwort["monate"][$jahr] ?? []) as $id) {
for ($m = 0; $m < 12; $m++) {
if (!array_key_exists($m, $antwort["monate"][$jahr][$id])) {
$antwort["monate"][$jahr][$id][$m] = null;
}
}
ksort($antwort["monate"][$jahr][$id]);
$antwort["monate"][$jahr][$id] = array_values($antwort["monate"][$jahr][$id]);
}
if ($jahresSummen !== null) {
$antwort["voll"][$jahr] = kennzahlenAus($jahresSummen, $grundJahr);
$gesamtSummen = summenPlus($gesamtSummen, $jahresSummen);
$gesamtGrund += $grundJahr;
}
if (isset($gleichSummen[$jahr])) {
/*
* Der Stichtag im jeweiligen Jahr. Der Tag wird auf die Laenge des
* Monats begrenzt: am 29. Februar gibt es in den anderen Jahren
* keinen 29. Februar, und ein ungueltiges Datum rutschte sonst
* stillschweigend in den Maerz.
*/
$tag = min(intval($heute->format("d")),
intval((new DateTimeImmutable($jahr . "-" . $heute->format("m") . "-01"))->format("t")));
$bis = sprintf("%d-%s-%02d", $jahr, $heute->format("m"), $tag);
$antwort["gleich"][$jahr] = kennzahlenAus(
$gleichSummen[$jahr], grundpreisImZeitraum($jahr . "-01-01", $bis));
}
}
// Die Summe ueber alles - dieselbe Rechnung, nur ueber alle Jahre.
$antwort["gesamt"] = $gesamtSummen === null ? null : kennzahlenAus($gesamtSummen, $gesamtGrund);
echo json_encode($antwort, JSON_UNESCAPED_UNICODE);
+65
View File
@@ -0,0 +1,65 @@
<?php
require_once("../helper.php");
if(!isset($_GET["TO"])){
$_GET["TO"] = 12;
}
$_GET["TO"] = intval($_GET["TO"]);
if(!isset($_GET["FROM"])){
$_GET["FROM"] = -24;
}
$_GET["FROM"] = intval($_GET["FROM"]);
$Query = "SELECT
UNIX_TIMESTAMP(CONCAT(date,' ',sunrise)) AS sunrise,
UNIX_TIMESTAMP(CONCAT(date,' ',sunset)) AS sunset
FROM solarLog.daylight
WHERE date BETWEEN DATE_ADD(NOW(),INTERVAL ".($_GET["FROM"]-24)." HOUR) and DATE_ADD(NOW(),INTERVAL ".$_GET["TO"]." HOUR)
ORDER BY date";
$linecolors["Solarleistung"] = "#FFFF00";
$linecolors["UG"] = "#FFaa00";
$linecolors["OG"] = "#FF4400";
$linecolors["Auto UG"] = "#00aaFF";
$linecolors["Auto OG"] = "#0044FF";
$linecolors["Heizstab"] = "#FF0000";
$linecolors["Batterieladung"] = "#00aa00";
$linecolors["Einspeisung"] = "#b0b0b0";
$linecolors["Ladestand"] = "#00aa00";
$linecolors["Vorhersage"] = "#2222FF";
if (checkLogin()) {
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
$result = mysqli_query($mysql, $Query);
if (!$result) {
echo "Error:<br>" . mysqli_error($mysql) . "<br />";
}
//$obj[] = (object)[]; // Cast empty array to object
while ($row = $result->fetch_assoc()) {
$ii = 1;
$anno = (object)[];
if($row["sunrise"] < (time()+$_GET["FROM"]*60*60)){
$anno->xMin = (time()+$_GET["FROM"]*60*60)*1000;
}else{
$anno->xMin = $row["sunrise"]*1000;
}
if($row["sunset"] > (time()+$_GET["TO"]*60*60)){
$anno->xMax = round(time()+$_GET["TO"]*60*60)*1000;
}else{
$anno->xMax = $row["sunset"]*1000;
}
$anno->borderWidth = 0;
if($row["sunset"] > (time()+$_GET["FROM"]*60*60)){
$obj[] = clone $anno;
}
}
}
//header('Content-Type: application/json');
echo json_encode($obj);
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
+65
View File
@@ -0,0 +1,65 @@
<?php
require_once("../helper.php");
$waterQuery = "SELECT UNIX_TIMESTAMP(datetime) AS time, rate AS 'Wasserverbrauch'
FROM solarLog.wasser
WHERE wasser.datetime BETWEEN DATE_SUB(NOW(),INTERVAL 24 HOUR) and NOW()
ORDER BY wasser.datetime";
$linecolors["Speicher oben"] = "#FF5500";
$linecolors["Speicher mitte"] = "#FFaa00";
$linecolors["Speicher unten"] = "#FFFF00";
$linecolors["Therme Vorlauf Fußboden"] = "#bb0000";
$linecolors["Therme Rücklauf"] = "#ee0000";
$linecolors["Heizstab Vorlauf"] = "#9900bb";
$linecolors["Heizstab Rücklauf"] = "#8800aa";
$linecolors["Fußboden Vorlauf"] = "#00FF00";
$linecolors["Fußboden Rücklauf"] = "#00aa00";
$linecolors["Wasserverbrauch"] = "#2222FF";
if (checkLogin()) {
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
$waterRes = mysqli_query($mysql,$waterQuery);
if(!$waterRes){
echo "Error:<br>".mysqli_error($mysql)."<br />";
}
$obj = (object)[]; // Cast empty array to object
$obj->labels = [];
$obj->datasets = [];
$i = 0;
$filled = 0;
if ($waterRes->num_rows > 1) {
$dataset = (object)[];
$row = $waterRes->fetch_assoc();
$dataset->borderColor = $linecolors["Wasserverbrauch"];
$dataset->backgroundColor = $linecolors["Wasserverbrauch"]."00";
$dataset->borderWidth=2;
$dataset->pointRadius= 0;
$dataset->pointHoverRadius= 5;
$dataset->tension=0.2;
$dataset->fill = "none";
$dataset->yAxisID = 'y1';
$dataset->label = "Wasserverbrauch";
$pt = (object)[];
$pt->x = $row["time"]*1000;
$pt->y = $row["Wasserverbrauch"];
$dataset->data[] = clone $pt;
while ($row = $waterRes->fetch_assoc()) {
$pt = (object)[];
$pt->x = $row["time"]*1000;
$pt->y = $row["Wasserverbrauch"];
$dataset->data[] = clone $pt;
}
$obj->datasets[] = clone $dataset;
}
}
//header('Content-Type: application/json');
echo json_encode($obj);
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
+117
View File
@@ -0,0 +1,117 @@
<?php
/**
* Heizstab im Pufferspeicher.
*
* GET das Bedienfenster (Werte und Logik: js/solar/bedienfenster.js)
* POST setMode=0 Automatik: der SolarManager regelt nach Überschuss
* POST setMode=1..60 feste Leistung in 100 W (60 = 6,0 kW)
* POST setMode=-1 aus: feste Leistung von 0 W, der Heizstab bleibt kalt
*
* Seit September 2026 gilt jede Änderung sofort, ohne "Save changes". Die
* Antwort ist leer, wenn der Befehl beim Heizstab ankam, sonst steht dort
* eine Meldung für das Fenster.
*/
require_once("../helper.php");
$hostname = "192.168.179.169";
if (!checkLogin()) {
exit;
}
if (isset($_POST["setMode"]) || isset($_GET["setMode"])) {
// Der Wert kommt aus dem Fenster (POST) oder von Hand aus der Adresse
// (GET) - immer nur einer von beiden.
$stufe = intval($_POST["setMode"] ?? $_GET["setMode"] ?? 0);
if ($stufe >= 1 && $stufe <= 60) {
$pfad = "/set?mode=man&pwr=" . ($stufe * 100);
} elseif ($stufe === 0) {
$pfad = "/set?mode=eco&pwr=0";
} elseif ($stufe === -1) {
// Aus ist eine feste Leistung von null: in der Automatik wuerde der
// Heizstab beim naechsten Ueberschuss wieder anlaufen.
$pfad = "/set?mode=man&pwr=0";
} else {
echo "Unbekannte Leistungsstufe.";
exit;
}
$remote = @fsockopen("tcp://" . $hostname, 80, $errno, $errstr, 5);
if ($remote) {
fwrite($remote, "GET " . $pfad . " HTTP/1.1\r\nHost: " . $hostname . "\r\nConnection: close\r\n\r\n");
stream_set_timeout($remote, 5);
fread($remote, 1024);
fclose($remote);
} else {
echo "Der Heizstab ist nicht erreichbar.";
}
exit;
}
?>
<div class="lade heiz">
<div class="lade-links">
<div class="lade-status">
<div class="heiz-tank" aria-hidden="true">
<svg viewBox="0 0 80 120">
<defs><clipPath id="heiz-innen"><rect x="14" y="8" width="52" height="100" rx="20"/></clipPath></defs>
<g clip-path="url(#heiz-innen)">
<rect x="0" y="0" width="80" height="42" data-heiz="t0"/>
<rect x="0" y="42" width="80" height="34" data-heiz="t1"/>
<rect x="0" y="76" width="80" height="44" data-heiz="t2"/>
</g>
<rect x="14" y="8" width="52" height="100" rx="20" class="heiz-huelle"/>
<path d="M26 103l4-6 4 6 4-6 4 6 4-6 4 6 4-6" class="heiz-spirale" data-heiz="spirale"/>
<path d="M26 108v7M54 108v7" class="heiz-huelle"/>
<text x="40" y="30" data-heiz="tt0">–</text>
<text x="40" y="63" data-heiz="tt1">–</text>
<text x="40" y="90" data-heiz="tt2" class="klein">–</text>
</svg>
</div>
<div class="lade-text">
<div class="lade-auto">Pufferspeicher <span data-heiz="pille" class="lade-pille"></span></div>
<div class="lade-gross" data-heiz="leistung">–</div>
<div class="lade-zeile" data-heiz="temps"></div>
</div>
</div>
<div class="lade-erklaerung" data-heiz="erklaerung"></div>
</div>
<div class="lade-rechts">
<div class="lade-abschnitt">
<h3 id="heiz-modus">Wie heizen?</h3>
<div class="lade-modi" role="radiogroup" aria-labelledby="heiz-modus">
<button type="button" class="lade-modus" role="radio" aria-checked="false" data-heiz-modus="auto" style="--c:#f7c000">
<span class="lade-sym"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg></span>
<b>Automatik</b><span>heizt mit dem Überschuss der Anlage</span>
</button>
<button type="button" class="lade-modus" role="radio" aria-checked="false" data-heiz-modus="aus" style="--c:#9aa6ad">
<span class="lade-sym"><svg viewBox="0 0 24 24"><path d="M12 3v9"/><path d="M7.5 6.5a7 7 0 1 0 9 0"/></svg></span>
<b>Aus</b><span>bleibt kalt, auch bei Überschuss</span>
</button>
<button type="button" class="lade-modus" role="radio" aria-checked="false" data-heiz-modus="fest" style="--c:#e5624f">
<span class="lade-sym"><svg viewBox="0 0 24 24"><path d="M12 3c3 4 5 6.5 5 10a5 5 0 0 1-10 0c0-2 1-3.5 2-4.5.3 1.5 1 2.5 2 3 0-3 .5-5.5 1-8.5z"/></svg></span>
<b>Fest</b><span>eingestellte Leistung, auch aus dem Netz</span>
</button>
</div>
</div>
<div class="lade-planung" data-heiz="fest" hidden>
<div>
<div class="lade-plan-zeile">
<label for="heiz-regler">Leistung</label>
<output data-heiz="reglerAusgabe"></output>
</div>
<input type="range" id="heiz-regler" data-heiz="regler" min="1" max="60" step="1" value="30" class="heiz-regler">
<div class="lade-marken"><span>0,1 kW</span><span>3 kW</span><span>6 kW</span></div>
</div>
<div class="lade-chips" role="group" aria-label="Schnellwahl">
<button type="button" class="lade-chip" aria-pressed="false" data-heiz-stufe="10">1 kW</button>
<button type="button" class="lade-chip" aria-pressed="false" data-heiz-stufe="20">2 kW</button>
<button type="button" class="lade-chip" aria-pressed="false" data-heiz-stufe="30">3 kW</button>
<button type="button" class="lade-chip" aria-pressed="false" data-heiz-stufe="60">6 kW</button>
</div>
</div>
<div class="lade-meldung" data-heiz="meldung" aria-live="polite">Änderungen gelten sofort</div>
</div>
</div>
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* Endpunkt der Logseite.
*
* GET ?action=list Zustand aller bekannten Dateien
* GET ?action=read&datei=runner&... Zeilen einer Datei
*
* Parameter von read:
* datei Schluessel aus logDateien()
* anzahl wieviele Zeilen (1 bis 5000)
* suche Text, der vorkommen muss
* level Mindestlevel: INFO, WARNING, ERROR
*
* Ein reiner Verteiler - gelesen wird in restricted/logdateien.php.
*/
require_once("../helper.php");
require_once("../restricted/logdateien.php");
if (!checkLogin()) {
http_response_code(403);
exit;
}
function jsonAntwort($data, $code = 200)
{
http_response_code($code);
header("Content-Type: application/json; charset=utf-8");
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
exit;
}
$action = $_GET["action"] ?? "list";
try {
switch ($action) {
case "list":
$liste = [];
foreach (array_keys(logDateien()) as $key) {
$liste[] = logZustand($key);
}
jsonAntwort(["dateien" => $liste]);
case "read":
jsonAntwort(logLesen(
$_GET["datei"] ?? "",
$_GET["anzahl"] ?? 300,
trim((string) ($_GET["suche"] ?? "")),
strtoupper(trim((string) ($_GET["level"] ?? "")))
));
default:
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
}
} catch (InvalidArgumentException $e) {
jsonAntwort(["error" => $e->getMessage()], 400);
} catch (Throwable $e) {
jsonAntwort(["error" => $e->getMessage()], 500);
}
+186
View File
@@ -0,0 +1,186 @@
<?php
/*
* Daten für das Meteogramm der Solar-Seite (js/solar/meteogramm.js).
*
* ?tage=5&rueck=24 tage 2…7 nach vorn, rueck 0…72 Stunden zurück
*
* Die Vorhersage kommt aus solarLog.weatherHours und weatherDays (geschrieben
* von SolarManager/gatherForecastData.py), die gemessenen Stunden links vom
* Jetzt-Strich aus weatherStation.
*
* Verschmolzen wird nur teilweise, und das ist der Kern dieser Datei: Die
* eigene Station misst Temperatur, Wind, Böe, Richtung, Feuchte und Druck.
* Sie hat weder Regenmesser noch Pyranometer noch Wolkenkamera. Bewölkung,
* Strahlung, Niederschlag und Wettercode bleiben deshalb auch in der
* Vergangenheit aus weatherHours - dort stehen für vergangene Stunden keine
* Vorhersagen mehr, sondern nachgerechnete Werte (ist_vorhersage = 0).
* Genau dafür holt der Sammler zwei Tage Vergangenheit mit.
*
* Zeiten gehen als Unix-Sekunden hinaus, Messreihen als Tupel und nicht als
* Objekte: bei 200 Stunden mit 16 Feldern spart das ein Vielfaches. Welche
* Stelle was bedeutet, steht unten bei SPALTEN und noch einmal im Katalog
* (js/solar/meteogrammKatalog.js) - beide Listen müssen zueinander passen.
*
* Die Live-Werte kommen nicht von hier, sondern über MQTT (weatherStation/#).
*/
require_once("../helper.php");
require_once("../restricted/costs.php"); // solarDb()
header("Content-Type: application/json; charset=utf-8");
if (!checkLogin()) {
http_response_code(403);
echo json_encode(["error" => "Nicht angemeldet."]);
exit;
}
/*
* Die Spaltenlage einer Stundenzeile. Neue Größen hängen HINTEN an - dann
* bleiben die Stellen bestehender Spuren gültig und der Katalog muss nur
* einen Schlüssel dazubekommen.
*
* 0 t Unix-Sekunden, volle Stunde
* 1 gemessen 1 = die eigene Station hat diese Stunde gemessen
* 2 temp °C 3 regen mm 4 schnee cm
* 5 wind km/h 6 boe km/h 7 richtung Grad
* 8 strahlung W/m² 9 wtief % 10 wmittel %
* 11 whoch % 12 code WMO 13 feuchte %
* 14 druck hPa 15 regenWkt %
*/
try {
$db = solarDb();
$tage = max(2, min(7, intval($_GET["tage"] ?? 5)));
$rueck = max(0, min(72, intval($_GET["rueck"] ?? 24)));
// Auf volle Stunden rasten: die Vorhersage kennt nur Stunden, und eine
// Achse, die bei 13:47 anfängt, hätte keine runden Tagesgrenzen.
$jetzt = time();
$von = (intdiv($jetzt, 3600) - $rueck) * 3600;
$bis = (intdiv($jetzt, 3600) + $tage * 24) * 3600;
// --- Vorhersage und nachgerechnete Stunden ---------------------------
$reihe = [];
$stmt = $db->prepare(
"SELECT UNIX_TIMESTAMP(datetime) AS t, temp, regen, schnee, wind, boe,
richtung, strahlung, wolken_tief, wolken_mittel, wolken_hoch,
wettercode, feuchte, druck, regen_wkt
FROM weatherHours
WHERE datetime BETWEEN FROM_UNIXTIME(?) AND FROM_UNIXTIME(?)
ORDER BY datetime");
$stmt->bind_param("ii", $von, $bis);
$stmt->execute();
$res = $stmt->get_result();
while ($z = $res->fetch_assoc()) {
$t = (int)$z["t"];
$reihe[$t] = [
$t, 0,
zahl($z["temp"], 1), zahl($z["regen"], 1), zahl($z["schnee"], 1),
zahl($z["wind"], 1), zahl($z["boe"], 1), ganz($z["richtung"]),
ganz($z["strahlung"]),
ganz($z["wolken_tief"]), ganz($z["wolken_mittel"]), ganz($z["wolken_hoch"]),
ganz($z["wettercode"]), ganz($z["feuchte"]), ganz($z["druck"]),
ganz($z["regen_wkt"]),
];
}
$stmt->close();
// --- Gemessene Stunden der eigenen Station ---------------------------
//
// Derselbe Ausdruck dient als Spalte und als GROUP BY - ein Schalter
// statt zweier Abfragen, wie in ajax/speicher.php.
//
// MAX(gustSpeed), aber AVG(windSpeed): Open-Meteos Böe ist die Spitze der
// Stunde. Mit einem Mittelwert auf der Messseite spränge die Böenlinie
// genau am Jetzt-Strich.
//
// Die Windrichtung ist ein Winkel und verträgt kein arithmetisches
// Mittel: aus 350° und 10° würden 180°, also die Gegenrichtung. Deshalb
// die Vektorsumme über Sinus und Kosinus.
if ($rueck > 0) {
$stmt = $db->prepare(
"SELECT FLOOR(UNIX_TIMESTAMP(datetime) / 3600) * 3600 AS t,
AVG(tempAmb) AS temp, AVG(windSpeed) AS wind,
MAX(gustSpeed) AS boe, AVG(hum) AS feuchte, AVG(qff) AS druck,
DEGREES(ATAN2(AVG(SIN(RADIANS(windDir))),
AVG(COS(RADIANS(windDir))))) AS richtung
FROM weatherStation
WHERE datetime >= FROM_UNIXTIME(?) AND datetime < FROM_UNIXTIME(?)
GROUP BY t ORDER BY t");
$stmt->bind_param("ii", $von, $jetzt);
$stmt->execute();
$res = $stmt->get_result();
while ($z = $res->fetch_assoc()) {
$t = (int)$z["t"];
if (!isset($reihe[$t])) {
continue; // gemessen, aber ohne Vorhersagezeile - nichts zu zeigen
}
$reihe[$t][1] = 1;
$reihe[$t][2] = zahl($z["temp"], 1);
$reihe[$t][5] = zahl($z["wind"], 1);
$reihe[$t][6] = zahl($z["boe"], 1);
$reihe[$t][7] = ((int)round($z["richtung"]) + 360) % 360;
$reihe[$t][13] = ganz($z["feuchte"]);
$reihe[$t][14] = ganz($z["druck"]);
}
$stmt->close();
}
ksort($reihe);
// --- Tage: Sonnenzeiten und Tagesspanne ------------------------------
//
// Sonnenauf- und -untergang kommen aus weatherDays und nicht aus
// daylight: daylight reicht nur bis morgen, das Meteogramm bis zu sieben
// Tage. Tage ohne Sonnenzeit (nachgetragenes Archiv) fallen hier heraus -
// sie liegen ohnehin weit vor dem angezeigten Fenster.
$tageListe = [];
$stmt = $db->prepare(
"SELECT UNIX_TIMESTAMP(date) AS t, temp_min, temp_max, wettercode,
UNIX_TIMESTAMP(CONCAT(date, ' ', sonnenauf)) AS auf,
UNIX_TIMESTAMP(CONCAT(date, ' ', sonnenunter)) AS unter
FROM weatherDays
WHERE date BETWEEN DATE(FROM_UNIXTIME(?)) AND DATE(FROM_UNIXTIME(?))
AND sonnenauf IS NOT NULL
ORDER BY date");
$stmt->bind_param("ii", $von, $bis);
$stmt->execute();
$res = $stmt->get_result();
while ($z = $res->fetch_assoc()) {
$tageListe[] = [
(int)$z["t"], zahl($z["temp_min"], 1), zahl($z["temp_max"], 1),
ganz($z["wettercode"]), (int)$z["auf"], (int)$z["unter"],
];
}
$stmt->close();
// Wie frisch die Vorhersage ist - das Modul schreibt daraus "Stand 09:40"
// und färbt es warnend, wenn der Sammler stehengeblieben ist.
$stand = 0;
if ($res = $db->query("SELECT UNIX_TIMESTAMP(MAX(abgerufen)) AS s FROM weatherHours")) {
$stand = (int)($res->fetch_assoc()["s"] ?? 0);
}
echo json_encode([
"von" => $von, "bis" => $bis, "jetzt" => $jetzt, "stand" => $stand,
"stunden" => array_values($reihe),
"tage" => $tageListe,
]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(["error" => "Meteogramm nicht abrufbar: " . $e->getMessage()]);
}
/** Zahl mit fester Nachkommastelle, NULL bleibt NULL. */
function zahl($wert, $stellen)
{
return $wert === null ? null : round((float)$wert, $stellen);
}
/** Ganze Zahl, NULL bleibt NULL - eine fehlende Bewölkung ist nicht 0 %. */
function ganz($wert)
{
return $wert === null ? null : (int)round((float)$wert);
}
+671
View File
@@ -0,0 +1,671 @@
<?php
namespace Bluerhinos;
/*
phpMQTT
A simple php class to connect/publish/subscribe to an MQTT broker
*/
/*
Licence
Copyright (c) 2010 Blue Rhinos Consulting | Andrew Milsted
andrew@bluerhinos.co.uk | http://www.bluerhinos.co.uk
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
/* phpMQTT */
class phpMQTT
{
protected $socket; /* holds the socket */
protected $msgid = 1; /* counter for message id */
public $keepalive = 10; /* default keepalive timmer */
public $timesinceping; /* host unix time, used to detect disconects */
public $topics = []; /* used to store currently subscribed topics */
public $debug = false; /* should output debug messages */
public $address; /* broker address */
public $port; /* broker port */
public $clientid; /* client id sent to brocker */
public $will; /* stores the will of the client */
protected $username; /* stores username */
protected $password; /* stores password */
public $cafile;
protected static $known_commands = [
1 => 'CONNECT',
2 => 'CONNACK',
3 => 'PUBLISH',
4 => 'PUBACK',
5 => 'PUBREC',
6 => 'PUBREL',
7 => 'PUBCOMP',
8 => 'SUBSCRIBE',
9 => 'SUBACK',
10 => 'UNSUBSCRIBE',
11 => 'UNSUBACK',
12 => 'PINGREQ',
13 => 'PINGRESP',
14 => 'DISCONNECT'
];
/**
* phpMQTT constructor.
*
* @param $address
* @param $port
* @param $clientid
* @param null $cafile
*/
public function __construct($address, $port, $clientid, $cafile = null)
{
$this->broker($address, $port, $clientid, $cafile);
}
/**
* Sets the broker details
*
* @param $address
* @param $port
* @param $clientid
* @param null $cafile
*/
public function broker($address, $port, $clientid, $cafile = null): void
{
$this->address = $address;
$this->port = $port;
$this->clientid = $clientid;
$this->cafile = $cafile;
}
/**
* Will try and connect, if fails it will sleep 10s and try again, this will enable the script to recover from a network outage
*
* @param bool $clean - should the client send a clean session flag
* @param null $will
* @param null $username
* @param null $password
*
* @return bool
*/
public function connect_auto($clean = true, $will = null, $username = null, $password = null): bool
{
while ($this->connect($clean, $will, $username, $password) === false) {
sleep(10);
}
return true;
}
/**
* @param bool $clean - should the client send a clean session flag
* @param null $will
* @param null $username
* @param null $password
*
* @return bool
*/
public function connect($clean = true, $will = null, $username = null, $password = null): bool
{
if ($will) {
$this->will = $will;
}
if ($username) {
$this->username = $username;
}
if ($password) {
$this->password = $password;
}
if ($this->cafile) {
$socketContext = stream_context_create(
[
'ssl' => [
'verify_peer_name' => true,
'cafile' => $this->cafile
]
]
);
$this->socket = stream_socket_client('tls://' . $this->address . ':' . $this->port, $errno, $errstr, 60, STREAM_CLIENT_CONNECT, $socketContext);
} else {
$this->socket = stream_socket_client('tcp://' . $this->address . ':' . $this->port, $errno, $errstr, 60, STREAM_CLIENT_CONNECT);
}
if (!$this->socket) {
$this->_errorMessage("stream_socket_create() $errno, $errstr");
return false;
}
stream_set_timeout($this->socket, 5);
stream_set_blocking($this->socket, 0);
$i = 0;
$buffer = '';
$buffer .= chr(0x00);
$i++; // Length MSB
$buffer .= chr(0x04);
$i++; // Length LSB
$buffer .= chr(0x4d);
$i++; // M
$buffer .= chr(0x51);
$i++; // Q
$buffer .= chr(0x54);
$i++; // T
$buffer .= chr(0x54);
$i++; // T
$buffer .= chr(0x04);
$i++; // // Protocol Level
//No Will
$var = 0;
if ($clean) {
$var += 2;
}
//Add will info to header
if ($this->will !== null) {
$var += 4; // Set will flag
$var += ($this->will['qos'] << 3); //Set will qos
if ($this->will['retain']) {
$var += 32;
} //Set will retain
}
if ($this->username !== null) {
$var += 128;
} //Add username to header
if ($this->password !== null) {
$var += 64;
} //Add password to header
$buffer .= chr($var);
$i++;
//Keep alive
$buffer .= chr($this->keepalive >> 8);
$i++;
$buffer .= chr($this->keepalive & 0xff);
$i++;
$buffer .= $this->strwritestring($this->clientid, $i);
//Adding will to payload
if ($this->will !== null) {
$buffer .= $this->strwritestring($this->will['topic'], $i);
$buffer .= $this->strwritestring($this->will['content'], $i);
}
if ($this->username !== null) {
$buffer .= $this->strwritestring($this->username, $i);
}
if ($this->password !== null) {
$buffer .= $this->strwritestring($this->password, $i);
}
$head = chr(0x10);
while ($i > 0) {
$encodedByte = $i % 128;
$i /= 128;
$i = (int)$i;
if ($i > 0) {
$encodedByte |= 128;
}
$head .= chr($encodedByte);
}
fwrite($this->socket, $head, 2);
fwrite($this->socket, $buffer);
$string = $this->read(4);
if (ord($string[0]) >> 4 === 2 && $string[3] === chr(0)) {
$this->_debugMessage('Connected to Broker');
} else {
$this->_errorMessage(
sprintf(
"Connection failed! (Error: 0x%02x 0x%02x)\n",
ord($string[0]),
ord($string[3])
)
);
return false;
}
$this->timesinceping = time();
return true;
}
/**
* Reads in so many bytes
*
* @param int $int
* @param bool $nb
*
* @return false|string
*/
public function read($int = 8192, $nb = false)
{
$string = '';
$togo = $int;
if ($nb) {
return fread($this->socket, $togo);
}
while (!feof($this->socket) && $togo > 0) {
$fread = fread($this->socket, $togo);
$string .= $fread;
$togo = $int - strlen($string);
}
return $string;
}
/**
* Subscribes to a topic, wait for message and return it
*
* @param $topic
* @param $qos
*
* @return string
*/
public function subscribeAndWaitForMessage($topic, $qos): string
{
$this->subscribe(
[
$topic => [
'qos' => $qos,
'function' => '__direct_return_message__'
]
]
);
do {
$return = $this->proc();
} while ($return === true);
return $return;
}
/**
* subscribes to topics
*
* @param $topics
* @param int $qos
*/
public function subscribe($topics, $qos = 0): void
{
$i = 0;
$buffer = '';
$id = $this->msgid;
$buffer .= chr($id >> 8);
$i++;
$buffer .= chr($id % 256);
$i++;
foreach ($topics as $key => $topic) {
$buffer .= $this->strwritestring($key, $i);
$buffer .= chr($topic['qos']);
$i++;
$this->topics[$key] = $topic;
}
$cmd = 0x82;
//$qos
$cmd += ($qos << 1);
$head = chr($cmd);
$head .= $this->setmsglength($i);
fwrite($this->socket, $head, strlen($head));
$this->_fwrite($buffer);
$string = $this->read(2);
$bytes = ord(substr($string, 1, 1));
$this->read($bytes);
}
/**
* Sends a keep alive ping
*/
public function ping(): void
{
$head = chr(0xc0);
$head .= chr(0x00);
fwrite($this->socket, $head, 2);
$this->timesinceping = time();
$this->_debugMessage('ping sent');
}
/**
* sends a proper disconnect cmd
*/
public function disconnect(): void
{
$head = ' ';
$head[0] = chr(0xe0);
$head[1] = chr(0x00);
fwrite($this->socket, $head, 2);
}
/**
* Sends a proper disconnect, then closes the socket
*/
public function close(): void
{
$this->disconnect();
stream_socket_shutdown($this->socket, STREAM_SHUT_WR);
}
/**
* Publishes $content on a $topic
*
* @param $topic
* @param $content
* @param int $qos
* @param bool $retain
*/
public function publish($topic, $content, $qos = 0, $retain = false): void
{
$i = 0;
$buffer = '';
$buffer .= $this->strwritestring($topic, $i);
if ($qos) {
$id = $this->msgid++;
$buffer .= chr($id >> 8);
$i++;
$buffer .= chr($id % 256);
$i++;
}
$buffer .= $content;
$i += strlen($content);
$head = ' ';
$cmd = 0x30;
if ($qos) {
$cmd += $qos << 1;
}
if (empty($retain) === false) {
++$cmd;
}
$head[0] = chr($cmd);
$head .= $this->setmsglength($i);
fwrite($this->socket, $head, strlen($head));
$this->_fwrite($buffer);
}
/**
* Writes a string to the socket
*
* @param $buffer
*
* @return bool|int
*/
protected function _fwrite($buffer)
{
$buffer_length = strlen($buffer);
for ($written = 0; $written < $buffer_length; $written += $fwrite) {
$fwrite = fwrite($this->socket, substr($buffer, $written));
if ($fwrite === false) {
return false;
}
}
return $buffer_length;
}
/**
* Processes a received topic
*
* @param $msg
*
* @retrun bool|string
*/
public function message($msg)
{
$tlen = (ord($msg[0]) << 8) + ord($msg[1]);
$topic = substr($msg, 2, $tlen);
$msg = substr($msg, ($tlen + 2));
$found = false;
foreach ($this->topics as $key => $top) {
if (preg_match(
'/^' . str_replace(
'#',
'.*',
str_replace(
'+',
"[^\/]*",
str_replace(
'/',
"\/",
str_replace(
'$',
'\$',
$key
)
)
)
) . '$/',
$topic
)) {
$found = true;
if ($top['function'] === '__direct_return_message__') {
return $msg;
}
if (is_callable($top['function'])) {
call_user_func($top['function'], $topic, $msg);
} else {
$this->_errorMessage('Message received on topic ' . $topic . ' but function is not callable.');
}
}
}
if ($found === false) {
$this->_debugMessage('msg received but no match in subscriptions');
}
return $found;
}
/**
* The processing loop for an "always on" client
* set true when you are doing other stuff in the loop good for
* watching something else at the same time
*
* @param bool $loop
*
* @return bool | string
*/
public function proc(bool $loop = true)
{
if (feof($this->socket)) {
$this->_debugMessage('eof receive going to reconnect for good measure');
fclose($this->socket);
$this->connect_auto(false);
if (count($this->topics)) {
$this->subscribe($this->topics);
}
}
$byte = $this->read(1, true);
if ((string)$byte === '') {
if ($loop === true) {
usleep(100000);
}
} else {
$cmd = (int)(ord($byte) / 16);
$this->_debugMessage(
sprintf(
'Received CMD: %d (%s)',
$cmd,
isset(static::$known_commands[$cmd]) === true ? static::$known_commands[$cmd] : 'Unknown'
)
);
$multiplier = 1;
$value = 0;
do {
$digit = ord($this->read(1));
$value += ($digit & 127) * $multiplier;
$multiplier *= 128;
} while (($digit & 128) !== 0);
$this->_debugMessage('Fetching: ' . $value . ' bytes');
$string = $value > 0 ? $this->read($value) : '';
if ($cmd) {
switch ($cmd) {
case 3: //Publish MSG
$return = $this->message($string);
if (is_bool($return) === false) {
return $return;
}
break;
}
}
}
if ($this->timesinceping < (time() - $this->keepalive)) {
$this->_debugMessage('not had something in a while so ping');
$this->ping();
}
if ($this->timesinceping < (time() - ($this->keepalive * 2))) {
$this->_debugMessage('not seen a packet in a while, disconnecting/reconnecting');
fclose($this->socket);
$this->connect_auto(false);
if (count($this->topics)) {
$this->subscribe($this->topics);
}
}
return true;
}
/**
* Gets the length of a msg, (and increments $i)
*
* @param $msg
* @param $i
*
* @return float|int
*/
protected function getmsglength(&$msg, &$i)
{
$multiplier = 1;
$value = 0;
do {
$digit = ord($msg[$i]);
$value += ($digit & 127) * $multiplier;
$multiplier *= 128;
$i++;
} while (($digit & 128) !== 0);
return $value;
}
/**
* @param $len
*
* @return string
*/
protected function setmsglength($len): string
{
$string = '';
do {
$digit = $len % 128;
$len >>= 7;
// if there are more digits to encode, set the top bit of this digit
if ($len > 0) {
$digit |= 0x80;
}
$string .= chr($digit);
} while ($len > 0);
return $string;
}
/**
* @param $str
* @param $i
*
* @return string
*/
protected function strwritestring($str, &$i): string
{
$len = strlen($str);
$msb = $len >> 8;
$lsb = $len % 256;
$ret = chr($msb);
$ret .= chr($lsb);
$ret .= $str;
$i += ($len + 2);
return $ret;
}
/**
* Prints a sting out character by character
*
* @param $string
*/
public function printstr($string): void
{
$strlen = strlen($string);
for ($j = 0; $j < $strlen; $j++) {
$num = ord($string[$j]);
if ($num > 31) {
$chr = $string[$j];
} else {
$chr = ' ';
}
printf("%4d: %08b : 0x%02x : %s \n", $j, $num, $num, $chr);
}
}
/**
* @param string $message
*/
protected function _debugMessage(string $message): void
{
if ($this->debug === true) {
echo date('r: ') . $message . PHP_EOL;
}
}
/**
* @param string $message
*/
protected function _errorMessage(string $message): void
{
error_log('Error:' . $message);
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
/**
* Web-Push-Abos: anmelden, abmelden, auflisten, Probe schicken.
*
* GET ?action=stand {schluessel, abos: [...]}
* POST ?action=anmelden {"abo": {endpoint, keys:{p256dh, auth}}, "name": "..."}
* POST ?action=abmelden {"endpoint": "..."} - dieses Gerät
* POST ?action=entfernen {"id": 3} - ein anderes Gerät
* POST ?action=probe {"titel": "...", "text": "..."}
*
* Verschickt wird nirgends hier: Das macht der Runner auf der NAS, der die
* Verschlüsselung und den privaten Schlüssel hat. Die Probe geht deshalb als
* MQTT-Nachricht an ihn - denselben Weg nimmt auch eine Automatik.
*/
require_once("../helper.php");
require_once("../restricted/push.php");
header("Content-Type: application/json; charset=utf-8");
if (!checkLogin()) {
http_response_code(403);
echo json_encode(["error" => "Nicht angemeldet."]);
exit;
}
function pushAntwort($daten, $code = 200)
{
http_response_code($code);
echo json_encode($daten, JSON_UNESCAPED_UNICODE);
exit;
}
$action = $_GET["action"] ?? "stand";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$body = json_decode(file_get_contents("php://input"), true);
if (!is_array($body)) {
pushAntwort(["error" => "Konnte die Anfrage nicht lesen."], 400);
}
try {
switch ($action) {
case "anmelden":
pushAboSpeichern($body["abo"] ?? [], $body["name"] ?? "",
$_SERVER["HTTP_USER_AGENT"] ?? "");
pushAntwort(["ok" => true, "abos" => pushAbos()]);
case "abmelden":
$weg = pushAboEntfernenNachEndpoint(strval($body["endpoint"] ?? ""));
pushAntwort(["ok" => true, "entfernt" => $weg, "abos" => pushAbos()]);
case "entfernen":
$weg = pushAboEntfernen($body["id"] ?? 0);
pushAntwort(["ok" => true, "entfernt" => $weg, "abos" => pushAbos()]);
case "probe":
$titel = trim(strval($body["titel"] ?? "")) ?: "Probe";
$text = trim(strval($body["text"] ?? "")) ?: "Wenn das ankommt, stimmt der Weg.";
pushProbe($titel, $text);
pushAntwort(["ok" => true]);
}
pushAntwort(["error" => "Unbekannte Aktion."], 400);
} catch (InvalidArgumentException $e) {
pushAntwort(["error" => $e->getMessage()], 400);
} catch (Throwable $e) {
pushAntwort(["error" => $e->getMessage()], 500);
}
}
if ($action === "stand") {
pushAntwort([
"schluessel" => pushSchluessel(),
"abos" => pushAbos(),
]);
}
pushAntwort(["error" => "Unbekannte Aktion."], 400);
+337
View File
@@ -0,0 +1,337 @@
<?php
/**
* Raum-Modal: alles, was in einem Raum haengt, auf einer Seite.
*
* Loest das frühere Thermostat-Modal ab. Der Grundriss zeigt Raeume, also
* sollte ein Klick darauf den Raum oeffnen und nicht nur seine Heizung - die
* Rollladen im selben Zimmer waren bisher gar nicht erreichbar.
*
* Welche Geraete zu einem Raum gehoeren, sagt die Spalte actors.room_id
* (Einstellungen -> Geraete).
*
* GET ?room=12 Inhalt des Modals, 12 = rooms.id
* POST ?action=command {"command_id": N, "params": {...}} - ein Kommando
* POST ?action=temp {"room": 12, "temp": 21} - Solltemperatur
* POST ?action=jalousie {"actor": 5} - Stand einer Jalousie
* POST ?action=my {"actor": 5} - aktuellen Stand als
* my-Position speichern
*
* Frueher hiess der Raum "EG_Bad" und wurde am Unterstrich zerlegt. Das hielt
* weder ein Leerzeichen im Namen aus (erst nachgeruestet) noch eine
* Umbenennung - die feste Nummer haelt beides.
*/
/** Ein Raum nach seiner festen Nummer, oder null. */
function raumNachNummer($nr)
{
foreach (allRooms() as $r) {
if ($r["nr"] === intval($nr)) {
return $r;
}
}
return null;
}
// Ein "Zu" an einer Jalousie faehrt herunter, wartet und neigt danach - das
// dauert in der Summe ueber eine Minute. Die Vorgabe von PHP reicht dafuer
// nicht; ajax/tahoma.php macht es an derselben Stelle genauso.
set_time_limit(180);
require_once("../helper.php");
require_once("../restricted/automations.php");
require_once("../restricted/commands.php");
require_once("../restricted/roomControls.php");
require_once("./phpMQTT.php");
if (!checkLogin()) {
http_response_code(403);
exit;
}
// Die Sitzung wird ab hier nur noch gelesen, und PHP haelt ihre Datei bis
// zum Ende der Anfrage exklusiv gesperrt. Ein "Zu" an einer Jalousie wartet
// weiter unten ueber eine Minute auf das Ende der Fahrt - solange stand jede
// weitere Anfrage desselben Browsers still, auch die, die waehrend der Fahrt
// nachsehen will, wie weit sie ist. Alle Schreibzugriffe auf $_SESSION
// stecken in checkLogin() und sind hier durch.
session_write_close();
function raumAntwort($data, $code = 200)
{
http_response_code($code);
header("Content-Type: application/json; charset=utf-8");
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
/**
* Die Antwort abschicken, aber weiterarbeiten.
*
* Nach einem Fahrbefehl muss noch jemand abwarten, wo die Jalousie stehen
* bleibt, und das aufschreiben - sonst steht in actor_states bis zum
* naechsten Poll des Runners der alte Wert. Das dem Browser zu ueberlassen
* ging schief: wer das Modal gleich nach dem Fahrbefehl zumachte, nahm den
* einzigen Zuschauer mit.
*
* Dieselbe Bauart wie in ajax/tahoma.php: Content-Length setzen, ausliefern,
* die Verbindung schliessen, weiterlaufen. Ohne FPM bleibt nur, die Puffer zu
* leeren - dann hat der Browser seine Daten zwar, wartet aber trotzdem.
*/
function raumAntwortUndWeiter($data)
{
http_response_code(200);
header("Content-Type: application/json; charset=utf-8");
$text = json_encode($data, JSON_UNESCAPED_UNICODE);
header("Content-Length: " . strlen($text));
echo $text;
// Der Aufrufer ist weg, das Skript soll trotzdem zu Ende kommen.
ignore_user_abort(true);
if (function_exists("fastcgi_finish_request")) {
fastcgi_finish_request();
return;
}
while (ob_get_level() > 0) {
ob_end_flush();
}
flush();
}
// --- Schaltende Aufrufe ---------------------------------------------------
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$body = json_decode(file_get_contents("php://input"), true);
if (!is_array($body)) {
raumAntwort(["error" => "Konnte die Anfrage nicht lesen."], 400);
}
try {
if (($_GET["action"] ?? "") === "command") {
$id = intval($body["command_id"] ?? 0);
$text = executeCommand($id,
is_array($body["params"] ?? null) ? $body["params"] : [],
!empty($body["probe"]));
// Bei einem Tahoma-Geraet ist mit dem Absenden noch nichts
// festgehalten: die Box meldet die neue Stellung erst, wenn die
// Fahrt zu Ende ist. Also Antwort raus und in Ruhe nachsehen.
$url = empty($body["probe"]) ? tahomaUrlZuKommando($id) : null;
if ($url === null) {
raumAntwort(["ok" => $text]);
}
raumAntwortUndWeiter(["ok" => $text]);
tahomaNachlesen($url);
exit;
}
if (($_GET["action"] ?? "") === "temp") {
// Derselbe Weg wie im alten Thermostat-Modal: <Zweig>/changeSetTemp.
// Der Zweig steht am Raum (rooms.thermostat) und wird nicht mehr
// aus Etage und Name zusammengesetzt - ein Thermostat in einem
// anderen Haus meldet vielleicht unter einem ganz anderen.
$raum = raumNachNummer($body["room"] ?? 0);
if (!$raum || empty($raum["thermostat"])) {
raumAntwort(["error" => "Dieser Raum hat kein Thermostat."], 400);
}
$temp = intval($body["temp"] ?? 0);
if ($temp < 10 || $temp > 30) {
raumAntwort(["error" => "Temperatur außerhalb 10–30 °C."], 400);
}
$mqtt = new Bluerhinos\phpMQTT(MQTT_BROKER, MQTT_PORT, "RoomTempSetter_" . getmypid());
if (!$mqtt->connect(true, null, "", "")) {
raumAntwort(["error" => "Keine Verbindung zum MQTT-Broker."], 500);
}
$mqtt->publish(rtrim($raum["thermostat"], "/") . "/changeSetTemp",
strval($temp), 0, false);
$mqtt->close();
raumAntwort(["ok" => "Solltemperatur " . $temp . " °C"]);
}
// Eine Jalousie waehrend ihrer Fahrt nachfuehren. actor_states wird
// sonst nur vom Runner fortgeschrieben, und der fragt die Box alle
// fuenf Minuten ab - so lange stand nach einem Fahrbefehl der alte
// Wert im Modal. Hier wird genau ein Geraet gelesen, das kostet die
// Box wenig, und der frische Stand landet gleich in der Tabelle.
if (($_GET["action"] ?? "") === "jalousie") {
$url = tahomaAktorUrl(intval($body["actor"] ?? 0));
if ($url === null) {
raumAntwort(["error" => "Kein Tahoma-Gerät mit dieser Nummer."], 404);
}
$zustaende = tahomaZustaende($url);
if ($zustaende === null) {
raumAntwort(["error" => "Tahoma-Box antwortet nicht."], 503);
}
tahomaZustaendeSchreiben($url, $zustaende);
$zahl = function ($feld) use ($zustaende) {
return isset($zustaende[$feld]) && is_numeric($zustaende[$feld])
? intval($zustaende[$feld]) : null;
};
raumAntwort([
"faehrt" => ($zustaende["core:MovingState"] ?? null) === true,
"position" => $zahl("core:ClosureState"),
"neigung" => $zahl("core:SlateOrientationState"),
"myPosition" => $zahl("core:Memorized1PositionState"),
"myNeigung" => $zahl("core:Memorized1OrientationState"),
]);
}
// Den Zustand einer WLED-Lampe live lesen: an/aus, Helligkeit, Farbe,
// Effekt und Preset. In actor_states stehen nur die ersten drei, und
// ohne Effekt und Preset koennte das Raum-Modal nicht zeigen, was
// gerade laeuft.
if (($_GET["action"] ?? "") === "wled") {
$db = commandDb();
$stmt = $db->prepare("SELECT url FROM actors WHERE id = ?");
$id = intval($body["actor"] ?? 0);
$stmt->bind_param("i", $id);
$stmt->execute();
$zeile = $stmt->get_result()->fetch_assoc();
$stmt->close();
if (!$zeile || strpos(strval($zeile["url"]), "wled://") !== 0) {
raumAntwort(["error" => "Keine WLED-Lampe mit dieser Nummer."], 404);
}
$ch = curl_init("http://" . rtrim(substr($zeile["url"], strlen("wled://")), "/") . "/json/state");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 3);
$stand = json_decode(strval(curl_exec($ch)), true);
curl_close($ch);
if (!is_array($stand)) {
raumAntwort(["error" => "Die Lampe antwortet nicht."], 503);
}
$segment = $stand["seg"][0] ?? [];
$farbe = $segment["col"][0] ?? null;
raumAntwort([
"an" => !empty($stand["on"]),
"bri" => isset($stand["bri"]) ? intval($stand["bri"]) : null,
"farbe" => is_array($farbe) && count($farbe) >= 3 ? array_map("intval", array_slice($farbe, 0, 3)) : null,
"effect" => isset($segment["fx"]) ? intval($segment["fx"]) : null,
"preset" => isset($stand["ps"]) ? intval($stand["ps"]) : null,
]);
}
// Den Stand, auf dem die Jalousie gerade steht, als my-Position in den
// Motor schreiben. Die Box kennt dafuer setMemorized1Position und bei
// Raffstores zusaetzlich setMemorized1Orientation; die Geraetesuche
// uebernimmt beide nicht als Kommando, deshalb geht es hier direkt.
// Gespeichert wird, was die Box meldet - nicht, was im Browser steht:
// der koennte einer Fahrt hinterherhinken.
if (($_GET["action"] ?? "") === "my") {
$url = tahomaAktorUrl(intval($body["actor"] ?? 0));
if ($url === null) {
raumAntwort(["error" => "Kein Tahoma-Gerät mit dieser Nummer."], 404);
}
$vorher = tahomaZustaende($url);
if ($vorher === null) {
raumAntwort(["error" => "Tahoma-Box antwortet nicht."], 503);
}
if (($vorher["core:MovingState"] ?? null) === true) {
raumAntwort(["error" => "Die Jalousie fährt noch – bitte warten, bis sie steht."], 409);
}
if (!isset($vorher["core:Memorized1PositionState"]) || !is_numeric($vorher["core:ClosureState"] ?? null)) {
raumAntwort(["error" => "Dieses Gerät kann keine my-Position speichern."], 400);
}
$position = intval($vorher["core:ClosureState"]);
$neigung = null;
tahomaKommando($url, "setMemorized1Position", [$position]);
$text = "my-Position " . $position . " %";
if (isset($vorher["core:Memorized1OrientationState"]) && is_numeric($vorher["core:SlateOrientationState"] ?? null)) {
$neigung = intval($vorher["core:SlateOrientationState"]);
tahomaKommando($url, "setMemorized1Orientation", [$neigung]);
$text .= ", Lamellen " . $neigung . " %";
}
// Die Box braucht einen Moment, bis sie den neuen Wert meldet.
sleep(2);
$nachher = tahomaZustaende($url);
if ($nachher !== null) {
tahomaZustaendeSchreiben($url, $nachher);
}
raumAntwort([
"ok" => $text . " gespeichert",
"myPosition" => $position,
"myNeigung" => $neigung,
]);
}
raumAntwort(["error" => "Unbekannte Aktion."], 400);
} catch (InvalidArgumentException $e) {
raumAntwort(["error" => $e->getMessage()], 400);
} catch (Throwable $e) {
raumAntwort(["error" => $e->getMessage()], 500);
}
}
// --- Inhalt des Modals ----------------------------------------------------
$raum = raumNachNummer($_GET["room"] ?? 0);
if (!$raum) {
echo "<p class='text-body-secondary mb-0'>Diesen Raum gibt es nicht (mehr).</p>";
exit;
}
// Derselbe Schluessel wie actors.room_id im Katalog - als Text, siehe raumListe().
$raumKey = strval($raum["nr"]);
// Alles im Raum, sortiert nach Bedienform. Das Thermostat selbst wird nicht
// als Karte gezeichnet: es wird zum Schieberegler im Heizungs-Reiter und
// braucht daneben keine zweite Bedienung - "Solltemperatur" stand sonst
// zweimal im Modal.
//
// Ob es einen Heizungs-Reiter gibt, entscheidet genau das: haengt in diesem
// Raum ein Thermostat? Frueher stand hier "hat der Raum eine Kachel" - das
// stimmte, solange nur Raeume mit Thermostat eine bekamen. Seit auch Kueche
// und Technik eine haben, bekamen sie einen Heizungsregler fuer eine
// Heizung, die es dort nicht gibt.
$katalog = deviceCatalog();
$hatThermostat = false;
$gruppen = [];
foreach ($katalog as $d) {
if ($d["room"] !== $raumKey) {
continue;
}
$form = bedienform($d);
if ($form === "heizung") {
$hatThermostat = true;
continue;
}
$gruppen[$form][] = $d;
}
// Die Heizung ist eine Gruppe wie die anderen, nur baut sie sich nicht aus
// einer Geraeteliste: ein Regler fuer den Raum statt einer Karte je Geraet.
$fertig = [];
if ($hatThermostat) {
// Ring mit Ist-Temperatur und ziehbarem Soll-Punkt, daneben - und + und
// die Schnellwahl. Alle Zahlen kommen live ueber MQTT (der Browser hoert
// ohnehin auf den Thermostat-Zweig) und werden von js/solar/raumfenster.js
// eingetragen. Das Thermostat nimmt ganze Grad von 10 bis 30 °C, siehe
// action=temp.
$fertig["heizung"] = "<div class='rf-heiz' data-rf-heiz-teil data-min='10' data-max='30'>"
. "<div class='rf-ring'><svg viewBox='0 0 170 170'>"
. "<defs><linearGradient id='rfHeizVerlauf' gradientUnits='userSpaceOnUse' gradientTransform='rotate(-135 85 85)' x1='15' y1='0' x2='155' y2='0'>"
. "<stop offset='0' stop-color='#4b9fd6'/><stop offset='.55' stop-color='#e8a33a'/><stop offset='1' stop-color='#e5624f'/>"
. "</linearGradient></defs>"
. "<circle class='rf-ring-spur' cx='85' cy='85' r='70' transform='rotate(135 85 85)'/>"
. "<circle class='rf-ring-ist' cx='85' cy='85' r='70' transform='rotate(135 85 85)'/>"
. "<circle class='rf-ring-griff' cx='85' cy='85' r='70' transform='rotate(135 85 85)' data-rf-ringzug/>"
. "<circle class='rf-ring-soll' cx='155' cy='85' r='10' data-rf-ringzug tabindex='0' role='slider'"
. " aria-label='Solltemperatur' aria-valuemin='10' aria-valuemax='30'/></svg>"
. "<div class='rf-ring-mitte'><small>im Raum</small><b data-rf-heiz='ist'>–</b><span data-rf-heiz='soll'>Soll –</span></div></div>"
. "<div class='rf-heiz-rechts'><h3>Solltemperatur</h3>"
. "<div class='rf-stepper'><button type='button' data-rf-schritt='-1' aria-label='1 Grad kälter'>−</button>"
. "<output data-rf-heiz='wert'>–</output>"
. "<button type='button' data-rf-schritt='1' aria-label='1 Grad wärmer'>+</button></div>"
. "<div class='rf-chips'>"
. "<button type='button' class='rf-chip' data-rf-soll='10' aria-pressed='false'>Minimum <span>10°</span></button>"
. "<button type='button' class='rf-chip' data-rf-soll='17' aria-pressed='false'>Eco <span>17°</span></button>"
. "<button type='button' class='rf-chip' data-rf-soll='21' aria-pressed='false'>Komfort <span>21°</span></button>"
. "<button type='button' class='rf-chip' data-rf-soll='23' aria-pressed='false'>Warm <span>23°</span></button></div>"
. "<div class='rf-hinweis' data-rf-heiz='hinweis' hidden><i class='bi bi-info-circle'></i><span></span></div>"
. "<div class='rf-pillen'><span class='rf-pille'><i class='bi bi-droplet'></i><span data-rf-heiz='feuchte'>–</span></span>"
. "<span class='rf-pille' data-rf-heiz='puffer' hidden><i class='bi bi-fire'></i>Pufferbetrieb</span></div>"
. "</div></div>";
}
// Der Thermostat-Zweig reist mit: das Modal fuellt Ist-Werte und Heizsymbol
// aus dem laufenden MQTT-Strom und muss wissen, wo es nachsehen soll.
echo "<div class='rf' id='roomView' data-room='" . $raum["nr"] . "'"
. " data-thermostat='" . htmlspecialchars((string)$raum["thermostat"], ENT_QUOTES) . "'>";
if (!$gruppen && !$fertig) {
echo "<p class='rf-leer'>Diesem Raum ist noch nichts zugeordnet. "
. "Das geht über das Haus-Symbol in der Karte „Automatismen“.</p>";
}
echo zeichneGruppen($gruppen, $fertig);
echo "<div id='roomFeedback' class='rf-meldung' aria-live='polite'>Änderungen gelten sofort</div>";
echo "</div>";
+235
View File
@@ -0,0 +1,235 @@
<?php
/**
* Endpunkt der Einstellungsseite.
*
* GET ?action=list&reihe=strom Zeitraeume einer Preistabelle
* POST ?action=save {"reihe": "strom", "zeilen": [ ... ]}
* GET ?action=kacheln Anzeigewerte aller Home-Kacheln
* POST ?action=kacheln-save {"floor": "OG", "room": "bad",
* "werte": [ ... ] oder null}
* GET ?action=geraete Geraete mit Raum, Verwendung und Alter
* POST ?action=geraete-raeume {"rooms": {"123": "11", ...}} Geraet -> Raumnummer
* POST ?action=geraet-loeschen {"id": 12}
* GET ?action=skoda-keys Stand der Fahrzeug-Schluessel (ohne Werte!)
* POST ?action=skoda-keys-save {"felder": {"API_KEY2": "..."},
* "loeschen": ["API_KEY3"]}
*
* GET ?action=grundriss Etagen, Raeume, Kachelpositionen
* POST ?action=etage-speichern {"code": "EG", "label": "...", "standard": true, "neu": false}
* POST ?action=etagen-reihenfolge {"codes": ["OG", "EG", ...]}
* POST ?action=etage-loeschen {"code": "EG"}
* POST ?action=etage-bild multipart: code, bild (Datei)
* POST ?action=raum-speichern {"nr": 0|12, "floor": "EG", "name": "...", "kuerzel": "", "thermostat": ""}
* POST ?action=raum-position {"nr": 12, "x": 120, "y": 80} (x/y null = vom Plan)
* POST ?action=raum-loeschen {"nr": 12}
* Die Grundriss-Aktionen antworten mit dem ganzen neuen Stand.
*
* GET ?action=energiefluss Abweichungen der Übersicht vom Katalog
* POST ?action=energiefluss-save {"knoten": [{id, aktiv, name, x, y, optionen}, ...]}
*
* Ein reiner Verteiler: geprueft und geschrieben wird in
* restricted/costs.php, kacheln.php bzw. grundriss.php, damit Maske und
* Endpunkt dieselbe Wahrheit ueber einen Zeitraum oder eine Kachel haben.
*/
require_once("../helper.php");
require_once("../restricted/costs.php");
require_once("../restricted/kacheln.php");
require_once("../restricted/automations.php");
require_once("../restricted/skodaKeys.php");
require_once("../restricted/grundriss.php");
require_once("../restricted/energiefluss.php");
if (!checkLogin()) {
http_response_code(403);
exit;
}
function jsonAntwort($data, $code = 200)
{
http_response_code($code);
header("Content-Type: application/json; charset=utf-8");
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
$action = $_GET["action"] ?? "list";
// Das Grundrissbild kommt als Formular mit Datei, nicht als JSON - deshalb
// vor dem JSON-Teil.
if ($_SERVER["REQUEST_METHOD"] === "POST" && $action === "etage-bild") {
try {
$bild = etageBildSpeichern($_POST["code"] ?? "", $_FILES["bild"] ?? null);
grundriss(true);
jsonAntwort(["bild" => $bild, "stand" => grundrissStand()]);
} catch (InvalidArgumentException $e) {
jsonAntwort(["error" => $e->getMessage()], 400);
} catch (Throwable $e) {
jsonAntwort(["error" => $e->getMessage()], 500);
}
}
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$body = json_decode(file_get_contents("php://input"), true);
if (!is_array($body)) {
jsonAntwort(["error" => "Konnte die Anfrage nicht lesen."], 400);
}
try {
switch ($action) {
case "save":
$anzahl = preiseSpeichern($body["reihe"] ?? "", $body["zeilen"] ?? []);
// Die gespeicherte Fassung zurueckgeben: die Maske zeigt danach
// die abgeleiteten Enddaten, nicht die eingetippten.
jsonAntwort([
"gespeichert" => $anzahl,
"zeilen" => preiseLaden($body["reihe"]),
]);
case "kacheln-save":
/*
* Fehlt "werte" oder steht dort null, faellt die Kachel auf
* ihre Vorgabe zurueck - dann kommt diese zurueck, damit die
* Maske zeigt, was jetzt gilt, statt zu leeren Zeilen.
*/
$floor = $body["floor"] ?? "";
$room = $body["room"] ?? "";
$werte = array_key_exists("werte", $body) ? $body["werte"] : null;
$gespeichert = kachelWerteSpeichern($floor, $room, $werte);
$vorgabe = [];
foreach (allRooms() as $r) {
if ($r["floor"] === $floor && $r["id"] === $room) {
$vorgabe = kachelVorgabe($r);
}
}
jsonAntwort([
"eigen" => $gespeichert !== null,
"werte" => $gespeichert !== null ? $gespeichert : $vorgabe,
]);
case "geraete-raeume":
jsonAntwort(["gespeichert" => saveRooms($body["rooms"] ?? [])]);
case "geraet-loeschen":
/*
* Loeschen ist endgueltig - abgelehnt wird es, wenn eine
* Automatik auf das Geraet zeigt (siehe geraetLoeschen()).
* Zurueck kommt die neue Liste, damit die Maske nicht raten
* muss, was jetzt gilt.
*/
$name = geraetLoeschen($body["id"] ?? 0);
jsonAntwort(["geloescht" => $name, "geraete" => geraeteListe()]);
case "etage-speichern":
etageSpeichern($body);
grundriss(true);
jsonAntwort(["stand" => grundrissStand()]);
case "etagen-reihenfolge":
etagenReihenfolge($body["codes"] ?? null);
grundriss(true);
jsonAntwort(["stand" => grundrissStand()]);
case "etage-loeschen":
etageLoeschen($body["code"] ?? "");
grundriss(true);
jsonAntwort(["stand" => grundrissStand()]);
case "raum-speichern":
$nr = raumSpeichern($body);
grundriss(true);
jsonAntwort(["nr" => $nr, "stand" => grundrissStand()]);
case "raum-position":
$pos = raumPosition($body["nr"] ?? 0, $body["x"] ?? null, $body["y"] ?? null);
grundriss(true);
jsonAntwort(["position" => $pos, "stand" => grundrissStand()]);
case "raum-loeschen":
raumLoeschen($body["nr"] ?? 0);
grundriss(true);
jsonAntwort(["stand" => grundrissStand()]);
case "energiefluss-save":
// Zurück kommt der gespeicherte Stand - ohne die Einträge,
// die dem Katalog entsprechen.
energieflussSpeichern($body["knoten"] ?? null);
jsonAntwort(["einstellungen" => (object)energieflussLaden()]);
case "skoda-keys-save":
/*
* Neue Schluessel eintragen. Zurueck kommt der neue Stand -
* aber nie ein Wert, siehe restricted/skodaKeys.php.
*/
jsonAntwort(skodaSchluesselSpeichern(
$body["felder"] ?? [], $body["loeschen"] ?? []));
default:
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
}
} catch (InvalidArgumentException $e) {
jsonAntwort(["error" => $e->getMessage()], 400);
} catch (RuntimeException $e) {
// Nicht die Eingabe, sondern die Umgebung - Datei fehlt, Rechte
// fehlen. Die Meldung sagt schon, was zu tun ist; kein Vorspann.
jsonAntwort(["error" => $e->getMessage()], 500);
} catch (Throwable $e) {
jsonAntwort(["error" => "Speichern fehlgeschlagen: " . $e->getMessage()], 500);
}
}
if ($action === "kacheln") {
try {
jsonAntwort([
"kacheln" => kachelListe(),
// Alle Symbolnamen - die üblichen zuerst und mit Beschriftung.
"symbole" => kachelIconNamen(),
// Auswahlliste je Geraet - dieselbe Quelle wie im Automatik-Editor.
"katalog" => kachelKatalog(),
// Fuer die Eingabe von Hand, wenn etwas nicht im Katalog steht.
"topics" => kachelTopicVorschlaege(),
"tabelleDa" => grundrissDa(),
"max" => KACHEL_MAX,
]);
} catch (Throwable $e) {
jsonAntwort(["error" => "Kacheln nicht abrufbar: " . $e->getMessage()], 500);
}
}
if ($action === "grundriss") {
try {
jsonAntwort(grundrissStand());
} catch (Throwable $e) {
jsonAntwort(["error" => "Grundriss nicht abrufbar: " . $e->getMessage()], 500);
}
}
if ($action === "energiefluss") {
try {
jsonAntwort(["einstellungen" => (object)energieflussLaden()]);
} catch (Throwable $e) {
jsonAntwort(["error" => "Übersicht nicht abrufbar: " . $e->getMessage()], 500);
}
}
if ($action === "skoda-keys") {
try {
jsonAntwort(skodaStand());
} catch (Throwable $e) {
jsonAntwort(["error" => "Schlüssel nicht abrufbar: " . $e->getMessage()], 500);
}
}
if ($action === "geraete") {
try {
jsonAntwort(["geraete" => geraeteListe(), "raeume" => raumListe()]);
} catch (Throwable $e) {
jsonAntwort(["error" => "Geräte nicht abrufbar: " . $e->getMessage()], 500);
}
}
if ($action === "list") {
try {
jsonAntwort(["zeilen" => preiseLaden($_GET["reihe"] ?? "")]);
} catch (InvalidArgumentException $e) {
jsonAntwort(["error" => $e->getMessage()], 400);
} catch (Throwable $e) {
jsonAntwort(["error" => "Laden fehlgeschlagen: " . $e->getMessage()], 500);
}
}
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
+610
View File
@@ -0,0 +1,610 @@
<?php
/*
* Daten der Škoda-Seite. Ein Endpunkt, mehrere Abschnitte:
*
* ?was=live letzter bekannter Stand, alle Spalten der jüngsten Zeile
* ?was=ladungen Ladevorgänge der letzten N Tage, aus Sitzungen gebildet
* ?was=kurve Verlauf einer einzelnen Ladung (SoC und kW über Zeit)
* ?was=gesundheit kWh je 100 Ladestandsprozent, je Ladung, mit Trend
* ?was=fahrten Fahrten zwischen zwei Parkständen, mit Koordinaten
* ?was=strecke gefahrene Kilometer über Woche, Monat und Jahr
*
* Die Fahrzeugdaten stehen in der Tabelle skoda, geschrieben von
* gatherSkodaData.py. Der Zeilentakt ist nicht fest: geschrieben wird, wenn
* das Fahrzeug etwas Neues meldet, mindestens stündlich. Alle Auswertungen
* hier müssen damit umgehen, dass die Abstände zwischen zwei Zeilen von vier
* Minuten bis zu einer Stunde reichen.
*
* Solange kein Fahrzeug angebunden ist, ist die Tabelle leer. Jeder Abschnitt
* liefert dann eine leere, aber wohlgeformte Antwort - die Seite zeigt
* daraufhin ihre Leerzustände und läuft nicht in Fehler.
*/
require_once("../helper.php");
header('Content-Type: application/json; charset=utf-8');
if (!checkLogin()) {
http_response_code(403);
echo json_encode(["error" => "Nicht angemeldet."]);
exit;
}
$was = isset($_GET["was"]) ? $_GET["was"] : "live";
$tage = isset($_GET["tage"]) ? max(1, min(3650, intval($_GET["tage"]))) : 90;
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
if ($mysql->connect_errno) {
http_response_code(500);
echo json_encode(["error" => "Datenbank nicht erreichbar."]);
exit;
}
$mysql->set_charset("utf8mb4");
/*
* Ladevorgänge aus den Einzelzeilen schneiden. Wird von mehreren Abschnitten
* gebraucht, deshalb einmal hier.
*
* Eine Ladung ist eine ununterbrochene Folge von Zeilen mit chg_state =
* 'CHARGING'. Getrennt wird durch eine Zeile mit einem anderen Zustand, nicht
* durch die Zeit dazwischen: gatherSkodaData.py schreibt nur, wenn das
* Fahrzeug etwas Neues meldet, und beim Laden an der Wallbox tut es das
* mitunter nur einmal in der Stunde. Früher trennte eine Pause über 30
* Minuten - eine Ladung von 51 auf 80 % am 13.09.2026 zerfiel dadurch in drei
* Stücke mit 0, 4 und 0 Prozentpunkten. Hört das Fahrzeug auf zu laden,
* steht das in der nächsten Zeile; die Zeit allein trennt nur noch, wenn sie
* so lang ist, dass der Abruf offenbar nicht lief (LADUNG_LUECKE_S).
*
* Dazu gehören die beiden Zeilen, die die Ladung einrahmen. Die davor sagt,
* mit welchem Ladestand und Zählerstand sie begann - die erste CHARGING-Zeile
* kommt oft erst Minuten später und hat dann schon geladen. Die danach
* (CONNECT_CABLE, READY_FOR_CHARGING, ...) trägt den erreichten Ladestand:
* bei einer Ladung bis zum Ziel ist das die einzige Zeile mit 80 %.
*
* Die Energie kommt aus der Differenz der Wallbox-Zählerstände statt aus einer
* Summe über Leistungswerte - das trifft den Ladevorgang auf die Wattstunde.
* Ein Zählerstand 0 ist kein Stand, sondern ein ausgefallener Abruf der
* Wallbox, und bleibt außen vor. Steht der Zähler still, wurde auswärts
* geladen (Schnellladesäule); dann bleibt wh null und die Kapazitätsrechnung
* lässt die Ladung aus.
*/
const LADUNG_LUECKE_S = 3 * 3600;
function sitzungenSQL($seit)
{
$luecke = LADUNG_LUECKE_S;
return "
SELECT MIN(datetime) AS beginn, MAX(datetime) AS ende,
MIN(soc_start) AS soc_von, MAX(soc) AS soc_bis,
CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc_start) AS SIGNED) AS zuwachs,
MAX(chg_type) AS art, MAX(target_soc) AS ziel,
ROUND(AVG(CASE WHEN laedt THEN chg_kw END), 2) AS kw_mittel,
ROUND(MAX(CASE WHEN laedt THEN chg_kw END), 2) AS kw_spitze,
COALESCE(MAX(NULLIF(wb_wh_total, 0)) - MIN(wh_start), 0) AS wh,
/*
* Fahrzeugseitige Energie nur, wenn sie lückenlos integriert werden
* konnte - siehe kwh_teil. Sonst NULL: eine halbe Summe sähe aus
* wie eine kleine Ladung und verfälschte die Kapazitätsrechnung.
*/
CASE WHEN SUM(zu_lang) = 0 THEN ROUND(SUM(kwh_teil), 3) END AS kwh_fahrzeug,
ROUND(AVG(CASE WHEN laedt THEN pv_kw END), 2) AS pv_kw,
COUNT(*) AS punkte
FROM (
SELECT b.*, SUM(b.neu) OVER (ORDER BY b.datetime) AS sitzung FROM (
SELECT a.*,
a.chg_state = 'CHARGING' AS laedt,
(a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke) AS weiter,
CASE WHEN a.chg_state = 'CHARGING'
AND NOT (a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke)
THEN 1 ELSE 0 END AS neu,
-- Beginn aus der Zeile davor, wenn sie nah genug liegt.
CASE WHEN a.chg_state = 'CHARGING' AND NOT (a.st_vor <=> 'CHARGING')
AND a.dt_vor <= $luecke
THEN LEAST(a.soc, COALESCE(a.soc_vor, a.soc))
ELSE a.soc END AS soc_start,
CASE WHEN a.chg_state = 'CHARGING' AND NOT (a.st_vor <=> 'CHARGING')
AND a.dt_vor <= $luecke
THEN COALESCE(NULLIF(a.wh_vor, 0), NULLIF(a.wb_wh_total, 0))
ELSE NULLIF(a.wb_wh_total, 0) END AS wh_start,
/*
* chg_kw ist die Ladeleistung, die das Fahrzeug selbst meldet -
* gemessen HINTER dem Ladegerät. Die API kennt keine
* Kilowattstunden, deshalb wird über die Zeit integriert, je
* Schritt mit dem Mittel aus vorigem und aktuellem Wert.
*
* Über mehr als 30 Minuten ohne Meldung wäre die Fläche unter
* der Kurve erfunden: die Leistung kann in der Zeit von 4 auf
* 10 kW gestiegen sein, ohne dass eine Zeile davon weiß.
* zu_lang merkt sich das, und die Ladung bekommt dann keinen
* fahrzeugseitigen Wert.
*/
CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor BETWEEN 1 AND $luecke
THEN (COALESCE(a.chg_kw, 0) + COALESCE(a.kw_vor, 0)) / 2
* a.dt_vor / 3600
ELSE 0 END AS kwh_teil,
CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor > 1800
THEN 1 ELSE 0 END AS zu_lang
FROM (
SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc,
wb_wh_total, pv_kw,
LAG(chg_state) OVER w AS st_vor,
TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) AS dt_vor,
LAG(soc) OVER w AS soc_vor,
LAG(wb_wh_total) OVER w AS wh_vor,
LAG(chg_kw) OVER w AS kw_vor
FROM skoda WHERE datetime >= $seit
WINDOW w AS (ORDER BY datetime)) a) b) t
-- Die Ladezeilen und die eine Zeile, mit der die Ladung endet.
WHERE laedt OR weiter
GROUP BY sitzung";
}
/*
* Rückfallwert für den Ladewirkungsgrad, solange er sich nicht messen lässt.
* Beim Laden mit Wechselstrom gehen im Ladegerät des Fahrzeugs grob zehn
* Prozent verloren.
*/
const LADE_WIRKUNGSGRAD_ANNAHME = 0.90;
/** Ab so vielen Ladungen gilt der gemessene Wirkungsgrad als belastbar. */
const WIRKUNGSGRAD_MIN_LADUNGEN = 5;
/**
* Wie viele Kilowattstunden ein Ladestandsprozent wert ist.
*
* Zwei Energien je Ladung stehen zur Verfügung, und der Unterschied zwischen
* ihnen ist genau das, was das Ladegerät verheizt:
*
* wh Differenz der Wallbox-Zählerstände - vor dem Ladegerät
* kwh_fahrzeug chg_kw über die Zeit integriert - hinter dem Ladegerät
*
* Für den Fahrverbrauch zählt, was die Batterie verlässt, also die zweite.
* Sie wird bevorzugt; erst wenn zu wenige Ladungen sie hergeben, wird der
* Zählerwert mit dem angenommenen Wirkungsgrad heruntergerechnet.
*
* Der Median statt des Mittels, weil einzelne Ladungen durch die Rundung des
* Ladestands weit ausreissen können.
*
* Gibt null zurück, solange es zu wenige Ladungen gibt. Die Oberfläche zeigt
* dann keine Kilowattstunden an, statt eine erfundene Zahl.
*/
function kapazitaetSchaetzen($mysql)
{
$sql = "SELECT ROUND(wh/1000/zuwachs*100, 3) AS je100_zaehler,
CASE WHEN kwh_fahrzeug > 0
THEN ROUND(kwh_fahrzeug/zuwachs*100, 3) END AS je100_fahrzeug
FROM (" . sitzungenSQL("NOW() - INTERVAL 365 DAY") . ") v
WHERE zuwachs >= 20 AND wh > 1000";
$res = $mysql->query($sql);
if (!$res) {
return null;
}
$zaehler = [];
$fahrzeug = [];
$paare = []; // beide Werte derselben Ladung, für den Wirkungsgrad
while ($z = $res->fetch_assoc()) {
$zaehler[] = floatval($z["je100_zaehler"]);
if ($z["je100_fahrzeug"] !== null) {
$fahrzeug[] = floatval($z["je100_fahrzeug"]);
$paare[] = floatval($z["je100_fahrzeug"]) / floatval($z["je100_zaehler"]);
}
}
if (count($zaehler) < 3) {
return null;
}
$gemessen = count($fahrzeug) >= WIRKUNGSGRAD_MIN_LADUNGEN;
if ($gemessen) {
$netto = median($fahrzeug);
$grad = median($paare);
} else {
$netto = median($zaehler) * LADE_WIRKUNGSGRAD_ANNAHME;
$grad = LADE_WIRKUNGSGRAD_ANNAHME;
}
return [
"je100_zaehler" => round(median($zaehler), 2),
"je100_netto" => round($netto, 2),
"wirkungsgrad" => round($grad, 3),
"gemessen" => $gemessen,
"je_soc_netto" => round($netto / 100, 4),
"n" => count($zaehler),
"n_fahrzeug" => count($fahrzeug),
];
}
/**
* Verlauf einer Ladung: Wallbox-Leistung dicht, Ladestand dazwischen gefüllt.
*
* Das Fahrzeug meldet beim Laden an der Wallbox mitunter nur einmal in der
* Stunde, die Wallbox steht in skoda_ladepunkte im Minutentakt (siehe
* solarLog_skoda_ladepunkte.sql). Der Ladestand zwischen zwei Meldungen wird
* nach der geflossenen Energie aufgefüllt, nicht nach der Zeit:
*
* soc(t) = soc_a + (soc_b - soc_a) * (wh(t) - wh_a) / (wh_b - wh_a)
*
* Das braucht weder Akkukapazität noch Wirkungsgrad. Gegengeprüft am
* 13.09.2026: zwischen 56 % (09:59) und 70 % (11:22) ergibt es für 10:29
* 59,6 % - gemeldet waren 60 %. Nach der Zeit wären es 61,1 % gewesen.
*
* Eine Meldung gilt für den Zeitpunkt, zu dem das Fahrzeug sie erfasst hat
* (cap_chg), nicht für den Abruf - dazwischen können Minuten mit 11 kW
* liegen. Der Zählerstand zu diesem Zeitpunkt kommt deshalb aus dem
* Wallbox-Verlauf selbst und nicht aus der Zeile.
*
* Vor der ersten und nach der letzten Meldung bleibt der Ladestand leer:
* hochgerechnet wird nichts.
*
* Liefert [verlauf, meldungen, wallbox]; ohne Punkte in skoda_ladepunkte
* zwei leere Listen - dann zeichnet die Seite wie früher aus den Zeilen.
*/
function ladeverlauf($mysql, $von, $bis)
{
// Welche Wallbox: die, die im Zeitraum Energie abgegeben hat.
$stmt = $mysql->prepare(
"SELECT wallbox, MAX(wh_total) - MIN(wh_total) AS wh, COUNT(*) AS n
FROM skoda_ladepunkte
WHERE datetime BETWEEN (? - INTERVAL 30 MINUTE) AND (? + INTERVAL 30 MINUTE)
GROUP BY wallbox ORDER BY wh DESC, n DESC LIMIT 1");
if (!$stmt) {
return [[], [], null]; // Tabelle fehlt noch
}
$stmt->bind_param("ss", $von, $bis);
$stmt->execute();
$wahl = $stmt->get_result()->fetch_assoc();
if (!$wahl) {
return [[], [], null];
}
$wallbox = $wahl["wallbox"];
$spalte = $wallbox === "og" ? "wbog_wh_total" : "wb_wh_total";
$stmt = $mysql->prepare(
"SELECT datetime, kw, wh_total, quelle FROM skoda_ladepunkte
WHERE wallbox = ? AND datetime BETWEEN (? - INTERVAL 30 MINUTE) AND (? + INTERVAL 30 MINUTE)
ORDER BY datetime");
$stmt->bind_param("sss", $wallbox, $von, $bis);
$stmt->execute();
$res = $stmt->get_result();
$verlauf = [];
while ($z = $res->fetch_assoc()) {
$verlauf[] = [
"datetime" => $z["datetime"],
"t" => strtotime($z["datetime"]),
"kw" => floatval($z["kw"]),
"wh" => $z["wh_total"] === null ? null : intval($z["wh_total"]),
"quelle" => $z["quelle"],
];
}
if (!$verlauf) {
return [[], [], null];
}
// Zählerstand zu einem beliebigen Zeitpunkt, aus dem Verlauf. Vor dem
// ersten Punkt ruhte die Wallbox - dann gilt der Stand, den die Zeile
// selbst trägt.
$zaehlerBei = function ($t, $ersatz) use ($verlauf) {
$vor = null;
foreach ($verlauf as $p) {
if ($p["wh"] === null) {
continue;
}
if ($p["t"] >= $t) {
if ($vor === null) {
return $ersatz ?: $p["wh"];
}
$spanne = $p["t"] - $vor["t"];
return $spanne > 0
? $vor["wh"] + ($p["wh"] - $vor["wh"]) * ($t - $vor["t"]) / $spanne
: $p["wh"];
}
$vor = $p;
}
return $vor ? $vor["wh"] : $ersatz;
};
// Die Meldungen des Fahrzeugs, die den Verlauf einrahmen: die letzte vor
// dem ersten Punkt, alle dazwischen, die erste nach dem letzten.
$erster = $verlauf[0]["datetime"];
$letzter = end($verlauf)["datetime"];
$stmt = $mysql->prepare(
"(SELECT COALESCE(cap_chg, datetime) AS erfasst, soc, chg_kw, chg_state, $spalte AS wh
FROM skoda WHERE COALESCE(cap_chg, datetime) <= ? AND soc IS NOT NULL
ORDER BY erfasst DESC LIMIT 1)
UNION
(SELECT COALESCE(cap_chg, datetime), soc, chg_kw, chg_state, $spalte
FROM skoda WHERE COALESCE(cap_chg, datetime) > ? AND COALESCE(cap_chg, datetime) < ?
AND soc IS NOT NULL)
UNION
(SELECT COALESCE(cap_chg, datetime), soc, chg_kw, chg_state, $spalte
FROM skoda WHERE COALESCE(cap_chg, datetime) >= ? AND soc IS NOT NULL
ORDER BY 1 LIMIT 1)
ORDER BY erfasst");
$stmt->bind_param("ssss", $erster, $erster, $letzter, $letzter);
$stmt->execute();
$res = $stmt->get_result();
$meldungen = [];
while ($z = $res->fetch_assoc()) {
$t = strtotime($z["erfasst"]);
$meldungen[] = [
"datetime" => $z["erfasst"],
"t" => $t,
"soc" => intval($z["soc"]),
"chg_kw" => $z["chg_kw"] === null ? null : floatval($z["chg_kw"]),
"chg_state"=> $z["chg_state"],
"wh" => $zaehlerBei($t, intval($z["wh"])),
];
}
// Ladestand je Punkt, zwischen den beiden Meldungen, die ihn einrahmen.
foreach ($verlauf as &$p) {
$p["soc"] = null;
for ($i = 1; $p["wh"] !== null && $i < count($meldungen); $i++) {
$a = $meldungen[$i - 1];
$b = $meldungen[$i];
if ($p["t"] < $a["t"] || $p["t"] > $b["t"]) {
continue;
}
$spanne = $b["wh"] - $a["wh"];
$p["soc"] = $spanne > 0
? round($a["soc"] + ($b["soc"] - $a["soc"]) * ($p["wh"] - $a["wh"]) / $spanne, 1)
: floatval($a["soc"]);
break;
}
unset($p["t"]);
}
unset($p);
foreach ($meldungen as &$m) {
unset($m["t"], $m["wh"]);
}
unset($m);
return [$verlauf, $meldungen, $wallbox];
}
/** Median eines nicht leeren Zahlenfeldes. */
function median($werte)
{
sort($werte);
return $werte[intdiv(count($werte), 2)];
}
$antwort = [];
switch ($was) {
/* ------------------------------------------------------------------ live -- */
case "live":
$res = $mysql->query("SELECT * FROM skoda ORDER BY id DESC LIMIT 1");
$zeile = $res ? $res->fetch_assoc() : null;
$antwort = ["stand" => $zeile];
// Wie lange schweigt das Fahrzeug schon? Sagt mehr als der Zeitstempel
// allein, weil die Seite auch aus dem Zwischenspeicher kommen kann.
if ($zeile) {
$res = $mysql->query("SELECT TIMESTAMPDIFF(SECOND, '"
. $mysql->real_escape_string($zeile["datetime"]) . "', NOW()) AS alter_s");
$r = $res ? $res->fetch_assoc() : null;
$antwort["alter_s"] = $r ? intval($r["alter_s"]) : null;
}
// Kennzahlen über den gesamten Bestand, für die Kopfzeile.
$res = $mysql->query("SELECT COUNT(*) AS zeilen, MIN(datetime) AS seit,
MAX(odo_km) AS km FROM skoda");
$antwort["bestand"] = $res ? $res->fetch_assoc() : null;
break;
/* -------------------------------------------------------------- ladungen -- */
case "ladungen":
$sql = "SELECT * FROM (" . sitzungenSQL("NOW() - INTERVAL $tage DAY") . ") v
ORDER BY beginn DESC LIMIT 200";
$res = $mysql->query($sql);
$antwort["ladungen"] = [];
while ($res && $z = $res->fetch_assoc()) {
$antwort["ladungen"][] = $z;
}
break;
/* ----------------------------------------------------------------- kurve -- */
case "kurve":
// Verlauf einer Ladung. Ohne Parameter die jüngste.
$von = isset($_GET["von"]) ? $_GET["von"] : null;
$bis = isset($_GET["bis"]) ? $_GET["bis"] : null;
if (!$von || !$bis) {
$sql = "SELECT beginn, ende FROM (" . sitzungenSQL("NOW() - INTERVAL 365 DAY") . ") v
ORDER BY beginn DESC LIMIT 1";
$res = $mysql->query($sql);
$z = $res ? $res->fetch_assoc() : null;
if (!$z) { $antwort["punkte"] = []; break; }
$von = $z["beginn"];
$bis = $z["ende"];
}
// Eine Zeile Vorlauf und Nachlauf, damit die Kurve nicht in der Luft endet.
$stmt = $mysql->prepare(
"SELECT datetime, soc, chg_kw, wb_kw, pv_kw, chg_state, wb_wh_total
FROM skoda
WHERE datetime BETWEEN (? - INTERVAL 15 MINUTE) AND (? + INTERVAL 15 MINUTE)
ORDER BY datetime");
$stmt->bind_param("ss", $von, $bis);
$stmt->execute();
$res = $stmt->get_result();
$antwort["punkte"] = [];
while ($z = $res->fetch_assoc()) { $antwort["punkte"][] = $z; }
$antwort["von"] = $von;
$antwort["bis"] = $bis;
list($antwort["verlauf"], $antwort["meldungen"], $antwort["wallbox"])
= ladeverlauf($mysql, $von, $bis);
break;
/* ------------------------------------------------------------ gesundheit -- */
case "gesundheit":
/*
* Kilowattstunden je hundert Ladestandsprozent. Der Wert liegt über der
* nutzbaren Kapazität, weil die Ladeverluste mitgezählt werden - absolut
* also nur eine Näherung, sein Verlauf über die Jahre dagegen die
* eigentliche Aussage.
*
* Nur lange Ladungen an der eigenen Wallbox taugen dazu: bei zwanzig
* Prozentpunkten Zuwachs wiegt ein gerundeter Ladestandspunkt fünf
* Prozent, und eine Schnellladung unterwegs hat auf unserer Seite keinen
* Zähler.
*/
$sql = "SELECT beginn, soc_von, soc_bis, zuwachs, art, wh,
ROUND(wh/1000/zuwachs*100, 2) AS je100
FROM (" . sitzungenSQL("NOW() - INTERVAL $tage DAY") . ") v
WHERE zuwachs >= 20 AND wh > 1000
ORDER BY beginn";
$res = $mysql->query($sql);
$antwort["ladungen"] = [];
while ($res && $z = $res->fetch_assoc()) { $antwort["ladungen"][] = $z; }
// Ausgleichsgerade über die Einzelwerte, x in Jahren. Die Steigung sagt,
// ob der Wert wandert; ihr Fehler, ab welcher Alterung man überhaupt
// etwas sehen könnte.
$n = count($antwort["ladungen"]);
$antwort["trend"] = null;
if ($n >= 6) {
$t0 = strtotime($antwort["ladungen"][0]["beginn"]);
$xs = []; $ys = [];
foreach ($antwort["ladungen"] as $l) {
$xs[] = (strtotime($l["beginn"]) - $t0) / (365.25 * 86400);
$ys[] = floatval($l["je100"]);
}
$mx = array_sum($xs) / $n;
$my = array_sum($ys) / $n;
$sxx = 0; $sxy = 0;
for ($i = 0; $i < $n; $i++) {
$sxx += ($xs[$i] - $mx) ** 2;
$sxy += ($xs[$i] - $mx) * ($ys[$i] - $my);
}
if ($sxx > 0) {
$steigung = $sxy / $sxx;
$achse = $my - $steigung * $mx;
$rest = 0;
for ($i = 0; $i < $n; $i++) {
$rest += ($ys[$i] - ($achse + $steigung * $xs[$i])) ** 2;
}
$streu = $n > 2 ? sqrt($rest / ($n - 2)) : 0;
$fehler = $streu / sqrt($sxx);
$antwort["trend"] = [
"steigung" => round($steigung, 3),
"fehler" => round($fehler, 3),
"achse" => round($achse, 2),
"prozent" => $achse != 0 ? round(100 * $steigung / $achse, 2) : null,
"nachweisbar" => $achse != 0 ? round(100 * 2 * $fehler / $achse, 1) : null,
"spanne_jahre" => round(max($xs), 2),
];
}
}
// Median je Jahr - der Stand, um den die Wolke streut.
$jahre = [];
foreach ($antwort["ladungen"] as $l) {
$j = substr($l["beginn"], 0, 4);
$jahre[$j][] = floatval($l["je100"]);
}
$antwort["jahre"] = [];
foreach ($jahre as $j => $werte) {
sort($werte);
$k = count($werte);
$antwort["jahre"][] = [
"jahr" => $j,
"n" => $k,
"q1" => round($werte[intdiv($k, 4)], 2),
"median" => round($werte[intdiv($k, 2)], 2),
"q3" => round($werte[intdiv(3 * $k, 4)], 2),
];
}
break;
/* --------------------------------------------------------------- fahrten -- */
case "fahrten":
/*
* Eine Fahrt liegt zwischen zwei Parkständen. Die API liefert nur die
* letzte bekannte PARKposition - fährt das Fahrzeug, kommt allein
* IN_MOTION ohne Koordinaten. Start und Ziel sind damit bekannt, die
* Strecke dazwischen nicht.
*
* CAST(... AS SIGNED) ist nötig: soc und odo_km sind vorzeichenlos, und
* eine Differenz unter null bricht die Abfrage sonst ab - genau das
* passiert, sobald zwischen zwei Ständen geladen wurde.
*/
$sql = "SELECT * FROM (
SELECT datetime AS ankunft, address AS nach, lat, lon, odo_km, soc,
LAG(datetime) OVER w AS abfahrt,
LAG(address) OVER w AS von,
LAG(lat) OVER w AS von_lat,
LAG(lon) OVER w AS von_lon,
CAST(odo_km AS SIGNED) - LAG(CAST(odo_km AS SIGNED)) OVER w AS km,
LAG(CAST(soc AS SIGNED)) OVER w - CAST(soc AS SIGNED) AS soc_ab
FROM skoda
WHERE park_state = 'PARKED' AND odo_km IS NOT NULL
AND datetime >= NOW() - INTERVAL $tage DAY
WINDOW w AS (ORDER BY datetime)) f
WHERE km > 0
ORDER BY ankunft DESC LIMIT 100";
$res = $mysql->query($sql);
$antwort["fahrten"] = [];
while ($res && $z = $res->fetch_assoc()) { $antwort["fahrten"][] = $z; }
// Aktueller Standort, für die Karte.
$res = $mysql->query("SELECT datetime, lat, lon, address, park_state
FROM skoda WHERE lat IS NOT NULL
ORDER BY id DESC LIMIT 1");
$antwort["standort"] = $res ? $res->fetch_assoc() : null;
$antwort["kapazitaet"] = kapazitaetSchaetzen($mysql);
break;
/* --------------------------------------------------------------- strecke -- */
case "strecke":
/*
* Gefahrene Kilometer über mehrere Zeiträume, gerechnet aus dem
* Kilometerstand: der Zähler läuft nur vorwärts, also ist die Differenz
* zwischen dem ältesten Stand im Zeitraum und dem jüngsten überhaupt die
* gefahrene Strecke. Das ist belastbarer als eine Summe über die
* erkannten Fahrten - fällt eine Zeile zwischendurch aus, fehlt die
* Fahrt in der Liste, im Zählerstand aber nicht.
*
* CAST(... AS SIGNED) ist nötig: odo_km ist vorzeichenlos, und eine
* Differenz unter null bräche die Abfrage ab.
*/
$zeitraeume = ["woche" => 7, "monat" => 30, "jahr" => 365];
$spalten = [];
foreach ($zeitraeume as $name => $spanne) {
$spalten[] = "MIN(CASE WHEN datetime >= NOW() - INTERVAL $spanne DAY
THEN CAST(odo_km AS SIGNED) END) AS von_$name";
}
$sql = "SELECT MAX(CAST(odo_km AS SIGNED)) AS jetzt, MIN(datetime) AS seit, "
. implode(", ", $spalten) . " FROM skoda WHERE odo_km IS NOT NULL";
$res = $mysql->query($sql);
$z = $res ? $res->fetch_assoc() : null;
$jetzt = ($z && $z["jetzt"] !== null) ? intval($z["jetzt"]) : null;
$antwort["odo"] = $jetzt;
$antwort["seit"] = $z ? $z["seit"] : null;
$antwort["strecke"] = [];
foreach ($zeitraeume as $name => $spanne) {
$von = ($z && $z["von_$name"] !== null) ? intval($z["von_$name"]) : null;
$antwort["strecke"][$name] = [
"tage" => $spanne,
"km" => ($von === null || $jetzt === null) ? null : $jetzt - $von,
/*
* Reicht die Aufzeichnung überhaupt so weit zurück? Ein Jahreswert
* aus drei Monaten Daten ist kein Jahreswert - die Anzeige muss
* das sagen dürfen, statt eine zu kleine Zahl als Jahr auszugeben.
*/
"vollstaendig" => ($z && $z["seit"] !== null)
&& strtotime($z["seit"]) <= time() - $spanne * 86400
];
}
break;
default:
http_response_code(400);
$antwort = ["error" => "Unbekannter Abschnitt."];
}
echo json_encode($antwort, JSON_UNESCAPED_UNICODE);
+254
View File
@@ -0,0 +1,254 @@
<?php
/*
* Befehle an das Fahrzeug.
*
* Aufruf per POST: befehl=laden_start | laden_stop | klima_start | klima_stop
* | lueftung_start | lueftung_stop | ladeziel
* temperatur=<Grad Celsius> (nur bei klima_start)
* prozent=50..100 in Zehnern (nur bei ladeziel)
*
* Warum über den Server und nicht unmittelbar aus dem Browser? Der API-
* Schlüssel darf die Seite nicht verlassen. Er steht in skoda.conf neben
* gatherSkodaData.py, wird hier gelesen und geht nur von hier nach draußen.
*
* Nur POST. Ein Befehl, der sich über einen Link auslösen liesse, wäre über
* ein vorausgeladenes Bild oder einen Klick auf eine fremde Seite fernsteuerbar.
*
* ---------------------------------------------------------------------------
* Ein Kontingent für alles
*
* Die API begrenzt auf 20 Anfragen je Stunde, und Befehle zählen genauso mit
* wie Abrufe. Die Doku schreibt "je Schlüssel" - gemessen stimmt das nicht,
* der Zähler gilt für alle Schlüssel des Kontos zusammen (die Rechnung steht
* bei _LIMIT in gatherSkodaData.py). Mehrere Schlüssel bringen also kein
* zusätzliches Kontingent.
*
* Abruf und Steuerung laufen in getrennten Prozessen ohne gemeinsamen Zähler,
* deshalb ist fest aufgeteilt:
*
* Abruf 14 / Stunde (gatherSkodaData.py)
* Befehle 4 / Stunde
* Reserve 2 / Stunde
*
* Ein eigener CMD_API_KEY trennt damit nicht die Kontingente, sondern nur,
* wer womit fragt - er lässt sich einzeln widerrufen, ohne den Abruf
* mitzunehmen. Welchen dieses Skript nimmt, entscheidet
* skodaBefehlsSchluessel(): den eigenen, wenn es ihn gibt, sonst API_KEY.
* ---------------------------------------------------------------------------
*
* Nicht angebunden ist die Standheizung. Ihr Start verlangt laut Spec die
* Sicherheits-PIN des Fahrzeugs ("spin"). Die dauerhaft auf dem NAS zu
* hinterlegen, nur damit ein Knopf im Dashboard funktioniert, ist eine andere
* Größenordnung als ein API-Schlüssel - das sollte bewusst entschieden werden,
* nicht nebenbei.
*/
require_once("../helper.php");
// Wo skoda.conf liegt, wie sie aussieht, welcher Schlüssel die Befehle
// schickt und wie viele je Stunde erlaubt sind: alles dort, damit der Reiter
// in den Einstellungen und dieser Endpunkt dieselbe Wahrheit benutzen.
require_once("../restricted/skodaKeys.php");
header('Content-Type: application/json; charset=utf-8');
/*
* Die erlaubten Befehle. Der Schlüssel kommt aus dem Formular, der Pfad
* niemals - so kann über den Parameter kein beliebiger Endpunkt der API
* angesprochen werden.
*/
const BEFEHLE = [
"laden_start" => ["pfad" => "charging/start", "text" => "Laden gestartet"],
"laden_stop" => ["pfad" => "charging/stop", "text" => "Laden gestoppt"],
"klima_start" => ["pfad" => "air-conditioning/start", "text" => "Klimatisierung gestartet"],
"klima_stop" => ["pfad" => "air-conditioning/stop", "text" => "Klimatisierung gestoppt"],
"lueftung_start" => ["pfad" => "active-ventilation/start", "text" => "Lüftung gestartet"],
"lueftung_stop" => ["pfad" => "active-ventilation/stop", "text" => "Lüftung gestoppt"],
// Die API nimmt das Ladeziel per PUT, alle anderen Befehle per POST.
"ladeziel" => ["pfad" => "charging/limit", "methode" => "PUT", "text" => "Ladeziel gesetzt"],
];
/* ------------------------------------------------------------------ Zugang */
if (!checkLogin()) {
antwort(403, false, "Nicht angemeldet.");
}
if ($_SERVER["REQUEST_METHOD"] !== "POST") {
antwort(405, false, "Nur per POST.");
}
$name = isset($_POST["befehl"]) ? $_POST["befehl"] : "";
if (!isset(BEFEHLE[$name])) {
antwort(400, false, "Unbekannter Befehl.");
}
$befehl = BEFEHLE[$name];
// Das Ladeziel vor dem Kontingent pruefen: eine falsche Zahl soll keinen der
// vier Befehle je Stunde kosten. Laut Spec nehmen die Fahrzeuge
// "typischerweise" 50 bis 100 in Zehnern und lehnen alles andere ab.
$prozent = null;
if ($name === "ladeziel") {
$prozent = isset($_POST["prozent"]) ? intval($_POST["prozent"]) : 0;
if ($prozent < 50 || $prozent > 100 || $prozent % 10 !== 0) {
antwort(400, false, "Das Ladeziel geht von 50 bis 100 Prozent in Zehnerschritten.");
}
}
$konfig = skodaBefehlsSchluessel();
if (!$konfig) {
antwort(503, false, "Kein API-Schlüssel hinterlegt - unter Einstellungen "
. "▸ Fahrzeug lässt sich einer eintragen.");
}
if (!budgetFrei(SKODA_BEFEHLE)) {
antwort(429, false, "Stündliches Kontingent für Befehle erschöpft ("
. SKODA_BEFEHLE . " je Stunde). Es teilt sich die 20 Anfragen der API"
. " mit dem Abruf des Fahrzeugzustands. Später erneut versuchen.");
}
/* ---------------------------------------------------------------- Ausführen */
$rumpf = null;
$text = $befehl["text"];
if ($name === "ladeziel") {
$rumpf = ["targetStateOfChargeInPercent" => $prozent];
$text = "Ladeziel " . $prozent . " % gesetzt";
}
if ($name === "klima_start") {
// Die Zieltemperatur kommt aus dem Formular und geht in einen Rumpf, den
// die API auswertet - deshalb hart begrenzen statt nur zu vertrauen.
$grad = isset($_POST["temperatur"]) ? floatval($_POST["temperatur"]) : 21.0;
$grad = max(16.0, min(29.5, round($grad * 2) / 2)); // halbe Grad
$rumpf = ["targetTemperature" => ["value" => $grad, "unit" => "CELSIUS"]];
}
list($status, $inhalt) = senden($konfig, $befehl["pfad"], $rumpf, $befehl["methode"] ?? "POST");
if ($status === 202) {
skodaAbrufAnstossen();
antwort(200, true, $text . ". Das Fahrzeug führt den Befehl aus; "
. "in der Anzeige erscheint es in einer halben Minute.");
}
antwort(200, false, fehlertext($status, $inhalt));
/* ------------------------------------------------------------- Bausteine -- */
/** Antwort ausgeben und beenden. */
function antwort($code, $ok, $text)
{
http_response_code($code);
echo json_encode(["ok" => $ok, "text" => $text], JSON_UNESCAPED_UNICODE);
exit;
}
/**
* Stundenzähler für Befehle. Gleiches Verfahren wie die Bremse in tile.php:
* eine kleine Datei, gegen gleichzeitige Zugriffe mit flock abgesichert.
*
* Das Fenster ist fest: es beginnt mit dem ersten Befehl und läuft eine Stunde.
* Die API zählt gleitend, hier stehen also im Grenzfall zwei Fenster
* nebeneinander - garantiert sind die $grenze Befehle, mehr geht auf das Risiko
* eines 429 von der API selbst.
*/
function budgetFrei($grenze)
{
$ort = null;
foreach (SKODA_ARBEITSORTE as $kandidat) {
if (is_dir($kandidat) && is_writable($kandidat)) {
$ort = $kandidat;
break;
}
}
if (!$ort) {
// Ohne Zähler lieber durchlassen als die Steuerung blockieren; die
// API bremst zur Not selbst mit 429.
return true;
}
$griff = fopen($ort . "/" . SKODA_ZAEHLER_DATEI, "c+");
if (!$griff || !flock($griff, LOCK_EX)) {
return true;
}
$stand = json_decode(stream_get_contents($griff), true);
if (!is_array($stand)) {
$stand = ["fenster" => 0, "n" => 0];
}
$jetzt = time();
if ($jetzt - $stand["fenster"] >= 3600) {
$stand = ["fenster" => $jetzt, "n" => 0];
}
$frei = $stand["n"] < $grenze;
if ($frei) {
$stand["n"]++;
}
ftruncate($griff, 0);
rewind($griff);
fwrite($griff, json_encode($stand));
flock($griff, LOCK_UN);
fclose($griff);
return $frei;
}
/** Den Befehl an die API schicken. Liefert [HTTP-Status, Rumpf]. */
function senden($konfig, $pfad, $rumpf, $methode = "POST")
{
$url = SKODA_API . rawurlencode($konfig["vin"]) . "/" . $pfad;
$kopf = [
"X-API-Key: " . $konfig["schluessel"],
"Accept: application/json",
];
$ch = curl_init($url);
$optionen = [
CURLOPT_CUSTOMREQUEST => $methode,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => SKODA_KENNUNG,
CURLOPT_TIMEOUT => 20,
CURLOPT_CONNECTTIMEOUT => 5,
];
if ($rumpf !== null) {
$kopf[] = "Content-Type: application/json";
$optionen[CURLOPT_POSTFIELDS] = json_encode($rumpf);
} else {
$optionen[CURLOPT_POSTFIELDS] = "";
}
$optionen[CURLOPT_HTTPHEADER] = $kopf;
curl_setopt_array($ch, $optionen);
$inhalt = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$fehler = curl_error($ch);
curl_close($ch);
if ($inhalt === false) {
error_log("skodaCmd: $url nicht erreichbar: $fehler");
return [0, null];
}
return [$status, $inhalt];
}
/**
* Aus dem Statuscode eine Auskunft machen, mit der man etwas anfangen kann.
* Die API liefert dazu einen Problembericht nach RFC 9457; dessen "detail"
* ist oft die eigentliche Erklärung.
*/
function fehlertext($status, $inhalt)
{
$detail = "";
$bericht = json_decode((string)$inhalt, true);
if (is_array($bericht) && !empty($bericht["detail"])) {
$detail = " (" . $bericht["detail"] . ")";
}
switch ($status) {
case 0: return "Die Škoda-API ist nicht erreichbar.";
case 400: return "Die API hat den Befehl abgelehnt." . $detail;
case 401: return "Der API-Schlüssel ist abgelaufen - in der MyŠkoda-App "
. "erneuern und unter Einstellungen ▸ Fahrzeug eintragen.";
case 403: return "Der Schlüssel darf diesen Befehl nicht ausführen." . $detail;
case 404: return "Fahrzeug nicht gefunden - stimmt die VIN in skoda.conf?";
case 422: return "Das Fahrzeug kann das gerade nicht." . $detail;
case 429: return "Kontingent der API erschöpft - später erneut versuchen.";
case 503:
case 504: return "Die API antwortet nicht rechtzeitig. Später erneut versuchen.";
default: return "Unerwartete Antwort der API (HTTP " . $status . ")." . $detail;
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
/*
* Verlauf für die Speicher-Ansicht der Solar-Seite (js/solar/speicher.js).
*
* ?was=verlauf&stunden=24 Ladestand und Leistung aus EnergyFlow (reicht
* Jahre zurück) und die BMU-Werte aus byd
* (Spreizung, Temperaturen, SOH - seit 15.09.2026)
* ?was=gesundheit SOH, größte Spreizung und Zähler je Tag aus byd
*
* Die Live-Werte kommen nicht von hier, sondern über MQTT
* (solarManager/byd/#, geschrieben von gatherBYDData.py).
*
* Zeiten gehen als Unix-Sekunden hinaus; ab sieben Tagen wird auf Stunden
* gemittelt, damit ein Monat nicht als 8.600 Punkte über die Leitung geht.
*/
require_once("../helper.php");
require_once("../restricted/costs.php"); // solarDb()
header("Content-Type: application/json; charset=utf-8");
if (!checkLogin()) {
http_response_code(403);
echo json_encode(["error" => "Nicht angemeldet."]);
exit;
}
$was = $_GET["was"] ?? "verlauf";
try {
$db = solarDb();
if ($was === "verlauf") {
$stunden = max(1, min(24 * 90, intval($_GET["stunden"] ?? 24)));
$stuendlich = $stunden > 24 * 3;
$gruppe = $stuendlich ? "FLOOR(UNIX_TIMESTAMP(datetime) / 3600) * 3600" : "UNIX_TIMESTAMP(datetime)";
$soc = [];
$res = $db->query("SELECT $gruppe AS t, AVG(soc) AS soc, AVG(battP) AS p
FROM EnergyFlow
WHERE datetime >= NOW() - INTERVAL $stunden HOUR
GROUP BY t ORDER BY t");
while ($res && $z = $res->fetch_assoc()) {
// battP > 0 heißt entladen; hinaus geht "positiv = laden", wie bei der BMU.
$soc[] = [(int)$z["t"], round((float)$z["soc"], 1), round(-(float)$z["p"])];
}
$bmu = [];
$res = $db->query("SELECT $gruppe AS t, AVG(spreizung) AS spr, MIN(t_min) AS tmin, MAX(t_max) AS tmax, AVG(soh) AS soh
FROM byd
WHERE datetime >= NOW() - INTERVAL $stunden HOUR
GROUP BY t ORDER BY t");
while ($res && $z = $res->fetch_assoc()) {
$bmu[] = [(int)$z["t"], round((float)$z["spr"], 1), (int)$z["tmin"], (int)$z["tmax"], round((float)$z["soh"], 1)];
}
echo json_encode([
"von" => time() - $stunden * 3600, "bis" => time(),
"soc" => $soc, "bmu" => $bmu,
]);
exit;
}
if ($was === "gesundheit") {
$tage = [];
$res = $db->query("SELECT DATE(datetime) AS tag, AVG(soh) AS soh, MAX(spreizung) AS spr,
MAX(geladen_kwh) AS geladen, MAX(entladen_kwh) AS entladen
FROM byd GROUP BY tag ORDER BY tag");
while ($res && $z = $res->fetch_assoc()) {
$tage[] = [$z["tag"], round((float)$z["soh"], 1), (int)$z["spr"],
(float)$z["geladen"], (float)$z["entladen"]];
}
echo json_encode(["tage" => $tage]);
exit;
}
http_response_code(400);
echo json_encode(["error" => "Unbekannter Abschnitt."]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(["error" => "Verlauf nicht abrufbar: " . $e->getMessage()]);
}
+252
View File
@@ -0,0 +1,252 @@
<?php
set_time_limit(60);
ob_start();
require_once("../helper.php");
require_once("../restricted/tahoma_EG.php");
// Ohne diese Pruefung liessen sich Rollladen von beliebiger Stelle abfragen
// und verfahren - das Tahoma-Token liegt serverseitig, ein Angreifer braucht
// es also gar nicht.
if (!checkLogin()) {
http_response_code(403);
exit;
}
//67ebd23e3a61763386d9
function getSSLPage($url, $tahoma_token)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_HEADER, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json', "Authorization: Bearer ".$tahoma_token));
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_SSLVERSION, 3);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
if (curl_errno($ch)) {
$error_msg = curl_error($ch);
//print $error_msg;
}
curl_close($ch);
return $result;
}
function postSSLPage($url, $body, $tahoma_token)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_HEADER, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json', "Authorization: Bearer ".$tahoma_token));
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_SSLVERSION, 3);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
if (curl_errno($ch)) {
$error_msg = curl_error($ch);
//print $error_msg;
}
curl_close($ch);
return $result;
}
function filter_devs($arr,$filter,$prefix=""){
$ret = Array();
$i = 0;
foreach($arr as $key => $dev){
$start = strpos(strtolower($dev["name"]),strtolower($filter));
if($start !== false){
$dev["name"] = str_replace("u_","",$dev["name"]);
$ret[$i]["name"] = $prefix.substr($dev["name"],strlen($filter));
$ret[$i]["id"] = $key;
$i++;
}
}
return $ret;
}
function searchForDevice($device){
$dev = false;
$devices = json_decode(file_get_contents($GLOBALS["tahoma_devlist"]), true);
if (strlen($device) < 3 && intval($device) >= 0 && intval($device) < sizeof($devices)) {
$dev = $devices[intval($device)];
} else if ($key = array_search($device, array_column($devices, "name"))) {
$dev = $devices[$key];
} else if ($key = array_search($device, array_column($devices, "id"))) {
$dev = $devices[$key];
} else {
return false;
}
return $dev;
}
if ($_GET["action"] == "devlist") {
$jalousien = array();
$url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/setup/devices';
$ret = getSSLPage($url,$tahoma_token);
$devices = json_decode($ret, true);
$i = 0;
foreach ($devices as $device) {
if ($device["controllableName"] == "io:ExteriorVenetianBlindIOComponent") {
$jalousien[$i]["name"] = $device["label"];
$jalousien[$i]["id"] = $device["deviceURL"];
$i = $i + 1;
echo $device["label"] . "(".$device["deviceURL"].")<br />";
}
}
//$file = fopen($tahoma_devlist, 'w');
//fwrite($file, json_encode($jalousien));
//fclose($file);
echo json_encode($jalousien);
}elseif ($_GET["action"] == "pos" && isset($_GET["device"])) {
$dev = searchForDevice($_GET["device"])["id"];
$devices = json_decode(file_get_contents($tahoma_devlist), true);
$jalousien = array();
$url = $url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/setup/devices/'.urlencode($dev)."/states";
$ret = getSSLPage($url,$tahoma_token);
$states = json_decode($ret, true);
$out = "{";
foreach ($states as $state){
if ($state["name"] == "core:SlateOrientationState")
$out .= "\"rotation\":".$state["value"].",";
if ($state["name"] == "core:ClosureState")
$out .= "\"position\":".$state["value"].",";
}
$out = substr($out,0,-1);
$out .= "}";
echo $out;
header('Connection: close');
header('Content-Length: '.ob_get_length());
ob_end_flush();
@ob_flush();
flush();
fastcgi_finish_request();
}elseif ($_GET["action"] == "moving" && isset($_GET["device"])) {
$dev = searchForDevice($_GET["device"])["id"];
$devices = json_decode(file_get_contents($tahoma_devlist), true);
$jalousien = array();
$url = $url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/setup/devices/'.urlencode($dev)."/states/core:MovingState";
$ret = getSSLPage($url,$tahoma_token);
$moving = json_decode($ret, true);
if($moving["value"])
echo "true";
else
echo "false";
header('Connection: close');
header('Content-Length: '.ob_get_length());
ob_end_flush();
@ob_flush();
flush();
fastcgi_finish_request();
}elseif ($_GET["action"] == "move" && isset($_GET["pos"]) && isset($_GET["angle"]) && isset($_GET["device"])) {
$angle = $_GET["angle"];
$pos = $_GET["pos"];
if ($pos < 0 || $pos > 100) {
echo "Position out of Range";
exit;
}
if ($angle < 0 || $angle > 100) {
echo "Angle out of Range";
exit;
}
$dev = searchForDevice($_GET["device"])["id"];
if($dev == false){
exit;
}
header('Connection: close');
header('Content-Length: '.ob_get_length());
$url = $url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/setup/devices/'.urlencode($dev)."/states/core:MovingState";
$ret = json_decode(getSSLPage($url,$tahoma_token),true);
$action = array();
$action["label"] = "myAction";
$action["actions"] = array();
$action["actions"][0]["deviceURL"] = $dev;
$action["actions"][0]["commands"] = array();
if(!isset($ret["value"])){
$ret = json_decode(getSSLPage($url,$tahoma_token),true);
}
if(!isset($ret["value"]) || $ret["value"] == false){
$url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/exec/apply';
echo "start";
ob_end_flush();
@ob_flush();
flush();
fastcgi_finish_request();
$action["actions"][0]["commands"][0]["name"] = "setClosureAndOrientation";
$action["actions"][0]["commands"][0]["parameters"][0] = intval($pos);
$action["actions"][0]["commands"][0]["parameters"][1] = intval($angle);
$res = json_decode(postSSLPage($url,json_encode($action),$tahoma_token));
if(!isset($res["execId"])){
sleep(1);
$res = json_decode(postSSLPage($url,json_encode($action),$tahoma_token)); //2nd try
}
}elseif($ret["value"] == true){
$url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/exec/apply';
echo "stop";
ob_end_flush();
@ob_flush();
flush();
fastcgi_finish_request();
$action["actions"][0]["commands"][0]["name"] = "stop";
$res = json_decode(postSSLPage($url,json_encode($action),$tahoma_token));
if(!isset($res["execId"])){
sleep(1);
$res = json_decode(postSSLPage($url,json_encode($action),$tahoma_token)); //2nd try
}
}
}
if ($_GET["action"] == "myactors") {
if(isset($_GET["filter"])){
$host = $_GET["filter"];
$host = str_replace("-", "", strtolower($host));
}else{
$host = gethostbyaddr("192.168.179.32");
$host = str_replace("-", "", strtolower(substr($host, 0, strpos($host, "."))));
}
$devices = json_decode(file_get_contents($tahoma_devlist), true);
switch ($host) {
case "tmpegbad":
echo json_encode(filter_devs($devices, "bad "));
break;
case "tmpegwozi":
echo json_encode(filter_devs($devices, "wozi "));
break;
case "tmpegflorian": //flori
$ret = filter_devs($devices, "florian ","Flori ");
$ret = array_merge($ret, filter_devs($devices, "magdalena ","Magdalena "));
echo json_encode($ret);
break;
case "tmpegmagdalena":
echo json_encode(filter_devs($devices, "magdalena "));
break;
case "tmpegschlafzimmer":
echo json_encode(filter_devs($devices, "schlafzimmer "));
break;
default:
$ret2=Array();
//$ret2[0]["name"] = $host;
//$ret2[0]["id"] = 99;
$ret = filter_devs($devices, "");
//$ret = array_merge($ret, $ret2);
echo json_encode($ret);
break;
}
header('Connection: close');
header('Content-Length: '.ob_get_length());
ob_end_flush();
@ob_flush();
flush();
fastcgi_finish_request();
//echo postSSLPage($url,json_encode($action));
}
+290
View File
@@ -0,0 +1,290 @@
<?php
/*
* Kachel-Zwischenspeicher für die Karte.
*
* Aufruf: tile.php?z=16&x=34567&y=22456
*
* Liegt die Kachel im Zwischenspeicher, kommt sie von dort. Fehlt sie, holt
* das NAS sie einmal bei OpenStreetMap, legt sie ab und liefert aus. Danach
* verlässt für diese Kachel nie wieder eine Anfrage das Haus.
*
* Warum überhaupt ein Zwischenspeicher und nicht der direkte Weg vom Browser
* zu tile.openstreetmap.org? Drei Gründe:
*
* 1. Die Kachelrichtlinie der OSM Foundation verlangt Zwischenspeicherung
* von mindestens sieben Tagen. Der direkte Weg erfüllt das nur, soweit
* der Browser mitspielt.
* 2. Ohne Zwischenspeicher ginge die Parkposition des Fahrzeugs - also die
* Wohnadresse - bei jedem Seitenaufruf als Kachelanfrage nach draußen.
* 3. Einmal geholte Gegenden bleiben ohne Internet benutzbar.
*
* Was die Richtlinie ausdrücklich verbietet, ist Vorratsspeicherung: Kacheln
* auf Verdacht holen, Gebiete vorab abgrasen, Offline-Archive anlegen. Dieses
* Skript holt deshalb ausschließlich das, was gerade jemand ansieht, und die
* Bremse unten begrenzt zusätzlich, wie viel das pro Minute sein darf.
*
* Richtlinie: https://operations.osmfoundation.org/policies/tiles/
*/
require_once("../helper.php");
/* ------------------------------------------------------------ Einstellungen */
/** Woher die Kacheln kommen. {z}/{x}/{y} werden ersetzt. */
const KACHEL_QUELLE = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
/*
* Die Richtlinie verlangt einen eigenen, kontaktierbaren User-Agent. Vorgaben
* von Bibliotheken werden ohne Vorwarnung geblockt. Die Domain genügt als
* Kontakt; wer lieber eine Mailadresse hinterlegt, ersetzt sie hier.
*/
const KENNUNG = "SmartController-SkodaKarte/1.0 (+https://nas.el-wa.org/smart)";
/*
* Wo die Kacheln liegen.
*
* Bevorzugt neben der Seite unter tiles/ - dort bleiben sie über Neustarts
* erhalten und liegen im Backup. PHP läuft auf dem NAS aber unter einem
* eigenen Benutzer, und DSM vergibt im Web-Verzeichnis ACLs, die ihm das
* Schreiben verwehren. Der POSIX-Modus täuscht: drwxrwxrwx mit einem "+"
* dahinter heisst, dass die ACL entscheidet, nicht die Rechtebits.
*
* Damit die Karte auch ohne Eingriff funktioniert, wird ersatzweise das
* Systemverzeichnis für flüchtige Dateien genommen. Das kostet nur den
* Bestand: es wird bei Gelegenheit geleert, die Kacheln müssen dann erneut
* geholt werden. Dauerhaft wird es mit einem Befehl als root:
*
* synoacltool -add /volume1/web/smart/tiles \
* "user:http:allow:rwxpdDaARWc--:fd--"
*
* Welcher Ort gerade benutzt wird, sagt speicherOrt() im Fehlerprotokoll,
* sobald zum ersten Mal eine Kachel geholt wird.
*/
const SPEICHER_ORTE = [
__DIR__ . "/../tiles", // dauerhaft, bevorzugt
"/var/services/tmp/smart-tiles", // flüchtig, aber immer beschreibbar
];
/** Zoomstufen, die ausgeliefert werden. Feiner als Hausnummern braucht es nicht. */
const ZOOM_MIN = 6;
const ZOOM_MAX = 18;
/** Wie lange der Browser eine Kachel behalten darf (Sekunden). */
const BROWSER_FRIST = 7 * 24 * 3600;
/**
* Nach dieser Zeit wird eine Kachel beim nächsten Ansehen neu geholt. Die
* Karte ändert sich, aber nicht schnell. Scheitert das Nachholen, bleibt die
* alte Kachel - die Karte ist dann veraltet, aber nie kaputt.
*/
const AUFFRISCHEN_NACH = 60 * 24 * 3600;
/*
* Bremse gegen den eigenen Fehler. Ein Kartenfeld dieser Größe braucht rund
* zwölf Kacheln je Ansicht; wer sich durch mehrere Zoomstufen klickt, kommt
* auf einige Dutzend. Alles weit darüber wäre eine Schleife im Frontend - und
* die sähe für OSM wie Vorratsspeicherung aus. Ist die Grenze erreicht, wird
* eine graue Ersatzkachel geliefert statt weiter nach draußen zu gehen.
*/
const HOLEN_JE_MINUTE = 150;
const HOLEN_JE_STUNDE = 2000;
/* ------------------------------------------------------------------ Zugriff */
if (!checkLogin()) {
http_response_code(403);
exit;
}
/* ----------------------------------------------------------------- Prüfung */
$z = filter_input(INPUT_GET, "z", FILTER_VALIDATE_INT);
$x = filter_input(INPUT_GET, "x", FILTER_VALIDATE_INT);
$y = filter_input(INPUT_GET, "y", FILTER_VALIDATE_INT);
// Ohne diese Prüfung liesse sich über z/x/y aus dem Speicherverzeichnis
// heraus schreiben - die Werte gehen schliesslich in einen Dateipfad.
$grenze = ($z === false || $z === null) ? 0 : (1 << $z);
if ($z === false || $z === null || $z < ZOOM_MIN || $z > ZOOM_MAX
|| $x === false || $x === null || $x < 0 || $x >= $grenze
|| $y === false || $y === null || $y < 0 || $y >= $grenze) {
http_response_code(400);
exit;
}
$pfad = speicherOrt() . "/$z/$x/$y.png";
/* -------------------------------------------------------------- Ausliefern */
$vorhanden = is_file($pfad);
$veraltet = $vorhanden && (time() - filemtime($pfad) > AUFFRISCHEN_NACH);
if (!$vorhanden || $veraltet) {
if (holeUndSpeichere($z, $x, $y, $pfad)) {
$vorhanden = true;
}
}
if ($vorhanden) {
liefereDatei($pfad);
} else {
liefereErsatzkachel();
}
exit;
/* ------------------------------------------------------------- Bausteine -- */
/**
* Der erste beschreibbare Ort aus SPEICHER_ORTE, einmal je Aufruf ermittelt.
* Existiert keiner, wird der letzte angelegt - der liegt im Systemverzeichnis
* für flüchtige Dateien und ist immer beschreibbar.
*/
function speicherOrt()
{
static $ort = null;
if ($ort !== null) {
return $ort;
}
foreach (SPEICHER_ORTE as $kandidat) {
if (is_dir($kandidat) && is_writable($kandidat)) {
return $ort = $kandidat;
}
}
$ort = SPEICHER_ORTE[count(SPEICHER_ORTE) - 1];
if (!is_dir($ort)) {
@mkdir($ort, 0755, true);
}
return $ort;
}
/**
* Eine Kachel bei der Quelle holen und ablegen.
* Gibt true zurück, wenn danach eine brauchbare Datei vorliegt.
*/
function holeUndSpeichere($z, $x, $y, $pfad)
{
if (!bremseErlaubt()) {
return is_file($pfad); // notfalls die veraltete Kachel
}
$url = str_replace(["{z}", "{x}", "{y}"], [$z, $x, $y], KACHEL_QUELLE);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => KENNUNG,
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 4,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 2,
]);
$inhalt = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
// Auf den PNG-Kopf prüfen, statt nur auf den Statuscode: eine Fehlerseite
// mit Status 200 wäre sonst als Kachel im Speicher gelandet und bliebe
// dort für immer.
if ($status !== 200 || $inhalt === false || strncmp($inhalt, "\x89PNG", 4) !== 0) {
return is_file($pfad);
}
if (!is_dir(dirname($pfad)) && !mkdir(dirname($pfad), 0755, true)) {
return is_file($pfad);
}
// Erst daneben schreiben, dann umbenennen. Sonst könnte ein zweiter
// Aufruf eine halb geschriebene Datei ausliefern.
$temp = $pfad . "." . getmypid() . ".teil";
if (file_put_contents($temp, $inhalt) === false) {
return is_file($pfad);
}
if (!rename($temp, $pfad)) {
unlink($temp);
return is_file($pfad);
}
return true;
}
/**
* Darf jetzt noch eine Kachel geholt werden?
*
* Gezählt wird in einer kleinen Datei, gegen gleichzeitige Zugriffe mit
* flock abgesichert. Zwei Fenster: eines je Minute, eines je Stunde. Läuft
* ein Fenster ab, fängt sein Zähler wieder bei null an.
*/
function bremseErlaubt()
{
$datei = speicherOrt() . "/.bremse.json";
$griff = fopen($datei, "c+");
if (!$griff) {
return true;
}
if (!flock($griff, LOCK_EX)) {
fclose($griff);
return true;
}
$stand = json_decode(stream_get_contents($griff), true);
if (!is_array($stand)) {
$stand = ["minute" => 0, "min_n" => 0, "stunde" => 0, "std_n" => 0];
}
$jetzt = time();
if ($jetzt - $stand["minute"] >= 60) { $stand["minute"] = $jetzt; $stand["min_n"] = 0; }
if ($jetzt - $stand["stunde"] >= 3600) { $stand["stunde"] = $jetzt; $stand["std_n"] = 0; }
$erlaubt = $stand["min_n"] < HOLEN_JE_MINUTE && $stand["std_n"] < HOLEN_JE_STUNDE;
if ($erlaubt) {
$stand["min_n"]++;
$stand["std_n"]++;
}
ftruncate($griff, 0);
rewind($griff);
fwrite($griff, json_encode($stand));
flock($griff, LOCK_UN);
fclose($griff);
if (!$erlaubt) {
error_log("tile.php: Bremse greift, keine weiteren Kacheln in diesem Fenster.");
}
return $erlaubt;
}
/** Eine gespeicherte Kachel ausliefern, mit Unterstützung für 304. */
function liefereDatei($pfad)
{
$zeit = filemtime($pfad);
$marke = '"' . md5($pfad . $zeit) . '"';
header("Content-Type: image/png");
header("Cache-Control: public, max-age=" . BROWSER_FRIST);
header("ETag: $marke");
header("Last-Modified: " . gmdate("D, d M Y H:i:s", $zeit) . " GMT");
$bekannt = isset($_SERVER["HTTP_IF_NONE_MATCH"]) ? trim($_SERVER["HTTP_IF_NONE_MATCH"]) : "";
if ($bekannt === $marke) {
http_response_code(304);
return;
}
header("Content-Length: " . filesize($pfad));
readfile($pfad);
}
/**
* Graue Ersatzkachel, wenn nichts zu holen war - kein Internet, Quelle
* gestört oder Bremse aktiv. Ein leeres Feld ist besser als ein kaputtes
* Bildsymbol, und es wird bewusst nicht zwischengespeichert.
*/
function liefereErsatzkachel()
{
header("Content-Type: image/png");
header("Cache-Control: no-store");
// 1x1 Punkt in Grau, von Leaflet auf Kachelgröße gezogen.
echo base64_decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"
. "+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
}
+134
View File
@@ -0,0 +1,134 @@
<?php
require_once("./phpMQTT.php");
require_once("../helper.php");
if ($_SERVER["REQUEST_METHOD"] === "POST") {
header("Content-Type: application/json; charset=utf-8");
if (!checkLogin()) {
http_response_code(401);
echo json_encode(["success" => false, "message" => "Nicht angemeldet."]);
exit;
}
$topic = $_POST["topic"] ?? "";
$payload = $_POST["payload"] ?? "";
$allowedTopics = [
"Gartenwasser/vorn/set",
"Gartenwasser/vorn/auto",
"Gartenwasser/hinten/set",
"Gartenwasser/hinten/auto"
];
if (!in_array($topic, $allowedTopics, true)) {
http_response_code(400);
echo json_encode(["success" => false, "message" => "Nicht erlaubtes MQTT-Topic."]);
exit;
}
if (str_ends_with($topic, "/set")) {
$command = json_decode($payload, true);
$validValve = is_array($command)
&& isset($command["valve"], $command["set"])
&& in_array((int) $command["valve"], [1, 6], true)
&& in_array($command["set"], ["open", "close"], true);
if ($validValve && $command["set"] === "open") {
$validValve = isset($command["timer"])
&& is_numeric($command["timer"])
&& (int) $command["timer"] >= 1
&& (int) $command["timer"] <= 120;
}
if (!$validValve) {
http_response_code(400);
echo json_encode(["success" => false, "message" => "Ungültiger Ventilbefehl."]);
exit;
}
$payload = json_encode($command, JSON_THROW_ON_ERROR);
} elseif (!in_array($payload, ["Hoch", "Vorn", "Trog", "Stop"], true)) {
http_response_code(400);
echo json_encode(["success" => false, "message" => "Ungültiger Automatikbefehl."]);
exit;
}
try {
$mqtt = new Bluerhinos\phpMQTT("localhost", 1883, "WateringWeb-" . bin2hex(random_bytes(4)));
if (!$mqtt->connect(true, null, "", "")) {
http_response_code(503);
echo json_encode(["success" => false, "message" => "MQTT-Broker nicht erreichbar."]);
exit;
}
$mqtt->publish($topic, $payload, 0, false);
$mqtt->close();
} catch (Throwable $error) {
http_response_code(503);
echo json_encode(["success" => false, "message" => "MQTT-Fehler: " . $error->getMessage()]);
exit;
}
echo json_encode(["success" => true, "topic" => $topic, "payload" => $payload]);
exit;
}
if (!checkLogin()) {
exit;
}
// Zonen des Fensters. Die Reihenfolge bestimmt die Reihenfolge der Karten;
// die Keys muessen zu wateringZones in js/solar/bewaesserungAnzeige.js passen,
// "bild" zu ZONENBILDER in js/solar/energiefluss.js. "dauern" sind die
// Schnellwahlen in Minuten, die erste ist die Vorgabe.
$zonen = [
["key" => "troughs", "label" => "Tröge", "bild" => "kuebel", "dauern" => [5, 10, 15, 30]],
["key" => "raisedBeds", "label" => "Beete", "bild" => "hochbeet", "dauern" => [30, 15, 45, 60]],
["key" => "frontGarden", "label" => "Vorn", "bild" => "garten", "dauern" => [30, 15, 45, 60]],
];
// Die Abläufe der Ventilsteuerung (Topic .../auto). Mehrere Einträge in
// "modi" starten bzw. stoppen gemeinsam.
$ablaeufe = [
["label" => "Tröge + Beete", "modi" => ["Gartenwasser/vorn" => "Trog", "Gartenwasser/hinten" => "Hoch"]],
["label" => "Tröge", "modi" => ["Gartenwasser/vorn" => "Trog"]],
["label" => "Beete", "modi" => ["Gartenwasser/hinten" => "Hoch"]],
["label" => "Vorn", "modi" => ["Gartenwasser/vorn" => "Vorn"]],
];
?>
<div class="lade wasser">
<div class="lade-abschnitt">
<h3>Abläufe</h3>
<div class="lade-chips wasser-ablaeufe">
<?php foreach ($ablaeufe as $ablauf): ?>
<button type="button" class="lade-chip" aria-pressed="false"
data-ablauf="<?= htmlspecialchars(json_encode($ablauf["modi"], JSON_UNESCAPED_SLASHES)) ?>"><?= htmlspecialchars($ablauf["label"]) ?></button>
<?php endforeach; ?>
</div>
<div class="lade-erklaerung">Ein Ablauf gießt die Zonen nacheinander – antippen startet, nochmal antippen stoppt. Die Regenautomatik läuft davon unabhängig zum Sonnenaufgang.</div>
</div>
<div class="wasser-zonen">
<?php foreach ($zonen as $zone):
$dauern = $zone["dauern"];
sort($dauern); ?>
<div class="wasser-zone" data-zone="<?= $zone["key"] ?>" data-dauer="<?= $zone["dauern"][0] ?>">
<div class="wasser-kopf">
<span class="wasser-bild" data-bild="<?= $zone["bild"] ?>" aria-hidden="true"></span>
<div class="wasser-titel">
<b><?= htmlspecialchars($zone["label"]) ?></b>
<span data-w="zustand">–</span>
</div>
<span class="lade-pille" data-w="pille"></span>
</div>
<div class="wasser-fuss">
<div class="lade-chips" role="group" aria-label="Dauer für <?= htmlspecialchars($zone["label"]) ?>">
<?php foreach ($dauern as $min): ?>
<button type="button" class="lade-chip" aria-pressed="false" data-min="<?= $min ?>"><?= $min ?>&#8201;min</button>
<?php endforeach; ?>
</div>
<button type="button" class="lade-start wasser-knopf" data-w="knopf"><span class="lade-punkt"></span><span data-w="knopfText">Gießen</span></button>
</div>
<div class="wasser-info" data-w="info"></div>
</div>
<?php endforeach; ?>
</div>
<div class="lade-meldung" data-w="meldung" aria-live="polite">Befehle gehen sofort an die Ventilsteuerung</div>
</div>
+144
View File
@@ -0,0 +1,144 @@
/* devanagari */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 200;
src: url(pxiByp8kv8JHgFVrLFj_Z11lFc-K.woff2) format('woff2');
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 200;
src: url(pxiByp8kv8JHgFVrLFj_Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 200;
src: url(pxiByp8kv8JHgFVrLFj_Z1xlFQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* devanagari */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 300;
src: url(pxiByp8kv8JHgFVrLDz8Z11lFc-K.woff2) format('woff2');
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 300;
src: url(pxiByp8kv8JHgFVrLDz8Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 300;
src: url(pxiByp8kv8JHgFVrLDz8Z1xlFQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* devanagari */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
src: url(pxiEyp8kv8JHgFVrJJbecmNE.woff2) format('woff2');
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
src: url(pxiEyp8kv8JHgFVrJJnecmNE.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
src: url(pxiEyp8kv8JHgFVrJJfecg.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* devanagari */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
src: url(pxiByp8kv8JHgFVrLEj6Z11lFc-K.woff2) format('woff2');
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
src: url(pxiByp8kv8JHgFVrLEj6Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
src: url(pxiByp8kv8JHgFVrLEj6Z1xlFQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* devanagari */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
src: url(pxiByp8kv8JHgFVrLCz7Z11lFc-K.woff2) format('woff2');
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
src: url(pxiByp8kv8JHgFVrLCz7Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
src: url(pxiByp8kv8JHgFVrLCz7Z1xlFQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* devanagari */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 800;
src: url(pxiByp8kv8JHgFVrLDD4Z11lFc-K.woff2) format('woff2');
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 800;
src: url(pxiByp8kv8JHgFVrLDD4Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 800;
src: url(pxiByp8kv8JHgFVrLDD4Z1xlFQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 874 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="#fff"
class="bi bi-heart-arrow" viewBox="0 0 16 16">
<path
d="M6.707 9h4.364c-.536 1.573 2.028 3.806 4.929-.5-2.9-4.306-5.465-2.073-4.929-.5H6.707L4.854 6.146a.5.5 0 1 0-.708.708L5.293 8h-.586L2.854 6.146a.5.5 0 1 0-.708.708L3.293 8h-.586L.854 6.146a.5.5 0 1 0-.708.708L1.793 8.5.146 10.146a.5.5 0 0 0 .708.708L2.707 9h.586l-1.147 1.146a.5.5 0 0 0 .708.708L4.707 9h.586l-1.147 1.146a.5.5 0 0 0 .708.708z" />
</svg>

After

Width:  |  Height:  |  Size: 491 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+1
View File
@@ -0,0 +1 @@
{"1":{"x":50,"y":75.3},"2":{"x":13,"y":84.5},"3":{"x":88.5,"y":92.5}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+1
View File
@@ -0,0 +1 @@
{"1":{"x":81,"y":29.9},"2":{"x":16.5,"y":36.4},"3":{"x":19.7,"y":40.9},"4":{"x":8.1,"y":45},"5":{"x":50,"y":49.7}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+1
View File
@@ -0,0 +1 @@
{"1":{"x":35.3,"y":16.7},"2":{"x":50,"y":37.8},"3":{"x":86,"y":23.7},"4":{"x":12.8,"y":50.9}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

@@ -0,0 +1 @@
{"1":{"x":36.2,"y":45.3},"2":{"x":65.9,"y":54},"3":{"x":95.5,"y":54.1}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

@@ -0,0 +1 @@
{"1":{"x":59.8,"y":23.4},"2":{"x":46.7,"y":56.2},"3":{"x":0,"y":0},"4":{"x":76.9,"y":88.3},"5":{"x":85.1,"y":115.3}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

@@ -0,0 +1 @@
{"1":{"x":59.8,"y":13},"2":{"x":59.8,"y":20.8},"3":{"x":28.9,"y":27.6},"4":{"x":26.5,"y":32.6}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

+1
View File
@@ -0,0 +1 @@
{"1":{"x":33.3,"y":16.6},"2":{"x":93.6,"y":23.5},"3":{"x":26.2,"y":81.4}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+1
View File
@@ -0,0 +1 @@
{"1":{"x":11.5,"y":30.4},"2":{"x":49.4,"y":26.9},"3":{"x":64.9,"y":29.9}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+1
View File
@@ -0,0 +1 @@
{"1":{"x":35.1,"y":16},"2":{"x":35.1,"y":51.5},"3":{"x":32.1,"y":66.5},"4":{"x":9.2,"y":3.3}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

@@ -0,0 +1 @@
{"1":{"x":50,"y":14},"2":{"x":14.1,"y":24.1},"3":{"x":59.2,"y":30.5}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+1
View File
@@ -0,0 +1 @@
{"1":{"x":50,"y":51.3},"2":{"x":15.6,"y":54.7}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+1
View File
@@ -0,0 +1 @@
{"1":{"x":50,"y":51.5}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

+1
View File
@@ -0,0 +1 @@
{"1":{"x":32.1,"y":32.2},"2":{"x":28.3,"y":52.1},"3":{"x":32,"y":71.4},"4":{"x":46.7,"y":52.5},"5":{"x":60.6,"y":52},"6":{"x":77.6,"y":51.5},"7":{"x":82.4,"y":75},"8":{"x":82.4,"y":27.5},"9":{"x":68,"y":27.7},"10":{"x":68,"y":75.2},"11":{"x":52.5,"y":75.2},"12":{"x":0,"y":0}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

+1
View File
@@ -0,0 +1 @@
{"1":{"x":34.9,"y":20.5},"2":{"x":49.3,"y":25.1},"3":{"x":25,"y":28.9},"4":{"x":30.8,"y":32.8},"5":{"x":-0.5,"y":36.8}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Some files were not shown because too many files have changed in this diff Show More