Compare commits

...
129 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
189 changed files with 39518 additions and 15566 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
+5
View File
@@ -44,6 +44,11 @@ logs/
.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
+990
View File
@@ -0,0 +1,990 @@
# Smart-Dashboard
Die Anzeige und Bedienung des Hauses: Grundrisse mit Messwerten, Solar- und
Verbrauchsverläufe, das Fahrzeug, die Automatiken, die Einstellungen. Alles,
was einen offenen Browser braucht, liegt hier.
Gegenstück ist das Repo **`admin/SolarManager`** unter
`/volume1/homes/wagner/SolarManager` — dort laufen die Hintergrundprozesse,
die Messwerte einsammeln und die Automatiken ausführen. Beide Seiten reden
über zwei Dinge miteinander und nie direkt: den **MQTT-Broker** für alles
Aktuelle, die **Datenbanken** für alles, was bleiben soll. Wer wissen will,
woher eine Zahl ursprünglich kommt, liest die README dort; hier steht, was
mit ihr im Browser passiert.
Dieses Verzeichnis ist zugleich der Web-Root der NAS
(`/volume1/web/smart`, ausgeliefert unter `https://nas.el-wa.org/smart`).
Der Arbeitsordner *ist* die laufende Anwendung — jede gespeicherte Datei ist
sofort live.
Für einige Teile reicht diese Landkarte nicht, weil ihr Verhalten aus dem
Zusammenspiel mehrerer Prozesse entsteht. Sie haben eigene, ausführliche
Dokumente unter [`doku/`](doku/README.md):
**[Automatiken](doku/automatiken.md)**, **[Zeitleiste](doku/zeitleiste.md)**,
**[Benachrichtigungen](doku/benachrichtigungen.md)**, **[Batterie](doku/byd.md)**,
**[Meteogramm](doku/meteogramm.md)**,
**[Solar-Übersicht](doku/uebersicht.md)**, **[Datenbanken](doku/datenbank.md)**
und **[Einstellungsseite](doku/einstellungen.md)**.
---
## Inhalt
1. [Auf einen Blick](#auf-einen-blick)
2. [Wie eine Seite entsteht](#wie-eine-seite-entsteht)
3. [Zwei Wege für Daten](#zwei-wege-für-daten)
4. [Die Seitenkarte](#die-seitenkarte)
5. [Die JavaScript-Schicht](#die-javascript-schicht)
6. [Die PHP-Schicht](#die-php-schicht)
7. [Die Datenbanken](#die-datenbanken)
8. [Vier Bausteine im Detail](#vier-bausteine-im-detail)
9. [Wiederkehrende Bauteile und Konventionen](#wiederkehrende-bauteile-und-konventionen)
10. [Wo fange ich an, wenn ich … ändern will](#wo-fange-ich-an-wenn-ich--ändern-will)
11. [Entwickeln und Prüfen](#entwickeln-und-prüfen)
---
## Auf einen Blick
```
smart/
├── index.php Router: Anmeldung prüfen, Seite auswählen, drei Includes
├── helper.php Session, Heimnetzerkennung, checkLogin(), httpGetJson()
├── authServer.php Passkeys (WebAuthn): Registrierung und Anmeldung
├── addUser.php Einmal-Link zum Anlegen eines neuen Schlüssels
├── .htaccess sperrt .git, *.py, *.sql, *.ini … für den Browser
│
├── restricted/ Vorlagen (was man sieht) und Modelle (was es weiß)
│ ├── header.php footer.php Rahmen jeder Seite, Menü, Skript-Einbindung
│ ├── home.php solar.php heat.php history.php
│ ├── skoda.php weather.php settings.php logs.php
│ ├── einfuehrung.php ← die neun Seitenvorlagen
│ ├── rooms.php Etagen und Räume lesen (homeMesh), Kachelvorgabe, Symbole
│ ├── grundriss.php Etagen, Räume, Kachelpositionen, Bilder ändern (Reiter Grundriss)
│ ├── zeitleiste.php Automatiken eines Tages: Lage, Bahn, ob dran, Läufe
│ ├── meshdb.php meshDb(): Verbindung zu homeMesh
│ ├── costs.php Preistabellen + solarDb()
│ ├── automations.php Automatiken, Gerätekatalog
│ ├── kacheln.php Anzeigewerte der Home-Kacheln (Modell zur Maske)
│ ├── roomControls.php Bedienelemente des Raum-Modals
│ ├── commands.php ein Kommando tatsächlich abschicken
│ ├── wallboxen.php beide Wallboxen über MQTT
│ ├── skodaKeys.php MyŠkoda-Schlüssel in skoda.conf (nie zurück an den Browser)
│ ├── logdateien.php Logdateien lesen
│ ├── aussenplan.php Schrägbild des Außengeländes (SVG, gerechnet)
│ ├── reiter.php das eine Reiter-Bauteil der ganzen Seite
│ ├── config.php Zahlen, die an der Wirklichkeit hängen
│ ├── mysql.php Zugangsdaten — NICHT im Git (mysql.php.example)
│ └── deviceDiscovery/ Python: Geräte suchen und in homeMesh eintragen
│
├── ajax/ Endpunkte. Verteiler, keine Logik (Ausnahmen unten)
├── js/solar/ der eigene JavaScript-Code
├── js/ Fremdbibliotheken, alle lokal (kein CDN)
├── css/solar.css das eigene Stylesheet (Theme + alle Bauteile)
├── assets/img/ mitgelieferte Grundriss-Renderings dieses Hauses, Icons
├── tiles/ beschreibbar, nicht im Git: Kartenkacheln, grundriss/ (hochgeladene Grundrisse)
└── *.sql Schema-Dateien zum Nachziehen von Hand
```
---
## Wie eine Seite entsteht
```mermaid
sequenceDiagram
autonumber
participant B as Browser
participant I as index.php
participant H as helper.php
participant T as Vorlage + Modelle
participant F as footer.php
B->>I: GET index.php?action=home&floor=OG
I->>H: checkLogin()
H-->>I: Heimnetz? sonst Passkey-Sitzung
Note over I: $pages[...] wählt Vorlage,<br/>Bibliotheken und Seitenskripte
I->>T: include header.php
T-->>B: Kopf und Menü, gebaut aus $floors
I->>T: include restricted/(seite).php
T-->>B: Karten, SVG, eingebettete JSON-Daten
I->>F: include footer.php
F-->>B: Bootstrap, ggf. mqtt.js/Chart.js/Leaflet,<br/>common.js, dann die Seitenskripte
B->>B: Skripte verbinden sich mit Broker und holen per fetch nach
```
**Anmeldung.** `helper.php` kennt zwei Wege. Aus dem Heimnetz
(`LOCAL_NETWORKS`, geprüft binär über `inet_pton`, ausschließlich anhand von
`REMOTE_ADDR` — Weiterleitungs-Header sind fälschbar und werden bewusst
ignoriert) ist man ohne Anmeldung drin. Von außen zählt ein Passkey:
`authServer.php` mit der WebAuthn-Bibliothek unter `restricted/WebAuthn`,
Schlüssel in der Datenbank `Logins`. Ein neuer Schlüssel entsteht nur über
einen Einmal-Link (`addUser.php`, Tabelle `addUser`, eine Minute gültig).
**Die Seitentabelle** in `index.php` ist die einzige Stelle, an der steht,
welche Seite es gibt und was sie braucht:
```php
"home" => ["template" => "home.php", "mqtt" => true, "charts" => false,
"meteogram" => false, "leaflet" => false,
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]],
```
`footer.php` lädt daraus die Bibliotheken — Chart.js nur wo gezeichnet wird,
mqtt.js nur wo Livewerte laufen, Leaflet nur auf der Fahrzeugseite. Danach
kommt immer `common.js` und dann die Seitenskripte, jeweils mit
`?v=<filemtime>`, damit ein Browser nie eine alte Fassung behält.
**Vorlage und Modell sind getrennt.** Eine Seitenvorlage baut Markup und ruft
dafür Funktionen aus den Modelldateien; die Modelldateien enthalten kein
Markup außer dort, wo sie ein Bedienelement zeichnen (`roomControls.php`,
`reiter.php`). Die Endpunkte in `ajax/` benutzen dieselben Modellfunktionen —
so kann eine Maske nicht etwas anderes behaupten als der Endpunkt, der sie
speichert.
---
## Zwei Wege für Daten
```mermaid
flowchart LR
subgraph Browser
JS["js/solar/*.js"]
SVG["SVG-Grundriss<br/>Diagramme<br/>Masken"]
end
BROKER{{"MQTT-Broker<br/><small>wss://mqtt.nas.el-wa.org:443</small>"}}
AJAX["ajax/*.php"]
MODELL["restricted/*.php<br/><small>Modell-Schicht</small>"]
SOLARLOG[("solarLog<br/><small>Verlauf</small>")]
HOMEMESH[("homeMesh<br/><small>Geräte, Automatiken</small>")]
GERAETE["Geräte<br/><small>Tahoma · WLED · Shelly · Wallboxen</small>"]
BROKER -->|"laufende Werte"| JS
JS --> SVG
JS <-->|"fetch: Verlauf, Struktur, Masken"| AJAX
AJAX --> MODELL
MODELL --> SOLARLOG
MODELL --> HOMEMESH
MODELL -.->|"Kommandos"| BROKER
MODELL -.->|"HTTP · Tahoma · WLED"| GERAETE
classDef speicher fill:#eaf5ee,stroke:#6fa981
classDef code fill:#fff6e5,stroke:#d0a548
class SOLARLOG,HOMEMESH,BROKER speicher
class JS,AJAX,MODELL code
```
Die Regel dahinter, und sie gilt ohne Ausnahme:
| Was | Weg | Warum |
|---|---|---|
| Aktueller Messwert (Leistung, Temperatur, Ladestand) | **MQTT über `wss`, direkt vom Broker** | Er weiß es ohnehin, und er sagt es von selbst — kein Nachfragen im Takt |
| Verlauf, Statistik, Gerätelisten, Formulare | **`fetch` auf `ajax/*.php`** | Das weiß nur die Datenbank |
| Jeder Knopfdruck (schalten, speichern, Fahrzeugbefehl) | **immer über den Server** | Zugangsdaten dürfen die Seite nicht verlassen, und der Broker ist von außen nicht erreichbar |
Der Browser abonniert je nach Seite `solarManager/#`, `weatherStation/#`,
`wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` und — auf der Home-Seite —
genau die Topics, die auf den Kacheln stehen (`tileTopics()` in `rooms.php`).
---
## Die Seitenkarte
| Seite (`?action=`) | Vorlage | Seitenskripte | holt per fetch | hört auf MQTT |
|---|---|---|---|---|
| `solar` (Vorgabe) | `solar.php` | `jahresstatistik.js`, `anlageKatalog.js`, `anlage.js`, `speicher.js`, `meteogrammKatalog.js`, `meteogramm.js`, `energieflussKatalog.js`, `energiefluss.js`, `bewaesserungAnzeige.js`, `ladefenster.js`, `bedienfenster.js`, `solarMQTT.js` | `speicher`, `meteogramm`, `getProdData`, `getConsData`, `getForecastData`, `getSunrise`, `getStats`, `carEG`, `carOG`, `heater`, `watering` | `solarManager/#`, `weatherStation/#`, `wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` |
| `home` | `home.php` | `autoActionFuncs.js`, `zeitleiste.js`, `raumfenster.js`, `homeMQTT.js` | `room.php`, `AutoAction.php` | die Topics der Kacheln, `Raumtemp/#` |
| `heat` | `heat.php` | `heatMQTT.js` | `getHeaterData`, `getWaterData`, `getSunrise` | `solarManager/#`, `weatherStation/#`, Wallbox-Topics |
| `history` | `history.php` | `jahresstatistik.js`, `historyMQTT.js` | `energyHistory` (6 ×), `getStats` | — |
| `skoda` | `skoda.php` | `skodaMQTT.js` | `skoda.php?was=…`, `skodaCmd.php`, `tile.php` | `solarManager/#` |
| `weather` | `weather.php` | `weatherMQTT.js` | nichts — hier steht noch das eingebettete Meteogramm von meteoblue (`js/meteogram.js`); das eigene gibt es bisher nur auf der Solar-Seite | `weatherStation/#` |
| `settings` | `settings.php` | `settings.js`, `grundriss.js`, `energieflussKatalog.js`, `energiefluss.js`, `energieflussEinstellungen.js`, `benachrichtigungen.js` | `settings.php?action=…`, `push.php?action=…` | — |
| `logs` | `logs.php` | `logs.js` | `logs.php?action=…` | — |
| `einfuehrung` | `einfuehrung.php` | `einfuehrung.js` | nichts — Folien und Bilder stehen in der Seite | — |
| `anzeige` | `solar.php` im Anzeige-Modus | wie `solar`, dazu `anzeige.js` | wie `solar` | wie `solar` |
**Anzeige-Modus** (`?action=anzeige`) — die Solar-Seite für ein festes Display,
gebaut für den Raspberry Pi 3B+ mit 800 × 480 im Kioskmodus. In `index.php` eine
Kopie des Eintrags `solar` mit `"anzeige" => true`: `header.php` lässt Kopfleiste
und Menü weg und setzt eine schmale Leiste (Übersicht, Anlage, Speicher,
Diagramme), `footer.php` den Fuß; die Realtime-Karte füllt einen Bildschirm,
die Übersicht bleibt quer. Bedienfenster, Diagramme und Bewässerung kommen
unverändert aus `solarMQTT.js`. `anzeige.js` ergänzt nur: zurück zur Übersicht
nach zwei Minuten ohne Berührung (offene Fenster schließen), Verbindungspunkt
(grün/gelb/rot) und Uhr in der Leiste, Neuladen alle sechs Stunden. Ohne
weitere Parameter läuft sie „ruhig“ — nur die Flüsse bewegen sich, in groben
Stufen — und ohne Mauszeiger; `&animation=1` schaltet alle Animationen ein,
`&maus=1` zeigt den Zeiger.
Und dasselbe als Abhängigkeitsbild — von der Seite bis in den Speicher:
```mermaid
flowchart TB
subgraph S["Seiten"]
HOME["home"]; SOLAR["solar"]; HIST["history"]; HEAT["heat"]
SKODA["skoda"]; SET["settings"]; LOGS["logs"]; WEA["weather"]
end
subgraph J["js/solar"]
COMMON["common.js<br/><small>immer geladen</small>"]
HOMEJS["homeMQTT.js"]; AUTOJS["autoActionFuncs.js"]; SOLJS["solarMQTT.js"]
HISTJS["historyMQTT.js"]; STATJS["jahresstatistik.js"]; HEATJS["heatMQTT.js"]
SKODAJS["skodaMQTT.js"]; SETJS["settings.js"]; LOGJS["logs.js"]; WEAJS["weatherMQTT.js"]
end
subgraph A["ajax"]
ROOM["room.php"]; AUTOA["AutoAction.php"]; SETA["settings.php"]
STATS["getStats.php"]; EHIST["energyHistory.php"]; PROD["getProdData<br/>getConsData<br/>getForecastData"]
HEATA["getHeaterData<br/>getWaterData"]; SKODAA["skoda.php<br/>skodaCmd.php<br/>tile.php"]; LOGA["logs.php"]
end
subgraph M["restricted (Modell)"]
ROOMS["rooms.php"]; COSTS["costs.php"]; AUTOM["automations.php"]
KACH["kacheln.php"]; RCTRL["roomControls.php"]; CMD["commands.php"]; LOGD["logdateien.php"]
end
SOLARLOG[("solarLog")]; HOMEMESH[("homeMesh")]; DATEIEN[/"Logdateien"/]; BROKER{{"Broker"}}
HOME --> HOMEJS & AUTOJS
SOLAR --> SOLJS & STATJS
HIST --> HISTJS & STATJS
HEAT --> HEATJS
SKODA --> SKODAJS
SET --> SETJS
LOGS --> LOGJS
WEA --> WEAJS
S -.-> COMMON
HOMEJS --> ROOM
AUTOJS --> AUTOA
SOLJS --> PROD
HISTJS --> EHIST
STATJS --> STATS
HEATJS --> HEATA
SKODAJS --> SKODAA
SETJS --> SETA
LOGJS --> LOGA
ROOM --> RCTRL & CMD & AUTOM
AUTOA --> AUTOM
SETA --> COSTS & KACH & AUTOM
STATS --> COSTS
SETA --> GRM["grundriss.php"]
SETA --> ENF["energiefluss.php"]
ENF --> HOMEMESH
KACH --> ROOMS
GRM --> ROOMS
LOGA --> LOGD
COSTS --> SOLARLOG
AUTOM --> HOMEMESH
CMD --> HOMEMESH
CMD -.-> BROKER
PROD --> SOLARLOG
EHIST --> SOLARLOG
HEATA --> SOLARLOG
SKODAA --> SOLARLOG
LOGD --> DATEIEN
classDef speicher fill:#eaf5ee,stroke:#6fa981
class SOLARLOG,HOMEMESH,DATEIEN,BROKER speicher
```
---
## Die JavaScript-Schicht
Kein Framework, kein Bundler. Jede Datei ist ein Skript, das der Browser so
lädt, wie es dasteht; geteilt wird über globale Funktionen aus `common.js`.
Die Namen sind deutsch, sobald es um die Sache geht — englisch nur dort, wo
eine Bibliothek es vorgibt.
### `common.js` — immer geladen
| Funktion | wofür |
|---|---|
| `powerToString(w)` | Leistung als `W` oder `kW`, dieselbe Rundung überall |
| `getData(chart, url, opt)` | Diagrammdaten holen, eintragen, alle 5 min auffrischen; optional Sonnenauf-/-untergang aus `getSunrise.php` dazu |
| `mountMeteogram()`, `updateWeatherCards()` | Wetterteile der Solar- und Wetterseite |
| `reiterMerken()` | offener Reiter steht in der Adresse und übersteht ein Neuladen |
| `loadingHTML(text)` | der eine Ladezustand für alle Modals |
### Die Seitenskripte
**`homeMQTT.js`** — Grundriss und Kacheln. Zwei Objekte: `homeMQTT` hält die
Verbindung und den Nachrichtenbaum (`mqttData`), `homeSVG` schreibt in die
SVG-Kacheln. Was auf einer Kachel steht, kommt nicht aus dem Skript, sondern
aus `homeRooms` — der Raumtabelle, die `home.php` als JSON in die Seite
schreibt. `homeSVG.kachelWert()` ist die Stelle, an der aus einer Nachricht
Text wird: Topic oder Wechselrichtersumme lesen, ggf. JSON-Pfad
herausgreifen, Vorzeichen drehen (`negativ`), skalieren (`format:
"leistung"` für Watt-Quellen, `"leistung_kw"` für Quellen, die schon
Kilowatt melden — die Wallboxen), runden (`stellen`), Einheit anhängen. Klick auf eine Kachel
öffnet das Raum-Modal über `ajax/room.php?room=<Raumnummer>`; bedient wird es
von `raumfenster.js`. Heizsymbol und Ist-Werte lesen aus dem Thermostat-Zweig
des Raums (`mqttZweig()`).
**`raumfenster.js`** — das Raum-Modal im Stil von Lade-, Heiz- und
Bewässerungsfenster: Reiter-Kacheln mit Zustand, Beschattung mit
Auf/Stop/my/Zu, Höhe und Lamellen in 25-%-Stufen, Ziehen im Fensterbild und
„aktuellen Stand als my speichern“; Heizung als Ring mit ziehbarem Soll-Punkt;
Lampen mit Birne als Schalter, Farbfeldern und Effekten. Jede Wahl gilt
sofort. Jalousien werden nach einem Befehl bis zum Stillstand verfolgt
(`rfJalousieVerfolgen()`), die Heizwerte kommen über `raumfensterMqtt()` live.
**`auswahl.js`** — eine Auswahlliste für die ganze Anwendung. Wird auf jeder
Seite geladen (`footer.php`) und ergänzt jedes `<select>` um einen eigenen
Knopf; das `<select>` bleibt stehen und hält weiter den Wert, vorhandene
`onchange`-Zuhörer merken nichts davon. Die aufgeklappte Liste hängt an
`<body>` und liegt mit `position: fixed` über der Seite — im Fluss machte sie
ein Modal höher, und der ganze Inhalt sprang. Ab neun Einträgen gibt es ein
Suchfeld, Gruppen (`<optgroup>`) bleiben Gruppen, die Tastatur bedient sie.
Neue `<select>` übernimmt ein `MutationObserver` von selbst. Wer den Wert im
Code setzt, ruft `auswahlAktualisieren(select)` oder löst `change` aus;
`data-auswahl="aus"` lässt ein Feld in Ruhe.
**`solarMQTT.js`** — die Solar-Seite. Startet die Energiefluss-Übersicht
(`energieflussStarten()`) samt Anlage-Ansicht, füllt die Bewässerungszonen,
darunter hängen die Diagramme Prognose, Verbrauch und Erzeugung über
`getData()`. Aktualisiert wird höchstens einmal je Sekunde
(`solarSvgUpdatePending`); die Setter (`setText`, `setAttr`) schreiben nur,
was sich geändert hat.
**Energiefluss-Übersicht** — drei Dateien mit klarer Arbeitsteilung:
| Datei | enthält |
|---|---|
| `energieflussKatalog.js` | **jeden Kreis genau einmal**: Name, Symbol, Farbe, Elternkreis, Lage, Ringskala, Klickziel und `lesen(m, opt)`, das aus den MQTT-Werten Leistung, Zeile darunter, Ringsegmente, Fluss, Plaketten und Symbolzustand macht. Ein neuer Kreis ist ein neuer Eintrag hier — sonst nichts. |
| `energiefluss.js` | den Renderer (`new Energiefluss(behälter, opt)`): Symbole (24er-Raster, animierbare Teile), Kreise mit Ring und Füllstandswelle, Flüsse als Bögen mit laufenden Punkten, Plaketten, Hochkant-Ansicht (x/y vertauscht unter 720 px Breite), Bearbeitungsmodus mit Ziehen. Kennt keinen Kreis beim Namen. |
| `energieflussEinstellungen.js` | Reiter „Übersicht“ in den Einstellungen: Vorschau mit Beispielwerten, Kreise an/aus, Name, Lage per Ziehen, PV-Flächen (welche Einträge von `P_PVn` zu welcher Fläche gehören). |
Was ein Haus vom Katalog abweicht, steht in `homeMesh.energiefluss`
(`homeMesh_energiefluss.sql`, gelesen/geschrieben in `restricted/energiefluss.php`) —
nur echte Abweichungen, eine leere Tabelle ist der Katalog. Ist ein Kreis aus,
hängen seine Kinder am nächsten eingeschalteten darüber, und sein Anteil im
Ring des Elternkreises fällt in das letzte Segment. Die Animationen stehen
still, solange die Karte nicht zu sehen ist, und entfallen bei
`prefers-reduced-motion`. Stile: Abschnitt „Energiefluss-Übersicht“ in
`css/solar.css`. Bis September 2026 stand hier `assets/img/realtime.svg` mit
festen Kreisen.
**Anlage-Ansicht** (Umschalter „Anlage“ oder Klick auf die Sonne) — dieselbe
Arbeitsteilung:
| Datei | enthält |
|---|---|
| `anlageKatalog.js` | die PV-Anlage dieses Hauses: Flächen mit Umriss, Wechselrichter (`invertersN` und ihre Eingänge in `P_PVn`), Strings ohne Einzelwerte (Gen24) und jede Platte mit ihrer echten Lage. Auch die Flächen im Sonnenring der Übersicht leiten sich daraus ab. |
| `anlage.js` | den Renderer (`new Anlage(behälter)`): Flächen mit Leistungsanteil, Lageplan (hochkant gedreht unter 800 px), Detail zu Platte, String oder Wechselrichter, Wechselrichterliste mit Auslastung und Zustand. |
Die Farbe einer Platte zeigt ihre Leistung im Vergleich zu den stärksten
ihrer Fläche (90. Perzentil) — nur Anzeige, kein Urteil. Ein Warnzeichen
bekommt sie erst, wenn sie bei genug Licht (Flächenmedian ab 30 W) unter 70 %
des Medians liegt. Meldet ein Wechselrichter nichts, stehen seine Platten auf
„ohne Daten“ statt „auffällig“. Den Verlauf je Platte gibt es nicht in der
Datenbank; die Ansicht schreibt ihn ab dem Seitenaufruf mit (40 Minuten).
**Speicher-Ansicht** (Umschalter „Speicher“ oder Klick auf die Batterie) —
`speicher.js` zeigt die Batterie bis zur Zelle: Kennzahlen (Ladestand mit kWh
und „voll/leer um“, Leistung, SOH, Zellspreizung, Temperaturen, Spannung,
Durchsatz mit gerechneten Zyklen, Zustand), den Turm aus BMU und Modulen mit
je 16 Zellbalken (Farbe und Höhe = Abweichung vom Mittel aller Zellen), das
gewählte Modul mit Zellspannungen und Temperaturfühlern sowie zwei
Verläufe aus `ajax/speicher.php` (Ladestand und Leistung aus `EnergyFlow`,
Spreizung und Temperatur aus `byd`). Live-Werte kommen über MQTT
`solarManager/byd/#` (`gatherBYDData.py`); fehlen sie, zeigt die Ansicht, was
der Wechselrichter weiß. Die Nennenergie je Modul (2,76 kWh, HVM) steht als
Konstante oben in `speicher.js` — die BMU meldet sie nicht. Wie die Werte
aus der BMU herauskommen, steht in **[doku/byd.md](doku/byd.md)**.
**Karte „Wetter“** — `meteogramm.js` zeichnet aus `meteogrammKatalog.js` drei
Felder mit gemeinsamer Zeitachse: Temperatur mit Wettersymbolen und
Tag/Nacht-Schattierung, Niederschlag mit der Bewölkung als Höhenband,
Wind mit Böen und Richtungspfeilen; dazu die Globalstrahlung auf der rechten
Achse des ersten Feldes. Umschaltbar 2 / 5 / 7 Tage, am Handy mit 2 Tagen
startend. **Links vom Jetzt-Strich stehen die gemessenen Werte der eigenen
Station**, rechts die Vorhersage aus `ajax/meteogramm.php`
(`weatherHours`/`weatherDays`, gefüllt von `gatherForecastData.py`). Das
eingebettete Meteogramm von meteoblue ist über einen Umschalter weiter
erreichbar und wird erst beim Hinschalten geladen. Einzelheiten in
**[doku/meteogramm.md](doku/meteogramm.md)**.
**`skodaMQTT.js`** — Fahrzeugseite. Holt alles über `ajax/skoda.php?was=…`
(live, ladungen, kurve, gesundheit, fahrten, strecke), zeichnet das Fahrzeug
als SVG-Draufsicht mit Ladestand im Akku, Ladeknopf und Klimaknopf, und die
Fahrtenkarte mit Leaflet über den eigenen Kachelspeicher `ajax/tile.php`.
Befehle gehen per POST an `ajax/skodaCmd.php` — nie direkt an die API.
**`jahresstatistik.js`** — ein Bauteil, zwei Seiten. `baueJahresstatistik()`
baut aus der Antwort von `getStats.php` die Vergleichstabelle (Jahre
nebeneinander, Gesamtspalte, Δ in Prozentpunkten bei Quoten, Sparklines je
Monat). Die Kennzahlen stehen **nicht** im Skript: sie beschreiben sich in der
Antwort selbst.
**`autoActionFuncs.js`** — der Automatik-Editor: Gerätewähler mit Suche,
Bedingungen in UND-/ODER-Gruppen, Aktionen mit Parametern, Klartext-Vorschau.
Er bekommt Gerätekatalog und Datensatz in einem Dokument von
`AutoAction.php?action=editor` — früher kamen je Bedingung zwei weitere
Anfragen dazu, und die Auswahlfelder füllten sich asynchron. Dazu die
Übersicht: Nachfolger einklappen (`toggleAutomationGroup`) und die Rückfrage
vor dem Löschen (`frageNachfolger`).
**`zeitleiste.js`** — die Automatismen als Zeitleiste eines Tages, neben der
bisherigen Liste (Umschalter oben in der Karte, gemerkt im Browser). Eine Bahn
je Geräteart, abgeleitet aus den geschalteten Geräten über `bedienform()` — die
Mehrheit gewinnt, Bewässerung wird am Gerätetyp erkannt. Die Lage rechnet
`restricted/zeitleiste.php` nach festen Regeln: feste Uhrzeit und Sonnenstand
als Punkt, Zeitfenster mit Messwert als Balken, Ketten hinter ihrem Auslöser,
ohne jede Zeit unter „Jederzeit“, pausierte unter „Pausiert“. Ob eine
Automatik an einem Tag dran ist, wird wie im Runner gerechnet (Wochentage,
Ferien, Feiertage, Vorabend); nicht dran heißt gestrichelt, nie ausgeblendet.
Der Zähler „x von y“ macht sichtbar, dass keine fehlt. Etagen lassen sich
einzeln einblenden. Sonnenzeiten kommen aus `solarLog.daylight`, für spätere
Tage vom selben Kalendertag eines Vorjahres.
**`grundriss.js`** — der Reiter Grundriss: Etagen, Räume, Grundrissbilder und
der Plan-Editor, in dem Kacheln gezogen und gesetzt werden. Eigene Datei, weil
`settings.js` schon für vier Reiter reicht; es nutzt `textSchuetzen()` von dort.
**`settings.js`** — vier Reiter in einer Datei, deutlich getrennt: Preistabellen,
Home-Kacheln, Geräte, Fahrzeug. Jeder Abschnitt hat sein Modell (`kachelModell`,
`geraeteModell`, `skodaModell`), seine `zeichne…()`-Funktion und eine `…Binden()`-Funktion,
die genau einen Ereignisbehandler auf den Container legt (Delegation) —
deshalb überlebt Bedienung jedes Neuzeichnen.
**`heatMQTT.js`, `historyMQTT.js`, `weatherMQTT.js`, `logs.js`** — jeweils
klein: Diagramme füllen bzw. Logzeilen nachladen und einfärben.
---
## Die PHP-Schicht
### Endpunkte (`ajax/`)
| Endpunkt | Aufruf | liefert / tut |
|---|---|---|
| `room.php` | `?room=<rooms.id>`, `POST ?action=command\|temp\|jalousie\|my\|wled` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden; Jalousie-Stand und WLED-Zustand (Effekt, Preset) live lesen; aktuellen Stand als my-Position in den Motor schreiben (`setMemorized1Position`/`-Orientation`) |
| `AutoAction.php` | `?action=editor\|list\|zeitleiste\|followers\|werte`, `POST save\|delete\|toggle` | Automatik-Editor, Übersicht als Liste und als Zeitleiste (`&datum=YYYY-MM-DD`) |
| `settings.php` | `?action=list\|kacheln\|geraete\|grundriss\|skoda-keys`, `POST save\|kacheln-save\|geraete-raeume\|geraet-loeschen\|etage-speichern\|etagen-reihenfolge\|etage-loeschen\|etage-bild\|raum-speichern\|raum-position\|raum-loeschen\|skoda-keys-save` | Einstellungsseite; die Grundriss-Aktionen antworten mit dem ganzen neuen Stand, `skoda-keys` meldet nur, ob ein Schlüssel hinterlegt ist und worauf er endet |
| `getStats.php` | `GET` | Jahresstatistik, alle Jahre, mit Metadaten je Kennzahl |
| `energyHistory.php` | `?series=prod\|cons&range=month\|year\|decade` | Verbrauchs-/Erzeugungsverlauf (Rohdaten oder Stundenarchiv) |
| `getProdData` `getConsData` `getForecastData` `getHeaterData` `getWaterData` | `?FROM=&TO=` | Chart.js-Datensätze für die jeweilige Karte |
| `getSunrise.php` | `?FROM=&TO=` | Sonnenauf- und -untergänge als Diagramm-Markierungen |
| `meteogramm.php` | `?tage=2..7&rueck=0..72` | Vorhersage und gemessene Stunden zu einer Reihe verschmolzen, für das Meteogramm |
| `skoda.php` | `?was=live\|ladungen\|kurve\|gesundheit\|fahrten\|strecke` | Fahrzeugauswertungen aus `solarLog.skoda` |
| `skodaCmd.php` | `POST befehl=…` | Laden/Klima/Lüftung am Fahrzeug |
| `tile.php` | `?z=&x=&y=` | Kartenkachel aus dem eigenen Zwischenspeicher, sonst einmalig von OSM |
| `logs.php` | `?action=list\|read` | Zustand und Zeilen der Logdateien |
| `carEG.php` `carOG.php` `carSteuerung.php` `carForm.php` `heater.php` `watering.php` `tahoma.php` | | Wallboxen, Heizstab, Bewässerung, Tahoma-Bedienung. Das Ladefenster (`carForm.php` + `js/solar/ladefenster.js`) liest live aus MQTT und schreibt jede Änderung sofort als JSON an `carSteuerung.php` (Aktionen `frc`, `modus`, `strom`, `plan`). Heizstab- und Bewässerungsfenster (`heater.php`, `watering.php` + `js/solar/bedienfenster.js`) arbeiten genauso: Zustand live aus MQTT, jede Wahl gilt sofort |
| `chartData.php` | (kein Endpunkt) | gemeinsame Bausteine der Chart-Endpunkte |
| `phpMQTT.php` | (Bibliothek) | MQTT aus PHP heraus, für Kommandos |
Die meisten Endpunkte sind **Verteiler**: Eingabe prüfen, Modellfunktion
rufen, JSON zurückgeben. Wo Logik in einem Endpunkt steht, hat das einen
Grund, der oben in der Datei erklärt ist (`getStats.php` rechnet die
Kennzahlen, `energyHistory.php` wählt die Quelle nach Zeitraum,
`skoda.php` bildet Fahrten und Ladungen aus einer unregelmäßigen Zeitreihe).
### Modelle (`restricted/`)
| Datei | Zuständig für | Wichtige Funktionen |
|---|---|---|
| `rooms.php` | Etagen und Räume lesen (`homeMesh`), Kachelvorgabe, Symbole | `grundriss()`, `allRooms()`, `roomsOnFloor()`, `floorsWithPlan()`, `standardEtage()`, `etageBild()`, `roomsWithTile()`, `tileTopics()`, `kachelVorgabe()`, `bootstrapIcons()`, `kachelIcon()` |
| `zeitleiste.php` | Automatiken eines Tages für die Zeitleiste | `zeitleiste()`, `zlTagPasst()`, `zlSonne()`, `zlBahn()` |
| `grundriss.php` | Etagen, Räume, Kachelpositionen und Grundrissbilder ändern | `grundrissStand()`, `etageSpeichern()`, `etagenReihenfolge()`, `etageLoeschen()`, `etageBildSpeichern()`, `raumSpeichern()`, `raumPosition()`, `raumLoeschen()` |
| `meshdb.php` | Verbindung zu `homeMesh` | `meshDb()` |
| `costs.php` | Preistabellen, Verbindung zu `solarLog` | `solarDb()`, `preiseLaden()`, `preiseSpeichern()`, `grundpreisImZeitraum()` |
| `automations.php` | Automatiken, Gerätekatalog, Raumzuordnung der Geräte | `deviceCatalog()`, `raumListe()`, `loadAutomation()`, `saveAutomation()`, `deleteAutomation()`, `automationFollowers()`, `pruefeKreis()`, `kalendertagChoices()`, `tagVorlagen()`, `stateValues()`, `geraeteListe()`, `geraetLoeschen()`, `saveRooms()` |
| `kacheln.php` | Anzeigewerte der Home-Kacheln (Maskenseite) | `kachelListe()`, `kachelKatalog()`, `kachelWertPruefen()`, `kachelWerteSpeichern()`, `kachelSymbole()` |
| `roomControls.php` | Welches Gerät welches Bedienelement bekommt | `bedienform()`, `zeichneBeschattung()`, `zeichneLicht()`, `zeichneMesswerte()` |
| `commands.php` | Ein Kommando abschicken (MQTT/WLED/HTTP/Tahoma) | `executeCommand()`, `sendeMqtt()`, `sendeWled()`, `sendeHttp()`, `sendeTahoma()` |
| `wallboxen.php` | Beide Wallboxen über MQTT | `wallboxen()`, `wallboxSetzen()` |
| `skodaKeys.php` | MyŠkoda-Schlüssel in `skoda.conf` (SolarManager): lesen, prüfen, eintragen — ein Wert geht nie an den Browser zurück | `skodaStand()`, `skodaSchluesselSpeichern()` |
| `logdateien.php` | Logdateien vom Ende her lesen | `logDateien()`, `logLesen()`, `logZerlegen()` |
| `aussenplan.php` | Schrägbild des Außengeländes | Projektion + SVG |
| `reiter.php` | Reiter | `reiterLeiste()`, `reiterFlaecheAuf/Zu()`, `reiterBlock()` |
| `config.php` | Zahlen, die an der Wirklichkeit hängen (welches Auto an welcher Box) | |
| `mysql.php` | Zugangsdaten, **nicht im Git** | Vorlage: `mysql.php.example` |
Include-Graph der Modellschicht — flach gehalten, damit man einer Zeile bis zur
Datenbank folgen kann, ohne zu springen:
```mermaid
flowchart LR
HELPER["helper.php"] --> MYSQL["mysql.php<br/><small>Zugangsdaten</small>"]
COSTS["costs.php"] --> MYSQL
MESH["meshdb.php<br/><small>meshDb()</small>"] --> MYSQL
ROOMS["rooms.php"] --> MESH
GRM["grundriss.php"] --> ROOMS
KACH["kacheln.php"] --> ROOMS
KACH -. "nur wenn vorhanden" .-> AUTOM["automations.php"]
AUTOM --> MESH
AUTOM --> ROOMS
CMD["commands.php"] --> MYSQL
CMD --> TAH["tahoma_EG.php"]
CMD --> PHPMQTT["ajax/phpMQTT.php"]
HOME["home.php"] --> ROOMS
HOME --> REITER
SET["settings.php"] --> COSTS & KACH & REITER
SET --> SKK["skodaKeys.php<br/><small>skoda.conf</small>"]
HEADER["header.php"] --> ROOMS
```
---
## Die Datenbanken
Drei Schemata, klar getrennte Zuständigkeit:
```mermaid
erDiagram
actors ||--o{ actor_states : "meldet"
actors ||--o{ actor_commands : "kann"
actor_commands ||--o{ command_parameters : "nimmt"
automations ||--o{ automation_conditions : "wenn"
automations ||--o{ automation_actions : "dann"
automation_conditions }o--|| actor_states : "vergleicht"
automation_actions }o--|| actor_commands : "schickt"
automation_actions ||--o{ automation_action_params : "mit"
```
**`homeMesh`** — Geräte und Automatiken (Schema: `homeMesh_DB-layout.sql`,
`homeMesh_automations.sql`).
| Tabelle | Inhalt | geschrieben von |
|---|---|---|
| `actors` | ein Gerät je Zeile, `url` bestimmt den Weg (`mqtt://`, `http://`, `wled://`, `io://`, `Logic`) | Discovery; `room` von Hand (Einstellungen → Geräte) |
| `actor_states` | Messwerte: `url` = Topic **oder** Feldname, `value_path` = Schlüssel darin, `current_value` | Discovery (Adressen), Runner (Werte) |
| `actor_commands`, `command_parameters` | was ein Gerät kann und welche Parameter dazugehören | Discovery |
| `automations`, `automation_conditions`, `automation_actions`, `automation_action_params` | das Regelwerk | Web-Editor |
| `automation_log` | was wann ausgelöst hat | Runner |
| `calendar_days` | Feiertage und Ferien | `fetch_calendar.py` (SolarManager) |
| `state_types` | Datentypen der Messwerte (Zahl, Text, Auswahl …) | fest |
**`solarLog`** — alles, was einen Verlauf hat.
| Tabelle | Inhalt | geschrieben von |
|---|---|---|
| `EnergyFlow`, `EnergyFlow_hourly` | Leistungen im Rohtakt und als Stundenarchiv | `solarManager.py` |
| `stats_daily` | Tageswerte für die Jahresstatistik | `solarManager.py` |
| `Heater`, `puffertemp`, `wasser`, `zisterne`, `windrad`, `kiga_windrad` | Heizung, Speicher, Wasser, Windrad | `solarManager.py` |
| `skoda`, `skoda_raw` | Fahrzeugzustand im Verlauf | `gatherSkodaData.py` |
| `skoda_ladepunkte` | Wallbox-Verlauf während einer Ladung, im Minutentakt, wird nicht ausgedünnt (`solarLog_skoda_ladepunkte.sql`) | `gatherSkodaData.py`; ältere Ladungen `skoda_ladepunkte_nachtragen.py` |
| `byd`, `byd_zellen` | BYD-Speicher direkt aus der BMU: Ladestand, SOH, Temperaturen, Spreizung, Zähler alle 5 Minuten; alle 128 Zellspannungen und 64 Temperaturen alle 15 Minuten (`solarLog_byd.sql`) | `gatherBYDData.py` |
| `weatherStation` | die eigene Wetterstation, alle 5 Minuten | die Station selbst (`/volume1/web/weatherStation.php`, außerhalb der Repos) |
| `weatherHours`, `weatherDays`, `weatherTilted`, `weatherForecastLog` | Wettervorhersage und Archiv; nichts wird gelöscht (`solarLog_weather.sql`, → [doku/meteogramm.md](doku/meteogramm.md)) | `gatherForecastData.py` (Open-Meteo), einmalig `wetterarchiv_nachtragen.py` |
| `daylight`, `simPower` | Sonnenzeiten (nur bis morgen), Ertragsprognose | `solarManager.py`; Solcast über `gatherSolar.php` |
| `gridCosts`, `gasCosts`, `fuelCosts` | Preiszeitreihen (Stichtag, kein Enddatum) | Einstellungsseite (`costs.php`) |
**`Logins`** — `users` (Passkeys) und `addUser` (Einmal-Links).
---
## Vier Bausteine im Detail
### 1. Grundriss und Home-Kacheln
Etagen, Räume, Kachelpositionen und Kachelwerte sind **Inhalt, kein Code**.
Sie liegen in `homeMesh.floors` und `homeMesh.rooms` (`homeMesh_grundriss.sql`)
und werden unter **Einstellungen → Grundriss** und **→ Home-Kacheln** gepflegt.
Bis September 2026 standen sie als Tabelle in `rooms.php`; für ein anderes
Haus hätte man den Quelltext umschreiben müssen.
| Tabelle | trägt |
|---|---|
| `floors` | `code` (fest: URL, SVG-Ids, `automations.floor`), `label`, Reihenfolge, Grundrissbild, Standard-Etage |
| `rooms` | feste Nummer `id`, Etage, Name, `kuerzel` (SVG-Ids), Kachel `x`/`y` (leer = keine Kachel), `thermostat` (MQTT-Zweig), `werte` (JSON, leer = Vorgabe) |
| `energiefluss` | Abweichungen der Solar-Übersicht vom Katalog: `aktiv`, `name`, `x`/`y`, `optionen` (JSON) — eigene Datei `homeMesh_energiefluss.sql`, keine Zeile = Katalog |
Auf die feste Raumnummer zeigen `actors.room_id` und das Raum-Modal
(`room.php?room=12`). Ein umbenannter Raum behält damit Geräte, Regler und
Kachel. `automations.floor` ist ein Fremdschlüssel auf `floors`: eine Etage mit
Automatiken lässt sich nicht löschen.
**Die Vorgabe einer Kachel** ist allein das Thermostat: mit `thermostat` Soll,
Ist und Feuchte aus diesem Zweig, sonst nur der Name. Derselbe Zweig trägt
Heizsymbol und Temperaturregler (`mqttZweig()` in `homeMQTT.js`,
`<zweig>/changeSetTemp` in `room.php`) — nichts setzt mehr voraus, dass ein
Thermostat unter `Raumtemp/<Etage>/<Raum>` meldet.
**Grundrissbilder** kommen per Upload nach `tiles/grundriss/` — das einzige
Verzeichnis, in das der Webserver schreiben darf, und nicht im Git. PNG, JPEG
oder WebP, geprüft am Inhalt; SVG bewusst nicht, es könnte Skript enthalten.
Der Name trägt einen Teil der Prüfsumme, ein neues Bild hat also eine neue
Adresse. Die mitgelieferten unter `assets/img` bleiben daneben gültig.
**Der Plan-Editor** (`js/solar/grundriss.js`, Modell `restricted/grundriss.php`)
löst `tools/kachelpositionen.php` ab: Kacheln werden gezogen, ein Raum ohne
Kachel mit „Platzieren“ und einem Klick gesetzt. Übernommen sind Raster,
Geisterkachel und die Umrechnung von der gezeigten Mitte zur gespeicherten
linken oberen Ecke. Jede Aktion antwortet mit dem ganzen neuen Stand. Nach
einer Änderung laden „Home-Kacheln“ und „Geräte“ beim nächsten Öffnen nach.
Ohne Etage mit platziertem Raum zeigt die Startseite statt der Zeichenfläche
den Weg zu den Einstellungen — der Anfang jeder neuen Installation.
```mermaid
flowchart LR
DB[("homeMesh.floors<br/>homeMesh.rooms")] --> GR["grundriss()<br/><small>rooms.php</small>"]
GR --> MIT["mitWerten()"]
MIT --> RWT["roomsWithTile()"]
RWT -->|"als JSON in die Seite"| HOMEJS["homeMQTT.js<br/><small>homeRooms</small>"]
RWT --> SVG["home.php<br/><small>SVG-Kacheln</small>"]
TT["tileTopics()"] -->|"abonniert genau diese"| BROKER{{Broker}}
BROKER --> HOMEJS
HOMEJS -->|"kachelWert()"| SVG
GRJS["grundriss.js<br/><small>Reiter Grundriss</small>"] --> SETA["ajax/settings.php"]
SETJS["settings.js<br/><small>Reiter Kacheln</small>"] --> SETA
SETA --> GRM["grundriss.php<br/><small>Etagen, Räume, Bilder</small>"] --> DB
SETA --> KACHM["kacheln.php<br/><small>Werte prüfen, speichern</small>"] --> DB
KACHM -->|"Auswahlliste"| KAT["deviceCatalog()<br/><small>automations.php</small>"]
```
Eine Wertdefinition ist ein kleines JSON-Objekt, überall in derselben Form:
```json
{ "topic": "Power_UG/status/em:0", "pfad": "total_act_power",
"einheit": "W", "stellen": 0, "negativ": true,
"icon": "box-arrow-up", "gross": true }
```
`wechselrichter` + `feld` statt `topic` summiert mehrere Wechselrichter;
`format: "leistung"` skaliert selbst zwischen W und kW (`"leistung_kw"`, wenn
die Quelle schon Kilowatt schickt); `negativ` dreht das
Vorzeichen (für einen Zähler, der verkehrt herum eingebaut ist); `icon` ist
der Name **irgendeines** Bootstrap-Icons — die Codepunkte liest
`bootstrapIcons()` aus `css/bootstrap-icons.min.css`, gezeichnet wird als
SVG-Text in der Icon-Schrift.
### 2. Das Raum-Modal
```mermaid
sequenceDiagram
participant B as Browser
participant R as ajax/room.php
participant RC as roomControls.php
participant C as commands.php
participant G as Gerät
B->>R: GET ?room=12 (rooms.id)
R->>RC: Geräte des Raums (actors.room_id) → Bedienformen
RC-->>B: fertiges Markup (Rollladen, Licht, Messwerte …)
B->>R: POST ?action=command {command_id, params}
R->>C: executeCommand()
alt mqtt://
C-->>G: Nutzlast auf das Topic
else wled://
C-->>G: JSON an /json/state
else http://
C-->>G: Abfrageargumente an die Geräte-URL
else Tahoma
C-->>G: exec/apply an die Box
end
```
`roomControls.php` entscheidet **an den Fähigkeiten**, nicht am Typnamen: Hat
das Gerät ein `setClosure`, wird es ein Rollladen mit Auf/Stop/Zu und
Höhenstufen, mit `my` kommt der my-Knopf dazu, mit Neigung die Lamellenstufen;
hat es drei Parameter rot/grün/blau, wird es eine Lampe mit Farbfeldern. Ein neu gefundenes Gerät bekommt so ohne Codeänderung ein
passendes Bedienelement.
`commands.php` ist das Gegenstück zu `transports.py` im SolarManager-Repo —
dieselbe Zuordnung „welche URL gehört zu welchem Weg“, einmal für den Runner,
einmal für den Browser.
### 3. Die Automatiken
Editor (`autoActionFuncs.js` + `AutoAction.php` + `automations.php`) schreibt
das Regelwerk nach `homeMesh`; ausgeführt wird es von
`autoActions/autoaction_runner.py` im anderen Repo. **Die Weboberfläche
schaltet nichts von sich aus** — sie beschreibt nur, was gelten soll.
**Drei Bänder statt eines Akkordeons: Wenn · Wann · Dann.** Das Akkordeon
schloss beim Öffnen die anderen Fächer — Auslöser und Aktion waren also nie
gleichzeitig zu sehen, ausgerechnet bei einer Regel, die genau daraus
besteht. Und das mittlere Fach hieß „Bedingungen", enthielt aber die
*Rahmen*bedingungen. Gebaut aus `.card` mit farbigem Rand und `.card-header`,
kein eigenes Bauteil.
**Der Rahmen steht zusammengefaltet da**, solange nichts von der Vorgabe
abweicht — und das ist der Normalfall. `rahmenKurzText()` schreibt ihn in
eine Zeile („täglich · rund um die Uhr"), `rahmenIstVorgabe()` entscheidet,
ob sie matt oder hell ist. Vorher nahm er die meiste Fläche für den
seltensten Inhalt.
**Neben jeder Bedingung läuft ihr aktueller Wert mit.** `?action=werte`
liefert alle acht Sekunden `{state_id: {value, unit}}` aus `actor_states` —
bewusst aus der Tabelle und nicht über MQTT, weil dort nur ein Teil der
Geräte auftaucht (die Jalousien hängen an der Tahoma-Box, die gerechneten
Werte an gar nichts); der Runner schreibt dagegen jeden Messwert zurück.
Das ersetzt den Satz, der früher am Ende stand und die Regel Wort für Wort
nacherzählte: statt zu wiederholen, was darüber steht, beantwortet die Zeile
die Frage, die man wirklich hat — warum läuft die Automatik gerade nicht?
Ein voller Punkt heißt erfüllt, ein leerer noch nicht, ein **gestrichelter
heißt „entscheidet der Runner"**. Bei `time`, `date`, `datetime`,
`deltatime` und `elapsed` fällt der Browser bewusst kein Urteil: ob
„Uhrzeit um 07:30" gerade zutrifft, hängt am Nachholfenster, am Tagesrand
und beim Datentyp `elapsed` am echten Abstand seit der letzten Auslösung —
das alles steht in `bedingung_erfuellt()` im Runner. Es hier nachzubauen
hieße, eine zweite Wahrheit zu pflegen, die irgendwann ausei­nanderläuft.
Lieber keine Aussage als eine, die manchmal falsch ist (`ZEITARTEN`).
Bedingungen mit derselben `group_no` sind mit UND verknüpft, verschiedene
Gruppen mit ODER: ausgewertet wird `any(all(gruppe))`. Eine Bedingung zeigt
auf einen `actor_states`-Eintrag, eine Aktion auf ein `actor_commands` samt
Parametern. Deshalb dürfen Zustands-Ids nicht wandern — siehe die
Geräte-Erkennung im nächsten Abschnitt.
**Eine Automatik kann eine andere auslösen.** Dafür steht im Editor auffallend
wenig Code: das gerechnete Gerät „Automatiken" führt jede Automatik als
Messwert, ihr Wert ist der Zeitpunkt der letzten Auslösung — für den
Gerätewähler ist es damit ein Gerät wie jedes andere. Die Messwerte legt der
Runner an und pflegt sie, `restricted/automations.php` liest sie nur
(`ausloeserStates()`, `automationTriggers()`).
Drei Stellen wissen trotzdem davon:
* `pruefeKreis()` lehnt beim Speichern ab, was sich mittelbar selbst auslösen
würde. Im Betrieb wäre ein Kreis kaum zu bemerken — die Sperrzeit begrenzt
ihn auf eine Auslösung je `lockout_secs`, und im Protokoll sieht das aus wie
eine Automatik, die halt oft läuft.
* `deleteAutomation()` fragt vorher. `fk_cond_state` steht auf
`ON DELETE CASCADE`, mit dem Messwert verschwände also still die Bedingung
des Nachfolgers: aus *zehn Minuten nach dem Wecker UND es ist hell* würde
ein bloßes *es ist hell*. Zur Wahl stehen Abhängen (Bedingung raus,
Nachfolger pausiert) und Mitlöschen.
* Die Übersicht rückt Nachfolger unter ihren Auslöser ein und zeigt sie
eingeklappt (`toggleAutomationGroup()`). Das ist **nur die Darstellung** —
jeder Nachfolger bleibt eine vollwertige Automatik mit eigener Pause,
eigenen Rahmenbedingungen und eigener Zeile im Protokoll. Sobald
„gruppiert" auch „gehört dazu" hieße, wäre zu klären, wem die Pause gehört.
**Die Tagesauswahl in vier Stufen, von grob nach fein.** Ganz oben drei
Vorlagen (`tagVorlagen()`): *täglich*, *werktags*, *Wochenende*. Sie setzen den
**ganzen** Rahmen, nicht nur die Wochentagsmaske — „werktags" heißt auch
„nicht an Feiertagen", „Wochenende" auch „dazu alle Feiertage". Wer das aus
Maske und zwei Schaltern von Hand zusammensetzt, vergisst den zweiten Teil.
Darunter sieben Wochentagsschalter, darunter Ferien und Feiertage mit je drei
Stufen — **nie · egal · immer** (`kalendertagChoices()`) —, und ganz unten ein
Satz, der zurückliest, was dabei herausgekommen ist: *„Läuft samstags und
sonntags, dazu an allen Feiertagen."*
Der Satz ist der eigentliche Gewinn: man muss das Bedienelement nicht
entziffern. `rahmenText()` in `autoActionFuncs.js` baut ihn, zieht drei und
mehr aufeinanderfolgende Tage zu „montags bis freitags" zusammen und gibt bei
gar keinem wählbaren Tag `""` zurück — dann steht dort die Warnung *„Kein Tag
ausgewählt"*, genau wie beim fehlenden Auslöser.
„Immer" zählt wie ein angehakter Wochentag. Das ist die einzige Art, *an
Wochenenden und Feiertagen* zu schreiben — für die Maske ist ein Feiertag am
Dienstag eben ein Dienstag —, und mit **gar keinem** Wochentag angehakt ergibt
es *nur an Feiertagen*.
Vorher standen dort zwei Auswahlfelder, deren Einträge ganze Sätze waren
(„zusätzlich, auch am falschen Wochentag"). Ein Auswahlfeld zeigt immer nur
den gewählten Eintrag — man sieht nie, was es sonst noch gibt, und die
Erklärung steht an der Stelle, wo ein Etikett hingehört.
`kalendertagWert()` prüft beim Speichern mit `is_numeric`, weil `intval("")`
sonst 0 wäre — und 0 heißt „nie": eine leere Angabe hätte eine Automatik
stillschweigend an Feiertagen abgeschaltet.
**„Nur einmal am Tag"** (`once_per_day`) sperrt eine Automatik nach dem
Auslösen bis Mitternacht. Für alles, was man hinterher von Hand wieder anders
stellt: ein Rollladen, den man um acht zugezogen hat, soll nicht um neun von
selbst wieder auffahren, nur weil eine Wolke weiterzieht.
**„Am Vorabend"** (`next_day`) lässt Wochentage, Ferien und Feiertage für
**morgen** gelten. Für alles, was abends für den nächsten Tag geschieht:
*Kinderrollos zu, wenn morgen Schule ist* heißt dann Mo–Fr, Ferien nie,
Feiertage nie — dieselben Tage wie der Wecker. `rahmenText()`,
`rahmenKurzText()` und die Spalte „Wann" der Übersicht nennen den Vorabend
vorne, damit auch „nicht an Feiertagen" als Folgetag gelesen wird. Uhrzeit
und Zeitfenster bleiben beim heutigen Tag.
Wie der Runner das auswertet — Datentyp `elapsed`, topologische Reihenfolge,
der Rahmen, was beim Pausieren geschieht — steht in
[`autoActions/README.md`](../../homes/wagner/SolarManager/autoActions/README.md)
im SolarManager-Repo.
### 4. Die Geräte-Erkennung
`restricted/deviceDiscovery/device_discovery.py` wird **von Hand** gestartet.
Die Module suchen je eine Geräteart (Tahoma, WLED, Shelly, MQTT/Home-Assistant-
Discovery, Gartenwasser, SolarManager-Werte, Wetterstation, Logic); die
Datenbanklogik liegt allein im Hauptskript.
Drei Eigenschaften, die man kennen muss:
* **Ein Suchlauf löscht nichts.** `clear_tables` steht in `config.ini` auf
`false`, weil die Automatiken auf die Ids von `actor_states` zeigen.
Bestehende Zeilen werden über den Schlüssel `(actor_id, state_name)`
aktualisiert, neue kommen dazu. Abgebaute Geräte verschwinden nur über
**Einstellungen → Geräte → Papierkorb** (und auch dort nur, wenn keine
Automatik mehr auf sie zeigt).
* **Shelly, zwei Wege — und zwar je Komponente.** Alte Shellys können kein
MQTT und bleiben ganz beim HTTP-Weg: in `actor_states.url` steht ein
Feldname der JSON-Antwort. Bei Gen2-Geräten mit eingeschaltetem `status_ntf`
steht dagegen das Topic in `url` (`Power_EG/status/em:0`) und der Schlüssel
in `value_path`.
Das gilt aber nur für Komponenten, die auch wirklich senden — die Liste
steht als `MQTT_KOMPONENTEN` in `modules/shelly_module.py` und enthält
heute `em` und `switch`. Der Pro 3EM etwa hat eine Temperaturkomponente im
RPC-Status, veröffentlicht sie aber nie; ein Messwert auf diesem Topic
bliebe für immer auf seinem letzten Wert stehen. Alles außerhalb der Liste
wird deshalb weiter abgefragt. Wer eine Komponente ergänzen will, hört
vorher mit (`mosquitto_sub -t '<präfix>/#' -v`).
Geschaltet werden auch die neuen Geräte über HTTP. Der Runner teilt seine
Messwerte deshalb je Wert zu, nicht je Gerät.
* **Vier Module suchen gar nichts.** Gartenwasser, SolarManager, Wetterstation
und Logic schreiben feste Geräte hin, weil sich diese Quellen nicht von
selbst melden. Wer einen Messwert vermisst, trägt im jeweiligen Modul eine
Zeile ein — bei der Wetterstation in `MESSWERTE`, beim SolarManager in
`GERAETE`. Bewusst knapp gehalten: `solarManager/#` allein trägt gut 130
Topics, und eine vollständige Liste machte die Auswahl im Automatik-Editor
unbrauchbar.
Die **Wetterstation** hängt am Websocket von `192.168.179.42`;
`wsMQTTbridge.py` im SolarManager legt ihre Felder unter `weatherStation/#`
ab. Von dort kommen Außentemperatur, Wind und Böe — die Außentemperatur ist
für den Hitzeschutz die bessere Bedingung als die Raumtemperatur (ist es
drinnen schon warm, ist es zum Verschatten zu spät), und ohne den Wind gibt
es für Markise und Sonnensegel keinen Sturmschutz. Die trägen Werte
(Temperatur, Feuchte, Druck, Taupunkt) gehen nur bei jeder einundzwanzigsten
Nachricht raus; sie sind retained, für eine Bedingung macht das keinen
Unterschied.
---
## Wiederkehrende Bauteile und Konventionen
**Zwei Radien, eine Anzeigeschrift.** `solar.css` tauscht die Palette über
Variablen aus, statt Regel für Regel zu überschreiben — dasselbe gilt für
Form und Schrift. `--ton-radius` (`.875rem`) gilt für Flächen, `--ton-radius-klein`
(`.5rem`) für alles, was in einer Reihe steht; beide hängen an
`--bs-border-radius` und `--bs-border-radius-sm`, also zieht jede Karte,
jedes Modal und jeder kleine Knopf von allein mit. `--font-anzeige` ist
Poppins — die Schrift lag seit jeher unter `assets/fonts` und trug nur die
vier Etagenknöpfe im Außenplan; jetzt trägt sie alle Überschriften,
`.card-title` und `.modal-title`. Fließtext und Zahlenkolonnen bleiben
bewusst bei der Schrift des Themes: Poppins bringt keine Tabellenziffern
mit, Messwerte würden sonst springen.
**Drei Klassen aus dem Automatik-Editor stehen absichtlich in `solar.css`
und nicht dort.** `.feld-als-text` ist ein Auswahlfeld, das wie Text
aussieht, bis man es anfährt — dieselbe Not haben das Raum-Modal und die
Kachel-Einstellungen, wo ebenfalls jede Kleinigkeit in einem Kasten steht.
`.punkt` ist an oder aus. `.wert-jetzt` ist die eine echte Ausnahme —
Bedingungen gibt es sonst nirgends — und als einzelne Klasse auch als solche
erkennbar.
**Reiter.** Es gibt genau eine Bauart, `restricted/reiter.php`. Umgeschaltet
wird von Bootstrap (`data-bs-toggle="tab"`), gemerkt wird der offene Reiter von
`reiterMerken()` in `common.js`. Weniger als zwei Reiter ergeben keine Leiste.
**Stylesheet-Reihenfolge — die häufigste Falle.** `header.php` lädt
`css/solar.css` **vor** `css/adminlte.min.css`. Eine eigene Regel mit gleicher
Spezifität wie eine AdminLTE-Regel verliert deshalb. Wenn eine Änderung
„nicht wirkt“, ist das fast immer der Grund: Selektor verlängern
(`.nav-tabs.reiter .nav-link` statt `.reiter .nav-link`), nicht `!important`.
**Zwischenspeicher.** Jedes eigene Skript und `solar.css` werden mit
`?v=<filemtime>` eingebunden — gespeichert heißt geladen.
**Sprache.** Bezeichner und Kommentare sind deutsch, wo es um die Sache geht;
englisch bleibt, was von außen vorgegeben ist (`floor`, `topic`, Chart.js-
Optionen, Spaltennamen). Kommentare erklären **warum**, nicht was.
**Fehlertoleranz vor Vollständigkeit.** Was eine Seite nicht zwingend braucht,
wird in `try/catch` geholt: fehlt die Mesh-Datenbank oder der Grundriss, zeigt
die Startseite einen Hinweis statt eines Fatal Errors.
**Zugriffsschutz.** `.htaccess` sperrt Punkt-Dateien (allen voran `.git`) und
alle Dateitypen, die der Browser nie anfordert (`.py`, `.sql`, `.ini`, `.md`
…). `restricted/mysql.php` steht in `.gitignore`; Vorlage ist
`mysql.php.example`. Zugangsdaten gehören nirgendwo sonst hin.
**Zeilenenden.** `.gitattributes` erzwingt LF — der Ordner wird von Linux
ausgeliefert.
---
## Wo fange ich an, wenn ich … ändern will
| Vorhaben | Anlaufstelle |
|---|---|
| Ein Raum oder eine Etage kommt dazu, heißt anders, bekommt ein neues Grundrissbild | Einstellungen → Grundriss (keine Codeänderung) — Kachel, Menü und MQTT-Abo ergeben sich daraus |
| Andere Werte auf einer Kachel | Einstellungen → Home-Kacheln (keine Codeänderung). Vorgabe ohne eigene Werte: `kachelVorgabe()` in `rooms.php` |
| Ein anderes Haus einrichten | `homeMesh_DB-layout.sql`, `homeMesh_automations.sql`, `homeMesh_grundriss.sql`, `homeMesh_energiefluss.sql` in dieser Reihenfolge; dann Einstellungen → Grundriss und → Übersicht |
| Kreis der Energiefluss-Übersicht aus, umbenennen, verschieben; PV-Flächen | Einstellungen → Übersicht (keine Codeänderung) |
| Neuer Kreis in der Energiefluss-Übersicht (Gerät, Etage, Quelle) | ein Eintrag in `js/solar/energieflussKatalog.js`; neues Symbol dazu in `SYMBOLE` (`energiefluss.js`) |
| Auswahlliste irgendwo | ein gewöhnliches `<select>` schreiben — `js/solar/auswahl.js` macht daraus die eigene Liste. Keine zweite Variante bauen |
| Neues Symbol auf einer Kachel | Name eines Bootstrap-Icons ins Feld tippen. Vorschlagsliste: `kachelSymbole()` in `kacheln.php` |
| Symbol für eine Gerätegruppe (Raum-Modal, Zeitleiste, Editor) | ein Bootstrap-Icon in `raumGruppenTitel()` (`roomControls.php`), `zeitleisteBahnen()` (`zeitleiste.php`) und `GERAETE_SYMBOL` (`autoActionFuncs.js`) — überall dasselbe. Fehlt es in der Icon-Schrift, eine eigene Maske in `css/solar.css` anlegen (Vorbild: `bi-beschattung`) |
| Neue Kennzahl in der Jahresstatistik | `KENNZAHLEN` **und** `kennzahlenAus()` in `ajax/getStats.php` — sonst nichts |
| Neues Diagramm auf einer Seite | Karte in der Vorlage, `<canvas>`, dazu ein Endpunkt nach dem Muster von `getProdData.php`, geladen mit `getData()` |
| Neue Seite | Zeile in `$pages` (`index.php`), Vorlage unter `restricted/`, Skript unter `js/solar/` |
| Anderes Bedienelement für ein Gerät | `bedienform()` in `roomControls.php` (nach Fähigkeit, nicht nach Typname) |
| Neue Geräteart erkennen | neues Modul unter `restricted/deviceDiscovery/modules/` + Eintrag in der Liste in `device_discovery.py` |
| Neuer Geräteweg zum Schalten | `commands.php` (Browser) **und** `transports.py` im SolarManager-Repo (Runner) |
| Aussehen der ganzen Seite | `css/solar.css`, oberster Abschnitt: die `--ton-*`-Tokens und die Bootstrap-Zuordnung |
| Reiter irgendwo | `reiterLeiste()`/`reiterBlock()` aus `restricted/reiter.php` benutzen, keine eigene Variante bauen. Ausnahme: das Raum-Modal hat Reiter-Kacheln mit Zustand (`zeichneGruppen()`), passend zu den anderen Bedienfenstern |
| Neue Einstellung | Reiter in `$reiter` (`restricted/settings.php`), Abschnitt in `settings.js`, Aktion in `ajax/settings.php`, Modellfunktion daneben |
---
## Entwickeln und Prüfen
Der Ordner ist die laufende Anwendung — es gibt keinen Build und keine
Staging-Stufe. Entsprechend gilt:
```bash
# PHP-Syntax (auf der NAS; /usr/bin/php ist 8.1 ohne mysqli)
/usr/local/bin/php82 -l restricted/kacheln.php
# JavaScript-Syntax
/var/packages/Node.js_v20/target/usr/local/bin/node --check js/solar/settings.js
# Modellfunktion einzeln ausprobieren, ohne Browser
/usr/local/bin/php82 -r 'require "restricted/automations.php"; print_r(geraeteListe()[0]);'
# Datenbank ansehen (readDB, nur SELECT)
/usr/local/mariadb10/bin/mysql -h 127.0.0.1 -e 'SELECT * FROM homeMesh.actors LIMIT 5;'
```
Für Masken lohnt eine **Probeseite**: eine kleine HTML-Datei, die `solar.css`
und das Skript lädt und `window.fetch` durch eine Attrappe mit echten Daten
ersetzt. Damit lässt sich das Zeichnen prüfen, ohne angemeldet zu sein und
ohne etwas zu speichern.
**Die Einführung** (Menü → Einführung) sind Folien für Einsteiger in zwei
Teilen: Bedienen im Alltag, Einrichten und Verwalten. Die Texte stehen in
`einfuehrungFolien()` in `restricted/einfuehrung.php`, die Bilder sind echte
Aufnahmen unter `assets/img/einfuehrung`. Ändert sich die Oberfläche, nimmt
`tools/einfuehrung/aufnehmen.ps1` sie auf dem Arbeitsrechner neu auf — Edge
ohne Fenster über das DevTools-Protokoll, mit echten Wartezeiten für die
Livewerte, Klicks und Scrollen laut `plan.json`:
```powershell
powershell -ExecutionPolicy Bypass -File tools\einfuehrung\aufnehmen.ps1 -Plan tools\einfuehrung\plan.json -Ausgabe assets\img\einfuehrung
```
Zu jedem Bild schreibt es eine `.json` mit der Lage der markierten Elemente;
daraus setzt die Seite ihre nummerierten Punkte, sie wandern also mit. Ort und
Koordinaten im Wetterkopf sowie die Endungen der Fahrzeugschlüssel deckt der
Plan vor der Aufnahme ab. Die Seite muss dafür im Heimnetz erreichbar sein.
Zwei Repositories, beide auf Gitea (`gitea.nas.el-wa.org`):
| Repo | Ort | Inhalt |
|---|---|---|
| `admin/Smart-Dashboard` | `/volume1/web/smart` | dieses hier |
| `admin/SolarManager` | `/volume1/homes/wagner/SolarManager` | Sammler, Brücken, Automatik-Runner |
Rekursive Suchen (`grep -r`) laufen über die SMB-Freigabe in Zeitüberschreitungen —
auf der NAS selbst sind sie sofort fertig.
+15 -15
View File
@@ -1,16 +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;
}
<?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;
}
?>
+342 -154
View File
@@ -9,11 +9,11 @@
* 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"
* GET ?action=rooms Zuordnung Geraet -> Raum zum Bearbeiten
* POST ?action=save JSON-Rumpf, legt an oder ueberschreibt
* POST ?action=delete {"id": N}
* 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
* POST ?action=saveRooms {"<actor_id>": "OG/Bad", ...}
*/
require_once("../helper.php");
@@ -33,7 +33,7 @@ function jsonAntwort($data, $code = 200)
}
$action = $_GET["action"] ?? "editor";
$floor = in_array($_GET["floor"] ?? "", ["UG", "EG", "OG"], true) ? $_GET["floor"] : "";
$floor = in_array($_GET["floor"] ?? "", $floors, true) ? $_GET["floor"] : "";
// --- Schreibende Aufrufe -------------------------------------------------
if ($_SERVER["REQUEST_METHOD"] === "POST") {
@@ -47,12 +47,15 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
$id = saveAutomation($body);
jsonAntwort(["id" => $id]);
case "delete":
deleteAutomation(intval($body["id"] ?? 0));
// 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))]);
case "saveRooms":
jsonAntwort(["gespeichert" => saveRooms($body["rooms"] ?? [])]);
default:
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
}
@@ -63,6 +66,34 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
}
}
// --- 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"];
@@ -78,7 +109,77 @@ if ($action === "list") {
. "<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 = [];
@@ -87,16 +188,52 @@ if ($action === "list") {
$aktiveTage[] = $tage[$i];
}
}
$wann = count($aktiveTage) === 7 ? "täglich" : implode(" ", $aktiveTage);
// 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"]) {
if ($a["on_holiday"] == 0) {
$wann .= ", nicht an Feiertagen";
}
if (!$a["on_vacation"]) {
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
@@ -105,13 +242,43 @@ if ($action === "list") {
}
$zuletzt = $a["last_run"] ? date("d.m. H:i", strtotime($a["last_run"])) : "–";
$klasse = $a["enabled"] ? "" : " class='opacity-50'";
$pause = $a["enabled"] ? "bi-pause-fill" : "bi-play-fill";
$titel = $a["enabled"] ? "Pausieren" : "Fortsetzen";
$id = intval($a["id"]);
echo "<tr" . $klasse . ">";
echo "<td>" . htmlspecialchars($a["name"]) . "</td>";
$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>";
@@ -130,73 +297,6 @@ if ($action === "list") {
exit;
}
// --- Geraete den Raeumen zuordnen ---------------------------------------
if ($action === "rooms") {
$labels = geraeteBeschriftungen();
$raeume = geraeteRaeume();
$vorschlag = raumVorschlaege();
$auswahl = raumListe();
$res = meshDb()->query("SELECT id, type FROM actors");
$geraete = [];
while ($row = $res->fetch_assoc()) {
$id = intval($row["id"]);
$geraete[] = [
"id" => $id,
"label" => $labels[$id] ?? "?",
"type" => $row["type"],
"room" => $raeume[$id] ?? null,
"tipp" => $vorschlag[$id] ?? null,
];
}
// Unzugeordnete nach oben: das ist die Arbeit, die noch zu tun ist.
usort($geraete, function ($a, $b) {
$offen = (int)!empty($b["room"]) - (int)!empty($a["room"]);
return $offen !== 0 ? $offen : strcoll($a["label"], $b["label"]);
});
$offen = count(array_filter($geraete, function ($g) { return empty($g["room"]); }));
$tipps = count(array_filter($geraete, function ($g) {
return empty($g["room"]) && !empty($g["tipp"]);
}));
echo "<div class='p-2'>";
echo "<p class='small text-body-secondary'>Die Zuordnung ordnet die Geräteliste im Editor "
. "nach Räumen statt nach Geräteart. Sie wird von Hand vergeben und bleibt bei "
. "einem neuen Geräte-Suchlauf erhalten.</p>";
if ($tipps) {
echo "<div class='alert alert-secondary py-2 small d-flex justify-content-between align-items-center'>"
. "<span>" . $tipps . " Gerät(e) verraten ihren Raum im MQTT-Topic.</span>"
. "<button type='button' class='btn btn-outline-secondary btn-sm' id='btnRoomTipps'>"
. "Vorschläge übernehmen</button></div>";
}
echo "<div class='table-responsive' style='max-height:60vh;overflow-y:auto'>";
echo "<table class='table table-sm table-hover align-middle'><thead><tr>"
. "<th>Gerät</th><th class='d-none d-sm-table-cell'>Art</th><th>Raum</th>"
. "</tr></thead><tbody>";
foreach ($geraete as $g) {
echo "<tr" . (empty($g["room"]) ? " class='table-active'" : "") . ">";
echo "<td>" . htmlspecialchars($g["label"]) . "</td>";
echo "<td class='d-none d-sm-table-cell small text-body-secondary'>"
. htmlspecialchars($g["type"]) . "</td>";
echo "<td><select class='form-select form-select-sm' data-actor='" . $g["id"] . "'"
. ($g["tipp"] ? " data-tipp='" . htmlspecialchars($g["tipp"], ENT_QUOTES) . "'" : "")
. ">";
echo "<option value=''" . (empty($g["room"]) ? " selected" : "") . ">–</option>";
foreach ($auswahl as $raum) {
echo "<option value='" . htmlspecialchars($raum["key"], ENT_QUOTES) . "'"
. ($g["room"] === $raum["key"] ? " selected" : "") . ">"
. htmlspecialchars($raum["label"]) . "</option>";
}
echo "</select></td></tr>";
}
echo "</tbody></table></div>";
echo "<p class='small text-body-secondary mb-0'>" . $offen . " von " . count($geraete)
. " Geräten noch ohne Raum.</p>";
echo "</div>";
exit;
}
// --- Editor --------------------------------------------------------------
$id = intval($_GET["id"] ?? 0);
$auto = $id > 0 ? loadAutomation($id) : null;
@@ -212,6 +312,9 @@ $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(),
@@ -234,99 +337,184 @@ $nutzdaten = json_encode([
die Reiter, damit der Rueckfall auf den ersten Eintrag dieselbe
Etage trifft wie die Startseite. -->
<select class="form-select" id="autoFloor">
<option value="OG">OG</option>
<option value="EG">EG</option>
<option value="UG">UG</option>
<?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>
<div class="accordion" id="actionAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseOne" aria-expanded="true">
<i class="bi bi-lightning-fill"></i>&nbsp;&nbsp;Auslöser
</button>
</h2>
<div id="collapseOne" class="accordion-collapse collapse show" data-bs-parent="#actionAccordion">
<div class="accordion-body">
<!-- 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> Alternative (oder)
</button>
<div class="alert alert-secondary mt-3 mb-0 py-2 small" id="condSummary"></div>
</div>
</div>
</div>
<!--
Drei Baender statt eines Akkordeons: Wenn - Wann - Dann.
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseTwo" aria-expanded="false">
<i class="bi bi-calendar-check-fill"></i>&nbsp;&nbsp;Bedingungen
</button>
</h2>
<div id="collapseTwo" class="accordion-collapse collapse" data-bs-parent="#actionAccordion">
<div class="accordion-body">
<div class="input-group mb-3">
<span class="input-group-text">Aktiver Zeitraum</span>
<div class="form-floating">
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">
<label for="tSpanFrom">Von</label>
</div>
<div class="form-floating">
<span class="input-group-text">bis</span>
<input type="time" class="form-control" id="tSpanTo" value="23:59">
<label for="tSpanTo">Bis</label>
</div>
</div>
Aktive Wochentage:
<div class="input-group mb-3" id="weekdayGroup"></div>
<div class="input-group mb-3">
<div class="input-group-text">
<input class="form-check-input mt-0 me-2" type="checkbox" id="actFerien">
<label class="form-check-label" for="actFerien">In den Ferien ausführen</label>
</div>
<div class="input-group-text">
<input class="form-check-input mt-0 me-2" type="checkbox" id="actFeier">
<label class="form-check-label" for="actFeier">An Feiertagen ausführen</label>
<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>
<div class="input-group mb-3">
<span class="input-group-text">Sperrzeit nach dem Auslösen</span>
<select class="form-select" id="lockoutSecs"></select>
</div>
<div class="input-group mb-0">
<div class="input-group-text">
<input class="form-check-input mt-0 me-2" 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>
<!--
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>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseThree" aria-expanded="false">
<i class="bi bi-play-fill"></i>&nbsp;&nbsp;Aktionen
</button>
</h2>
<div id="collapseThree" class="accordion-collapse collapse" data-bs-parent="#actionAccordion">
<div class="accordion-body">
<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> Aktion
</button>
<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 -73
View File
@@ -1,73 +1,12 @@
<?php
require_once("../helper.php");
if (checkLogin()) {
chdir("/volume1/homes/wagner/");
putenv('PYTHONPATH="/var/services/homes/wagner/.local/lib/python3.8/site-packages/');
$close = 0;
if (isset($_POST["evStart/Stop"])) {
if ($_POST["evStart/Stop"] == "Laden starten") {
$close = 1;
exec("python wattpilot.py -start 2>&1", $output, $return_var);
} elseif ($_POST["evStart/Stop"] == "Laden stoppen") {
$close = 1;
exec("python wattpilot.py -stop 2>&1", $output, $return_var);
}
$close = 1;
} else if (isset($_POST["evMode"])) {
// Eingaben pruefen, bevor sie in die Kommandozeile gehen. ftt kam bisher
// ungeprueft aus dem Formular und liess sich mit "12:00; befehl" zum
// Anhaengen beliebiger Shell-Befehle missbrauchen.
$fte = intval($_POST["fte"]);
if ($fte > 100)
$fte = 100;
else if ($fte < 1)
$fte = 1;
$fte = $fte * 140;
$evAmp = intval($_POST["evAmp"]);
if ($evAmp > 16)
$evAmp = 16;
if ($evAmp < 6)
$evAmp = 6;
// Das Formularfeld ist ein <input type="time">, liefert also HH:MM.
// Alles andere gilt als "keine Ladeplanung" und faellt in den else-Zweig.
$ftt = "";
if (isset($_POST["ftt"]) && preg_match('/^([01][0-9]|2[0-3]):[0-5][0-9]$/', $_POST["ftt"])) {
$ftt = $_POST["ftt"];
}
$setTime = gmdate("Y-m-d\TH:i:s", time()) . ".000";
if ($_POST["evMode"] == "eco") {
$close = 1;
if ($ftt !== "" && $fte > 0) {
exec("python wattpilot.py -eco -time " . escapeshellarg($ftt) . " -energy " . $fte . " -maxCurr " . $evAmp . " -setTime " . escapeshellarg($setTime) . " 2>&1", $output, $return_var);
} else {
exec("/bin/python wattpilot.py -e 2>&1", $output, $return_var);
}
} else if ($_POST["evMode"] == "default") {
$close = 1;
if ($ftt !== "" && $fte > 0) {
exec("python wattpilot.py -default -time " . escapeshellarg($ftt) . " -energy " . $fte . " -maxCurr " . $evAmp . " -setTime " . escapeshellarg($setTime) . " 2>&1", $output, $return_var);
} else {
exec("python wattpilot.py -d 2>&1", $output, $return_var);
}
} else if ($_POST["evMode"] == "NextTrip") {
$close = 1;
if ($ftt !== "" && $fte > 0) {
exec("python wattpilot.py -trip -time " . escapeshellarg($ftt) . " -energy " . $fte . " -maxCurr " . $evAmp . " -setTime " . escapeshellarg($setTime) . " 2>&1", $output, $return_var);
} else {
exec("python wattpilot.py -t 2>&1", $output, $return_var);
}
}
$close = 1;
}
} else {
$close = 1;
}
if (!$close) {
$formId = "carEG_form";
include __DIR__ . "/carForm.php";
}
<?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";
+94 -76
View File
@@ -1,81 +1,99 @@
<?php
/*
* Formular des Auto-Lademodals. carEG.php und carOG.php zeigen dasselbe
* Formular; nur die Ansteuerung der Wallbox dahinter unterscheidet sich.
* $formId trennt die beiden Formulare fuer das JavaScript.
* 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="d-grid gap-2 col-6 mx-auto my-4">
<button class="btn btn-primary" id="evStart/Stop">Laden starten</button>
<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>
<!--begin::Form-->
<form id="<?= $formId ?>">
<div class="card m-3">
<div class="card-header py-1 px-3">
<div class="card-title">Modus</div>
</div>
<!--begin::Body-->
<div class="card-body">
<div class="col-sm-10">
<div class="form-check">
<input class="form-check-input" type="radio" name="evMode" id="EV_Default" value="default">
<label class="form-check-label" for="EV_Default"> Immer Laden </label></input>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="evMode" id="EV_Eco" value="eco">
<label class="form-check-label" for="EV_Eco"> Überschuss </label></input>
</div>
<div class="form-check disabled">
<input class="form-check-input" type="radio" name="evMode" id="EV_NextTrip" value="NextTrip">
<label class="form-check-label" for="EV_NextTrip"> Ladeplanung </label></input>
</div>
</div>
</div>
</div>
<div class="card m-3">
<div class="card-header py-1 px-3">
<div class="card-title">Max. Ladestrom</div>
</div>
<div class="card-body">
<div class="col-sm-10">
<div class="form-check">
<input class="form-check-input" type="radio" name="evAmp" id="EV_6A" value="6">
<label class="form-check-label" for="EV_6A"> 6A </label></input>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="evAmp" id="EV_10A" value="10">
<label class="form-check-label" for="EV_10A"> 10A </label></input>
</div>
<div class="form-check disabled">
<input class="form-check-input" type="radio" name="evAmp" id="EV_16A" value="16">
<label class="form-check-label" for="EV_16A"> 16A </label></input>
</div>
</div>
</div>
</div>
<div class="card m-3">
<div class="card-header py-1 px-3">
<div class="card-title">Ladeplanung</div>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="form-check">
<label class="form-label" for="addedCharge">Zusätzlich benötigte Ladung (%): </label>
<div style="font-size: 15px;color: #297195;display: inline-block;" id="modal-slider-label">sdf</div>
<input type="range" id="modal-slider" name="fte" class="form-range range-color-track" min="1" max="100" />
<div class="d-flex justify-content-between">
<span class="max-amount f-16 font-weight-normal darkGray-color text-right mt-1">1%</span>
<span class="max-amount f-16 font-weight-normal darkGray-color text-right mt-1">100%</span>
</div>
<!--<input name="fte" class="form-control" type="number" max="100" min="1" value="" />-->
</div>
</div>
<div class="row mb-3">
<div class="form-check">
<label class="form-label" for="planedTime">Zeitpunkt zudem geladen sein soll: </label>
<input id="EV_NextTripTime" name="ftt" class="form-control" size="2" type="time" value=""/>
</div>
</div>
</div>
</div>
</form>
+12 -58
View File
@@ -1,58 +1,12 @@
<?php
require_once("../helper.php");
if (checkLogin()) {
$close = 0;
if (isset($_POST["evStart/Stop"])) {
if ($_POST["evStart/Stop"] == "Laden starten") {
$close = 1;
httpGetJson("http://192.168.179.122/api/set?frc=2");
} else if ($_POST["evStart/Stop"] == "Laden stoppen") {
$close = 1;
httpGetJson("http://192.168.179.122/api/set?frc=1");
}
$close = 1;
} else if (isset($_POST["evMode"])) {
if ($_POST["fte"] > 100)
$_POST["fte"] = 100;
else if ($_POST["fte"] < 0)
$_POST["fte"] = 0;
if ($_POST["evAmp"] > 16)
$_POST["evAmp"] = 16;
if ($_POST["evAmp"] < 6)
$_POST["evAmp"] = 6;
$_POST["fte"] = $_POST["fte"] * 90;
$seconds = strtotime('1970-01-01 ' . $_POST["ftt"] . ':00GMT');
if ($_POST["evMode"] == "eco") {
$close = 1;
if ($_POST["ftt"] && $_POST["fte"] > 0) {
httpGetJson("http://192.168.179.122/api/set?ate=" . $_POST["fte"] . "&att=" . $seconds . "&amp=" . $_POST["evAmp"] . "&fup=true&lmo=4");
} else {
httpGetJson("http://192.168.179.122/api/set?amp=" . $_POST["evAmp"] . "&fup=true&lmo=4");
}
} else if ($_POST["evMode"] == "default") {
$close = 1;
if ($_POST["ftt"] && $_POST["fte"] > 0) {
httpGetJson("http://192.168.179.122/api/set?ate=" . $_POST["fte"] . "&att=" . $seconds . "&amp=" . $_POST["evAmp"] . "&fup=true&lmo=3");
} else {
httpGetJson("http://192.168.179.122/api/set?amp=" . $_POST["evAmp"] . "&fup=true&lmo=3");
}
} else if ($_POST["evMode"] == "NextTrip") {
$close = 1;
if ($_POST["ftt"] && $_POST["fte"] > 0) {
httpGetJson("http://192.168.179.122/api/set?ate=" . $_POST["fte"] . "&att=" . $seconds . "&amp=" . $_POST["evAmp"] . "&fup=true&lmo=5");
} else {
httpGetJson("http://192.168.179.122/api/set?amp=" . $_POST["evAmp"] . "&fup=true&lmo=5");
}
}
$close = 1;
}
} else {
$close = 1;
}
if (!$close) {
$formId = "carOG_form";
include __DIR__ . "/carForm.php";
}
<?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]);
+161 -161
View File
@@ -1,162 +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]}]}';
<?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 -171
View File
@@ -1,171 +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]}]}';
<?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 -105
View File
@@ -1,105 +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]}]}';
<?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]}]}';
+164 -164
View File
@@ -1,165 +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]}]}';
<?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 -211
View File
@@ -1,211 +1,369 @@
<?php
require_once("../helper.php");
require_once("../restricted/costs.php");
if(!isset($_GET["year"])){
$_GET["year"] = date("Y");
}else{
$_GET["year"] = intval($_GET["year"]);
}
if($_GET["year"] < 2018 || $_GET["year"] > date("Y")){
exit;
}
if(isset($_GET["type"])){
if(strtolower($_GET["type"]) =="lastyear"){
$_GET["year"] = date("Y")-1;
}elseif(strtolower($_GET["type"]) =="prelastyear"){
$_GET["year"] = date("Y")-2;
}
}
/**
* Die Jahresstatistik als eine Abfrage - jetzt auf stats_daily.
*
* Gerechnet wird nicht mehr ueber die Rohdaten aus EnergyFlow, sondern ueber
* die Tageswerte. Zwei Gruende:
*
* 1. Die Rohdaten werden nach zwoelf Monaten ausgeduennt bzw. ins
* Stundenarchiv verschoben (solarlog-rollup.sh). Eine Kachel, die auf
* EnergyFlow rechnet, zeigt fuer alte Jahre danach 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
*
* Einheiten sind in stats_daily bereits bereinigt: PL*_EV und PL*_EVog
* liefern Kilowatt, alle uebrigen Leistungsspalten Watt. In der alten
* Abfrage stand das als /12 gegen /12000 nebeneinander.
*
* Die Solaranteile teilen jetzt durch NULLIF(...): ohne Ladung im Jahr kam
* vorher 0/0 heraus.
*
* "Ersparnis Batterie" rechnet den Nutzen netto: was die entladene Energie
* aus dem Netz gekostet haette, abzueglich der Verguetung, die fuer den zum
* Laden verwendeten Strom entgangen ist. Brutto waere die Kachel zu
* freundlich - der Speicher ist nicht umsonst zu fuellen.
*
* Achtung beim Lesen: "Verbrauchsersparnis" ist der GESAMTE Eigenverbrauch
* zum Strompreis. "Ersparnis Batterie" und "Ersparnis Solarladung" sind
* Teilmengen davon, nach Herkunft bzw. Verwendung herausgegriffen. Sie
* duerfen nicht addiert werden.
*
* Der Grundpreis fehlt weiterhin bewusst - er haengt an den Tagen des
* Jahres, nicht an einem Messwert, und kommt aus grundpreisImJahr() dazu.
*/
function statistikAbfrage($jahr)
{
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
SUM(d.netz_bezug_kwh) AS 'Stromverbrauch',
SUM(d.netz_bezug_kwh * preis.cost) AS 'Stromkosten',
SUM((d.eigenverbrauch_kwh + IFNULL(d.pv_fehlbetrag_kwh,0)) * preis.cost) AS 'Verbrauchsersparnis',
SUM(d.netz_einsp_kwh * preis.gain) AS 'Einspeisevergütung',
SUM(d.heizstab_solar_kwh * gas.cost / NULLIF(gas.kwhPerLitre,0)) AS 'Ersparnis Heizung',
SUM(d.batt_entladen_kwh * preis.cost - d.batt_laden_kwh * preis.gain) AS 'Ersparnis Batterie',
SUM(d.autarkie_avg * d.stunden_erfasst) / NULLIF(SUM(d.stunden_erfasst),0) AS 'Ø Autarkie',
SUM(d.auto_eg_kwh) AS 'Autoladung ges. EG',
SUM(d.auto_eg_solar_kwh) / NULLIF(SUM(d.auto_eg_kwh),0) * 100 AS 'Autoladung Solar EG',
SUM(d.auto_eg_solar_kwh * preis.cost) AS 'Ersparnis Solarladung EG',
SUM(d.auto_eg_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS 'Benzin gespart EG',
SUM(d.auto_eg_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) * sprit.cost) AS 'Ersparnis Benzin EG',
SUM(d.auto_og_kwh) AS 'Autoladung ges. OG',
SUM(d.auto_og_solar_kwh) / NULLIF(SUM(d.auto_og_kwh),0) * 100 AS 'Autoladung Solar OG',
SUM(d.auto_og_solar_kwh * preis.cost) AS 'Ersparnis Solarladung OG',
SUM(d.auto_og_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS 'Benzin gespart OG',
SUM(d.auto_og_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) * sprit.cost) AS 'Ersparnis Benzin OG'
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)
WHERE YEAR(d.tag) = " . intval($jahr) . ";";
}
$Query = statistikAbfrage($_GET["year"]);
$PrevQuery = statistikAbfrage($_GET["year"] - 1);
// Reihenfolge wie in der Abfrage, mit "Verg.+Einsp. Strom" an Stelle 4.
$units = Array("kWh","€","€","€","€","€","€","%",
"kWh","%","€","L","€",
"kWh","%","€","L","€");
// Nur bei Verbrauch und Kosten ist weniger besser.
$LessIsBetter = array_merge([true, true], array_fill(0, count($units) - 2, false));
function array_insert($array,$values,$offset) {
return array_slice($array, 0, $offset, true) + $values + array_slice($array, $offset, NULL, true);
}
if (checkLogin()) {
$html = "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
$mysql = solarDb();
$Res = mysqli_query($mysql,$Query);
$ResPrev = mysqli_query($mysql,$PrevQuery);
if(!$Res || !$ResPrev){
echo "Error:<br>".mysqli_error($mysql)."<br />";
}
if ($Res->num_rows > 0) {
$i = 0;
$row = $Res->fetch_assoc();
$rowPrev = $ResPrev->fetch_assoc();
// Der Grundpreis faellt unabhaengig vom Verbrauch an und laesst sich
// deshalb nicht aus den Messwerten summieren.
$row["Stromkosten"] += grundpreisImJahr($_GET["year"]);
$rowPrev["Stromkosten"] += grundpreisImJahr($_GET["year"] - 1);
//$row["Vergütung+Einsparung Strom"] = $row["Einspeisevergütung"]+$row["Verbrauchsersparnis"];
$row = array_insert($row,["Verg.+Einsp. Strom" => $row["Einspeisevergütung"]+$row["Verbrauchsersparnis"]],4);
$rowPrev = array_insert($rowPrev,["Verg.+Einsp. Strom" => $rowPrev["Einspeisevergütung"]+$rowPrev["Verbrauchsersparnis"]],4);
$i = 0;
foreach ($row as $name => $value) {
if(str_starts_with($name,"Autoladung ges.")){
$html .= "</div><hr class='mt-3 mb-3 border-light' />";
$html .= "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
}
/*$html .= "<div class='col-12 col-sm-4 col-md-2 col-xl-1'>
<div class='info-box'>
<div class='info-box-content'>
<span class='info-box-text'>".$name."</span>
<span class='info-box-number'>".
number_format(floatval($value),2,",",".")
."<small>".$units[$i++]."</small>
</span>
</div>
<!-- /.info-box-content -->
</div>
</div>";*/
$html .= "<div class='col'>
<div class='card stats border-0'>
<div class='card-body bg-dark bg-gradient rounded-top pb-1 pt-1'>
".$name."
</div>
<div class='card-footer text-center'>";
if($_GET["year"] != date("Y")){
if($LessIsBetter[$i]){
if($value == 0)
$dev=100;
else
$dev = $rowPrev[$name]/$value;
//$first = round($rowPrev[$name]*10/$rowPrev[$name]); //allow for 10% deviation for displaying no tendency
//$second = round($value); //allow for 10% deviation for displaying no tendency
$arrowGood = "down";
$arrowBad = "up";
}else{
if($rowPrev[$name] == 0)
$dev=100;
else
$dev = $value/$rowPrev[$name];
//$first = round($value); //allow for 10% deviation for displaying no tendency
//$second = round($rowPrev[$name]); //allow for 10% deviation for displaying no tendency
$arrowGood = "up";
$arrowBad = "down";
}
if($dev > 1.05){
$html .= "<span class='float-right text-success-emphasis'>
<i class='bi bi-arrow-".$arrowGood."' style='font-size: 0.9em;'></i> ";
}elseif($dev < 0.95){
$html .= "<span class='float-right text-danger-emphasis'>
<i class='bi bi-arrow-".$arrowBad."' style='font-size: 0.9em;'></i> ";
}else{
$html .= "<span class='float-right'>
<i class='bi bi-arrow-right' style='font-size: 0.9em;'></i> ";
}
}
else{
$html .= "<span class='float-right'>";
}
$html .= number_format(floatval($value),2,",",".")
."<small> ".$units[$i]."</small></span></div>
<!-- /.info-box-content -->
</div>
</div>";
$i++;
}
}
$html .= "</div>";
}
//header('Content-Type: application/json');
echo $html;
//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]}]}';
<?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 -65
View File
@@ -1,65 +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]}]}';
<?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 -65
View File
@@ -1,65 +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]}]}';
<?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 -57
View File
@@ -1,57 +1,117 @@
<?php
require_once("../helper.php");
if (checkLogin()) {
$close = 0;
$hostname = "192.168.179.169";
if (isset($_POST["setMode"]) || isset($_GET["setMode"])) {
//send new value
$_POST["setMode"] = intval($_POST["setMode"]) + intval($_GET["setMode"]);
if($_POST["setMode"] > 0 && $_POST["setMode"] < 60){
$pwr = $_POST["setMode"]*100;
$headers[] = "GET /set?mode=man&pwr=".$pwr." HTTP/1.1";
$headers[] = "Host: ".$hostname;
$headers[] = "";
$remote = fsockopen("tcp://".$hostname, 80, $errno, $errstr, 5);
fwrite($remote, implode("\r\n", $headers)."\r\n");
$file = '';
$file .= fread($remote, 1024);
fclose($remote);
}elseif($_POST["setMode"] == 0){
$headers[] = "GET /set?mode=eco&pwr=0 HTTP/1.1";
$headers[] = "Host: ".$hostname;
$headers[] = "";
$remote = fsockopen("tcp://".$hostname, 80, $errno, $errstr, 5);
fwrite($remote, implode("\r\n", $headers)."\r\n");
$file = '';
$file .= fread($remote, 1024);
fclose($remote);
}
$close = 1;
}
} else {
$close = 1;
}
if (!$close) {
echo <<<ENDE
<!--begin::Form-->
<form id="heater_form">
<div class="col-sm-11">
<div class="form-check">
<label class="form-label" for="addedCharge">Leistung: </label>
<div style="font-size: 15px;color: #297195;display: inline-block;" id="modal-slider-label">0</div>
<input type="range" id="modal-slider" name="setMode" class="form-range range-color-track" min="0" max="60" />
<div class="d-flex justify-content-between">
<span class="max-amount f-16 font-weight-normal darkGray-color text-right mt-1">Auto</span>
<span class="max-amount f-16 font-weight-normal darkGray-color text-right mt-1">6.0 kW</span>
</div>
</div>
</div>
</form>
ENDE;
}
<?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 -671
View File
File diff suppressed because it is too large Load Diff
+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);
+227 -33
View File
@@ -6,14 +6,32 @@
* 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 (siehe
* den Zuordnungsdialog in der Karte "Automatismen").
* Welche Geraete zu einem Raum gehoeren, sagt die Spalte actors.room_id
* (Einstellungen -> Geraete).
*
* GET ?room=EG_Bad Inhalt des Modals
* GET ?room=12 Inhalt des Modals, 12 = rooms.id
* POST ?action=command {"command_id": N, "params": {...}} - ein Kommando
* POST ?action=temp {"room": "EG_Bad", "temp": 21} - Solltemperatur
* 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.
@@ -30,6 +48,14 @@ if (!checkLogin()) {
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);
@@ -38,6 +64,38 @@ function raumAntwort($data, $code = 200)
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);
@@ -46,15 +104,30 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
}
try {
if (($_GET["action"] ?? "") === "command") {
$text = executeCommand(intval($body["command_id"] ?? 0),
$id = intval($body["command_id"] ?? 0);
$text = executeCommand($id,
is_array($body["params"] ?? null) ? $body["params"] : [],
!empty($body["probe"]));
raumAntwort(["ok" => $text]);
// 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") {
// Bewusst derselbe Weg wie im alten Thermostat-Modal: das Topic
// Raumtemp/<Etage>/<Raum>/changeSetTemp.
$raum = preg_replace('/[^A-Za-z0-9_]/', '', strval($body["room"] ?? ""));
// 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);
@@ -63,11 +136,113 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
if (!$mqtt->connect(true, null, "", "")) {
raumAntwort(["error" => "Keine Verbindung zum MQTT-Broker."], 500);
}
$mqtt->publish("Raumtemp/" . str_replace("_", "/", $raum) . "/changeSetTemp",
$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);
@@ -78,11 +253,13 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
// --- Inhalt des Modals ----------------------------------------------------
$schluessel = preg_replace('/[^A-Za-z0-9_]/', '', strval($_GET["room"] ?? ""));
$teile = explode("_", $schluessel, 2);
$etage = $teile[0] ?? "";
$name = $teile[1] ?? "";
$raumKey = $etage . "/" . $name;
$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
@@ -113,31 +290,48 @@ foreach ($katalog as $d) {
// einer Geraeteliste: ein Regler fuer den Raum statt einer Karte je Geraet.
$fertig = [];
if ($hatThermostat) {
$karte = "<div class='card mb-2 raum-geraet'><div class='card-body py-2'>"
. "<div class='d-flex justify-content-between align-items-baseline'>"
. "<label class='form-label mb-0 small' for='modal-slider'>Solltemperatur</label>"
. "<span id='modal-slider-label' class='fw-semibold text-nowrap ms-2' style='color:#297195'>–</span>"
. "</div>"
. "<input type='range' id='modal-slider' class='form-range range-color-track' min='10' max='30'>"
// Der Ist-Zustand kommt live ueber MQTT, nicht aus der Datenbank -
// der Browser hoert ohnehin schon auf Raumtemp/#, und ein Wert von
// vorhin waere neben dem Regler nur irrefuehrend.
. "<div class='d-flex flex-wrap gap-3 small text-body-secondary'>"
. "<span><i class='bi bi-thermometer-half me-1'></i><span data-mqtt='Temp[degC]'>–</span> °C</span>"
. "<span><i class='bi bi-droplet me-1'></i><span data-mqtt='rHum[%]'>–</span> %rF</span>"
. "<span data-mqtt-heizung class='d-none'><i class='bi bi-fire text-danger me-1'></i>heizt</span>"
. "</div></div></div>";
$fertig["heizung"] = $karte;
// 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>";
}
echo "<div class='p-1' id='roomView' data-room='" . htmlspecialchars($schluessel, ENT_QUOTES) . "'>";
// 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='text-body-secondary mb-0'>Diesem Raum ist noch nichts zugeordnet. "
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='small mt-2'></div>";
echo "<div id='roomFeedback' class='rf-meldung' aria-live='polite'>Änderungen gelten sofort</div>";
echo "</div>";
+171 -2
View File
@@ -4,14 +4,41 @@
*
* 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, damit Maske und Endpunkt dieselbe Wahrheit ueber
* einen Zeitraum haben.
* 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);
@@ -28,6 +55,20 @@ function jsonAntwort($data, $code = 200)
$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)) {
@@ -43,16 +84,144 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
"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"] ?? "")]);
+279 -35
View File
@@ -7,6 +7,7 @@
* ?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
@@ -41,54 +42,100 @@ if ($mysql->connect_errno) {
$mysql->set_charset("utf8mb4");
/*
* Ladevorgänge aus den Einzelzeilen schneiden. Aufeinanderfolgende Zeilen mit
* chg_state = 'CHARGING' gehören zusammen, eine Pause über 30 Minuten trennt
* zwei Vorgänge. Wird von mehreren Abschnitten gebraucht, deshalb einmal hier.
* 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.
* Steht der Zähler still, wurde auswärts geladen (Schnellladesäule); dann
* bleibt wh null und die Kapazitätsrechnung lässt die Ladung aus.
* 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) AS soc_von, MAX(soc) AS soc_bis,
CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc) AS SIGNED) AS zuwachs,
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(chg_kw), 2) AS kw_mittel, ROUND(MAX(chg_kw), 2) AS kw_spitze,
MAX(wb_wh_total) - MIN(wb_wh_total) AS wh,
ROUND(SUM(kwh_teil), 3) AS kwh_fahrzeug,
ROUND(AVG(pv_kw), 2) AS pv_kw, COUNT(*) AS punkte
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 s.*, SUM(s.neu) OVER (ORDER BY s.datetime) AS sitzung FROM (
SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc,
wb_wh_total, pv_kw,
CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING'
AND TIMESTAMPDIFF(MINUTE, LAG(datetime) OVER w, datetime) <= 30
THEN 0 ELSE 1 END AS neu,
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,
/*
* Fahrzeugseitige Energie: chg_kw ist die Ladeleistung, die
* das Fahrzeug selbst meldet - gemessen HINTER dem Ladegerät.
* Die API kennt keine Kilowattstunden, deshalb wird hier über
* die Zeit integriert, je Schritt mit dem Mittel aus vorigem
* und aktuellem Wert (Trapez). Beim Laden mit Wechselstrom
* liegt die Leistung fast konstant an, da ist das genau genug.
* 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.
*
* Die Bedingung hält die Summe innerhalb einer Ladung: über
* eine Lücke von mehr als 30 Minuten hinweg wäre die Fläche
* unter der Kurve frei erfunden.
* Ü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 LAG(chg_state) OVER w <=> 'CHARGING'
AND TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime)
BETWEEN 1 AND 1800
THEN (COALESCE(chg_kw, 0) + COALESCE(LAG(chg_kw) OVER w, 0)) / 2
* TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) / 3600
ELSE 0 END AS kwh_teil
FROM skoda WHERE datetime >= $seit
WINDOW w AS (ORDER BY datetime)) s) t
WHERE chg_state = 'CHARGING'
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";
}
@@ -166,6 +213,154 @@ function kapazitaetSchaetzen($mysql)
];
}
/**
* 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)
{
@@ -236,6 +431,9 @@ case "kurve":
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 -- */
@@ -358,6 +556,52 @@ case "fahrten":
$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."];
+64 -79
View File
@@ -3,8 +3,9 @@
* Befehle an das Fahrzeug.
*
* Aufruf per POST: befehl=laden_start | laden_stop | klima_start | klima_stop
* | lueftung_start | lueftung_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
@@ -14,28 +15,25 @@
* ein vorausgeladenes Bild oder einen Klick auf eine fremde Seite fernsteuerbar.
*
* ---------------------------------------------------------------------------
* Zwei Schlüssel, zwei Kontingente
* Ein Kontingent für alles
*
* Die API begrenzt auf 20 Anfragen je Stunde **und Schlüssel**, und Befehle
* zählen genauso mit wie Abrufe. Steht in skoda.conf ein eigener
* CMD_API_KEY, hat die Steuerung damit ihr eigenes Kontingent und nimmt dem
* Abruf des Fahrzeugzustands nichts weg. Beide Schlüssel werden in der
* MyŠkoda-App angelegt.
* 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.
*
* Ohne eigenen Schlüssel greift dieses Skript auf API_KEY zurück - dann
* teilen sich beide ein Kontingent, und weil sie in getrennten Prozessen
* laufen und keinen gemeinsamen Zähler haben, ist es fest aufgeteilt:
* Abruf und Steuerung laufen in getrennten Prozessen ohne gemeinsamen Zähler,
* deshalb ist fest aufgeteilt:
*
* eigener CMD_API_KEY nur API_KEY
* Abruf 18 / Stunde 14 / Stunde
* Befehle 15 / Stunde 4 / Stunde
* Reserve 2 / Stunde 2 / Stunde
* Abruf 14 / Stunde (gatherSkodaData.py)
* Befehle 4 / Stunde
* Reserve 2 / Stunde
*
* gatherSkodaData.py stellt sich selbst um, sobald der zweite Schlüssel in
* skoda.conf auftaucht - dort ist nichts von Hand nachzuziehen.
*
* Der zweite Schlüssel ist kein Freibrief, den Abruf zu beschleunigen: die
* Kachelrichtlinie zielt auf die Last, nicht auf die Buchführung.
* 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
@@ -46,24 +44,13 @@
*/
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');
const API_BASIS = "https://public.api.connect.skoda-auto.cz/api/v1/vehicles/";
const KONFIG = "/volume1/homes/wagner/SolarManager/skoda.conf";
const KENNUNG = "SmartController-SkodaSteuerung/1.0 (+https://nas.el-wa.org/smart)";
/** Befehle je Stunde - siehe Kopfkommentar. Hängt davon ab, ob die
* Steuerung einen eigenen Schlüssel hat oder ihn mit dem Abruf teilt. */
const BEFEHLE_EIGENER_SCHLUESSEL = 15;
const BEFEHLE_GETEILT = 4;
/** Wo der Stundenzähler liegt. Muss für den Webserver beschreibbar sein. */
const ZAEHLER_ORTE = [
"/volume1/web/smart/tiles", // falls per synoacltool freigegeben
"/var/services/tmp/smart-tiles", // sonst
];
/*
* Die erlaubten Befehle. Der Schlüssel kommt aus dem Formular, der Pfad
* niemals - so kann über den Parameter kein beliebiger Endpunkt der API
@@ -76,6 +63,8 @@ const BEFEHLE = [
"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 */
@@ -93,23 +82,37 @@ if (!isset(BEFEHLE[$name])) {
}
$befehl = BEFEHLE[$name];
$konfig = konfigLesen();
if (!$konfig) {
antwort(503, false, "Kein API-Schlüssel hinterlegt - skoda.conf fehlt.");
// 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.");
}
}
$grenze = $konfig["EIGENER"] ? BEFEHLE_EIGENER_SCHLUESSEL : BEFEHLE_GETEILT;
if (!budgetFrei($grenze)) {
$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 ("
. $grenze . " je Stunde)."
. ($konfig["EIGENER"] ? "" : " Ein eigener CMD_API_KEY in skoda.conf"
. " würde der Steuerung ein eigenes Kontingent geben.")
. " Später erneut versuchen.");
. 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.
@@ -118,11 +121,12 @@ if ($name === "klima_start") {
$rumpf = ["targetTemperature" => ["value" => $grad, "unit" => "CELSIUS"]];
}
list($status, $inhalt) = senden($konfig, $befehl["pfad"], $rumpf);
list($status, $inhalt) = senden($konfig, $befehl["pfad"], $rumpf, $befehl["methode"] ?? "POST");
if ($status === 202) {
antwort(200, true, $befehl["text"] . ". Das Fahrzeug führt den Befehl aus; "
. "in der Anzeige erscheint es beim nächsten Abruf.");
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));
@@ -136,39 +140,19 @@ function antwort($code, $ok, $text)
exit;
}
/** API-Schlüssel und VIN aus skoda.conf lesen. Format: NAME=wert je Zeile. */
function konfigLesen()
{
if (!is_readable(KONFIG)) {
return null;
}
$werte = [];
foreach (file(KONFIG, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $zeile) {
$zeile = trim($zeile);
if ($zeile === "" || $zeile[0] === "#" || strpos($zeile, "=") === false) {
continue;
}
list($k, $v) = explode("=", $zeile, 2);
$werte[strtoupper(trim($k))] = trim(trim($v), "\"'");
}
if (empty($werte["API_KEY"]) || empty($werte["VIN"])) {
return null;
}
// Eigener Schluessel fuer die Steuerung, wenn hinterlegt - sonst der
// gemeinsame. Welcher es wurde, entscheidet unten ueber das Budget.
$werte["EIGENER"] = !empty($werte["CMD_API_KEY"]);
$werte["SCHLUESSEL"] = $werte["EIGENER"] ? $werte["CMD_API_KEY"] : $werte["API_KEY"];
return $werte;
}
/**
* 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 (ZAEHLER_ORTE as $kandidat) {
foreach (SKODA_ARBEITSORTE as $kandidat) {
if (is_dir($kandidat) && is_writable($kandidat)) {
$ort = $kandidat;
break;
@@ -180,7 +164,7 @@ function budgetFrei($grenze)
return true;
}
$griff = fopen($ort . "/.befehle.json", "c+");
$griff = fopen($ort . "/" . SKODA_ZAEHLER_DATEI, "c+");
if (!$griff || !flock($griff, LOCK_EX)) {
return true;
}
@@ -205,18 +189,18 @@ function budgetFrei($grenze)
}
/** Den Befehl an die API schicken. Liefert [HTTP-Status, Rumpf]. */
function senden($konfig, $pfad, $rumpf)
function senden($konfig, $pfad, $rumpf, $methode = "POST")
{
$url = API_BASIS . rawurlencode($konfig["VIN"]) . "/" . $pfad;
$url = SKODA_API . rawurlencode($konfig["vin"]) . "/" . $pfad;
$kopf = [
"X-API-Key: " . $konfig["SCHLUESSEL"],
"X-API-Key: " . $konfig["schluessel"],
"Accept: application/json",
];
$ch = curl_init($url);
$optionen = [
CURLOPT_POST => true,
CURLOPT_CUSTOMREQUEST => $methode,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => KENNUNG,
CURLOPT_USERAGENT => SKODA_KENNUNG,
CURLOPT_TIMEOUT => 20,
CURLOPT_CONNECTTIMEOUT => 5,
];
@@ -257,7 +241,8 @@ function fehlertext($status, $inhalt)
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.";
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;
+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 -252
View File
@@ -1,252 +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));
}
<?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));
}
+47 -53
View File
@@ -73,68 +73,62 @@ if (!checkLogin()) {
exit;
}
// Zonen des Modals. Die Reihenfolge bestimmt die Reihenfolge der Karten, die
// Keys muessen zu wateringZones in js/solar/solarMQTT.js passen.
// 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", "minuten" => 5],
["key" => "raisedBeds", "label" => "Beete", "minuten" => 30],
["key" => "frontGarden", "label" => "Vorn", "minuten" => 30],
["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]],
];
// Was die Automatik je Zone vorhat, fuellt solarMQTT.js aus dem RainStatus.
$planZeilen = "";
foreach ($zonen as $zone) {
$planZeilen .= " <div>{$zone['label']}: <span data-watering-plan=\"{$zone['key']}\">--</span></div>\n";
}
echo <<<HTML
<div class="p-3">
<div id="watering-feedback" class="alert d-none mb-3" role="status"></div>
<div class="card mb-3">
<div class="card-body">
<div class="fw-semibold mb-3">Automatik</div>
<div class="d-grid gap-2 mb-3">
<button type="button" class="btn btn-outline-primary" data-watering-auto-toggle data-watering-auto="Trog,Hoch" data-watering-topics="Gartenwasser/vorn,Gartenwasser/hinten" data-watering-label="Tröge und Hochbeete">Tröge und Hochbeete</button>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-primary btn-sm flex-fill" data-watering-auto-toggle data-watering-auto="Trog" data-watering-topic="Gartenwasser/vorn" data-watering-label="Tröge">Tröge</button>
<button type="button" class="btn btn-outline-primary btn-sm flex-fill" data-watering-auto-toggle data-watering-auto="Hoch" data-watering-topic="Gartenwasser/hinten" data-watering-label="Hochbeete">Hochbeete</button>
<button type="button" class="btn btn-outline-primary btn-sm flex-fill" data-watering-auto-toggle data-watering-auto="Vorn" data-watering-topic="Gartenwasser/vorn" data-watering-label="Garten vorn">Garten vorn</button>
</div>
</div>
<div class="small text-muted">
$planZeilen <div class="fst-italic" data-watering-plan-updated></div>
</div>
// 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>
HTML;
foreach ($zonen as $zone) {
$key = $zone["key"];
$label = $zone["label"];
$minuten = $zone["minuten"];
echo <<<HTML
<div class="card mb-2">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="fw-semibold">$label</span>
<span class="badge bg-secondary" data-watering-state="$key">unbekannt</span>
</div>
<div class="d-flex align-items-center gap-2 mb-2">
<div class="input-group input-group-sm" style="max-width: 7.5rem;">
<input class="form-control" type="number" min="1" max="120" value="$minuten" data-watering-timer="$key" aria-label="Dauer in Minuten für $label">
<span class="input-group-text">Min.</span>
<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>
<button type="button" class="btn btn-success px-4 ms-auto" data-watering-toggle data-watering-zone="$key">Ein</button>
<span class="lade-pille" data-w="pille"></span>
</div>
<div class="small text-muted">
Zuletzt <span data-watering-last="$key">--</span> · <span data-watering-duration="$key">--</span><br>
Heute <span data-watering-today="$key">--</span> · Vortag <span data-watering-prevday="$key">--</span>
<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>
HTML;
}
echo "\n</div>\n";
<div class="lade-meldung" data-w="meldung" aria-live="polite">Befehle gehen sofort an die Ventilsteuerung</div>
</div>
+143 -143
View File
@@ -1,144 +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;
/* 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;
}
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

+4 -4
View File
@@ -1,5 +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 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>

Before

Width:  |  Height:  |  Size: 495 B

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

@@ -0,0 +1 @@
{"1":{"x":50,"y":66.2},"2":{"x":24.3,"y":79},"3":{"x":51,"y":79},"4":{"x":84.7,"y":79}}
+286 -286
View File
@@ -1,287 +1,287 @@
<svg xmlns="http://www.w3.org/2000/svg" style="border:0px solid gray;background-color: #212529;" viewBox="0 0 630 750" width= "100%" height= "100%">
<g style="fill:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;stroke:#666666;fill:#666666;" stroke-width="10" transform="scale (2) translate(0 -25)">
<path
id="Puffer"
d="m 227,68 c 0,0 0,-28 -74,-28 -75,0 -75,27 -75,27 V 353 c 0,0 10,31 76,31 66,0 75,-32 75,-32 z"
style="fill:#212529;fill-opacity:1;stroke-width:2;" />
<ellipse
ry="3"
rx="1"
cy="112"
cx="211"
id="path902" />
<ellipse
ry="5"
rx="3"
cy="163"
cx="211"
id="path904" />
<ellipse
ry="5"
rx="3"
cy="282"
cx="210"
id="path904-6" />
<ellipse
ry="5"
rx="3"
cy="345"
cx="213"
id="path904-2" />
<ellipse
ry="3"
rx="1"
cy="332"
cx="213"
id="path902-4" />
<ellipse
ry="3"
rx="1"
cy="121"
cx="100"
id="path902-0" />
<ellipse
ry="3"
rx="1"
cy="164"
cx="99" />
<ellipse
ry="3"
rx="1"
cy="175"
cx="99"
id="path902-8" />
<ellipse
ry="3"
rx="1"
cy="223"
cx="98"
id="path902-2" />
<ellipse
ry="3"
rx="1"
cy="257"
cx="98"
id="path902-07" />
<path
id="heatweRLtube1"
d="m 211,282 h 95 v -44"
style="fill:none;" />
<path
id="path976"
d="m 212,163 h 58"
style="fill:none;" />
<path
id="path978"
d="M 32,140 H 2 v 61 H 32 Z"
style="fill:none;stroke-width:1px;" />
<path
id="path980"
d="M 99,121 H 9 v 18"
style="fill:none;" />
<path
id="path982"
d="M 33,175 H 98"
style="fill:none;" />
<path
id="path984"
d="m 8,201 v 21 H 98"
style="fill:none;" />
<path
id="fbVLtube"
d="m 99,164 h 7 V 244 H 8 v 23"
style="fill:none;" />
<path
id="fbRLtube"
d="m 8,302 v 32 H 107 v -79 h -9"
style="fill:none;" />
<path
id="path990"
d="m 211,112 h 39 V 68"
style="fill:none;" />
<path
id="path992"
d="M 26,151 H 8 v 2 H 6 v 4 l -1,1 v 30 H 28 v -34 l -1,-1 v -4 z"
style="fill:none;stroke-width:1px;" />
<path
id="path994"
d="M 28,167 H 5"
style="fill:none;stroke-width:1px;" />
<path
id="path996"
d="M 5,161 H 28"
style="fill:none;stroke-width:1px;" />
<path
id="path998"
d="M 27,156 H 7"
style="fill:none;stroke-width:1px;s" />
<path
id="path1000"
d="m 10,152 1,2 H 24 l 1,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1002"
d="m 24,167 v 5 h 4"
style="fill:none;stroke-width:1px;" />
<path
id="path1004"
d="M 47,268 H 1 v 34 H 47 v -34 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1006"
d="m 8,269 v 2 c 0,0 -1,0 -1,1 0,1 1,1 1,1 H 40 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 c 0,0 -1,0 -1,1 0,1 1,1 1,1 H 40 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 c 0,0 -1,0 -1,1 0,1 1,1 1,1 H 40 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 c 0,0 -1,0 -1,1 0,1 1,1 1,1 H 40 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 c 0,0 -1,0 -1,1 -0,1 1,1 1,1 h 31 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 v 4"
style="fill:none;stroke-width:1px;" />
<path
id="path1008"
d="M 294,238 Z"
style="fill:none;stroke-width:1px;" />
<path
id="heizerRLtube2"
d="m 295,238 h 10"
style="fill:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
id="path1012"
d="M 270,163 Z"
style="fill:none;stroke-width:1px;" />
<path
id="heizregler"
d="m 282,247 v 7 c 0,0 0,4 7,4 6,0 7,-4 7,-4 v -7 c 0,0 -0,3 -8,3 -7,0 -6,-3 -6,-3 z"
style="fill:none;stroke-width:1px;" />
<path
id="Heizstab"
d="m 293,246 v -74 c 0,0 -0,-2 -4,-2 -4,0 -4,2 -4,2 v 74 c 0,0 -0,1 4,1 4,-0 4,-1 4,-1 z"
style="fill:#212529;stroke-width:1px;" />
<path
id="path1020"
d="m 270,163 h 19 v 5"
style="fill:none;" />
<path
id="path1022"
d="m 282,247 c 0,0 -0,-2 2,-2"
style="fill:none;stroke-width:1px;" />
<path
id="path1024"
d="m 296,247 c 0,0 0,-2 -3,-3"
style="fill:none;stroke-width:1px;" />
<path
id="path976-2"
d="m 213,332 h 75"
style="fill:none;stroke-width:4;" />
<path
id="path1043"
d="m 240,338 0,-2 -7,3 6,3 -0,-2 4,-0 -0,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1043-9"
d="m 240,169 0,-2 -7,3 6,3 -0,-2 4,-0 -0,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1043-5"
d="m 65,228 0,-2 -7,3 6,3 -0,-2 4,-0 -0,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1043-3"
d="m 65,250 0,-2 -7,3 6,3 -0,-2 4,-0 -0,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1043-94"
d="m 253,288 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1043-94-5"
d="m 236,103 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1043-94-2"
d="m 65,342 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1043-94-6"
d="m 65,126 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1043-94-9"
d="m 65,180 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1123"
d="m 253,52 h 9 c 0,0 3,-0 5,1 1,1 1,5 1,5 l -0,3 c 0,0 2,0 3,1 1,1 1,3 1,3 h -11 c 0,0 -0,-2 1,-4 1,-1 2,-1 2,-1 v -3 c 0,0 0,-2 -1,-3 -1,-0 -2,-0 -2,-0 h -9"
style="fill:none;stroke-width:1px;" />
<path
id="path1125"
d="m 266,68 -0,2"
style="fill:none;stroke-width:1px;" />
<path
id="path1127"
d="m 266,73 v 1"
style="fill:none;stroke-width:1px;" />
<path
id="path1129"
d="m 268,68 v 2"
style="fill:none;stroke-width:1px;" />
<path
id="path1131"
d="m 268,73 v 1"
style="fill:none;stroke-width:1px;" />
<path
id="path1133"
d="m 271,69 v 2"
style="fill:none;stroke-width:1px;" />
<path
id="path1135"
d="M 271,74 V 75"
style="fill:none;stroke-width:1px;" />
<path
id="path1137"
d="m 285,314 h -4 v 12 h 4"
style="fill:none;stroke-width:1px;" />
<path
id="path1139"
d="m 280,318 h -4 v -5 h -4 v 5 h -4 c 0,0 -1,-0 -2,1 -0,1 -0,4 -0,4 0,0 -0,3 2,3 2,0 2,-2 2,-3 0,-1 2,-1 2,-1 l 9,0"
style="fill:none;stroke-width:1px;" />
<path
id="path1141"
d="m 278,312 h -9 c -1,0 -2,-1 0,-1 2,0 2,1 4,1 2,0 3,-1 5,-1 2,0 0,1 -0,1 z"
style="fill:none;stroke-width:1px;" />
</g>
<g style="font-size: 28px; font-weight: 600; font-family: sans-serif; text-anchor: middle; fill: rgb(200, 210, 210); letter-spacing: 1px;" >
<text id="PufferOtxt" dy="200" dx="320">
--.- °C
</text>
<text id="PufferMtxt" dy="400" dx="320">
--.- °C
</text>
<text id="PufferUtxt" dy="600" dx="320">
--.- °C
</text>
<text id="heaterRL" dy="505" dx="530" style="font-size: 22px;">
--.- °C
</text>
<text id="heaterVL" dy="260" dx="530" style="font-size: 22px;">
--.- °C
</text>
<text id="fbVL" dy="465" dx="70" style="font-size: 22px;">
--.- °C
</text>
<text id="fbRL" dy="605" dx="70" style="font-size: 22px;">
--.- °C
</text>
<text id="thermeRL" dy="385" dx="70" style="font-size: 22px;">
--.- °C
</text>
<text id="thermeVLfb" dy="290" dx="110" style="font-size: 22px;">
--.- °C
</text>
<text id="thermeVLww" dy="180" dx="70" style="font-size: 22px;">
--.- °C
</text>
<text id="triacLbl" dy="350" dx="515" style="font-size: 22px;">
Triac:
</text>
<text id="triac" dy="375" dx="515" style="font-size: 22px;">
--.- °C
</text>
</g>
<svg xmlns="http://www.w3.org/2000/svg" style="border:0px solid gray;background-color: #212529;" viewBox="0 0 630 750" width= "100%" height= "100%">
<g style="fill:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;stroke:#666666;fill:#666666;" stroke-width="10" transform="scale (2) translate(0 -25)">
<path
id="Puffer"
d="m 227,68 c 0,0 0,-28 -74,-28 -75,0 -75,27 -75,27 V 353 c 0,0 10,31 76,31 66,0 75,-32 75,-32 z"
style="fill:#212529;fill-opacity:1;stroke-width:2;" />
<ellipse
ry="3"
rx="1"
cy="112"
cx="211"
id="path902" />
<ellipse
ry="5"
rx="3"
cy="163"
cx="211"
id="path904" />
<ellipse
ry="5"
rx="3"
cy="282"
cx="210"
id="path904-6" />
<ellipse
ry="5"
rx="3"
cy="345"
cx="213"
id="path904-2" />
<ellipse
ry="3"
rx="1"
cy="332"
cx="213"
id="path902-4" />
<ellipse
ry="3"
rx="1"
cy="121"
cx="100"
id="path902-0" />
<ellipse
ry="3"
rx="1"
cy="164"
cx="99" />
<ellipse
ry="3"
rx="1"
cy="175"
cx="99"
id="path902-8" />
<ellipse
ry="3"
rx="1"
cy="223"
cx="98"
id="path902-2" />
<ellipse
ry="3"
rx="1"
cy="257"
cx="98"
id="path902-07" />
<path
id="heatweRLtube1"
d="m 211,282 h 95 v -44"
style="fill:none;" />
<path
id="path976"
d="m 212,163 h 58"
style="fill:none;" />
<path
id="path978"
d="M 32,140 H 2 v 61 H 32 Z"
style="fill:none;stroke-width:1px;" />
<path
id="path980"
d="M 99,121 H 9 v 18"
style="fill:none;" />
<path
id="path982"
d="M 33,175 H 98"
style="fill:none;" />
<path
id="path984"
d="m 8,201 v 21 H 98"
style="fill:none;" />
<path
id="fbVLtube"
d="m 99,164 h 7 V 244 H 8 v 23"
style="fill:none;" />
<path
id="fbRLtube"
d="m 8,302 v 32 H 107 v -79 h -9"
style="fill:none;" />
<path
id="path990"
d="m 211,112 h 39 V 68"
style="fill:none;" />
<path
id="path992"
d="M 26,151 H 8 v 2 H 6 v 4 l -1,1 v 30 H 28 v -34 l -1,-1 v -4 z"
style="fill:none;stroke-width:1px;" />
<path
id="path994"
d="M 28,167 H 5"
style="fill:none;stroke-width:1px;" />
<path
id="path996"
d="M 5,161 H 28"
style="fill:none;stroke-width:1px;" />
<path
id="path998"
d="M 27,156 H 7"
style="fill:none;stroke-width:1px;s" />
<path
id="path1000"
d="m 10,152 1,2 H 24 l 1,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1002"
d="m 24,167 v 5 h 4"
style="fill:none;stroke-width:1px;" />
<path
id="path1004"
d="M 47,268 H 1 v 34 H 47 v -34 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1006"
d="m 8,269 v 2 c 0,0 -1,0 -1,1 0,1 1,1 1,1 H 40 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 c 0,0 -1,0 -1,1 0,1 1,1 1,1 H 40 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 c 0,0 -1,0 -1,1 0,1 1,1 1,1 H 40 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 c 0,0 -1,0 -1,1 0,1 1,1 1,1 H 40 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 c 0,0 -1,0 -1,1 -0,1 1,1 1,1 h 31 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 v 4"
style="fill:none;stroke-width:1px;" />
<path
id="path1008"
d="M 294,238 Z"
style="fill:none;stroke-width:1px;" />
<path
id="heizerRLtube2"
d="m 295,238 h 10"
style="fill:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
id="path1012"
d="M 270,163 Z"
style="fill:none;stroke-width:1px;" />
<path
id="heizregler"
d="m 282,247 v 7 c 0,0 0,4 7,4 6,0 7,-4 7,-4 v -7 c 0,0 -0,3 -8,3 -7,0 -6,-3 -6,-3 z"
style="fill:none;stroke-width:1px;" />
<path
id="Heizstab"
d="m 293,246 v -74 c 0,0 -0,-2 -4,-2 -4,0 -4,2 -4,2 v 74 c 0,0 -0,1 4,1 4,-0 4,-1 4,-1 z"
style="fill:#212529;stroke-width:1px;" />
<path
id="path1020"
d="m 270,163 h 19 v 5"
style="fill:none;" />
<path
id="path1022"
d="m 282,247 c 0,0 -0,-2 2,-2"
style="fill:none;stroke-width:1px;" />
<path
id="path1024"
d="m 296,247 c 0,0 0,-2 -3,-3"
style="fill:none;stroke-width:1px;" />
<path
id="path976-2"
d="m 213,332 h 75"
style="fill:none;stroke-width:4;" />
<path
id="path1043"
d="m 240,338 0,-2 -7,3 6,3 -0,-2 4,-0 -0,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1043-9"
d="m 240,169 0,-2 -7,3 6,3 -0,-2 4,-0 -0,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1043-5"
d="m 65,228 0,-2 -7,3 6,3 -0,-2 4,-0 -0,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1043-3"
d="m 65,250 0,-2 -7,3 6,3 -0,-2 4,-0 -0,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1043-94"
d="m 253,288 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1043-94-5"
d="m 236,103 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1043-94-2"
d="m 65,342 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1043-94-6"
d="m 65,126 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1043-94-9"
d="m 65,180 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
style="fill:none;stroke-width:1px;" />
<path
id="path1123"
d="m 253,52 h 9 c 0,0 3,-0 5,1 1,1 1,5 1,5 l -0,3 c 0,0 2,0 3,1 1,1 1,3 1,3 h -11 c 0,0 -0,-2 1,-4 1,-1 2,-1 2,-1 v -3 c 0,0 0,-2 -1,-3 -1,-0 -2,-0 -2,-0 h -9"
style="fill:none;stroke-width:1px;" />
<path
id="path1125"
d="m 266,68 -0,2"
style="fill:none;stroke-width:1px;" />
<path
id="path1127"
d="m 266,73 v 1"
style="fill:none;stroke-width:1px;" />
<path
id="path1129"
d="m 268,68 v 2"
style="fill:none;stroke-width:1px;" />
<path
id="path1131"
d="m 268,73 v 1"
style="fill:none;stroke-width:1px;" />
<path
id="path1133"
d="m 271,69 v 2"
style="fill:none;stroke-width:1px;" />
<path
id="path1135"
d="M 271,74 V 75"
style="fill:none;stroke-width:1px;" />
<path
id="path1137"
d="m 285,314 h -4 v 12 h 4"
style="fill:none;stroke-width:1px;" />
<path
id="path1139"
d="m 280,318 h -4 v -5 h -4 v 5 h -4 c 0,0 -1,-0 -2,1 -0,1 -0,4 -0,4 0,0 -0,3 2,3 2,0 2,-2 2,-3 0,-1 2,-1 2,-1 l 9,0"
style="fill:none;stroke-width:1px;" />
<path
id="path1141"
d="m 278,312 h -9 c -1,0 -2,-1 0,-1 2,0 2,1 4,1 2,0 3,-1 5,-1 2,0 0,1 -0,1 z"
style="fill:none;stroke-width:1px;" />
</g>
<g style="font-size: 28px; font-weight: 600; font-family: sans-serif; text-anchor: middle; fill: rgb(200, 210, 210); letter-spacing: 1px;" >
<text id="PufferOtxt" dy="200" dx="320">
--.- °C
</text>
<text id="PufferMtxt" dy="400" dx="320">
--.- °C
</text>
<text id="PufferUtxt" dy="600" dx="320">
--.- °C
</text>
<text id="heaterRL" dy="505" dx="530" style="font-size: 22px;">
--.- °C
</text>
<text id="heaterVL" dy="260" dx="530" style="font-size: 22px;">
--.- °C
</text>
<text id="fbVL" dy="465" dx="70" style="font-size: 22px;">
--.- °C
</text>
<text id="fbRL" dy="605" dx="70" style="font-size: 22px;">
--.- °C
</text>
<text id="thermeRL" dy="385" dx="70" style="font-size: 22px;">
--.- °C
</text>
<text id="thermeVLfb" dy="290" dx="110" style="font-size: 22px;">
--.- °C
</text>
<text id="thermeVLww" dy="180" dx="70" style="font-size: 22px;">
--.- °C
</text>
<text id="triacLbl" dy="350" dx="515" style="font-size: 22px;">
Triac:
</text>
<text id="triac" dy="375" dx="515" style="font-size: 22px;">
--.- °C
</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

-893
View File
@@ -1,893 +0,0 @@
<style>
/* Fluss-Animation als transform statt stroke-dashoffset.
stroke-dashoffset ist nicht kompositierbar - jede Stufe kostete einen
Neuzeichenvorgang auf dem Hauptthread. Ein transform kann der Compositor
dagegen selbst verschieben.
Die Punkte laufen eine Periode (111 Einheiten) weit, danach steht der
naechste an derselben Stelle - der Zyklus schliesst nahtlos.
--dx/--dy stehen pro Linie im style-Attribut, --w setzt setFlowWidth().
steps(40) = 20 Bewegungen pro Sekunde, wie in der urspruenglichen
Fassung. Das deckelt die Neuzeichnungen fuer den Fall, dass der Browser
das transform doch nicht kompositiert; auf dem Compositor kostet die
Stufung nichts. Fuer volle Fluessigkeit: linear statt steps(40). */
.stream, .stream-rev {
animation: flow 2s steps(40) infinite;
}
.stream-rev {
animation-direction: reverse;
}
@keyframes flow {
to { transform: translate(var(--dx), var(--dy)); }
}
.stream circle, .stream-rev circle {
r: calc(var(--w, 0px) / 2);
fill: gray;
}
.powerTextClass {
font-size: 27px;
font-family: sans-serif;
text-anchor: middle;
fill: rgb(200, 210, 210);
letter-spacing: 0px;
}
.infoTextClass {
font-size: 18px;
font-family: sans-serif;
text-anchor: middle;
fill: rgb(200, 210, 210);
}
.tooltipTextClass {
font-size: 18px;
font-family: sans-serif;
text-anchor: left;
fill: rgb(200, 210, 210);
}
.clickable {
cursor: pointer;
}
</style>
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="-100 -100 1600 950" width="100%" height="100%" id="realtimeSvgAni">
<defs>
<filter
id="glow" filterUnits="userSpaceOnUse"
x="-20%" y="-20%" width="120%" height="120%">
<feGaussianBlur in="SourceGraphic" stdDeviation="7" result="blur5" />
<feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur10" />
<feGaussianBlur in="SourceGraphic" stdDeviation="14" result="blur20" />
<feGaussianBlur in="SourceGraphic" stdDeviation="17" result="blur30" />
<feGaussianBlur in="SourceGraphic" stdDeviation="20" result="blur50" />
<feMerge result="blur-merged">
<feMergeNode in="blur5" />
<feMergeNode in="blur10" />
<feMergeNode in="blur10" />
<feMergeNode in="blur20" />
</feMerge>
<feMerge>
<feMergeNode in="blur-merged" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<clipPath id="clip_consOGani"><rect x="-30" y="-30" width="404.4" height="60" transform="translate(770.0,380.0) rotate(-64.1790)"/></clipPath>
<clipPath id="clip_consAni"><rect x="-30" y="-30" width="400.0" height="60" transform="translate(430.0,380.0) rotate(0.0000)"/></clipPath>
<clipPath id="clip_consEGani"><rect x="-30" y="-30" width="386.5" height="60" transform="translate(770.0,380.0) rotate(62.6501)"/></clipPath>
<clipPath id="clip_consUGani"><rect x="-30" y="-30" width="396.2" height="60" transform="translate(770.0,380.0) rotate(120.3791)"/></clipPath>
<clipPath id="clip_consHeatAni"><rect x="-30" y="-30" width="410.0" height="60" transform="translate(770.0,380.0) rotate(0.0000)"/></clipPath>
<clipPath id="clip_consEVani"><rect x="-30" y="-30" width="360.0" height="60" transform="translate(920.0,670.0) rotate(0.0000)"/></clipPath>
<clipPath id="clip_consEVOGani"><rect x="-30" y="-30" width="360.0" height="60" transform="translate(920.0,70.0) rotate(0.0000)"/></clipPath>
<clipPath id="clip_consGridani"><rect x="-30" y="-30" width="390.0" height="60" transform="translate(100.0,380.0) rotate(0.0000)"/></clipPath>
<clipPath id="clip_pvani"><rect x="-30" y="-30" width="413.6" height="60" transform="translate(180.0,130.0) rotate(45.0000)"/></clipPath>
<clipPath id="clip_battani"><rect x="-30" y="-30" width="413.6" height="60" transform="translate(180.0,630.0) rotate(-45.0000)"/></clipPath>
</defs>
<g opacity="0">
<animate begin="lastInitAni.end" attributetype="CSS" attributeName="opacity" from="0.0" to="1.0"
dur="0.5s" repeatCount="1" calcMode="spline" keyTimes="0;1" keySplines=".1,0,.27,1"
fill="freeze" />
<animate begin="pvInfo.click" attributetype="CSS" attributeName="opacity" from="1.0" to="0.0"
dur="1s"
repeatCount="1" calcMode="spline" keyTimes="0;1" keySplines=".1,0,.27,1" fill="freeze" />
<animate begin="closeDetails.click" attributetype="CSS" attributeName="opacity" from="0.0"
to="1.0"
dur="1s" repeatCount="1" calcMode="spline" keyTimes="0;1" keySplines=".1,0,.27,1"
fill="freeze" />
<!-- CONS pos: 330, 280
+340, 0 (center +100 in x und y)
Cons OG pos: 330,280
+500 -300 (center +90 in x und y)
-->
<line x1="770" y1="380" x2="920" y2="70" style="stroke:#777;stroke-width:2;stroke-dasharray:1,6;" />
<g clip-path="url(#clip_consOGani)"><g id="consOGani" class="stream" style="--dx:48.347px;--dy:-99.918px"><circle cx="721.7" cy="479.9"/><circle cx="770.0" cy="380.0"/><circle cx="818.3" cy="280.1"/><circle cx="866.7" cy="180.2"/><circle cx="915.0" cy="80.2"/><circle cx="963.4" cy="-19.7"/></g></g>
<line x1="430" y1="380" x2="770" y2="380" style="stroke:#777;stroke-width:2;stroke-dasharray:1,6;" />
<g clip-path="url(#clip_consAni)"><g id="consAni" class="stream" style="--dx:111.000px;--dy:0.000px"><circle cx="319.0" cy="380.0"/><circle cx="430.0" cy="380.0"/><circle cx="541.0" cy="380.0"/><circle cx="652.0" cy="380.0"/><circle cx="763.0" cy="380.0"/><circle cx="874.0" cy="380.0"/></g></g>
<line x1="770" y1="380" x2="920" y2="670" style="stroke:#777;stroke-width:2;stroke-dasharray:1,6;" />
<g clip-path="url(#clip_consEGani)"><g id="consEGani" class="stream" style="--dx:50.996px;--dy:98.592px"><circle cx="719.0" cy="281.4"/><circle cx="770.0" cy="380.0"/><circle cx="821.0" cy="478.6"/><circle cx="872.0" cy="577.2"/><circle cx="923.0" cy="675.8"/></g></g>
<line x1="770" y1="380" x2="600" y2="670" style="stroke:#777;stroke-width:2;stroke-dasharray:1,6;" />
<g clip-path="url(#clip_consUGani)"><g id="consUGani" class="stream" style="--dx:-56.135px;--dy:95.759px"><circle cx="826.1" cy="284.2"/><circle cx="770.0" cy="380.0"/><circle cx="713.9" cy="475.8"/><circle cx="657.7" cy="571.5"/><circle cx="601.6" cy="667.3"/><circle cx="545.5" cy="763.0"/></g></g>
<line x1="770" y1="380" x2="1120" y2="380" style="stroke:#777;stroke-width:2;stroke-dasharray:1,6;" />
<g clip-path="url(#clip_consHeatAni)"><g id="consHeatAni" class="stream" style="--dx:111.000px;--dy:0.000px"><circle cx="659.0" cy="380.0"/><circle cx="770.0" cy="380.0"/><circle cx="881.0" cy="380.0"/><circle cx="992.0" cy="380.0"/><circle cx="1103.0" cy="380.0"/><circle cx="1214.0" cy="380.0"/></g></g>
<line x1="920" y1="670" x2="1220" y2="670" style="stroke:#777;stroke-width:2;stroke-dasharray:1,6;" />
<g clip-path="url(#clip_consEVani)"><g id="consEVani" class="stream" style="--dx:111.000px;--dy:0.000px"><circle cx="809.0" cy="670.0"/><circle cx="920.0" cy="670.0"/><circle cx="1031.0" cy="670.0"/><circle cx="1142.0" cy="670.0"/><circle cx="1253.0" cy="670.0"/></g></g>
<line x1="920" y1="70" x2="1220" y2="70" style="stroke:#777;stroke-width:2;stroke-dasharray:1,6;" />
<g clip-path="url(#clip_consEVOGani)"><g id="consEVOGani" class="stream" style="--dx:111.000px;--dy:0.000px"><circle cx="809.0" cy="70.0"/><circle cx="920.0" cy="70.0"/><circle cx="1031.0" cy="70.0"/><circle cx="1142.0" cy="70.0"/><circle cx="1253.0" cy="70.0"/></g></g>
<line x1="100" y1="380" x2="430" y2="380" style="stroke:#777;stroke-width:2;stroke-dasharray:1,6;" />
<g clip-path="url(#clip_consGridani)"><g id="consGridani" class="stream" style="--dx:111.000px;--dy:0.000px"><circle cx="-11.0" cy="380.0"/><circle cx="100.0" cy="380.0"/><circle cx="211.0" cy="380.0"/><circle cx="322.0" cy="380.0"/><circle cx="433.0" cy="380.0"/></g></g>
<line x1="180" y1="130" x2="430" y2="380" style="stroke:#777;stroke-width:2;stroke-dasharray:1,6;" />
<g clip-path="url(#clip_pvani)"><g id="pvani" class="stream" style="--dx:78.489px;--dy:78.489px"><circle cx="101.5" cy="51.5"/><circle cx="180.0" cy="130.0"/><circle cx="258.5" cy="208.5"/><circle cx="337.0" cy="287.0"/><circle cx="415.5" cy="365.5"/><circle cx="494.0" cy="444.0"/></g></g>
<line x1="180" y1="630" x2="430" y2="380" style="stroke:#777;stroke-width:2;stroke-dasharray:1,6;" />
<g clip-path="url(#clip_battani)"><g id="battani" class="stream" style="--dx:78.489px;--dy:-78.489px"><circle cx="101.5" cy="708.5"/><circle cx="180.0" cy="630.0"/><circle cx="258.5" cy="551.5"/><circle cx="337.0" cy="473.0"/><circle cx="415.5" cy="394.5"/><circle cx="494.0" cy="316.0"/></g></g>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg"
style="position:absolute;top:0;"
viewBox="-100 -100 1600 950" width="100%" height="100%" id="realtimeSvg">
<defs>
<filter id="shadow" x="-0.3" y="-0.3" width="160%" height="160%">
<feMorphology in="SourceAlpha" result="offOut" operator="dilate" radius="0.7" />
<feGaussianBlur result="blurOut" in="offOut" stdDeviation="0.5" />
<feBlend in="SourceGraphic" in2="blurOut" mode="normal" />
</filter>
<filter id="shadowMiddle" x="-0.4" y="-0.4" width="180%" height="180%">
<feMorphology in="SourceAlpha" result="offOut" operator="dilate" radius="3" />
<feGaussianBlur result="blurOut" in="offOut" stdDeviation="1" />
<feBlend in="SourceGraphic" in2="blurOut" mode="normal" />
</filter>
<filter id="shadowBig" x="-0.45" y="-0.45" width="190%" height="190%">
<feMorphology in="SourceAlpha" result="offOut" operator="dilate" radius="3" />
<feGaussianBlur result="offOut" in="offOut" stdDeviation="2" />
<feBlend in="SourceGraphic" in2="offOut" mode="normal" />
</filter>
<g id="circGrid">
<line x2='27.7' y2='76.5' x1='-18.9' y1='61.4' style='stroke-width:4px' />
<line x2='16.5' y2='115.9' x1='-8.1' y1='120.6' style='stroke:#222;stroke-width:2;' />
<line x2='34.5' y2='154.2' x1='15.2' y1='170.1' style='stroke:#222;stroke-width:2;' />
<line x2='68.7' y2='179' x1='59.5' y1='202.3' style='stroke:#222;stroke-width:2;' />
<line x2='110.7' y2='184.3' x1='113.8' y1='209.1' style='stroke:#222;stroke-width:2;' />
<line x2='150' y2='168.8' x1='164.7' y1='189' style='stroke:#222;stroke-width:2;' />
<line x2='176.9' y2='136.2' x1='199.5' y1='146.8' style='stroke:#222;stroke-width:2;' />
<line x2='184.8' y2='94.7' x1='209.8' y1='93.1' style='stroke:#222;stroke-width:2;' />
<line x2='171.8' y2='54.5' x1='192.9' y1='41.1' style='stroke:#222;stroke-width:2;' />
<line x2='140.9' y2='25.5' x1='153' y1='3.6' style='stroke:#222;stroke-width:2;' />
<line x2='100' y2='24' x1='100' y1='-24' style='stroke-width:4px' />
</g>
<g id="circGrid6kW">
<line x2='27.7' y2='76.5' x1='-18.9' y1='61.4' style='stroke-width:4px' />
<line x2='26.4' y2='142.5' x1='4.7' y1='155' style='stroke:#222;stroke-width:2;' />
<line x2='82.3' y2='183.1' x1='77.1' y1='207.6' style='stroke:#555;stroke-width:4px;' />
<line x2='149.9' y2='168.8' x1='164.6' y1='189.0' style='stroke:#222;stroke-width:2;' />
<line x2='184.5' y2='108.9' x1='209.4' y1='111.5' style='stroke:#555;stroke-width:4px;' />
<line x2='163.2' y2='43.1' x1='181.7' y1='26.4' style='stroke:#222;stroke-width:2;' />
<line x2='100' y2='24' x1='100' y1='-24' style='stroke-width:4px' />
</g>
<linearGradient id="gradient1" x1="0" x2="0" y1="0" y2="1">
<stop id="tmpH" offset="0%" stop-color="#2389BA" stop-opacity="0.7" />
<stop id="tmpM" offset="50%" stop-color="#2389BA" stop-opacity="0.7" />
<stop id="tmpL" offset="100%" stop-color="#2389BA" stop-opacity="0.7" />
</linearGradient>
<filter id="fillpartialEV" primitiveUnits="objectBoundingBox" x="-5%" y="-5%" width="110%"
height="110%">
<feFlood flood-color="#1f5a66" />
<feComposite id="evChargeState" y="100%" operator="in" in2="SourceGraphic" />
<feComposite operator="over" in2="SourceGraphic" />
</filter>
<filter id="fillpartialHeat" primitiveUnits="objectBoundingBox" x="-5%" y="-5%" width="110%"
height="110%">
<feFlood flood-color="#5E2B21" />
<feComposite id="heatChargeState" y="100%" operator="in" in2="SourceGraphic" />
<feComposite operator="over" in2="SourceGraphic" />
</filter>
<filter id="fillpartialBat" primitiveUnits="objectBoundingBox" x="-5%" y="-5%" width="110%"
height="110%">
<feFlood flood-color="#25481C" />
<feComposite id="batChargeState" y="100%" operator="in" in2="SourceGraphic" />
<feComposite operator="over" in2="SourceGraphic" />
</filter>
<filter id="fillpartialWater" primitiveUnits="objectBoundingBox" x="-5%" y="-5%" width="110%"
height="110%">
<feFlood flood-color="#182C43" />
<feComposite id="waterState" y="50%" operator="in" in2="SourceGraphic" />
<feComposite operator="over" in2="SourceGraphic" />
</filter>
<g id="heater" transform="rotate(-90),scale(0.07,0.07)">
<path
d="M485.924,134.508c14.381,0,26.076-11.669,26.076-26.017v-17.39c0-14.348-11.695-26.017-26.076-26.017H468.61v-8.737
c0-23.89-19.466-43.331-43.39-43.331s-43.39,19.441-43.39,43.331v8.737h-26.034v-8.737c0-23.89-19.466-43.331-43.39-43.331
c-23.924,0-43.39,19.441-43.39,43.331v8.737h-26.034v-8.737c0-23.89-19.466-43.331-43.39-43.331
c-23.924,0-43.39,19.441-43.39,43.331v8.737h-26.034v-8.737c0-23.89-19.466-43.331-43.39-43.331S43.39,32.458,43.39,56.348v8.737
H17.356V39.051H0v34.712v52.068v34.712h17.356v-26.034H43.39v269.017H8.678c-4.797,0-8.678,3.881-8.678,8.678v34.712
c0,4.797,3.881,8.678,8.678,8.678c4.797,0,8.678-3.881,8.678-8.678v-26.034H43.39v34.78c0,23.89,19.466,43.322,43.39,43.322
s43.39-19.432,43.39-43.322v-0.068h26.034v0.068c0,23.89,19.466,43.322,43.39,43.322c23.924,0,43.39-19.432,43.39-43.322v-0.068
h26.034v0.068c0,23.89,19.466,43.322,43.39,43.322c23.924,0,43.39-19.432,43.39-43.322v-0.068h26.034v0.068
c0,23.89,19.466,43.322,43.39,43.322s43.39-19.432,43.39-43.322v-0.068h17.314c14.381,0,26.076-11.669,26.076-26.017v-17.39
c0-14.347-11.695-26.017-26.076-26.017H468.61V134.508H485.924z M468.61,82.441h17.314c4.805,0,8.72,3.881,8.72,8.661v17.39
c0,4.78-3.915,8.661-8.72,8.661H468.61V82.441z M43.39,117.153H17.356V82.441H43.39V117.153z M112.814,73.763v52.068v269.017
v52.068v8.746c0,14.313-11.678,25.966-26.034,25.966s-26.034-11.653-26.034-25.966V125.831V73.763V56.348
c0-14.322,11.678-25.975,26.034-25.975s26.034,11.653,26.034,25.975V73.763z M156.203,438.237h-26.034v-34.712h26.034V438.237z
M156.203,386.169h-26.034V134.508h26.034V386.169z M156.203,117.153h-26.034V82.441h26.034V117.153z M225.627,73.763v52.068
v269.017v52.068v8.746c0,14.313-11.678,25.966-26.034,25.966s-26.034-11.653-26.034-25.966v-8.746v-52.068V125.831V73.763V56.348
c0-14.322,11.678-25.975,26.034-25.975s26.034,11.653,26.034,25.975V73.763z M269.017,438.237h-26.034v-34.712h26.034V438.237z
M269.017,386.169h-26.034V134.508h26.034V386.169z M269.017,117.153h-26.034V82.441h26.034V117.153z M338.441,73.763v52.068
v269.017v52.068v8.746c0,14.313-11.678,25.966-26.034,25.966s-26.034-11.653-26.034-25.966v-8.746v-52.068V125.831V73.763V56.348
c0-14.322,11.678-25.975,26.034-25.975s26.034,11.653,26.034,25.975V73.763z M381.831,438.237h-26.034v-34.712h26.034V438.237z
M381.831,386.169h-26.034V134.508h26.034V386.169z M381.831,117.153h-26.034V82.441h26.034V117.153z M451.254,73.763v52.068
v269.017v52.068v8.746c0,14.313-11.678,25.966-26.034,25.966s-26.034-11.653-26.034-25.966v-8.746v-52.068V125.831V73.763V56.348
c0-14.322,11.678-25.975,26.034-25.975s26.034,11.653,26.034,25.975V73.763z M485.924,403.525c4.805,0,8.72,3.881,8.72,8.661
v17.39c0,4.78-3.915,8.661-8.72,8.661H468.61v-34.712H485.924z" />
</g>
<g id="gas">
<path fill-opacity="1" stroke-width="0.2" stroke-linejoin="round"
d="M 42.75,19L 42.75,31.4977C 44.5834,31.378 48.9184,32.7881 48.2917,49.0833C 49.0833,53.8333 54.625,53.8333 55.8125,47.5C 57,42.75 53.8333,42.75 52.25,28.5C 52.25,26.9167 52.25,22.1667 45.9167,19L 45.9167,17.4167C 52.25,17.4167 57,25.3333 57,26.9167C 57,28.5 55.4167,28.5 55.4167,28.5C 55.4167,28.5 55.8125,38 57.3958,39.5833C 58.9792,41.1667 58.5833,45.9167 58.5833,49.0833C 58.5833,52.25 53.8333,55.4167 52.25,55.4167C 50.6667,55.4167 45.9167,55.4167 45.9167,50.6667C 45.9167,46.3403 45.2599,36.7598 42.75,35.0854L 42.75,55.4167C 43.6244,55.4167 44.3333,56.1255 44.3333,57L 44.3333,60.1667L 19,60.1667L 19,57C 19,56.1255 19.7089,55.4167 20.5833,55.4167L 20.5833,19C 20.5833,17.2511 22.0011,15.8333 23.75,15.8333L 39.5833,15.8333C 41.3322,15.8333 42.75,17.2511 42.75,19 Z M 25.7291,33.2501L 37.6041,33.25C 38.4786,33.25 39.5833,32.1453 39.5833,31.2709L 39.5833,20.9792C 39.5833,20.1048 38.4786,19 37.6041,19L 25.7292,19C 24.8547,19 23.75,20.1048 23.75,20.9792L 23.75,31.2709C 23.75,32.1453 24.8547,33.2501 25.7291,33.2501 Z " />
</g>
<g id="water">
<path
d="M283.897,92.846c-36.582-49.345-73.688-89.267-74.061-89.664C207.944,1.153,205.296,0,202.523,0
c-2.774,0-5.423,1.152-7.314,3.182c-0.371,0.397-37.478,40.319-74.06,89.664c-49.971,67.403-75.308,119.726-75.308,155.513
c0,86.396,70.287,156.688,156.682,156.688c86.396,0,156.683-70.29,156.683-156.688C359.206,212.572,333.868,160.25,283.897,92.846z
M218.171,354.342c-8.213,1.941-16.68,2.926-25.162,2.926c-60.294,0-109.347-49.055-109.347-109.35
c0-8.312,2.559-23.373,14.75-47.914c1.225-2.467,4.046-3.691,6.687-2.908c2.639,0.785,4.33,3.357,4.007,6.091
c-0.28,2.361-0.421,4.584-0.421,6.607c0,64.629,45.966,120.77,109.298,133.484c2.607,0.525,4.5,2.795,4.545,5.455
C222.575,351.396,220.761,353.729,218.171,354.342z" />
</g>
<g id="raisedBedIcon" filter="url(#shadow)">
<path d="M2 8h20v12H2V8Z" fill="none" stroke="currentColor" stroke-width="1.8" />
<path d="M4 12h16M5 16h14" fill="none" stroke="currentColor" stroke-width="1.4" />
<path d="M7 8V4m0 2L5 4m2 2 2-2M13 8V3m0 3-2-2m2 2 2-2M18 8V4m0 2-2-2m2 2 2-2" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
</g>
<g id="flowerPotIcon" filter="url(#shadow)">
<path d="M3 8h18l-2 4H5L3 8Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
<path d="M6 12h12l-2 8H8l-2-8Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
<path d="M12 8V3m0 3L9 4m3 2 3-2" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
</g>
<g id="gardenPlantIcon" filter="url(#shadow)">
<path d="M12 19V10m0 4-4-3m4 1 4-4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 3C9 3 7 5 7 8c-3 0-5 2-5 5s2 5 5 5h10c3 0 5-2 5-5s-2-5-5-5c0-3-2-5-5-5Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
<path d="M5 22h14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</g>
<g id="voltage">
<path stroke="#212529" stroke-width="0.2px" d="M11.46,18V13.58H9.86L12.54,8v3.89h1.6Z" />
</g>
<path id="powerTextPath" d="M 100, 100 m -87, 0 a 87,87 0 1,1 174,0 a 87,87 0 1,1 -174,0"></path>
<path id="infoTextPath" d="M 100, 100 m -105, 0 a 105,105 0 1,0 210,0 a 105,105 0 1,0 -210,0"></path>
<g id="pvPlate" fill="none" stroke-width="4" stroke="#f7c000">
<rect width="70" height="40" x="0" y="0" rx="5" ry="5" />
</g>
<g id="ElementBG">
<path fill="#212529" stroke-width="1.5"
d="M 99.80277789465349 -12.999827891201505 A 113 113 0 1 0 100 -13" />
<path fill="none" stroke-width="4"
d="M 99.86386438745993 22.00011880076356 A 78 78 0 1 0 100 22" />
</g>
<g id="plugIcon" filter="url(#shadow)" transform="translate(30,27),scale(6,6)" fill="#71afcd">
<path d="M15.21,6.73V2.63A.72.72,0,0,0,14.4,2h0a.71.71,0,0,0-.75.66V6.73Z" />
<path d="M10.39,6.73V2.62A.7.7,0,0,0,9.6,2h0a.73.73,0,0,0-.78.66V6.73Z" />
<path
d="M6.71,7.23C6.37,7.22,6,8,6,8.8V10c0,2.48.57,4.62,1.63,5.28S9,16,10.35,16.77V22h3.3V16.77c1.38-.8,1.66-.85,2.72-1.51S18,12.46,18,10V8.8c0-.78-.37-1.58-.71-1.57Z" />
</g>
<g id="ogIcon" filter="url(#shadow)" transform="translate(52,45),scale(4,4)">
<polygon fill="#70CCBB"
points="12.01 1.5 2 10.32 3.11 10.32 3.11 21.5 5.12 21.5 5.12 10.32 12 4.13 18.89 10.32 18.89 21.5 20.89 21.5 20.89 10.32 22 10.32 12.01 1.5" />
<path fill="#70CCBB"
d="M8.55,12.65v3.49a2.12,2.12,0,0,0,2.13,2.09h.67s0,3.23,0,3.27h1.49s0-3.24,0-3.28h.77a1.82,1.82,0,0,0,1.8-1.85l0-3.73" />
<path fill="#70CCBB" d="M10.76,11.41v-1.3c0-.45-.17-.67-.52-.68s-.54.22-.54.69v1.67h1.06Z" />
<path fill="#70CCBB"
d="M14.3,11.41c0-.44,0-.88,0-1.3s-.17-.67-.51-.68-.55.22-.55.69v1.67H14.3Z" />
<path fill="#212529"
d="M9.86,13.86h4.28a0,0,0,0,1,0,0v2a1,1,0,0,1-1,1H10.86a1,1,0,0,1-1-1v-2A0,0,0,0,1,9.86,13.86Z" />
</g>
<g id="heatIcon" filter="url(#shadow)" transform="translate(52,70),scale(4,4)" fill="#C2614E">
<path clip-path="M-22 2.24h42V22h-42z"
d="M16.543 8.028c-.023 1.503-.523 3.538-2.867 4.327.734-1.746.846-3.417.326-4.979-.695-2.097-3.014-3.735-4.557-4.627-.527-.306-1.203.074-1.193.683.02 1.112-.318 2.737-1.959 4.378C4.107 9.994 3 12.251 3 14.517 3 17.362 5 21 9 21c-4.041-4.041-1-7.483-1-7.483C8.846 19.431 12.988 21 15 21c1.711 0 5-1.25 5-6.448 0-3.133-1.332-5.511-2.385-6.899-.347-.458-1.064-.198-1.072.375" />
</g>
<g id="egIcon" filter="url(#shadow)" transform="translate(52,50),scale(4,4)">
<rect stroke="#70CC9C" stroke-width="2" fill="none" x="3" y="3" width="18" height="18" rx="2" />
<path fill="#70CC9C"
d="M8.55,9.2v3.47a2.11,2.11,0,0,0,2.13,2.08h.67s0,3.22,0,3.26h1.49s0-3.22,0-3.26h.77a1.81,1.81,0,0,0,1.8-1.84l0-3.71" />
<path fill="#70CC9C" d="M10.76,8V6.67c0-.45-.17-.66-.52-.67s-.54.22-.54.68V8.34h1.06Z" />
<path fill="#70CC9C" d="M14.3,8c0-.44,0-.87,0-1.3S14.12,6,13.78,6s-.55.22-.55.68V8.34H14.3Z" />
<path fill="#212529"
d="M9.86,10.41h4.28a0,0,0,0,1,0,0v2a1,1,0,0,1-1,1H10.86a1,1,0,0,1-1-1v-2A0,0,0,0,1,9.86,10.41Z" />
</g>
<g id="ugIcon" filter="url(#shadow)" transform="translate(52,50),scale(4,4)">
<path
style="fill:none;stroke:#83a55b;stroke-width:2;stroke-linecap:round;stroke-linejoin:round"
d="M 2.977125,13.982235 V 2.973455 h 17.952227 l 0.03597,16.261337"
id="path12" />
<path fill="#83A55B"
d="M8.55,9.2v3.47a2.11,2.11,0,0,0,2.13,2.08h.67s0,3.22,0,3.26h1.49s0-3.22,0-3.26h.77a1.81,1.81,0,0,0,1.8-1.84l0-3.71" />
<path fill="#83A55B" d="M10.76,8V6.67c0-.45-.17-.66-.52-.67s-.54.22-.54.68V8.34h1.06Z" />
<path fill="#83A55B" d="M14.3,8c0-.44,0-.87,0-1.3S14.12,6,13.78,6s-.55.22-.55.68V8.34H14.3Z" />
<path fill="#212529"
d="M9.86,10.41h4.28a0,0,0,0,1,0,0v2a1,1,0,0,1-1,1H10.86a1,1,0,0,1-1-1v-2A0,0,0,0,1,9.86,10.41Z" />
<path
style="fill:none;stroke:#83a55b;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;"
d="M 23.96101,19.98372 C 11.24143,20.28899 11.139675,15.0994 -3.106255,14.081835" />
</g>
<g id="carIcon" filter="url(#shadow)" transform="translate(45,45),scale(4.8,4.8)"
fill="#212529" stroke-linecap="round">
<path d="M16.22,15.37l-9.3-.16L3,15,.88,14.47.75,10.39A2.78,2.78,0,0,1,1.31,9.2L2.55,7.63" />
<path
d="M.78,7.46,7,7l.87,0L11.37,7a5.57,5.57,0,0,1,1.06.23,9.43,9.43,0,0,1,2.16,1L17.23,9.9l4.22.7a1.73,1.73,0,0,1,1.21.93l.26.49a2.84,2.84,0,0,1,.32,1.6l0,.23A1.52,1.52,0,0,1,22.53,15h0a1.53,1.53,0,0,1-.56.21l-.89.15H15.59" />
<path fill="#888888" stroke="none"
d="M16.06,11c-1.85.11-3.7.14-5.55.16a13.36,13.36,0,0,1-2.82,0,2.62,2.62,0,0,1-.87-.4,1.28,1.28,0,0,1-.53-1.05,1.4,1.4,0,0,1,.09-.51,2.67,2.67,0,0,1,.22-.35,2.79,2.79,0,0,1,.5-.58s.09,0,.11,0a.06.06,0,0,1,0,.05h0c0,.25,0,.49,0,.71s0,.23,0,.31a.75.75,0,0,0,.08.18c.07.09.17.14.17.08a1.81,1.81,0,0,0,.43.05,14.41,14.41,0,0,1,2.68.33c1.82.26,3.64.53,5.46.88a.08.08,0,0,1,.06.09A.07.07,0,0,1,16.06,11Z" />
<circle cx="4.94" cy="15.08" r="1.98" />
<circle cx="19.19" cy="15.08" r="1.98" />
</g>
<g id="plugCordIcon" transform="scale(0.08,0.08),rotate(340),translate(-350,1200)">
<path
d="M489.8,280.92c0-13.9-11.3-25.2-25.2-25.2h-74.1c-62.1,0-112.4,50.2-112.7,112.2c-0.1,32.3-23,61-55,65
c-38.4,4.8-71.2-25.2-71.2-62.6v-56.4c57.6-11.7,101-62.6,101-123.7v-64c0-9.1-7.3-16.4-16.4-16.4h-31.3v-79.1
c0-12.4-9-23.4-21.3-24.6c-14.1-1.4-26,9.7-26,23.6v80.1H95.2v-79.1c0-12.4-9-23.4-21.4-24.6c-14.1-1.3-26,9.7-26,23.6v80.1H16.4
c-9.1,0-16.4,7.3-16.4,16.4v64c0,61.1,43.4,112,101,123.7v53.5c0,61.5,47.7,114,109.2,116.3c64.6,2.4,118-49.4,118-113.5v-0.9
c0-34.9,28.3-63.1,63.1-63.1h73.2C478.5,306.12,489.8,294.82,489.8,280.92L489.8,280.92z" />
</g>
<g id="lockIcon" transform="translate(80,130),scale(0.11,0.11)" style="">
<path
d="M65,330h200c8.284,0,15-6.716,15-15V145c0-8.284-6.716-15-15-15h-15V85c0-46.869-38.131-85-85-85
S80,38.131,80,85v45H65c-8.284,0-15,6.716-15,15v170C50,323.284,56.716,330,65,330z M207.481,219.356l-42.5,42.5
c-2.929,2.929-6.768,4.394-10.606,4.394s-7.678-1.465-10.606-4.394l-21.25-21.25c-5.858-5.858-5.858-15.354,0-21.213
c5.857-5.858,15.355-5.858,21.213,0l10.644,10.643l31.894-31.893c5.857-5.858,15.355-5.858,21.213,0
C213.34,204.002,213.34,213.498,207.481,219.356z M110,85c0-30.327,24.673-55,55-55s55,24.673,55,55v45H110V85z"
style="stroke-width:15px;stroke:rgb(30,30,40);fill:#778;" />
</g>
<g filter="url(#shadow)" id="clock" style="fill:#cc0;"
transform="translate(80,32),scale(0.07,0.07)">
<path
d="m256,51.8c-112.6,0-204.2,91.6-204.2,204.2s91.6,204.2 204.2,204.2 204.2-91.6 204.2-204.2-91.6-204.2-204.2-204.2v-1.42109e-14zm0,449.2c-135.1,0-245-109.9-245-245s109.9-245 245-245 245,109.9 245,245-109.9,245-245,245z" />
<path
d="m327.9,276.4h-71.9c-11.3,0-20.4-9.1-20.4-20.4v-132.2c0-11.3 9.1-20.4 20.4-20.4 11.3,0 20.4,9.1 20.4,20.4v111.8h51.5c11.3,0 20.4,9.1 20.4,20.4s-9.1,20.4-20.4,20.4z" />
</g>
<g id="sunIcon" filter="url(#shadow)" transform="translate(30,27),scale(6,6)"
style="stroke:#f7c000;stroke-width:2;stroke-linecap:round;">
<path id="Pfad_7565" data-name="Pfad 7565" fill="#f7c002" stroke-width="0"
d="M12,8a4,4,0,1,0,4,4A4,4,0,0,0,12,8Z" />
<line x1="12" y1="3" x2="12" y2="5" />
<line x1="12" y1="19" x2="12" y2="21" />
<line x1="19" y1="12" x2="21" y2="12" />
<line x1="3" y1="12" x2="5" y2="12" />
<line x1="5.7" y1="18.5" x2="7" y2="17" />
<line x1="5.7" y1="5.7" x2="7" y2="7" />
<line x1="18.5" y1="18.5" x2="16.7" y2="17" />
<line x1="18.5" y1="5.7" x2="16.7" y2="7" />
</g>
<g id="battIcon" filter="url(#shadow)" transform="translate(30,25),scale(6,6)">
<g>
<rect fill="#6cbe58" x="9.25" y="3" width="5.5" height="4" rx="1" ry="1" />
<path fill="#6cbe58"
d="M16.28,5H7.72A1.21,1.21,0,0,0,6.5,6.2V19.73A1.28,1.28,0,0,0,7.72,21h8.56a1.28,1.28,0,0,0,1.22-1.27V6.2A1.21,1.21,0,0,0,16.28,5Z" />
<rect id="discharged" fill="#212529" rx="1" ry="1" x="8" y="6.5" width="8"
height="13" />
<use id="charge" fill="#cccc00" href="#voltage" />
</g>
</g>
<g id="gridIcon" filter="url(#shadow)" transform="translate(30,27),scale(6,6)">
<path fill="#808090"
d="M13.38,8.54V6.63H18.3V8.06h.95V5.7l-6.35-1L12,2l-1,2.8-6.25.9V8.07H5.6V6.63h4.84V8.54l-7.59,1.9v2.84H3.8V11.76H9.87L6.93,22H9.11l.57-2.46h4.59L14.8,22h2.27L14.13,11.76H20.2v1.52h.95V10.44Zm-3.32,8.81L12,11.11l1.91,6.24Z" />
</g>
<g id="inverterIcon" filter="url(#shadowBig)" fill="#b2b2b2" stroke-width="2px" stroke="#666"
transform="translate(43,43),scale(1.6,1.6)">
<rect stroke-width="2px" x="5.65" y="3" width="58.7" height="64" rx="6.8" />
<circle cx="35.03" cy="29.61" r="11" />
<circle cx="15" cy="40" r="4" />
<path
d="M40.19,52H36.73a2,2,0,0,0-3.46,0H29.81a1.08,1.08,0,1,0,0,2.15h3.46a2,2,0,0,0,3.46,0h3.46a1.08,1.08,0,1,0,0-2.15Z" />
<path fill="#e2001a"
d="M34.89,61.87c-3.24,0-5.87-.73-5.87-1.62s2.63-1.64,5.87-1.64,5.87.74,5.87,1.64S38.13,61.87,34.89,61.87Z" />
<line x1="5.07" y1="53.08" x2="28.73" y2="53.08" />
<line x1="41.27" y1="53.08" x2="64.93" y2="53.08" />
</g>
<g id="northIcon" fill="#808090">
<path
d="M42.066 0v20h3.752V6.957L53.881 20h4.053V0h-3.752v13.355L45.996 0zm5.57 26.688l-25.77 69.949c-.823 2.238 1.658 4.248 3.677 2.978L50 84.195l24.455 15.42c2.02 1.273 4.504-.738 3.68-2.978l-25.79-70c-.472-1.096-1.283-1.632-2.384-1.635c-1.1-.003-2.017.856-2.324 1.686zm-.136 14.83V79.86L29.1 91.463z"></path>
</g>
<g filter="url(#shadow)" id="ecoIcon" transform="translate(60,12)">
<path style="stroke:#6cbe58;fill:rgb(30,30,40);" stroke-width="3" stroke-linejoin="round"
d="M 57,22.1667C 49.0833,28.5 52.25,31.6667 50.6667,36.4167C 49.0833,41.1667 50.6667,42.75 44.3333,47.5C 36.2227,53.583 29.2917,49.875 29.2917,49.875C 27.7083,51.4583 21.7708,54.2292 21.7708,54.2292L 20.0143,50.7285C 26.3476,49.1452 27.7083,47.1042 27.7083,47.1042C 26.125,43.9375 23.75,38 28.5,30.0833C 33.25,22.1667 57,22.1667 57,22.1667 Z M 30.0833,44.3333L 31.6667,45.9167C 31.6667,45.9167 34.8333,44.3333 36.4167,39.5833C 38,34.8333 45.9167,26.9167 45.9167,26.9167C 42.75,28.5 36.4167,34.8333 34.8333,39.5833C 33.25,44.3333 30.0833,44.3333 30.0833,44.3333 Z " />
</g>
<g filter="url(#shadow)" id="alertIcon" fill="#aa0" transform="translate(34,28),scale(2.2,2.2)">
<path
d="M214.413,746a4.455,4.455,0,0,1-3.84-2.166,4.249,4.249,0,0,1,0-4.334l25.572-43.331a4.483,4.483,0,0,1,7.679,0L269.4,739.5a4.249,4.249,0,0,1,0,4.334,4.452,4.452,0,0,1-3.84,2.166H214.413ZM240,706a4,4,0,0,0-4,4v16a4,4,0,0,0,8,0V710A4,4,0,0,0,240,706Zm0,36a4,4,0,1,0-4-4A4,4,0,0,0,240,742Z"
id="attention" transform="translate(-209.969 -694)" />
</g>
</defs>
<g id="pvInfo" class="clickable" transform="translate(330,280)">
<animateMotion begin="0.5s" dur="2s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".1,0,.27,1" path="M0 0 L-250 -250" fill="freeze">
</animateMotion>
<animate id="openDetails" begin="click" attributetype="CSS" attributeName="opacity" from="1"
to="1"
dur="0.1s" repeatCount="1" calcMode="spline"
fill="freeze">
</animate>
<!--<g filter="url(#glow)">-->
<use href="#ElementBG" stroke="#574400" />
<use href="#sunIcon" />
<path id="pvArc" fill="none" stroke="#f7c000" stroke-width="30" />
<path id="pv2Arc" fill="none" stroke="#f78d00" stroke-width="30" />
<path id="pv3Arc" fill="none" stroke="#f72300" stroke-width="30" />
<use href="#circGrid" style="stroke:#574400;" />
<!--</g>-->
<text filter="url(#shadowMiddle)" class="powerTextClass">
<textPath id="pvText" href="#powerTextPath" startOffset="15%">--.-- kW</textPath>
<textPath id="pv3txt" href="#infoTextPath" startOffset="12%">--.-- kW</textPath>
<textPath id="pv2txt" href="#infoTextPath" startOffset="35%">--.-- kW</textPath>
<textPath id="pv1txt" href="#infoTextPath" startOffset="55%">--W</textPath>
</text>
</g>
<circle id="closeDetails" fill="transparent" transform="translate(250,124)">
<animate begin="click" attributetype="CSS" attributeName="r" from="120" to="0" dur="0.1s"
repeatCount="1"
fill="freeze" />
<animate begin="pvInfo.click" attributeName="r" from="0" to="120" dur="0.1s" repeatCount="1"
fill="freeze" />
</circle>
<!--NON-PV-->
<g id="nonPVGroup">
<animate begin="pvInfo.click" attributetype="CSS" attributeName="opacity" from="1.0" to="0.0"
dur="1s"
repeatCount="1" calcMode="spline" keyTimes="0;1" keySplines=".1,0,.27,1" fill="freeze" />
<animate begin="closeDetails.click" attributetype="CSS" attributeName="opacity" from="0.0"
to="1.0"
dur="1s" repeatCount="1" calcMode="spline" keyTimes="0;1" keySplines=".1,0,.27,1"
fill="freeze" />
<g transform="translate(330,280), scale(0.90,0.90)" id="heatInfo" class="clickable" onclick="openModal('heater')">
<animateMotion begin="0.5s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".1,0,.27,1" path="M0 0 L340 0" fill="freeze"></animateMotion>
<animateMotion begin="1.2s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".6,0,.27,1" path="M340 0 L700 0" fill="freeze"></animateMotion>
<!--<g
filter="url(#glow)">-->
<use href="#ElementBG" stroke="#5E2B21" />
<path fill="url(#gradient1)" stroke="#5E2B21" stroke-width="4"
d="M 99.86386438745993 22.00011880076356 A 78 78 0 1 0 100 22" />
<use href="#heatIcon" />
<path id="heatArc" fill="none" stroke="#C2614E" stroke-width="30" />
<use href="#circGrid6kW" style="stroke:#5E2B21;" />
<!--</g>-->
<text filter="url(#shadowMiddle)" class="powerTextClass">
<textPath id="heatText" href="#powerTextPath" startOffset="15%">--.-- kW</textPath>
<textPath id="heatUp" href="#infoTextPath" startOffset="9%">--°C</textPath>
<textPath id="heatDown" href="#infoTextPath" startOffset="36%">--°C</textPath>
</text>
<use id="heatEco" href="#ecoIcon" style="display:none;" />
<use id="heatDefault" href="#alertIcon" style="display:none;" />
</g>
<g transform="translate(330,280), scale(0.90,0.90)" class="clickable" id="CarInfoOG" onclick="openModal('CarOG')">
<animateMotion begin="0.5s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".1,0,.27,1" path="M0 0 L340 0" fill="freeze" />
<animateMotion id="lastInitAni" begin="1.2s" dur=".7s" repeatCount="1" calcMode="spline"
keyTimes="0;1" keySplines=".6,0,.27,1" path="M340 0 L800 -300" fill="freeze" />
<!--<g
filter="url(#glow)">-->
<use href="#ElementBG" stroke="#182C43" />
<!--<path
filter="url(#fillpartialEV)" fill="#212529" stroke="#642445" stroke-width="4" d="M 99.863863
22.00012 A 78 78 0 1 0 100 22" />-->
<use href="#carIcon" stroke="#4b71a6" />
<use id="evChargeOG" href="#voltage" transform="translate(167,45),rotate(90),scale(5,5)"
style="fill:#cccc00; display:none;" />
<path id="evArcOG" fill="none" stroke="#4b71a6" stroke-width="30" />
<use href="#circGrid" style="stroke:#182C43;" />
<!--</g>-->
<use id="evPlugOG" href="#plugCordIcon" style="fill:#4b71a6;display:none;" />
<!--<use
href="#voltage" style="fill:#888;" transform="scale(3, 3),translate(7,32), rotate(50)" />-->
<!--<use
href="#gas" style="fill:#888;" transform="scale(0.55, 0.55),translate(210,319), rotate(337)"
/>-->
<text filter="url(#shadowMiddle)" class="powerTextClass">
<textPath id="evTextOG" href="#powerTextPath" startOffset="15%">--.-- kW</textPath>
<!--<textPath
id="evFuelOG" href="#infoTextPath" startOffset="40%">-%</textPath>-->
<!--<textPath
id="evSOCOG" href="#infoTextPath" startOffset="15%">-%</textPath>-->
</text>
<!--<use
id="evLockOG" href="#lockIcon" style="display:none;" />-->
<use id="evEcoOG" href="#ecoIcon" style="display:none;" />
<use id="evDefaultOG" href="#plugCordIcon" transform="translate(105,-65),rotate(20)"
style="fill:#a53;display:none;" />
<use id="evNextTripOG" href="#clock" style="display:none;" />
<text dy="160" dx="100" id="evRemText" class="powerTextClass" />
</g>
<g transform="translate(330,280), scale(0.90,0.90)" class="clickable" id="CarInfo" onclick="openModal('CarEG')">
<animateMotion begin="0.5s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".1,0,.27,1" path="M0 0 L340 0" fill="freeze" />
<animateMotion id="lastInitAni" begin="1.2s" dur=".7s" repeatCount="1" calcMode="spline"
keyTimes="0;1" keySplines=".6,0,.27,1" path="M340 0 L800 300" fill="freeze" />
<!--<g
filter="url(#glow)">-->
<use href="#ElementBG" stroke="#1f5a66" />
<path filter="url(#fillpartialEV)" fill="#212529" stroke="#1f5a66" stroke-width="4"
d="M 99.863863 22.00012 A 78 78 0 1 0 100 22" />
<use href="#carIcon" stroke="#60afbf" />
<use id="evCharge" href="#voltage" transform="translate(167,45),rotate(90),scale(5,5)"
style="fill:#cccc00; display:none;" />
<path id="evArc" fill="none" stroke="#60afbf" stroke-width="30" />
<use href="#circGrid" style="stroke:#1f5a66;" />
<use id="evPlug" href="#plugCordIcon" style="fill:#60afbf;display:none;" />
<!--</g>-->
<use href="#voltage" style="fill:#888;" transform="scale(3, 3),translate(7,32), rotate(50)" />
<use href="#gas" style="fill:#888;"
transform="scale(0.55, 0.55),translate(210,319), rotate(337)" />
<text filter="url(#shadowMiddle)" class="powerTextClass">
<textPath id="evText" href="#powerTextPath" startOffset="15%">--.-- kW</textPath>
<textPath id="evFuel" href="#infoTextPath" startOffset="40%">--%</textPath>
<textPath id="evSOC" href="#infoTextPath" startOffset="15%">--%</textPath>
</text>
<use id="evLock" href="#lockIcon" style="display:none;" />
<use id="evEco" href="#ecoIcon" style="display:none;" />
<use id="evDefault" href="#plugCordIcon" transform="translate(105,-65),rotate(20)"
style="fill:#a53;display:none;" />
<use id="evNextTrip" href="#clock" style="display:none;" />
<text dy="160" dx="100" id="evRemText" class="powerTextClass" />
</g>
<g transform="translate(330,280), scale(0.90,0.90)" id="UGInfo">
<animateMotion begin="0.5s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".1,0,.27,1" path="M0 0 L340 0" fill="freeze" />
<animateMotion begin="1.2s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".6,0,.27,1" path="M340 0 L180 300" fill="freeze" />
<!--<g
filter="url(#glow)">-->
<use href="#ElementBG" stroke="#314619" />
<use href="#ugIcon" />
<path id="ugArc" fill="none" stroke="#83A55B" stroke-width="30" />
<use href="#circGrid" style="stroke:#314619;" />
<!--</g>-->
<text filter="url(#shadowMiddle)" class="powerTextClass">
<textPath id="ugText" href="#powerTextPath" startOffset="15%">--.-- kW</textPath>
</text>
</g>
<g transform="translate(330,280), scale(0.90,0.90)" id="EGInfo">
<animateMotion begin="0.5s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".1,0,.27,1" path="M0 0 L340 0" fill="freeze" />
<animateMotion begin="1.2s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".6,0,.27,1" path="M340 0 L500 300" fill="freeze" />
<!--<g
filter="url(#glow)">-->
<use href="#ElementBG" stroke="#18423B" />
<use href="#egIcon" />
<path id="egArc" fill="none" stroke="#70CC9C" stroke-width="30" />
<use href="#circGrid" style="stroke:#18423B;" />
<!--</g>-->
<text filter="url(#shadowMiddle)" class="powerTextClass">
<textPath id="egText" href="#powerTextPath" startOffset="15%">--.-- kW</textPath>
</text>
<use id="heatEG" transform="translate(120,170)"
style='display:none;mix-blend-mode: difference;fill:#70CCBB;' href="#heater" />
</g>
<g transform="translate(330,280), scale(0.90,0.90)" id="waterInfo" class="clickable" onclick="openModal('watering')">
<animateMotion begin="0.5s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".1,0,.27,1" path="M0 0 L340 0" fill="freeze" />
<animateMotion begin="1.2s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".6,0,.27,1" path="M340 0 L120 -300" fill="freeze" />
<use href="#ElementBG" stroke="#182C43" />
<path filter="url(#fillpartialWater)" fill="#212529" stroke="#182C43" stroke-width="4"
d="M 99.863863 22.00012 A 78 78 0 1 0 100 22" />
<use href="#water" transform="translate(60,55), scale(0.20,0.20)" fill="#3A6292" />
<!-- Wasserstand und Temperatur werden von der Bewässerungssteuerung nicht unterstützt.
<text id="waterText" filter="url(#shadowMiddle)" class="powerTextClass" text-anchor="middle" x="100" y="55">-.- cm</text>
<text id="waterTemp" filter="url(#shadowMiddle)" class="powerTextClass" text-anchor="middle" x="100" y="88">-.- °C</text>
-->
<use id="wateringFrontIcon" href="#gardenPlantIcon" transform="translate(152,162),rotate(310),scale(1.3)" fill="#6c757d" color="#6c757d">
<title>Vorn: aus</title>
</use>
<use id="wateringTroughIcon" href="#flowerPotIcon" transform="translate(18,100),rotate(75),scale(1.3)" fill="#6c757d" color="#6c757d">
<title>Tröge: aus</title>
</use>
<use id="wateringBedsIcon" href="#raisedBedIcon" transform="translate(66,174),rotate(15),scale(1.3)" fill="#6c757d" color="#6c757d">
<title>Beete: aus</title>
</use>
<text filter="url(#shadowMiddle)" class="powerTextClass">
<textPath id="wateringFrontStatus" href="#infoTextPath" startOffset="46%">Aus </textPath>
<textPath id="wateringTroughStatus" href="#infoTextPath" startOffset="10%">Aus </textPath>
<textPath id="wateringBedsStatus" href="#infoTextPath" startOffset="28%">Aus </textPath>
</text>
</g>
<g transform="translate(330,280), scale(0.90,0.90)" id="OGInfo">
<animateMotion begin="0.5s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".1,0,.27,1" path="M0 0 L340 0" fill="freeze" />
<animateMotion begin="1.2s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".6,0,.27,1" path="M340 0 L500 -300" fill="freeze" />
<use href="#ElementBG" stroke="#18423B" />
<use href="#ogIcon" />
<path id="ogArc" fill="none" stroke="#70CCBB" stroke-width="30" />
<use href="#circGrid" style="stroke:#18423B;" />
<text filter="url(#shadowMiddle)" class="powerTextClass">
<textPath id="ogText" href="#powerTextPath" startOffset="15%">--.-- kW</textPath>
</text>
<use id="heatOG" transform="translate(120,165)"
style='display:none;mix-blend-mode: difference;fill:#70CCBB;' href="#heater" />
</g>
<g transform="translate(330,280)">
<animateMotion begin="0.5s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".1,0,.27,1" path="M0 0 L340 0" fill="freeze" />
<!--<g
filter="url(#glow)">-->
<use href="#ElementBG" stroke="#1D4154" />
<use href="#plugIcon" />
<path id="consumerArc" fill="none" stroke="#71afcd" stroke-width="30" />
<use href="#circGrid" style="stroke:#1D4154;" />
<!--</g>-->
<text filter="url(#shadowMiddle)" class="powerTextClass">
<textPath id="consumerText" href="#powerTextPath" startOffset="15%">--.-- kW</textPath>
<textPath id="consumerTextAllg" href="#infoTextPath" startOffset="25%">--.-- kW</textPath>
<!--<textPath id="consumerTextEG" href="#infoTextPath" startOffset="35%">.- kW</textPath>
<textPath id="consumerTextUG" href="#infoTextPath" startOffset="55%">-.- kW</textPath> -->
</text>
</g>
<g transform="translate(330,280)">
<animateMotion begin="0.5s" dur="1.5s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".1,0,.27,1" path="M0 0 L-250 250" fill="freeze" />
<!--<g
filter="url(#glow)">-->
<use href="#ElementBG" stroke="#25481C" />
<path id="batArc" fill="none" stroke="#6cbe58" stroke-width="30" />
<path filter="url(#fillpartialBat)" fill="#212529" stroke="#25481C" stroke-width="4"
d="M 99.863863 22.00012 A 78 78 0 1 0 100 22" />
<use href="#battIcon" />
<use href="#circGrid" style="stroke:#25481C;" />
<use id="batLim" href="#clock" style="" transform="translate(4,40)" />
<!--</g>-->
<text id="batLimText" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(103,132)"></text>
<text filter="url(#shadowMiddle)" class="powerTextClass">
<textPath id="batText" href="#powerTextPath" startOffset="15%">--.-- kW</textPath>
<textPath id="batSOC" href="#infoTextPath" startOffset="26%">--.- %</textPath>
</text>
</g>
<g transform="translate(330,280)">
<animateMotion begin="0.5s" dur="1s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".1,0,.27,1" path="M0 0 L-330 0" fill="freeze" />
<!--<g
filter="url(#glow)">-->
<use href="#ElementBG" stroke="#2D2D34" />
<path id="gridArc" fill="none" stroke="#808090" stroke-width="30" />
<use href="#circGrid" style="stroke:#2D2D34;" />
<!--</g>-->
<text filter="url(#shadowMiddle)" class="powerTextClass">
<textPath id="gridText" href="#powerTextPath" startOffset="15%">--.-- kW</textPath>
<textPath id="il1txt" href="#infoTextPath" startOffset="10%">--.- A</textPath>
<textPath id="il2txt" href="#infoTextPath" startOffset="25%">--.- A</textPath>
<textPath id="il3txt" href="#infoTextPath" startOffset="40%">--.- A</textPath>
</text>
<use href="#gridIcon" />
</g>
<g transform="translate(330,280), scale(1.1,1.1)" class="tooltip-trigger"
data-tooltip-text="The amazing pink box">
<!--<g
filter="url(#glow)">-->
<use href="#ElementBG" stroke="#2D2D34" />
<!--</g>-->
<text filter="url(#shadowMiddle)" class="powerTextClass">
<textPath id="genText" href="#powerTextPath" startOffset="22%">Autarkie: </textPath>
<textPath id="genInfoText" href="#infoTextPath" startOffset="25%"></textPath>
</text>
<use href="#inverterIcon" />
</g>
</g>
<g id="pvDetail" opacity="0" transform="scale(0.1,0.1)">
<animate begin="openDetails.begin" attributetype="CSS" attributeName="opacity" from="0" to="1.0"
dur="0.7s" repeatCount="1" calcMode="spline" keyTimes="0;1" keySplines=".1,0,.27,1"
fill="freeze" />
<animateTransform attributeName="transform"
type="scale"
from="0.1,0.1"
to="1,1"
begin="openDetails.begin"
dur="0.3s" repeatCount="1" fill="freeze" />
<animate begin="closeDetails.click" attributetype="CSS" attributeName="opacity" from="1.0"
to="0"
dur="0.4s" repeatCount="1" calcMode="spline" keyTimes="0;1" keySplines=".1,0,.27,1"
fill="freeze" />
<animateTransform attributeName="transform"
type="scale"
from="1,1"
to="0.1,0.1"
begin="closeDetails.click"
dur="0.7s" repeatCount="1" fill="freeze" />
<use href="#northIcon" transform="translate(1200,10),rotate(10)" />
<!--<polygon points="370 0, 1300 0, 1300 700, 0 700, 0 250, 370 250" fill="rgb(24, 27, 31)" />-->
<polygon points="560,80 720,80 720,300 1160,300 1160,370 560,370" stroke-linejoin="round"
fill="#f7c002" stroke-width="30" stroke="#f7c002" opacity="0.1" />
<use href="#pvPlate" transform="translate(600,80), rotate(90)" />
<use href="#pvPlate" transform="translate(600,150), rotate(90)" />
<use href="#pvPlate" transform="translate(600,220), rotate(90)" />
<use href="#pvPlate" transform="translate(600,290), rotate(90)" />
<use href="#pvPlate" transform="translate(640,80), rotate(90)" />
<use href="#pvPlate" transform="translate(640,150), rotate(90)" />
<use href="#pvPlate" transform="translate(640,220), rotate(90)" />
<use href="#pvPlate" transform="translate(640,290), rotate(90)" />
<use href="#pvPlate" transform="translate(680,80), rotate(90)" />
<use href="#pvPlate" transform="translate(680,150), rotate(90)" />
<use href="#pvPlate" transform="translate(680,220), rotate(90)" />
<use href="#pvPlate" transform="translate(680,290), rotate(90)" />
<use href="#pvPlate" transform="translate(720,80), rotate(90)" />
<use href="#pvPlate" transform="translate(720,150), rotate(90)" />
<use href="#pvPlate" transform="translate(720,220), rotate(90)" />
<use href="#pvPlate" transform="translate(790,290)" />
<use href="#pvPlate" transform="translate(720,330)" />
<use href="#pvPlate" transform="translate(790,330)" />
<use href="#pvPlate" transform="translate(870,290)" />
<use href="#pvPlate" transform="translate(870,330)" />
<use href="#pvPlate" transform="translate(940,330)" />
<use href="#pvPlate" transform="translate(1160,300), rotate(90)" />
<use href="#pvPlate" transform="translate(1120,300), rotate(90)" />
<use href="#pvPlate" transform="translate(1080,300), rotate(90)" />
<text id="det_pv0P" filter="url(#shadowBig)" class="infoTextClass" dx="55" dy="30"
transform="translate(560, 150), scale(1.5,1.5)">-.-- kW</text>
<polygon points="980,60 1160,60 1160,265 980,265" stroke-linejoin="round" fill="#F76D00"
stroke-width="30" stroke="#F76D00" opacity="0.1" />
<use href="#pvPlate" transform="translate(1160,200), rotate(90), scale(1,1.125)" />
<use href="#pvPlate" transform="translate(1115,200), rotate(90), scale(1,1.125)" />
<use href="#pvPlate" transform="translate(1070,200), rotate(90), scale(1,1.125)" />
<use href="#pvPlate" transform="translate(1160,130), rotate(90), scale(1,1.125)" />
<use href="#pvPlate" transform="translate(1115,130), rotate(90), scale(1,1.125)" />
<use href="#pvPlate" transform="translate(1070,130), rotate(90), scale(1,1.125)" />
<use href="#pvPlate" transform="translate(1025,130), rotate(90), scale(1,1.125)" />
<use href="#pvPlate" transform="translate(1160,60), rotate(90), scale(1,1.125)" />
<use href="#pvPlate" transform="translate(1115,60), rotate(90), scale(1,1.125)" />
<use href="#pvPlate" transform="translate(1070,60), rotate(90), scale(1,1.125)" />
<text id="det_pv1P" filter="url(#shadowBig)" class="infoTextClass" dx="55" dy="30"
transform="translate(990, 140), scale(1.5,1.5)">-.-- kW</text>
<use href="#pvPlate" x="200" y="680" />
<text id="det_pv33P" filter="url(#shadowMiddle)" class="infoTextClass" x="200" y="680" dx="36"
dy="26">#19</text>
<use href="#pvPlate" x="380" y="550" />
<text id="det_pv32P" filter="url(#shadowMiddle)" class="infoTextClass" x="380" y="550" dx="36"
dy="26">#19</text>
<use href="#pvPlate" x="380" y="590" />
<text id="det_pv31P" filter="url(#shadowMiddle)" class="infoTextClass" x="380" y="590" dx="36"
dy="26">#18</text>
<use href="#pvPlate" x="380" y="630" />
<text id="det_pv30P" filter="url(#shadowMiddle)" class="infoTextClass" x="380" y="630" dx="36"
dy="26">#17</text>
<use href="#pvPlate" x="310" y="510" />
<text id="det_pv29P" filter="url(#shadowMiddle)" class="infoTextClass" x="310" y="510" dx="36"
dy="26">#16</text>
<use href="#pvPlate" x="310" y="550" />
<text id="det_pv28P" filter="url(#shadowMiddle)" class="infoTextClass" x="310" y="550" dx="36"
dy="26">#15</text>
<use href="#pvPlate" x="310" y="590" />
<text id="det_pv27P" filter="url(#shadowMiddle)" class="infoTextClass" x="310" y="590" dx="36"
dy="26">#14</text>
<use href="#pvPlate" x="310" y="630" />
<text id="det_pv26P" filter="url(#shadowMiddle)" class="infoTextClass" x="310" y="630" dx="36"
dy="26">#13</text>
<use href="#pvPlate" x="240" y="510" />
<text id="det_pv25P" filter="url(#shadowMiddle)" class="infoTextClass" x="240" y="510" dx="36"
dy="26">#12</text>
<use href="#pvPlate" x="240" y="550" />
<text id="det_pv24P" filter="url(#shadowMiddle)" class="infoTextClass" x="240" y="550" dx="36"
dy="26">#11</text>
<use href="#pvPlate" x="240" y="590" />
<text id="det_pv23P" filter="url(#shadowMiddle)" class="infoTextClass" x="240" y="590" dx="36"
dy="26">#10</text>
<use href="#pvPlate" x="240" y="630" />
<text id="det_pv22P" filter="url(#shadowMiddle)" class="infoTextClass" x="240" y="630" dx="36"
dy="26">#9</text>
<use href="#pvPlate" x="170" y="510" />
<text id="det_pv20P" filter="url(#shadowMiddle)" class="infoTextClass" x="170" y="510" dx="36"
dy="26">#8</text>
<use href="#pvPlate" x="170" y="550" />
<text id="det_pv21P" filter="url(#shadowMiddle)" class="infoTextClass" x="170" y="550" dx="36"
dy="26">#7</text>
<use href="#pvPlate" x="170" y="590" />
<text id="det_pv19P" filter="url(#shadowMiddle)" class="infoTextClass" x="170" y="590" dx="36"
dy="26">#6</text>
<use href="#pvPlate" x="170" y="630" />
<text id="det_pv18P" filter="url(#shadowMiddle)" class="infoTextClass" x="170" y="630" dx="36"
dy="26">#5</text>
<use href="#pvPlate" x="100" y="510" />
<text id="det_pv14P" filter="url(#shadowMiddle)" class="infoTextClass" x="100" y="510" dx="36"
dy="26">#1</text>
<use href="#pvPlate" x="100" y="550" />
<text id="det_pv15P" filter="url(#shadowMiddle)" class="infoTextClass" x="100" y="550" dx="36"
dy="26">#2</text>
<use href="#pvPlate" x="100" y="590" />
<text id="det_pv16P" filter="url(#shadowMiddle)" class="infoTextClass" x="100" y="590" dx="36"
dy="26">#3</text>
<use href="#pvPlate" x="100" y="630" />
<text id="det_pv17P" filter="url(#shadowMiddle)" class="infoTextClass" x="100" y="630" dx="36"
dy="26">#4</text>
<use href="#pvPlate" transform="translate(600,500)" />
<text id="det_pv13P" filter="url(#shadowMiddle)" class="infoTextClass" x="600" y="500" dx="36"
dy="26">- W</text>
<use href="#pvPlate" transform="translate(500,500)" />
<text id="det_pv12P" filter="url(#shadowMiddle)" class="infoTextClass" x="500" y="500" dx="36"
dy="26">- W</text>
<use href="#pvPlate" transform="translate(460,120), rotate(-90)" />
<text id="det_pv5P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(460,120), rotate(-90)" dx="36" dy="26">- W</text>
<use href="#pvPlate" transform="translate(460,190), rotate(-90)" />
<text id="det_pv4P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(460,190), rotate(-90)" dx="36" dy="26">- W</text>
<use href="#pvPlate" transform="translate(460,270), rotate(-90)" />
<text id="det_pv9P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(460,270), rotate(-90)" dx="36" dy="26">- W</text>
<use href="#pvPlate" transform="translate(460,340), rotate(-90)" />
<text id="det_pv8P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(460,340), rotate(-90)" dx="36" dy="26">- W</text>
<use href="#pvPlate" transform="translate(460,420), rotate(-90)" />
<text id="det_pv11P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(460,420), rotate(-90)" dx="36" dy="26">- W</text>
<use href="#pvPlate" transform="translate(460,490), rotate(-90)" />
<text id="det_pv10P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(460,490), rotate(-90)" dx="36" dy="26">- W</text>
<use href="#pvPlate" transform="translate(400,120), rotate(-90)" />
<text id="det_pv7P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(400,120), rotate(-90)" dx="36" dy="26">- W</text>
<use href="#pvPlate" transform="translate(400,190), rotate(-90)" />
<text id="det_pv6P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(400,190), rotate(-90)" dx="36" dy="26">- W</text>
<use href="#pvPlate" transform="translate(400,270), rotate(-90)" />
<text id="det_pv3P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(400,270), rotate(-90)" dx="36" dy="26">- W</text>
<use href="#pvPlate" transform="translate(400,340), rotate(-90)" />
<text id="det_pv2P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(400,340), rotate(-90)" dx="36" dy="26">- W</text>
<text id="invList" filter="url(#shadowMiddle)" class="tooltipTextClass"
transform="translate(80,255)"></text>
<rect width="305" height="210" x="70" y="250" rx="5" ry="5" fill="none" stroke-width="4px"
stroke="#f7c000" />
</g>
<g id="tooltip" visibility="hidden">
<rect x="2" y="2" width="200" height="60" fill="black" opacity="0.4" rx="2" ry="2" />
<rect width="200" height="60" fill="white" rx="2" ry="2" />
<text x="4" y="6">Tooltip</text>
</g>
</svg>
+365 -365
View File
@@ -1,366 +1,366 @@
<?php
/*
* Copyright (C) 2022 Lukas Buchs
* license https://github.com/lbuchs/WebAuthn/blob/master/LICENSE MIT
*
* Server test script for WebAuthn library. Saves new registrations in session.
*
* JAVASCRIPT | SERVER
* ------------------------------------------------------------
*
* REGISTRATION
*
* window.fetch -----------------> getCreateArgs
* |
* navigator.credentials.create <-------------'
* |
* '-------------------------> processCreate
* |
* alert ok or fail <----------------'
*
* ------------------------------------------------------------
*
* VALIDATION
*
* window.fetch ------------------> getGetArgs
* |
* navigator.credentials.get <----------------'
* |
* '-------------------------> processGet
* |
* alert ok or fail <----------------'
*
* ------------------------------------------------------------
*/
require_once './restricted/WebAuthn/src/WebAuthn.php';
require_once("./helper.php"); // startet die Session, liefert isLocal() und mysql.php
try {
// read get argument and post body
$fn = filter_input(INPUT_GET, 'fn');
// Registrierung und Loeschen von Anmeldedaten nur nach einem gueltigen
// Einmalschluessel aus addUser.php (checkAdduser setzt mayRegister) oder
// aus dem lokalen Netz. Ohne diese Wache genuegen zwei Aufrufe von
// beliebiger Stelle, um einen eigenen Passkey fuer das fest hinterlegte
// Konto anzulegen - der Einmalschluessel schuetzte nur die Anzeige in
// index.php, nicht diesen Endpunkt.
if ($fn === 'getCreateArgs' || $fn === 'processCreate' || $fn === 'clearRegistrations') {
$freigegeben = isset($_SESSION['mayRegister'])
&& (time() - $_SESSION['mayRegister']) < 600;
if (!$freigegeben && !isLocal()) {
http_response_code(403);
header('Content-Type: application/json');
print(json_encode(['success' => false, 'msg' => 'Registrierung nicht freigegeben.']));
exit;
}
}
$requireResidentKey = false;
$userVerification = false;
$formats = [];
$msg="";
$formats[] = 'android-key';
$formats[] = 'android-safetynet';
$formats[] = 'apple';
$formats[] = 'fido-u2f';
$formats[] = 'none';
$formats[] = 'packed';
$formats[] = 'tpm';
$userId = "E071229F004A4CDE";
$userName = "SmartHomeWagner";
$userDisplayName = "2025Bym0";
$post = trim(file_get_contents('php://input'));
if ($post) {
$post = json_decode($post, null, 512, JSON_THROW_ON_ERROR);
}
$rpId = "nas.el-wa.org";
if ($rpId === false) {
throw new Exception('invalid relying party ID');
}
// cross-platform: true, if type internal is not allowed
// false, if only internal is allowed
// null, if internal and cross-platform is allowed
$crossPlatformAttachment = null;
// new Instance of the server library.
// make sure that $rpId is the domain name.
$WebAuthn = new lbuchs\WebAuthn\WebAuthn('WebAuthn Library', $rpId, $formats);
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/isrg-root-x2.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/solo.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/solokey_f1.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/solokey_r1.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/apple.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/yubico.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/hypersecu.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/globalSign.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/googleHardware.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/microsoftTpmCollection.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/mds');
// ------------------------------------
// request for create arguments
// ------------------------------------
if ($fn === 'getCreateArgs') {
$createArgs = $WebAuthn->getCreateArgs(\hex2bin($userId), $userName, $userDisplayName, 60*4, $requireResidentKey, $userVerification, $crossPlatformAttachment);
header('Content-Type: application/json');
print(json_encode($createArgs));
// save challange to session. you have to deliver it to processGet later.
$_SESSION['challenge'] = $WebAuthn->getChallenge();
// ------------------------------------
// request for get arguments
// ------------------------------------
} else if ($fn === 'getGetArgs') {
$ids = [];
$mysql = new mysqli($mysql_server,$mysql_user,$mysql_pass,$mysql_db);
$result = mysqli_query($mysql,"SELECT credentialId FROM users WHERE userId = '".base64_encode($userId)."';");
if(!$result){
$msg = "Error:<br>".mysqli_error($mysql)."<br />";
}
if ($result->num_rows > 0) {
while($row = $result->fetch_assoc()) {
$ids[] = base64_decode($row["credentialId"]);
}
}
/*
if ($requireResidentKey) {
if (!isset($_SESSION['registrations']) || !is_array($_SESSION['registrations']) || count($_SESSION['registrations']) === 0) {
throw new Exception('we do not have any registrations in session to check the registration');
}
} else {
// load registrations from session stored there by processCreate.
// normaly you have to load the credential Id's for a username
// from the database.
if (isset($_SESSION['registrations']) && is_array($_SESSION['registrations'])) {
foreach ($_SESSION['registrations'] as $reg) {
if ($reg->userId === $userId) {
$ids[] = $reg->credentialId;
}
}
}
}
*/
if (count($ids) === 0) {
throw new Exception('no registrations in session for userId ' . $userId);
}
$getArgs = $WebAuthn->getGetArgs($ids, 60*4, true, true, true, true, true, $userVerification);
header('Content-Type: application/json');
print(json_encode($getArgs));
// save challange to session. you have to deliver it to processGet later.
$_SESSION['challenge'] = $WebAuthn->getChallenge();
// ------------------------------------
// process create
// ------------------------------------
} else if ($fn === 'processCreate') {
$mysql = new mysqli($mysql_server,$mysql_user,$mysql_pass,$mysql_db);
$clientDataJSON = !empty($post->clientDataJSON) ? base64_decode($post->clientDataJSON) : null;
$attestationObject = !empty($post->attestationObject) ? base64_decode($post->attestationObject) : null;
$challenge = $_SESSION['challenge'] ?? null;
// processCreate returns data to be stored for future logins.
// in this example we store it in the php session.
// Normally you have to store the data in a database connected
// with the username.
$data = $WebAuthn->processCreate($clientDataJSON, $attestationObject, $challenge, $userVerification === 'required', true, false);
// add user infos
$data->userId = $userId;
$data->userName = $userName;
$data->userDisplayName = $userDisplayName;
//set Null to 0
$data->signatureCounter ??= 0;
/*
if (!isset($_SESSION['registrations']) || !array_key_exists('registrations', $_SESSION) || !is_array($_SESSION['registrations'])) {
$_SESSION['registrations'] = [];
}*/
if(!mysqli_query($mysql,"INSERT INTO users SET userId = '".base64_encode($data->userId)."', credentialId = '".base64_encode($data->credentialId)."', credentialPublicKey = '".base64_encode($data->credentialPublicKey)."', signatureCounter = '".base64_encode($data->signatureCounter)."', name = '".mysqli_real_escape_string($mysql,filter_input(INPUT_GET, 'name'))."';")){
$msg = "Error:<br>".mysqli_error($mysql)."<br />";
}
else{
if ($data->rootValid === false) {
$msg = 'registration ok, but certificate does not match any of the selected root ca.';
}
// $msg = "Data: ".json_last_error();//json_encode($data);//'registration success.';
}
/*
$_SESSION['registrations'][] = $data;
*/
$return = new stdClass();
$return->success = true;
$return->msg = $msg;
header('Content-Type: application/json');
print(json_encode($return));
// ------------------------------------
// proccess get
// ------------------------------------
} else if ($fn === 'processGet') {
$clientDataJSON = !empty($post->clientDataJSON) ? base64_decode($post->clientDataJSON) : null;
$authenticatorData = !empty($post->authenticatorData) ? base64_decode($post->authenticatorData) : null;
$signature = !empty($post->signature) ? base64_decode($post->signature) : null;
$userHandle = !empty($post->userHandle) ? base64_decode($post->userHandle) : null;
$id = !empty($post->id) ? base64_decode($post->id) : null;
$challenge = $_SESSION['challenge'] ?? '';
$credentialPublicKey = null;
// looking up correspondending public key of the credential id
// you should also validate that only ids of the given user name
// are taken for the login.
$mysql = new mysqli($mysql_server,$mysql_user,$mysql_pass,$mysql_db);
$result = mysqli_query($mysql,"SELECT credentialPublicKey, userId, name FROM users WHERE credentialId = '".base64_encode($id)."';");
if(!$result){
$msg = "Error:<br>".mysqli_error($mysql)."<br />";
}
if ($result->num_rows > 0) {
$row = $result->fetch_assoc();
$credentialPublicKey = base64_decode($row["credentialPublicKey"]);
$reg = (object) ['userId' => base64_decode($row["userId"])];
$username = $row["name"];
} /*else {
if (isset($_SESSION['registrations']) && is_array($_SESSION['registrations'])) {
foreach ($_SESSION['registrations'] as $reg) {
if ($reg->credentialId === $id) {
$credentialPublicKey = $reg->credentialPublicKey;
break;
}
}
}
}*/
if ($credentialPublicKey === null) {
throw new Exception('Public Key for credential ID not found!');
}
// if we have resident key, we have to verify that the userHandle is the provided userId at registration
if ($requireResidentKey && $userHandle !== hex2bin($reg->userId)) {
throw new \Exception('userId doesnt match (is ' . bin2hex($userHandle) . ' but expect ' . $reg->userId . ')');
}
// process the get request. throws WebAuthnException if it fails
$WebAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $challenge, null, $userVerification === 'required');
$return = new stdClass();
$return->success = true;
$authKey = strval(random_int(0,99999999));
$result = mysqli_query($mysql,"UPDATE users SET authKey=".$authKey.", lastAuth=NOW() WHERE credentialId = '".base64_encode($id)."';");
$_SESSION["Logged"] = true;
$_SESSION["user"] = $username;
$_SESSION["authKey"] = $authKey;
header('Content-Type: application/json');
print(json_encode($return));
// ------------------------------------
// proccess clear registrations
// ------------------------------------
} else if ($fn === 'clearRegistrations') {
$_SESSION['registrations'] = null;
$_SESSION['challenge'] = null;
$return = new stdClass();
$return->success = true;
$return->msg = 'all registrations deleted';
header('Content-Type: application/json');
print(json_encode($return));
// ------------------------------------
// display stored data as HTML
// ------------------------------------
} /*else if ($fn === 'getStoredDataHtml') {
$html = '<!DOCTYPE html>' . "\n";
$html .= '<html><head><style>tr:nth-child(even){background-color: #f2f2f2;}</style></head>';
$html .= '<body style="font-family:sans-serif">';
if (isset($_SESSION['registrations']) && is_array($_SESSION['registrations'])) {
$html .= '<p>There are ' . count($_SESSION['registrations']) . ' registrations in this session:</p>';
foreach ($_SESSION['registrations'] as $reg) {
$html .= '<table style="border:1px solid black;margin:10px 0;">';
foreach ($reg as $key => $value) {
if (is_bool($value)) {
$value = $value ? 'yes' : 'no';
} else if (is_null($value)) {
$value = 'null';
} else if (is_object($value)) {
$value = chunk_split(strval($value), 64);
} else if (is_string($value) && strlen($value) > 0 && htmlspecialchars($value, ENT_QUOTES) === '') {
$value = chunk_split(bin2hex($value), 64);
}
$html .= '<tr><td>' . htmlspecialchars($key) . '</td><td style="font-family:monospace;">' . nl2br(htmlspecialchars($value)) . '</td>';
}
$html .= '</table>';
}
} else {
$html .= '<p>There are no registrations.</p>';
}
$html .= '</body></html>';
header('Content-Type: text/html');
print $html;
// ------------------------------------
// get root certs from FIDO Alliance Metadata Service
// ------------------------------------
} */else if ($fn === 'queryFidoMetaDataService') {
$mdsFolder = './restricted/WebAuthn/rootCertificates/mds';
$success = false;
$msg = null;
// fetch only 1x / 24h
$lastFetch = \is_file($mdsFolder . '/lastMdsFetch.txt') ? \strtotime(\file_get_contents($mdsFolder . '/lastMdsFetch.txt')) : 0;
if ($lastFetch + (3600*48) < \time()) {
$cnt = $WebAuthn->queryFidoMetaDataService($mdsFolder);
$success = true;
\file_put_contents($mdsFolder . '/lastMdsFetch.txt', date('r'));
$msg = 'successfully queried FIDO Alliance Metadata Service - ' . $cnt . ' certificates downloaded.';
} else {
$msg = 'Fail: last fetch was at ' . date('r', $lastFetch) . ' - fetch only 1x every 48h';
}
$return = new stdClass();
$return->success = $success;
$return->msg = $msg;
header('Content-Type: application/json');
print(json_encode($return));
}
} catch (Throwable $ex) {
$return = new stdClass();
$return->success = false;
$return->msg = $ex->getMessage();
header('Content-Type: application/json');
print(json_encode($return));
<?php
/*
* Copyright (C) 2022 Lukas Buchs
* license https://github.com/lbuchs/WebAuthn/blob/master/LICENSE MIT
*
* Server test script for WebAuthn library. Saves new registrations in session.
*
* JAVASCRIPT | SERVER
* ------------------------------------------------------------
*
* REGISTRATION
*
* window.fetch -----------------> getCreateArgs
* |
* navigator.credentials.create <-------------'
* |
* '-------------------------> processCreate
* |
* alert ok or fail <----------------'
*
* ------------------------------------------------------------
*
* VALIDATION
*
* window.fetch ------------------> getGetArgs
* |
* navigator.credentials.get <----------------'
* |
* '-------------------------> processGet
* |
* alert ok or fail <----------------'
*
* ------------------------------------------------------------
*/
require_once './restricted/WebAuthn/src/WebAuthn.php';
require_once("./helper.php"); // startet die Session, liefert isLocal() und mysql.php
try {
// read get argument and post body
$fn = filter_input(INPUT_GET, 'fn');
// Registrierung und Loeschen von Anmeldedaten nur nach einem gueltigen
// Einmalschluessel aus addUser.php (checkAdduser setzt mayRegister) oder
// aus dem lokalen Netz. Ohne diese Wache genuegen zwei Aufrufe von
// beliebiger Stelle, um einen eigenen Passkey fuer das fest hinterlegte
// Konto anzulegen - der Einmalschluessel schuetzte nur die Anzeige in
// index.php, nicht diesen Endpunkt.
if ($fn === 'getCreateArgs' || $fn === 'processCreate' || $fn === 'clearRegistrations') {
$freigegeben = isset($_SESSION['mayRegister'])
&& (time() - $_SESSION['mayRegister']) < 600;
if (!$freigegeben && !isLocal()) {
http_response_code(403);
header('Content-Type: application/json');
print(json_encode(['success' => false, 'msg' => 'Registrierung nicht freigegeben.']));
exit;
}
}
$requireResidentKey = false;
$userVerification = false;
$formats = [];
$msg="";
$formats[] = 'android-key';
$formats[] = 'android-safetynet';
$formats[] = 'apple';
$formats[] = 'fido-u2f';
$formats[] = 'none';
$formats[] = 'packed';
$formats[] = 'tpm';
$userId = "E071229F004A4CDE";
$userName = "SmartHomeWagner";
$userDisplayName = "2025Bym0";
$post = trim(file_get_contents('php://input'));
if ($post) {
$post = json_decode($post, null, 512, JSON_THROW_ON_ERROR);
}
$rpId = "nas.el-wa.org";
if ($rpId === false) {
throw new Exception('invalid relying party ID');
}
// cross-platform: true, if type internal is not allowed
// false, if only internal is allowed
// null, if internal and cross-platform is allowed
$crossPlatformAttachment = null;
// new Instance of the server library.
// make sure that $rpId is the domain name.
$WebAuthn = new lbuchs\WebAuthn\WebAuthn('WebAuthn Library', $rpId, $formats);
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/isrg-root-x2.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/solo.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/solokey_f1.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/solokey_r1.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/apple.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/yubico.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/hypersecu.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/globalSign.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/googleHardware.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/microsoftTpmCollection.pem');
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/mds');
// ------------------------------------
// request for create arguments
// ------------------------------------
if ($fn === 'getCreateArgs') {
$createArgs = $WebAuthn->getCreateArgs(\hex2bin($userId), $userName, $userDisplayName, 60*4, $requireResidentKey, $userVerification, $crossPlatformAttachment);
header('Content-Type: application/json');
print(json_encode($createArgs));
// save challange to session. you have to deliver it to processGet later.
$_SESSION['challenge'] = $WebAuthn->getChallenge();
// ------------------------------------
// request for get arguments
// ------------------------------------
} else if ($fn === 'getGetArgs') {
$ids = [];
$mysql = new mysqli($mysql_server,$mysql_user,$mysql_pass,$mysql_db);
$result = mysqli_query($mysql,"SELECT credentialId FROM users WHERE userId = '".base64_encode($userId)."';");
if(!$result){
$msg = "Error:<br>".mysqli_error($mysql)."<br />";
}
if ($result->num_rows > 0) {
while($row = $result->fetch_assoc()) {
$ids[] = base64_decode($row["credentialId"]);
}
}
/*
if ($requireResidentKey) {
if (!isset($_SESSION['registrations']) || !is_array($_SESSION['registrations']) || count($_SESSION['registrations']) === 0) {
throw new Exception('we do not have any registrations in session to check the registration');
}
} else {
// load registrations from session stored there by processCreate.
// normaly you have to load the credential Id's for a username
// from the database.
if (isset($_SESSION['registrations']) && is_array($_SESSION['registrations'])) {
foreach ($_SESSION['registrations'] as $reg) {
if ($reg->userId === $userId) {
$ids[] = $reg->credentialId;
}
}
}
}
*/
if (count($ids) === 0) {
throw new Exception('no registrations in session for userId ' . $userId);
}
$getArgs = $WebAuthn->getGetArgs($ids, 60*4, true, true, true, true, true, $userVerification);
header('Content-Type: application/json');
print(json_encode($getArgs));
// save challange to session. you have to deliver it to processGet later.
$_SESSION['challenge'] = $WebAuthn->getChallenge();
// ------------------------------------
// process create
// ------------------------------------
} else if ($fn === 'processCreate') {
$mysql = new mysqli($mysql_server,$mysql_user,$mysql_pass,$mysql_db);
$clientDataJSON = !empty($post->clientDataJSON) ? base64_decode($post->clientDataJSON) : null;
$attestationObject = !empty($post->attestationObject) ? base64_decode($post->attestationObject) : null;
$challenge = $_SESSION['challenge'] ?? null;
// processCreate returns data to be stored for future logins.
// in this example we store it in the php session.
// Normally you have to store the data in a database connected
// with the username.
$data = $WebAuthn->processCreate($clientDataJSON, $attestationObject, $challenge, $userVerification === 'required', true, false);
// add user infos
$data->userId = $userId;
$data->userName = $userName;
$data->userDisplayName = $userDisplayName;
//set Null to 0
$data->signatureCounter ??= 0;
/*
if (!isset($_SESSION['registrations']) || !array_key_exists('registrations', $_SESSION) || !is_array($_SESSION['registrations'])) {
$_SESSION['registrations'] = [];
}*/
if(!mysqli_query($mysql,"INSERT INTO users SET userId = '".base64_encode($data->userId)."', credentialId = '".base64_encode($data->credentialId)."', credentialPublicKey = '".base64_encode($data->credentialPublicKey)."', signatureCounter = '".base64_encode($data->signatureCounter)."', name = '".mysqli_real_escape_string($mysql,filter_input(INPUT_GET, 'name'))."';")){
$msg = "Error:<br>".mysqli_error($mysql)."<br />";
}
else{
if ($data->rootValid === false) {
$msg = 'registration ok, but certificate does not match any of the selected root ca.';
}
// $msg = "Data: ".json_last_error();//json_encode($data);//'registration success.';
}
/*
$_SESSION['registrations'][] = $data;
*/
$return = new stdClass();
$return->success = true;
$return->msg = $msg;
header('Content-Type: application/json');
print(json_encode($return));
// ------------------------------------
// proccess get
// ------------------------------------
} else if ($fn === 'processGet') {
$clientDataJSON = !empty($post->clientDataJSON) ? base64_decode($post->clientDataJSON) : null;
$authenticatorData = !empty($post->authenticatorData) ? base64_decode($post->authenticatorData) : null;
$signature = !empty($post->signature) ? base64_decode($post->signature) : null;
$userHandle = !empty($post->userHandle) ? base64_decode($post->userHandle) : null;
$id = !empty($post->id) ? base64_decode($post->id) : null;
$challenge = $_SESSION['challenge'] ?? '';
$credentialPublicKey = null;
// looking up correspondending public key of the credential id
// you should also validate that only ids of the given user name
// are taken for the login.
$mysql = new mysqli($mysql_server,$mysql_user,$mysql_pass,$mysql_db);
$result = mysqli_query($mysql,"SELECT credentialPublicKey, userId, name FROM users WHERE credentialId = '".base64_encode($id)."';");
if(!$result){
$msg = "Error:<br>".mysqli_error($mysql)."<br />";
}
if ($result->num_rows > 0) {
$row = $result->fetch_assoc();
$credentialPublicKey = base64_decode($row["credentialPublicKey"]);
$reg = (object) ['userId' => base64_decode($row["userId"])];
$username = $row["name"];
} /*else {
if (isset($_SESSION['registrations']) && is_array($_SESSION['registrations'])) {
foreach ($_SESSION['registrations'] as $reg) {
if ($reg->credentialId === $id) {
$credentialPublicKey = $reg->credentialPublicKey;
break;
}
}
}
}*/
if ($credentialPublicKey === null) {
throw new Exception('Public Key for credential ID not found!');
}
// if we have resident key, we have to verify that the userHandle is the provided userId at registration
if ($requireResidentKey && $userHandle !== hex2bin($reg->userId)) {
throw new \Exception('userId doesnt match (is ' . bin2hex($userHandle) . ' but expect ' . $reg->userId . ')');
}
// process the get request. throws WebAuthnException if it fails
$WebAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $challenge, null, $userVerification === 'required');
$return = new stdClass();
$return->success = true;
$authKey = strval(random_int(0,99999999));
$result = mysqli_query($mysql,"UPDATE users SET authKey=".$authKey.", lastAuth=NOW() WHERE credentialId = '".base64_encode($id)."';");
$_SESSION["Logged"] = true;
$_SESSION["user"] = $username;
$_SESSION["authKey"] = $authKey;
header('Content-Type: application/json');
print(json_encode($return));
// ------------------------------------
// proccess clear registrations
// ------------------------------------
} else if ($fn === 'clearRegistrations') {
$_SESSION['registrations'] = null;
$_SESSION['challenge'] = null;
$return = new stdClass();
$return->success = true;
$return->msg = 'all registrations deleted';
header('Content-Type: application/json');
print(json_encode($return));
// ------------------------------------
// display stored data as HTML
// ------------------------------------
} /*else if ($fn === 'getStoredDataHtml') {
$html = '<!DOCTYPE html>' . "\n";
$html .= '<html><head><style>tr:nth-child(even){background-color: #f2f2f2;}</style></head>';
$html .= '<body style="font-family:sans-serif">';
if (isset($_SESSION['registrations']) && is_array($_SESSION['registrations'])) {
$html .= '<p>There are ' . count($_SESSION['registrations']) . ' registrations in this session:</p>';
foreach ($_SESSION['registrations'] as $reg) {
$html .= '<table style="border:1px solid black;margin:10px 0;">';
foreach ($reg as $key => $value) {
if (is_bool($value)) {
$value = $value ? 'yes' : 'no';
} else if (is_null($value)) {
$value = 'null';
} else if (is_object($value)) {
$value = chunk_split(strval($value), 64);
} else if (is_string($value) && strlen($value) > 0 && htmlspecialchars($value, ENT_QUOTES) === '') {
$value = chunk_split(bin2hex($value), 64);
}
$html .= '<tr><td>' . htmlspecialchars($key) . '</td><td style="font-family:monospace;">' . nl2br(htmlspecialchars($value)) . '</td>';
}
$html .= '</table>';
}
} else {
$html .= '<p>There are no registrations.</p>';
}
$html .= '</body></html>';
header('Content-Type: text/html');
print $html;
// ------------------------------------
// get root certs from FIDO Alliance Metadata Service
// ------------------------------------
} */else if ($fn === 'queryFidoMetaDataService') {
$mdsFolder = './restricted/WebAuthn/rootCertificates/mds';
$success = false;
$msg = null;
// fetch only 1x / 24h
$lastFetch = \is_file($mdsFolder . '/lastMdsFetch.txt') ? \strtotime(\file_get_contents($mdsFolder . '/lastMdsFetch.txt')) : 0;
if ($lastFetch + (3600*48) < \time()) {
$cnt = $WebAuthn->queryFidoMetaDataService($mdsFolder);
$success = true;
\file_put_contents($mdsFolder . '/lastMdsFetch.txt', date('r'));
$msg = 'successfully queried FIDO Alliance Metadata Service - ' . $cnt . ' certificates downloaded.';
} else {
$msg = 'Fail: last fetch was at ' . date('r', $lastFetch) . ' - fetch only 1x every 48h';
}
$return = new stdClass();
$return->success = $success;
$return->msg = $msg;
header('Content-Type: application/json');
print(json_encode($return));
}
} catch (Throwable $ex) {
$return = new stdClass();
$return->success = false;
$return->msg = $ex->getMessage();
header('Content-Type: application/json');
print(json_encode($return));
}
+6 -6
View File
File diff suppressed because one or more lines are too long
+4 -4
View File
File diff suppressed because one or more lines are too long
+3730 -354
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
# Vertiefende Dokumentation
Die [README im Wurzelverzeichnis](../README.md) beschreibt das Ganze: welche
Seite es gibt, welche Datei was tut, wo eine Zahl herkommt. Sie ist die
Landkarte.
Hier liegen die **Tiefenbohrungen** — für die Teile, bei denen die Landkarte
nicht reicht, weil das Verhalten aus dem Zusammenspiel mehrerer Prozesse
entsteht und die Regeln nicht aus dem Quelltext einer einzelnen Datei
abzulesen sind.
| Dokument | Worum es geht |
|---|---|
| [automatiken.md](automatiken.md) | Die Automatiken (AutoActions): Datenmodell, Editor, Runner, wie eine Bedingung wirklich ausgewertet wird, Verkettung, Sperren, Fehlerbilder |
| [zeitleiste.md](zeitleiste.md) | Die Zeitleiste der Automatiken: wie der Server den Tag ausrechnet und wie der Browser ihn zeichnet |
| [benachrichtigungen.md](benachrichtigungen.md) | Meldungen aus Automatiken: Web Push ohne App, E-Mail, das Gerät „Benachrichtigungen“ |
| [meteogramm.md](meteogramm.md) | Das eigene Meteogramm: Vorhersage und eigene Messung in einem Bild, Katalog und Renderer, das Wetterarchiv |
| [uebersicht.md](uebersicht.md) | Die Solar-Übersicht: Katalog und Renderer, wie aus Watt ein Ring, ein Fluss und ein Füllstand wird |
| [byd.md](byd.md) | Die Batterie bis zur Zelle: das nachgebaute BMU-Protokoll, das Zeitfenster des Netzwerkmoduls, Byte-Lagen, Historie, Anzeige |
| [datenbank.md](datenbank.md) | Die drei Schemata: wer was schreibt, wie Messreihen verdichtet werden, welche Tabelle man für welche Auswertung nimmt |
| [einstellungen.md](einstellungen.md) | Die Einstellungsseite: was jeder Reiter speichert, welche Regeln das Modell durchsetzt und was die Seite bewusst nicht kann |
---
## Die drei Prozesse, die zusammenspielen
Nichts hier ist eine einzelne Anwendung. Drei Dinge laufen gleichzeitig, und
sie reden nur über MQTT und über die Datenbanken miteinander — nie direkt.
```mermaid
flowchart LR
subgraph Browser["Browser"]
UI["Weboberfläche<br/><small>PHP + JS, /volume1/web/smart</small>"]
end
subgraph NAS["NAS, Hintergrundprozesse (SolarManager)"]
RUN["autoaction_runner.py<br/><small>führt Automatiken aus</small>"]
SM["solarManager.py<br/><small>sammelt Anlagenwerte</small>"]
SK["gatherSkodaData.py<br/><small>Fahrzeug</small>"]
end
subgraph Speicher["Speicher"]
MQTT[["MQTT-Broker<br/><small>alles Aktuelle</small>"]]
HM[("homeMesh<br/><small>Geräte, Automatiken, Grundriss</small>")]
SL[("solarLog<br/><small>Messreihen, Sonnenzeiten</small>")]
end
subgraph Geraete["Geräte"]
TAH["Tahoma-Box<br/><small>Jalousien</small>"]
SHE["Shelly, WLED, ESP32"]
end
UI -- "liest/schreibt Regeln" --> HM
UI -- "abonniert" --> MQTT
UI -- "schaltet von Hand" --> TAH & SHE
RUN -- "Regelwerk" --> HM
RUN -- "Messwerte zurück" --> HM
RUN -- "Sonnenzeiten" --> SL
RUN -- "hört + schaltet" --> MQTT
RUN -- "liest + schaltet" --> TAH & SHE
SM --> MQTT & SL
SK --> SL
```
**Wichtig für das Verständnis aller folgenden Kapitel:**
* Die **Weboberfläche schaltet nichts von selbst.** Sie beschreibt, was
gelten soll (`homeMesh`), und zeigt, was ist. Ausgeführt wird im Runner.
* **`actor_states.current_value` pflegt der Runner.** Ohne ihn stünde dort
der Wert vom Tag des Gerätesuchlaufs. Deshalb zeigt auch der Editor
aktuelle Zahlen, obwohl er nur eine Tabelle liest.
* Der Runner ist ein **Dauerprozess, kein Cronjob** — er braucht den
Vorzustand (`cond_met`), um steigende Flanken zu erkennen.
---
## Lesereihenfolge
1. [README](../README.md), Abschnitt „Auf einen Blick“ — Seiten und Dateien.
2. [automatiken.md](automatiken.md) — das Regelwerk und seine Ausführung.
3. [zeitleiste.md](zeitleiste.md) — die Darstellung desselben Regelwerks
über einen Tag.
Wer nur etwas ändern will, findet die Einstiegspunkte am Ende beider
Dokumente unter „Wo fange ich an, wenn ich …“.
+452
View File
@@ -0,0 +1,452 @@
# Die Automatiken (AutoActions)
Eine Automatik ist ein Satz: **„Wenn … , dann …, aber nur wenn der Rahmen
passt.“** Sie wird im Browser gebaut, liegt in der Datenbank `homeMesh` und
wird von einem Dauerprozess auf der NAS ausgeführt.
```
Rahmen Auslöser Aktionen
────── ──────── ────────
Mo–Fr, Uhrzeit um 07:00 Magdalena Fenster: Auf
nicht in Ferien, UND Außentemperatur > −10 °C Magdalena Tür: Auf
06:00–09:00,
Sperrzeit 15 min
```
Beteiligt sind drei Stellen:
| Wo | Datei | Aufgabe |
|---|---|---|
| Browser | `js/solar/autoActionFuncs.js` | Editor: Modell bauen, zeichnen, abschicken |
| Web-Server | `restricted/automations.php`, `ajax/AutoAction.php` | Laden, Prüfen, Speichern, Beschreiben |
| NAS | `SolarManager/autoActions/autoaction_runner.py` | Auswerten und Ausführen |
![Der Automatik-Editor](bilder/editor.png)
---
## 1. Datenmodell
Alles steht in `homeMesh` — dort, wo auch die Geräte liegen, damit
Fremdschlüssel greifen können. Schema: [`homeMesh_automations.sql`](../homeMesh_automations.sql).
```mermaid
erDiagram
actors ||--o{ actor_states : "hat Messwerte"
actors ||--o{ actor_commands : "hat Kommandos"
actor_commands ||--o{ command_parameters : "hat Parameter"
automations ||--o{ automation_conditions : "Auslöser"
automations ||--o{ automation_actions : "Aktionen"
automations ||--o{ automation_log : "Protokoll"
automation_actions ||--o{ automation_action_params : "Werte"
actor_states ||--o{ automation_conditions : "state_id"
actor_commands ||--o{ automation_actions : "command_id"
command_parameters ||--o{ automation_action_params : "parameter_id"
automations {
int id
varchar name
varchar floor "Reiter in der Übersicht"
tinyint enabled "pausiert oder nicht"
time window_from "Rahmen: Zeitfenster"
time window_to
tinyint weekdays "Bitmaske Mo..So"
tinyint on_vacation "0 nie, 1 egal, 2 zusätzlich"
tinyint on_holiday "0 nie, 1 egal, 2 zusätzlich"
tinyint next_day "Rahmen gilt für morgen"
tinyint force_once "am Fensterende notfalls doch"
tinyint once_per_day "höchstens einmal am Tag"
int lockout_secs "Sperrzeit nach einem Lauf"
int hold_secs "Haltezeit vor dem Auslösen"
tinyint cond_met "Laufzustand: war die Bedingung zuletzt wahr?"
datetime last_run "Laufzustand: wann zuletzt ausgelöst"
timestamp changed "Signal an den Runner"
}
automation_conditions {
int automation_id
int group_no "gleich = UND, verschieden = ODER"
int state_id "zeigt auf actor_states"
enum operator "ASCII, nie das hübsche Zeichen"
varchar value "Schwelle als Text"
}
```
Vier Felder sind **nachgerüstet** und stehen deshalb nicht in der
Schema-Datei, sondern in eigenen Skripten im SolarManager-Repo
(`autoActions/`): `next_day` (`vorabend.sql`), `once_per_day` und die
Dreiwertigkeit von `on_vacation`/`on_holiday` (`rahmen_erweitern.sql`),
`hold_secs` (`haltezeit.sql`), sowie der Datentyp `elapsed` samt gerechnetem
Gerät „Automatiken“ (`automatik_ausloeser.sql`). Wer die Datenbank neu
aufsetzt, spielt sie nach `homeMesh_automations.sql` ein.
Drei Spalten in `automations` sind **kein Regelwerk, sondern Laufzustand**:
`cond_met`, `last_run` und `changed`. Sie werden vom Runner geschrieben.
Wer eine Automatik per SQL kopiert, sollte sie leeren.
### Warum `state_id` und nicht „Gerät + Feld“
Eine Bedingung zeigt mit einer einzigen Zahl auf `actor_states`. Damit hängen
Gerät, Messwertname, Datentyp, Einheit und Quelle (Topic, HTTP-Feld, Tahoma)
an dieser Zeile. Der Editor muss nichts davon wissen: Er listet Geräte und
deren Messwerte. Genau deshalb funktioniert auch die Verkettung
(→ [Abschnitt 6](#6-verkettung-eine-automatik-löst-die-nächste-aus)) ohne eine
einzige Sonderregel im Editor.
---
## 2. Der Editor
Der Editor arbeitet **auf einem Modell und zeichnet daraus die Oberfläche** —
nicht umgekehrt:
```js
autoModel.groups = [ [ {state_id, operator, value}, … ], … ] // innen UND, außen ODER
autoModel.actions = [ {command_id, params: {parameterID: wert}}, … ]
```
Der Gerätekatalog (`deviceCatalog()`) kommt **im selben Dokument** mit, es
gibt also kein Nachladen je Zeile.
```mermaid
sequenceDiagram
autonumber
participant B as Browser<br/>autoActionFuncs.js
participant A as ajax/AutoAction.php
participant M as restricted/automations.php
participant DB as homeMesh
B->>A: GET ?action=editor&id=42
A->>M: loadAutomation(42) + deviceCatalog()
M->>DB: SELECT automations / conditions / actions
A-->>B: HTML + Katalog + Datensatz (ein Dokument)
Note over B: autoModel füllen, rendern
loop alle 8 s, solange der Editor offen ist
B->>A: GET ?action=werte&ids=…
A->>DB: SELECT current_value FROM actor_states
A-->>B: aktuelle Messwerte → Punkte neben den Zeilen
end
B->>A: POST ?action=save (JSON)
A->>M: saveAutomation()
M->>M: pruefeKreis()
M->>DB: UPDATE automations; DELETE+INSERT conditions/actions
A-->>B: {ok, id}
Note over DB: changed = NOW() → der Runner lädt neu
```
Drei Eigenheiten, die man kennen sollte:
* **Speichern heißt löschen und neu einfügen.** Bedingungen und Aktionen
bekommen dabei neue IDs. Deshalb reicht `changed` allein dem Runner nicht,
um Änderungen zu erkennen (→ [Abschnitt 4](#4-der-runner)).
* **Live-Werte neben jeder Bedingung** kommen aus `actor_states`, nicht aus
MQTT: Über den Broker kommt nur ein Teil der Geräte (Jalousien hängen an
der Tahoma-Box, gerechnete Werte an gar nichts). Eine Tabelle, ein Zugriff,
alle Gerätearten.
* **Kreise werden beim Speichern abgelehnt** (`pruefeKreis()`), nicht im
Betrieb geheilt. Im Protokoll sähe ein Kreis nur wie „läuft halt oft“ aus.
### Der Rahmen in Worten
`rahmenText()` im Editor und `describeConditions()` auf dem Server erzeugen
denselben Satz in Alltagssprache („werktags, 06:00–09:00, höchstens einmal
am Tag“). Wer die Bedeutung eines Feldes ändert, muss beide anfassen —
sonst steht in der Übersicht etwas anderes als im Editor.
---
## 3. Die drei Teile einer Automatik
### 3.1 Rahmen: darf sie überhaupt?
| Feld | Bedeutung | Fallstrick |
|---|---|---|
| `enabled` | pausiert oder nicht | Pausierte laden der Runner gar nicht erst — ihre Nachfolger stehen mit still |
| `weekdays` | Bitmaske, Bit 0 = Montag | |
| `on_vacation`, `on_holiday` | **dreiwertig**: 0 nie, 1 egal, 2 zusätzlich | „zusätzlich“ zählt wie ein passender Wochentag; ein Verbot schlägt eine Erweiterung |
| `next_day` | Der Rahmen gilt für **morgen** (Vorabend-Form) | Uhrzeit und Fenster bleiben bei heute |
| `window_from`/`window_to` | Zeitfenster; `von > bis` heißt über Mitternacht | |
| `lockout_secs` | Sperrzeit nach einem Lauf (0 / 60 / 900) | |
| `hold_secs` | Haltezeit: so lange muss die Bedingung **ununterbrochen** erfüllt sein (0 = sofort, frei als Stunden:Minuten bis 23:59) | Zählt im Speicher des Runners — ein Neustart fängt die Zeit von vorn an |
| `once_per_day` | höchstens ein Lauf je Kalendertag | |
| `force_once` | am Ende des Fensters notfalls doch ausführen | greift nur, wenn im Fenster gar nichts lief |
Warum dreiwertig? „Wochenenden **und** Feiertage“ ließ sich vorher nicht
schreiben: Die Wochentagsmaske kennt nur Sa und So, und ein Feiertag am
Dienstag ist eben ein Dienstag. Mit „Feiertage: zusätzlich“ kommt er über
den Kalender (`calendar_days`, gefüllt von `fetch_calendar.py`) herein.
Warum `next_day`? „Kinderrollos zu, wenn **morgen** Schule ist“ ließ sich mit
dem heutigen Tag nur annähern (So–Do, heute keine Ferien) — und das ging am
letzten Ferientag, am Abend vor einem Feiertag und am Abend eines Feiertags
daneben.
### 3.2 Auslöser: `any(all(gruppe))`
Bedingungen mit derselben `group_no` sind mit **UND** verknüpft, verschiedene
Gruppen mit **ODER**. Im Editor ist eine Gruppe ein gerahmter Block und
zwischen den Blöcken steht ein ODER — die Klammerung ist also gezeichnet und
nicht bloß vereinbart.
> Eine Automatik **ohne** Bedingung löst nie aus. Sonst würde sie nach einem
> Gerätesuchlauf, der ihren Messwert entfernt hat, plötzlich dauernd feuern.
### 3.3 Aktionen
Eine Aktion ist ein Kommando eines Geräts plus je Parameter ein Wert
(`automation_action_params`, eine Zeile je Parameter statt fester Spalten).
Geschickt wird über denselben Transport-Zoo wie beim Lesen
(→ [Abschnitt 4](#4-der-runner)); Jalousien bekommen dabei die
Kugelschreiber-Mechanik (erst Neigung 0, dann das Ziel), siehe
`TahomaTransport` und `sendeTahoma()` in `restricted/commands.php`.
---
## 4. Der Runner
`autoaction_runner.py` läuft als Dauerprozess.
```mermaid
flowchart TB
START([Start]) --> LOAD["Regelwerk laden<br/><small>Automatiken, Messwerte, Kommandos</small>"]
LOAD --> SUB["MQTT abonnieren<br/>Sammlerfäden starten"]
SUB --> TAKT
subgraph TAKT["Takt, alle tick_seconds (10 s)"]
direction TB
T1["Werte einsammeln<br/><small>MQTT-Puffer + Queue der Sammler</small>"]
T2["durchlauf(): jede Automatik prüfen"]
T3["Ergebnisse des Versands verbuchen"]
T4["alle reload_seconds: Signatur vergleichen"]
T1 --> T2 --> T3 --> T4
end
T4 -- "Signatur geändert" --> LOAD
T2 -- "Aktionen" --> VERSAND[["Versand<br/><small>je Gerät der Reihe nach,<br/>über Geräte hinweg parallel</small>"]]
SAMMLER[["Sammler<br/><small>HTTP/WLED 1 min,<br/>Tahoma 5 min</small>"]] -. "Queue" .-> T1
```
**Warum ein Dauerprozess und nicht Cron?**
1. Schwellwerte („Temperatur über 22 °C“) sollen greifen, wenn die Nachricht
hereinkommt — nicht im nächsten Minutenraster.
2. Nur so gibt es einen Vorzustand für die steigende Flanke.
3. `actor_states.current_value` würde sonst niemand fortschreiben.
**Warum drei Fäden?** Früher wurde mitten in der Hauptschleife gepollt:
neunzehn Tahoma-Geräte nacheinander, jedes mit bis zu zehn Sekunden
Zeitlimit. Eine Runde dauerte 45 statt 30 Sekunden, dadurch kam **jede
dritte Minute nie vor** — und ein Auslöser „um 18:26“ wurde schlicht nie
wahr. Heute hat jeder Transport seinen eigenen Faden, die Hauptschleife
macht nur noch Billiges, und die Uhr wird gar nicht mehr abgetastet, sondern
gegen `datetime.now()` verglichen.
### Transporte
| URL des Aktors | Transport | Lesen | Schreiben |
|---|---|---|---|
| `mqtt://…` | MQTT | abonniert, gepuffert | `publish` |
| `http://…` | HTTP | Poll, 1 min | Abfrageargumente |
| `wled://…` | WLED | Poll, 1 min | JSON an `/json/state` |
| `io://`, `rts://`, `internal://`, `ogp://` | Tahoma | Poll, 5 min | `exec/apply` |
| `Logic` | gerechnet | Uhrzeit, Datum, Sonnenauf-/-untergang | — |
| `Automatik` | gerechnet | letzte Auslösung je Automatik | — |
Ausnahme beim Lesen: Steht in `actor_states.url` ein **Topic**, liest der
MQTT-Transport — auch wenn das Gerät über HTTP geschaltet wird. So sind die
Shellys der zweiten Generation angebunden.
### Woran der Runner merkt, dass er neu laden muss
Nicht an `changed` allein — eine gelöschte Automatik ändert den größten
Zeitstempel nicht, und wer nur die Uhrzeit einer Bedingung verstellt, ändert
in `automations` gar keine Spalte. Stattdessen eine **Signatur**: Zeilenzahl
plus Summe der `CRC32` je Zeile, über Bedingungen, Aktionen, Aktionsparameter
*und* die Geräte­tabellen (dort auch `url` und `value_path`, weil ein
Discovery-Lauf bestehende Zeilen umschreibt).
---
## 5. Die Auswertung im Detail
Das Herzstück, `durchlauf()`, für jede Automatik in topologischer Reihenfolge
(Auslöser vor Nachfolger):
```mermaid
flowchart TB
A["Tag bestimmen<br/><small>heute, mit next_day morgen</small>"] --> B{"tag_passt?<br/><small>Wochentag, Ferien, Feiertag</small>"}
B -- nein --> Z1
B -- ja --> C{"im Zeitfenster?"}
C -- nein --> Z1["Fenster zu:<br/>force_once prüfen,<br/>Flanke zurücksetzen"]
C -- ja --> D{"Bedingung erfüllt?<br/><small>any(all(gruppe))</small>"}
D -- nein --> E["cond_met = 0"]
D -- ja --> HZ{"hold_secs erfüllt?<br/><small>lange genug am Stück</small>"}
HZ -- nein --> G
HZ -- ja --> F{"cond_met schon 1?"}
F -- ja --> G["nichts tun<br/><small>keine neue Flanke</small>"]
F -- nein --> H{"once_per_day<br/>und lief heute?"}
H -- ja --> G
H -- nein --> I{"innerhalb<br/>lockout_secs?"}
I -- ja --> J["Flanke verwerfen"]
I -- nein --> K["ausloesen()"]
K --> L["Aktionen in den Versand,<br/>last_run + Protokoll,<br/>eigenen Auslöserwert setzen"]
```
### Nur die steigende Flanke
`cond_met` hält fest, ob die Bedingung beim letzten Durchlauf schon erfüllt
war. Ohne das würde „Temperatur über 22 °C“ in jedem Takt erneut feuern.
### Haltezeit: erst, wenn es dabei bleibt
Manches zeigt sich nicht in einem einzelnen Messwert, sondern erst in seiner
Dauer. „Der Wasserzähler läuft“ ist jedes Händewaschen; „läuft seit einer
halben Stunde ohne Pause“ ist ein offener Hahn. `hold_secs` schiebt deshalb
die Flanke nach hinten: Erst wenn die Bedingung so viele Sekunden **am
Stück** erfüllt war, gilt sie als erfüllt.
```
Bedingung ──┐ ┌──────────────────────────────────┐
└───┘ └────
↑ ↑ ↑
angefangen nach 30 min: Bedingung weg,
(Zeit läuft) ausgelöst Zeit verfällt
```
Entscheidend ist, dass `cond_met` bis dahin auf 0 bleibt: Die Flanke wird
**aufgeschoben, nicht verbraucht**. Alles danach — Tagessperre, Sperrzeit,
Protokoll — bleibt unverändert.
Was man dazu wissen sollte:
* **Ein Aussetzer setzt zurück.** Auch ein einzelner Takt genügt; genau das
ist mit „ununterbrochen“ gemeint.
* **Gezählt wird im Speicher** (`erfuellt_seit`), nicht in der Datenbank. Es
ist Laufzustand wie `war_aktiv` — und nach einem Neustart weiß niemand, ob
die Bedingung zwischendurch angelegen hat. Die Zeit beginnt dann von vorn.
* **Die Messrate ist die Untergrenze der Genauigkeit.** Ein Zähler, der alle
fünf Minuten meldet, macht die Haltezeit auf fünf Minuten genau. Mit einem
punktgenauen Zeit-Auslöser („um 16:30“, nur im Nachholfenster wahr) ist sie
gar nicht sinnvoll zu kombinieren — sie würde nie reif.
* **Das Zeitfenster schneidet sie ab.** Geht das Fenster zu, verfällt eine
angefangene Haltezeit: Sie soll innerhalb des Fensters voll gelaufen sein.
### Die drei Zeitformen
| Schreibweise | Operator | Bedeutung | Gedacht für |
|---|---|---|---|
| `um 16:30` | `=` | ab dieser Minute, plus **Nachholfenster** (5 min) | der Normalfall: einmal täglich zu einem Termin |
| `ab 16:30` | `>=` | von da bis Mitternacht wahr | alles, was auch nach einem verpassten Takt noch laufen soll (Bewässerung) |
| `vor 16:30` | `<` | bis dahin wahr | als Zusatzbedingung |
Ausgelöst wird in allen drei Fällen **höchstens einmal** — wegen der Flanke.
Das Nachholfenster ersetzt die früher verlangte Punktgenauigkeit: Ein
Neustart oder ein hängendes Gerät kostet die Automatik nicht mehr den ganzen
Tag. Gerechnet wird modulo 24 h, damit ein Ziel um 23:58 auch um 00:01 noch
zieht.
```
Takt: ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ (alle 10 s)
│ │
um 16:30 └── wahr ──────┘ (16:30:00 bis 16:34:59, Nachholfenster)
↑ Flanke: genau hier wird ausgelöst
ab 16:30 └── wahr ───────────────────────────────────► 23:59
↑ Flanke: ebenfalls nur einmal
```
### Sonnenstand und Ketten
Beim Sonnenauf-/-untergang trägt der Operator zusätzlich **das Vorzeichen des
Versatzes**: `+ 00:30` = eine halbe Stunde danach, `>=- 00:30` = ab einer
halben Stunde davor, `<+ 00:30` = bis eine halbe Stunde danach. Über den
Tagesrand wird gerechnet und nicht abgeschnitten — „sechs Stunden vor
Sonnenaufgang“ landet dann eben am Vorabend.
Beim Datentyp `elapsed` (Verkettung) ist es fast dasselbe, mit einem
wichtigen Unterschied: Verglichen wird der **echte Abstand**, nicht die
Uhrzeit im Tag. Sonst machte ein Lauf von vorgestern um 05:50 die Bedingung
heute um 06:00 wahr, an einem Tag, an dem der Auslöser gar nicht lief.
`ab + 00:10` gilt zusätzlich nur am selben Kalendertag.
### Sperrzeit, „einmal am Tag“, „auf jeden Fall“
| Mittel | Wogegen | Verhalten |
|---|---|---|
| `hold_secs` | Zustände, die erst durch ihre Dauer auffallen (Wasser läuft, Fenster steht offen) | Die Flanke wird **aufgeschoben**, bis die Bedingung lange genug am Stück steht. Der Gegenspieler zur Sperrzeit: die bremst die Wiederholung, diese den ersten Lauf |
| `lockout_secs` | Messwerte, die um die Schwelle pendeln (22,1 / 21,9 / 22,1) | Die Flanke wird **verworfen**, nicht aufgehoben — ein Rollladen, der eine Viertelstunde später doch losfährt, wäre unangenehmer als einer, der gar nicht fährt |
| `once_per_day` | Dinge, die man hinterher von Hand anders stellt | Nach dem ersten Lauf ist bis Mitternacht Ruhe. Die Sperrzeit taugt dafür nicht: sie zählt Sekunden und verfehlte am nächsten Morgen den Termin |
| `force_once` | verpasste Fenster | Beim Zugehen des Fensters, aber nur, wenn darin nichts lief (zusätzlich gegen `last_run` geprüft, damit ein Neustart nicht doppelt auslöst) |
---
## 6. Verkettung: eine Automatik löst die nächste aus
Das gerechnete Gerät **„Automatiken“** führt jede Automatik als Messwert;
ihr Wert ist der Zeitpunkt der letzten Auslösung (Datentyp `elapsed`, URL
`auto:<id>`). Damit ist eine Automatik für den Editor ein Messwert wie jeder
andere — „Wecker Magdalena + 00:10“.
```mermaid
flowchart LR
W["Wecker Magdalena<br/><small>um 07:00</small>"] -->|"+ 00:10"| R["Magdalena Rollos<br/><small>und es ist hell</small>"]
R -->|"+ 00:05"| S["Schlafzimmer<br/>Tür auf"]
```
Warum nicht einfach eine Verzögerung an der Aktion? Weil der Nachfolger eine
**vollwertige Automatik mit eigenem Rahmen** bleiben soll: „zehn Minuten
später, aber nur wenn es dann schon hell ist“ ließe sich als bloße
Verzögerung nicht formulieren — die Zusatzbedingung gilt erst zum späteren
Zeitpunkt.
Was daran hängt:
* **Reihenfolge:** `reihenfolge_bestimmen()` sortiert topologisch, damit ein
Versatz von null noch im selben Takt greift. Nach dem Auslösen trägt der
Runner den eigenen Auslöserwert sofort nach.
* **Pausieren wirkt weiter:** Der Runner lädt nur `enabled = 1`. Wer den
Wecker pausiert, lässt morgens auch die Rollos unten — gewollt.
* **Anlegen/Löschen der Messwerte** macht `ausloeser_nachfuehren()` vor jedem
Regelwerk-Laden. Angelegt wird auch für pausierte Automatiken, sonst
löschte `ON DELETE CASCADE` still die Bedingung des Nachfolgers.
* **Löschen mit Anhang:** `deleteAutomation($id, "abhaengen"|"mitloeschen")`.
Der Editor fragt nach, wenn Nachfolger existieren.
* **Kreise** lehnt der Server beim Speichern ab; der Runner meldet sie nur
und lässt die Beteiligten weiterlaufen.
---
## 7. Protokoll und Fehlerbilder
`automation_log` bekommt je Lauf eine Zeile (`fired` / `forced`) und je
fehlgeschlagenem Kommando eine weitere (`error`). Einträge älter als 30 Tage
räumt der Runner selbst weg.
| Beobachtung | Wahrscheinliche Ursache |
|---|---|
| Automatik läuft gar nicht, ohne Protokolleintrag | Rahmen: falscher Wochentag, Ferien/Feiertag auf „nie“, Fenster zu eng — oder die Bedingung war nie *neu* erfüllt (`cond_met` stand schon auf 1) |
| Läuft einmal und dann nicht mehr am selben Tag | `once_per_day` |
| Läuft kurz nacheinander nicht erneut | `lockout_secs` |
| Läuft gar nicht, obwohl die Bedingung sichtbar zutrifft | `hold_secs`: sie war noch nicht lange genug am Stück erfüllt, oder ein Aussetzer hat die Zeit zurückgesetzt |
| Kette bleibt stehen | Auslöser pausiert oder an dem Tag nicht dran |
| Änderung im Editor wirkt nicht | Regelwerk-Signatur: erst beim nächsten `reload_seconds`-Fenster (30 s) |
| `error` im Protokoll | Gerät nicht erreichbar, Kommando oder Transport gibt es nicht mehr |
---
## 8. Wo fange ich an, wenn ich …
| Vorhaben | Ort |
|---|---|
| … einen neuen Operator oder Datentyp anbieten | `operatorsForType()` in `restricted/automations.php` **und** `bedingung_erfuellt()` im Runner |
| … ein neues Rahmenfeld einführen | Spalte in `homeMesh_automations.sql`, `saveAutomation()`/`loadAutomation()`, Editor (`renderRahmen`, `leseFormular`), `durchlauf()` im Runner, Satz in `rahmenText()` |
| … eine neue Geräteart anschließen | eine Klasse in `SolarManager/autoActions/transports.py` (Lesen + Senden), Erkennung an der Aktor-URL |
| … verstehen, warum etwas lief | `automation_log`, dann die Zeitleiste des Tages (→ [zeitleiste.md](zeitleiste.md)) |
| … das Nachholfenster ändern | `catchup_minutes` in `config.ini` des Runners |
| … eine weitere Stufe für die Sperre | `lockoutChoices()` in `restricted/automations.php` — eine Zeile, der Rest zieht nach |
| … die Grenzen der Haltezeit ändern | `haltezeitPruefen()` in `restricted/automations.php` **und** `haltezeitAusUhr()` im Editor |
| … auf Dauerzustände reagieren (Wasser läuft, Tür steht offen) | Haltezeit im Rahmen, dazu eine Aktion des Geräts „Benachrichtigungen“ (→ [benachrichtigungen.md](benachrichtigungen.md)) |
+149
View File
@@ -0,0 +1,149 @@
# Benachrichtigungen
Automatiken können melden: als Nachricht auf dem Handy oder als E-Mail.
Beides ohne eine eigene App — die Meldung aufs Handy kommt über **Web Push**
aus dem Dashboard selbst.
```mermaid
flowchart LR
A["Automatik<br/><small>Aktion: Benachrichtigungen</small>"] --> R
P["Probe aus den Einstellungen"] -->|"MQTT benachrichtigung/#"| R
R["autoaction_runner.py<br/><small>BenachrichtigungTransport</small>"] --> PUSH["Push-Dienst<br/><small>Google, Mozilla, Apple</small>"] --> H["Handy / Browser<br/><small>sw.js zeigt die Meldung</small>"]
R --> MAIL["SMTP"] --> M["Postfach"]
DB[("homeMesh.push_abos")] --> R
KEY[["push_vapid.json<br/><small>Schlüsselpaar</small>"]] --> R
```
---
## 1. Das Gerät „Benachrichtigungen“
Ein gerechnetes Gerät wie „Zeitpunkt“: Es hängt an keinem Netzwerkanschluss,
sondern bündelt Kommandos, die der Runner selbst ausführt. Angelegt wird es
vom Gerätesuchlauf (`benachrichtigung_module.py`), Aktor-URL
`Benachrichtigung`.
| Kommando | `command_url` | Parameter |
|---|---|---|
| Nachricht aufs Handy | `push` | Titel, Text |
| E-Mail | `mail` | Betreff, Text |
**Warum als Gerät und nicht als eigener Aktionstyp?** Weil der Editor, die
Übersicht und die Zeitleiste damit nichts Neues lernen müssen: Für sie ist es
ein Gerät mit Kommandos wie jedes andere. Eine Meldung ist deshalb eine
gewöhnliche Aktion — sie lässt sich mit anderen Aktionen mischen, im Rahmen
begrenzen und verketten.
**Keine Empfängerliste je Automatik.** Wer eine Meldung bekommt, steht an
einer Stelle: die Handys in `homeMesh.push_abos`, die Mailadressen in der
`config.ini` des Runners. Ein Empfängerfeld je Aktion wäre eine zweite
Liste, die mit der ersten auseinanderläuft.
---
## 2. Web Push — ohne App
```mermaid
sequenceDiagram
participant B as Browser (Handy)
participant W as ajax/push.php
participant DB as homeMesh.push_abos
participant R as Runner
participant D as Push-Dienst
B->>B: sw.js registrieren, Erlaubnis holen
B->>D: subscribe(VAPID-Public-Key)
D-->>B: Abo (endpoint + 2 Schlüssel)
B->>W: POST ?action=anmelden
W->>DB: INSERT … ON DUPLICATE KEY UPDATE
Note over R: Automatik löst aus
R->>DB: Abos lesen
R->>D: verschlüsseltes Paket, VAPID-signiert
D->>B: push-Ereignis → showNotification()
```
Drei Teile, die zusammengehören:
| Teil | Wo | Aufgabe |
|---|---|---|
| Service Worker | `sw.js` (Wurzelverzeichnis!) | nimmt das `push`-Ereignis an und zeigt die Meldung; ein Klick holt die Seite nach vorn |
| Abo | `homeMesh.push_abos` | Endpoint plus zwei Schlüssel je Gerät |
| Schlüsselpaar | `SolarManager/push_vapid.json` | der private Teil unterschreibt jede Meldung, der öffentliche steckt im Abo |
Wissenswertes:
* **`sw.js` muss im Wurzelverzeichnis liegen.** Ein Service Worker gilt nur
für Adressen unterhalb seines eigenen Ortes.
* **Er speichert nichts zwischen.** Das Dashboard lebt von aktuellen Werten;
ein Cache wäre hier keine Beschleunigung, sondern eine Fehlerquelle.
* **iPhone:** Safari erlaubt Web Push erst, wenn die Seite über
„Teilen ▸ Zum Home-Bildschirm“ abgelegt und von dort geöffnet wurde.
* **Das Abo gehört dem Browser, nicht dem Konto.** Jedes Gerät meldet sich
einzeln an; in den Einstellungen stehen alle nebeneinander.
* **Der Endpoint ist das Geheimnis.** Wer ihn hat, kann dem Gerät schreiben —
deshalb geht er nie an den Browser zurück, auch nicht in der Liste.
* **Tote Abos räumt der Runner selbst weg:** Antwortet der Dienst mit 404
oder 410, gibt es das Gerät nicht mehr.
* Der Schlüssel wird **nie neu erzeugt**, solange Abos daran hängen —
`vapid_erzeugen.py` überschreibt eine vorhandene Datei nicht.
---
## 3. E-Mail
Ein gewöhnlicher SMTP-Versand aus dem Runner, eingetragen in dessen
`config.ini`:
```ini
[mail]
server = smtp.example.net
port = 587 ; 587 = STARTTLS, 465 = von Anfang an verschlüsselt
benutzer =
passwort =
von =
an = einer@example.net, zweiter@example.net
```
Unverschlüsselten Versand gibt es nicht: Bei 465 wird die Verbindung
verschlüsselt aufgebaut, sonst mit STARTTLS gewechselt. Fehlen Server oder
Empfänger, meldet die Automatik einen Fehler ins Protokoll (`automation_log`),
statt still nichts zu tun.
---
## 4. Der zweite Weg: MQTT
Der Runner hört zusätzlich auf `benachrichtigung/#`:
| Topic | Nutzlast | Wirkung |
|---|---|---|
| `benachrichtigung/push` | `{"titel": "...", "text": "..."}` | Meldung an alle Geräte |
| `benachrichtigung/mail` | `{"betreff": "...", "text": "..."}` | Mail an die eingetragenen Empfänger |
Darüber schickt die **Probe** aus den Einstellungen — die Weboberfläche muss
so nichts verschlüsseln und braucht den privaten Schlüssel nie. Denselben Weg
kann jedes Skript auf der NAS nehmen.
Zugestellt wird im Haupttakt des Runners, nicht im MQTT-Faden: Eine
Datenbankverbindung gehört einem Faden, und der Rückruf des Brokers läuft in
einem eigenen.
---
## 5. Die Pakete
`pywebpush`, `py_vapid` und `http_ece` liegen wie alle übrigen Fremdpakete
**im SolarManager-Ordner**, nicht in `site-packages`. Weil der Runner aus
`autoActions/` startet, hängt `transports.py` diesen Ordner an den Suchpfad.
---
## 6. Wo fange ich an, wenn ich …
| Vorhaben | Ort |
|---|---|
| … einen weiteren Kanal (Telegram, Signal, WhatsApp) | ein Kommando in `benachrichtigung_module.py`, ein `elif` in `BenachrichtigungTransport.senden()` |
| … den Text einer Meldung ändern | in der Automatik selbst — Titel und Text sind Parameter der Aktion |
| … ein Handy loswerden | Einstellungen → Meldungen → Papierkorb; oder auf dem Gerät selbst abmelden |
| … den Schlüssel erneuern | `push_vapid.json` löschen, `vapid_erzeugen.py` laufen lassen, danach müssen sich **alle** Geräte neu anmelden |
| … nachsehen, warum nichts ankam | `autoActions.log` auf der NAS und die Spalten `zuletzt`/`fehler` in `push_abos` |
Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

+273
View File
@@ -0,0 +1,273 @@
# Die Batterie: BYD Battery-Box HVM
Der Wechselrichter weiß von der Batterie nur, was er zum Laden braucht.
Alles darunter — 128 Zellspannungen, 64 Temperaturfühler, Gesundheit,
Gesamtzähler, Zellausgleich — kennt nur die **BMU**, die Steuerung im
Batterieschrank. `gatherBYDData.py` im SolarManager spricht sie direkt an.
![Die Speicher-Ansicht](bilder/speicher.png)
```mermaid
flowchart LR
BMU["BMU der Battery-Box<br/><small>192.168.16.254:8080</small>"] -->|"Modbus-RTU in TCP"| G["gatherBYDData.py<br/><small>im solarManager.py-Prozess</small>"]
G -->|"solarManager/byd/…"| M[["MQTT"]]
G -->|"alle 5 / 15 Min."| DB[("solarLog<br/>byd · byd_zellen")]
M --> B["speicher.js<br/><small>Live</small>"]
DB --> A["ajax/speicher.php"] --> B
WR["Gen24<br/><small>Modbus</small>"] -->|"SOC, P_Akku"| M
```
---
## 1. Warum überhaupt direkt an die BMU
| Frage | Gen24 über Modbus | BMU über Port 8080 |
|---|---|---|
| Wie voll ist sie? | ja (`SOC`) | ja, oft eine Nachkommastelle anders |
| Wie viel lädt sie gerade? | ja (`P_Akku`) | ja, als Spannung × Strom |
| Wie alt ist sie? | — | **SOH** in % |
| Welche Zelle schwächelt? | — | **128 Zellspannungen** in mV |
| Wie warm wird sie? | eine Zahl | **64 Fühler** einzeln |
| Wie viel ging je durch? | — | **Gesamtzähler** geladen/entladen |
| Gleicht sie gerade aus? | — | Bitmaske je Zelle |
| Meldet sie einen Fehler? | — | 16 Fehlerbits im Klartext |
Die linke Spalte reicht für den Energiefluss. Für die Frage „hält die
Batterie noch?“ reicht sie nicht — deshalb der zweite Weg.
Das Protokoll ist dasselbe, das die Hersteller-App **BE Connect** spricht:
Modbus-RTU, in TCP verpackt. Ablauf und Byte-Lagen stammen aus dem
ioBroker-Adapter [bydhvs](https://github.com/christianh17/ioBroker.bydhvs)
(MIT). Sie sind nirgends offiziell dokumentiert — wer hier etwas ändert,
ändert an einer nachgebauten Schnittstelle.
---
## 2. Der Ablauf: alles in **einer** Verbindung
```mermaid
sequenceDiagram
participant S as gatherBYDData
participant B as BMU
S->>B: TCP verbinden (Timeout 1,5 s)
S->>B: 0 stamm – Seriennummer, Module, Firmware
B-->>S: p0
S->>B: 1 echt – SOC, SOH, Strom, Spannung, Fehler, Zähler
B-->>S: p1
S->>B: 2 typ – HVM oder HVS
B-->>S: p2
S->>B: 3 messen – Zellmessung starten
Note over S,B: 8 s warten – so lange misst die BMU
S->>B: 4 status
S->>B: 5 zellen (viermal)
B-->>S: p5 p6 p7 p8
S->>B: Verbindung schließen
```
| Name | Bytes | Was zurückkommt |
|---|---|---|
| `stamm` | `010300000066c5e0` | Seriennummer, Modulzahl, BMS-Version |
| `echt` | `01030500001984cc` | die laufenden Werte |
| `typ` | `010300100003040e` | HVM (16 Zellen/Modul) oder HVS (32) |
| `messen` | `0110055000020400018100f853` | Zellmessung anstoßen |
| `status` | `010305510001d517` | Quittung nach der Messung |
| `zellen` | `01030558004104e5` | viermal nacheinander, jedes Mal der nächste Block |
**Die Reihenfolge ist Pflicht, und die Verbindung darf dazwischen nicht
abreißen.** Eine neue Verbindung, die gleich mit `echt` anfängt, schließt die
BMU wortlos. Auch die acht Sekunden Messpause werden in derselben Verbindung
abgewartet — sie offen zu halten ist billiger, als sich neu anzumelden.
Jede Antwort wird geprüft, bevor sie gedeutet wird: Rahmenlänge aus Byte 2,
CRC-16 (Modbus, Polynom `0xA001`) über das Ganze, und das Ausnahmebit
`0x80` in Byte 1. Eine Antwort, die zu kurz ankommt, wird weitergelesen; eine
mit falscher Prüfsumme fliegt raus. Schließt die Gegenseite ohne Antwort,
gilt das nicht als Fehler, sondern als `_Abgewiesen` — siehe nächster
Abschnitt.
---
## 3. Die Eigenheit, die den ganzen Aufbau bestimmt
Zwischen Netzwerk und BMU sitzt ein Serien-Server **Hi-Link HLK-RM08K**. Der
startet **etwa alle 102 Sekunden neu** — von allein, ob jemand fragt oder
nicht. Beobachtet am 15.09.2026:
```
├──────── ~102 s ────────┤
▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░▓▓▓▓▓▓▓▓▓░░░░
└─ ~15 s ─┘└── offen ──┘ └ Neustart ┘
Neustart: die ersten 1–2
niemand Verbindungen werden
antwortet bedient, alle weiteren
sofort mit 0 Bytes
```
Daraus folgt der Aufbau des Sammlers:
* **Er klopft**, statt zu pollen: jede Sekunde ein Verbindungsversuch mit 1,5
s Timeout (`_KLOPFEN`, `_VERBINDEN`). Der 3-Sekunden-Takt des Managers wäre
zu grob, das Fenster ist nur wenige Sekunden breit.
* **Nach einem Erfolg ruht er 80 Sekunden** (`_RUHE`) und ist damit kurz vor
dem nächsten Neustart wieder da. Ergebnis: rund alle 100 Sekunden ein
vollständiger Satz.
* **Eine zugeschlagene Tür ist kein Fehler.** `_Abgewiesen`, Timeouts und
`ConnectionError` landen im Debug-Protokoll, nicht als Warnung. Erst wenn
zehn Minuten (`_VERALTET`) gar nichts durchkommt, gibt es eine Warnung —
und `ok` fällt auf 0, damit die Oberfläche alte Zahlen nicht als frisch
ausgibt.
* **Nie breit im Netz scannen.** Das Modul verträgt keine parallelen
Verbindungen; ein Portscan macht das Fenster für den Sammler zu.
> Die BMU hängt unter **192.168.16.254**. Das NAS-Netz ist ein /16, deshalb
> ist sie ohne Routing erreichbar. Port 80 desselben Moduls zeigt dessen
> Weboberfläche (Basic-Auth) — für uns ohne Bedeutung.
Wie alle langsamen Sammler blockiert `gatherData()` nie: Die Schleife läuft
als Hintergrund-Task, zurück kommt immer der letzte Stand. Stirbt die
Schleife, startet der nächste Aufruf sie neu.
---
## 4. Wo welcher Wert im Paket steht
Die laufenden Werte stehen alle in `p1`:
| Wert | Lage | Umrechnung |
|---|---|---|
| SOC | 3 | `int16` |
| SOH | 9 | `int16` |
| Strom | 11 | `int16 / 10` → A, positiv = laden |
| T max / T min | 15 / 17 | `int16`, nur Rückfall ohne Fühlerwerte |
| Fehlerbits | 29 | `uint16`, 16 Bits mit festem Wortlaut |
| Spannung | 35 | `uint16 / 100` → V |
| geladen | 37 | **wortvertauscht** `/ 10` → kWh |
| entladen | 41 | **wortvertauscht** `/ 10` → kWh |
**Die eine Abweichung vom Adapter:** Die Gesamtzähler stehen bei dieser HVM
mit dem niedrigen Wort zuerst. Mit der Lesart des Adapters kämen 31 Millionen
kWh heraus — ein Wert, der so offensichtlich falsch ist, dass er die
Byte-Lage verrät.
Die Zellen kommen über vier Antworten verteilt, weil ein Modbus-Rahmen sie
nicht fasst:
```
p5: Zellen 1– 16 ab Byte 101 p5[17..32]: Ausgleichsbits (roh)
p6: Zellen 17– 80 ab Byte 5
p7: Zellen 81–128 ab Byte 5 + Fühler 1–30 ab Byte 103
p8: Fühler 31–64 ab Byte 5
```
Wie viele es sind, sagt nicht die Konstante, sondern das Gerät: Modulzahl aus
`p0[36] % 16`, Bauart aus `p2[5]`. HVM hat 16 Zellen und 8 Fühler je Modul,
HVS 32 und 12. Bei acht Modulen also 128 und 64.
Zwei Stellen sind bewusst vorsichtig:
* **Als gültig zählt nur, was zwischen 2000 und 4000 mV liegt.** Minimum,
Maximum und Spreizung werden aus diesen Werten gebildet — nicht belegte
Plätze fallen sonst als „0 mV“ ins Minimum.
* **Die Ausgleichsbits gehen roh weiter** (`ausgleichRoh`, 32 Hexzeichen mit
`0x` davor, damit MQTT sie nicht als Zahl liest). Welches Bit zu welcher
Zelle gehört, ist **nicht bestätigt** — dafür müsste man zusehen, während
die BMU wirklich ausgleicht. Gezählt wird deshalb nur, *wie viele* Bits
gesetzt sind.
---
## 5. Was herauskommt
Alles unter `solarManager/byd/…`, geschrieben aus der Datenklasse `BYDData`:
| Topic | Bedeutung |
|---|---|
| `ok` | 1, solange der Stand jünger als zehn Minuten ist |
| `stand` | Unix-Zeit des letzten vollständigen Zyklus |
| `soc`, `soh` | % laut BMU |
| `spannung`, `strom`, `leistung` | V, A, W — positiv = laden |
| `tMin`, `tMax` | °C über alle Fühler |
| `zelleMin`, `zelleMax`, `zelleMinNr`, `zelleMaxNr`, `spreizung` | mV bzw. 1…128 |
| `ausgleich`, `ausgleichRoh` | Zahl der ausgeglichenen Zellen, dazu die rohe Maske |
| `fehler`, `fehlertext` | Bitmaske und derselbe Inhalt als Satz |
| `geladen`, `entladen` | kWh seit Inbetriebnahme |
| `zellen`, `temperaturen` | JSON-Listen, 128 bzw. 64 Werte |
| `module`, `firmware`, `seriennummer` | Stammdaten |
Historie in `solarLog` (Schema: `solarLog_byd.sql` im Web-Repo):
| Tabelle | Takt | Inhalt |
|---|---|---|
| `byd` | alle 5 Min. | eine Zeile mit SOC, SOH, Spannung, Strom, Temperaturen, Spreizung, Zählern — knapp 290 am Tag |
| `byd_zellen` | alle 15 Min. | alle Zellspannungen und Fühlerwerte als JSON, rund 1 kB je Zeile |
Warum JSON und nicht 192 Spalten? Weil diese Werte nie einzeln abgefragt,
sondern immer als Satz gezeichnet werden. Und warum wird `byd` nie
ausgedünnt? Weil die Tabelle genau dafür da ist, zu zeigen, wie sich SOH,
Spreizung und Temperaturen über **Jahre** entwickeln — das ist der Zweck,
nicht der Platzverbrauch.
---
## 6. Die Ansicht im Browser
Auf der Solar-Seite, dritter Reiter der Realtime-Karte („Speicher“), gebaut
von `js/solar/speicher.js`:
| Teil | Quelle |
|---|---|
| Kennzahlen-Kacheln | MQTT, live |
| Turm aus Modulen und Zellen | `zellen`/`temperaturen` aus MQTT |
| Detailspalte rechts (gewähltes Modul) | dasselbe, nur ausschnittweise |
| Ladestand und Leistung über die Zeit | `ajax/speicher.php?was=verlauf` → `EnergyFlow` |
| Spreizung und Temperatur über die Zeit | dieselbe Antwort → `byd` |
`ajax/speicher.php` kann noch etwas, das niemand abruft: `?was=gesundheit`
liefert SOH, größte Spreizung und die Zähler **je Tag** — die Zahlenreihe für
ein Alterungsdiagramm, das es noch nicht gibt. Wer es bauen will, braucht
also nur die Anzeige.
Drei Entscheidungen, die man beim Lesen des Codes sonst sucht:
* **Ohne BMU bleibt die Ansicht nützlich.** Fehlen die Topics, zeigt sie
Ladestand, Leistung und deren Verlauf aus dem Wechselrichter und schreibt
darüber, woher die Zahlen kommen. Turm und Zellwerte machen einem Hinweis
Platz, statt leer dazustehen.
* **Die Nennenergie ist eine Konstante im Skript** (`KWH_JE_MODUL`, HVM 2,76
kWh, HVS 2,56). Die BMU meldet die Modulzahl, aber nicht diese Größe —
ohne sie gäbe es kein „≈ 10,6 von 22,1 kWh“ und keine Zyklenzahl.
* **Der Verlauf wird erst beim Hinsehen geholt.** Ein `IntersectionObserver`
löst den ersten Abruf aus, danach alle fünf Minuten — und nur, solange der
Reiter sichtbar und das Fenster im Vordergrund ist.
Die Zellen sind überall gleich durchgezählt, in der BMU wie in der
Datenbank wie in der Anzeige: **Modul m hat die Zellen 16·(m−1)+1 bis 16·m
und die Fühler 8·(m−1)+1 bis 8·m.**
---
## 7. Fehlerbilder
| Beobachtung | Wahrscheinliche Ursache |
|---|---|
| Ansicht sagt „keine Daten von der BMU“ | Sammler aus, Adresse falsch, oder etwas belegt die einzige Verbindung des Moduls |
| „Die Werte der BMU sind veraltet“ | Seit über zehn Minuten kein Zyklus durchgekommen — Protokoll (`solarOutput.log`) nach „BYD:“ durchsehen |
| Zähler in Millionenhöhe | Wortvertauschung (siehe Abschnitt 4) — trifft andere Battery-Box-Bauarten anders |
| Spreizung springt auf hunderte mV | Eine Zelle liefert 0 mV: Filter 2000–4000 greift nicht mehr, weil zu viele Plätze leer sind |
| `zellen` kommt als leere Liste | `p5`–`p8` kamen an, aber die Messung lief noch — die acht Sekunden Pause sind knapp bemessen |
| Nichts geht mehr, seit jemand das Netz gescannt hat | Das Modul bedient nur ein, zwei Verbindungen; abwarten, bis es das nächste Mal neu startet |
---
## 8. Wo fange ich an, wenn ich …
| Vorhaben | Ort |
|---|---|
| … eine andere Battery-Box anschließen (HVS, andere Modulzahl) | nichts — Modulzahl und Bauart kommen aus `p0`/`p2`; nur `KWH_JE_MODUL` in `speicher.js` stimmt dann nicht mehr |
| … die Adresse ändern | `[byd] host` in der `config.ini` des SolarManagers |
| … seltener oder öfter abfragen | `_RUHE` in `gatherBYDData.py` — kleiner als ~80 s bringt nichts, das Fenster kommt nicht öfter |
| … die Historie feiner aufzeichnen | `_DB_ZEILE` / `_DB_ZELLEN`, dazu den Platzbedarf in `solarLog_byd.sql` gegenrechnen |
| … die Bitlage des Zellausgleichs bestätigen | `byd_zellen.ausgleich` (roh) zu einem Zeitpunkt auswerten, an dem `ausgleich > 0` war |
| … einen weiteren BMU-Wert anzeigen | Feld in `BYDData` ergänzen (geht von selbst nach MQTT), dann `speicher.js` |
| … verstehen, warum die Zahlen der BMU und des Gen24 nicht gleich sind | sie messen an verschiedenen Stellen — die BMU an den Zellen, der Wechselrichter hinter dem Wandler |
+215
View File
@@ -0,0 +1,215 @@
# Die Datenbanken
Drei Schemata auf derselben MariaDB der NAS (Port 3310). Die Trennung ist
keine Ordnungsliebe, sondern Zuständigkeit:
| Schema | Inhalt | Kennzeichen |
|---|---|---|
| **`homeMesh`** | Geräte, Automatiken, Grundriss — **was gilt** | klein, viele Fremdschlüssel, wird von Hand gepflegt |
| **`solarLog`** | Messreihen, Statistik, Preise — **was war** | groß, schreibt fast nur die NAS, wird verdichtet |
| **`Logins`** | Passkeys und Einmal-Links | winzig, sicherheitsrelevant |
Wer von wo verbindet:
| Aufrufer | Funktion / Datei | Zugang |
|---|---|---|
| Weboberfläche | `meshDb()` (`restricted/meshdb.php`) | `homeMesh` |
| Weboberfläche | `solarDb()` (`restricted/costs.php`) | `solarLog` |
| Weboberfläche | `commandDb()` (`restricted/commands.php`) | `homeMesh`, für Kommandos |
| Weboberfläche | `checkLogin()` (`helper.php`) | `Logins` |
| NAS-Prozesse | `config.ini` im SolarManager | beide, eigene Benutzer |
Die Zugangsdaten stehen in `restricted/mysql.php` (Web) bzw. `config.ini`
(NAS) — beides nicht im Git.
---
## 1. `homeMesh` — was gilt
```mermaid
erDiagram
floors ||--o{ rooms : "hat"
rooms ||--o{ actors : "room_id"
actors ||--o{ actor_states : "meldet"
actors ||--o{ actor_commands : "kann"
actor_commands ||--o{ command_parameters : "nimmt"
state_types ||--o{ actor_states : "Datentyp"
automations ||--o{ automation_conditions : "wenn"
automations ||--o{ automation_actions : "dann"
automations ||--o{ automation_log : "Protokoll"
automation_conditions }o--|| actor_states : "vergleicht"
automation_actions }o--|| actor_commands : "schickt"
automation_actions ||--o{ automation_action_params : "mit"
floors ||--o{ automations : "Reiter"
energiefluss }o--|| floors : "keine Beziehung, nur Abweichungen"
```
### Geräte
| Tabelle | Inhalt | Geschrieben von |
|---|---|---|
| `actors` | ein Gerät je Zeile. `url` entscheidet den Weg: `mqtt://`, `http://`, `wled://`, `io://`/`rts://`/`internal://`/`ogp://` (Tahoma), `Logic`, `Automatik`. `room_id` verknüpft mit `rooms` | Gerätesuchlauf; `room_id` von Hand (Einstellungen → Geräte) |
| `actor_states` | alles Lesbare. `url` ist **entweder** ein MQTT-Topic **oder** ein Feldname im HTTP-JSON, `value_path` der Schlüssel darin, `current_value` der letzte Wert | Suchlauf (Adressen), Runner (Werte) |
| `actor_commands`, `command_parameters` | alles Schaltbare und die Parameter dazu (`url` = Stelle im Befehl) | Suchlauf |
| `state_types` | Datentypen: `integer`, `float`, `bool`, `string`, `time`, `date`, `datetime`, `deltatime`, `elapsed` … | fest |
Zwei Dinge, die immer wieder überraschen:
* **`sensors`/`sensor_states` sind unbenutzt.** Der Suchlauf legt auch reine
Messgeräte in `actors`/`actor_states` ab — ein Gerätebegriff für alles.
* **Der Suchlauf löscht nie** (`clear_tables = false`). Er schreibt mit
`ON DUPLICATE KEY UPDATE` auf den URLs. Ein `TRUNCATE` würde neue IDs
vergeben und **alle Automatiken auf falsche Geräte zeigen lassen**.
Karteileichen räumt man in den Einstellungen weg
(→ [einstellungen.md](einstellungen.md#5-geräte)).
### Automatiken
Eigenes Dokument: [automatiken.md](automatiken.md#1-datenmodell). Kurz:
`automations` (Rahmen + Laufzustand), `automation_conditions` (Auslöser,
`group_no` = UND/ODER), `automation_actions` + `automation_action_params`
(was geschickt wird), `automation_log` (30 Tage), `calendar_days` (Ferien
und Feiertage, jährlich per `fetch_calendar.py`).
### Grundriss und Anzeige
| Tabelle | Inhalt | Besonderheit |
|---|---|---|
| `floors` | Etagen: `code` (fest — steht in URLs, SVG-Ids und `automations.floor`), Bezeichnung, Reihenfolge, Grundrissbild, Standard-Etage | Schema `homeMesh_grundriss.sql` |
| `rooms` | feste Nummer `id`, Etage, Name, `kuerzel` (SVG-Ids), Kachelposition `x`/`y` (leer = keine Kachel), `thermostat` (MQTT-Zweig), `werte` (JSON, leer = Vorgabe) | die Nummer überlebt Umbenennen und Umziehen |
| `energiefluss` | **nur Abweichungen** der Solar-Übersicht vom Katalog: `aktiv`, `name`, `x`/`y`, `optionen` | keine Zeile = Katalogwert; Schema `homeMesh_energiefluss.sql` |
Die drei `view_*`-Objekte sind Lesehilfen aus der Anfangszeit des
Gerätesuchlaufs; die Anwendung benutzt sie nicht.
---
## 2. `solarLog` — was war
Stand heute rund 170 MB, verteilt auf wenige große Reihen:
| Tabelle | Zeilen | Inhalt | Geschrieben von |
|---|---:|---|---|
| `EnergyFlow` | ~108.000 | Momentanleistungen alle 5 min: PV, Netz, Batterie, Etagen, Heizstab, Wallbox | `solarManager.py` |
| `EnergyFlow_hourly` | ~38.000 | Stundenarchiv in kWh — die Langzeitquelle | Rollup-Job |
| `stats_daily` | ~1.500 | Tageswerte für die Jahresstatistik, inklusive vorberechneter Größen | Rollup-Job |
| `Heater` | ~632.000 | Heizung und Heizstab | `solarManager.py` |
| `puffertemp` | ~247.000 | Puffertemperaturen | `solarManager.py` |
| `wasser`, `zisterne` | ~300.000 | Wasserzähler und Zisterne | `gatherWaterData.py` |
| `windrad`, `kiga_windrad` | ~255.000 | Windräder | `solarManager.py` |
| `weatherStation` | ~195.000 | die eigene Wetterstation, alle 5 min | die Station selbst, über `/volume1/web/weatherStation.php` |
| `weatherHours`, `weatherDays` | ~40.000 / 1.600 | Wettervorhersage **und Archiv**, stündlich bzw. täglich (→ [meteogramm.md](meteogramm.md)) | `gatherForecastData.py` (Open-Meteo) |
| `weatherTilted`, `weatherForecastLog` | je nach Nutzung | Einstrahlung in Modulebene; die erste Vorhersage je Stunde | `gatherForecastData.py` |
| `daylight` | 663 | Sonnenauf- und -untergang, nur bis morgen | `solarManager.py` (gerechnet, nicht abgerufen) |
| `simPower` | ~82.000 | Ertragsprognose, 7 Tage voraus | Solcast, 4×/Tag über `/volume1/web/gatherSolar.php` |
| `byd`, `byd_zellen` | 1.559 / 492 | BYD-Speicher: alle 5 min Ladestand, SOH, Temperaturen; alle 15 min 128 Zellspannungen und 64 Temperaturen (→ [byd.md](byd.md)) | `gatherBYDData.py` |
| `skoda`, `skoda_raw`, `skoda_ladepunkte` | ~1.900 | Fahrzeugzustand, Rohantwort, Ladeverlauf im Minutentakt | `gatherSkodaData.py` |
| `gridCosts`, `gasCosts`, `fuelCosts` | 13 | Preiszeitreihen | Einstellungsseite |
| `car`, `Status`, `actors`, `sensors`, `autoActions*`, `WindradLog` | — | Altlasten aus der Vorgängerfassung | — |
### Drei Stufen der Verdichtung
```mermaid
flowchart LR
A["EnergyFlow<br/><small>alle 5 min, Momentanleistung</small>"] -->|"nachts, Rollup"| B["EnergyFlow_hourly<br/><small>Stunden in kWh</small>"]
A --> C["stats_daily<br/><small>Tageswerte + vorberechnete Größen</small>"]
A -. "nach 12 Monaten ausgedünnt" .-> A
B --> H2["Historie: Jahr, Jahrzehnt"]
A --> H1["Historie: Monat<br/><small>der laufende Tag fehlt im Archiv</small>"]
C --> S["Jahresstatistik<br/><small>ajax/getStats.php</small>"]
```
Wichtig beim Auswerten:
* **Für einen Monat auf `EnergyFlow` rechnen**, sonst fehlt der laufende Tag
(das Archiv wird nur nachts fortgeschrieben).
* **Für Jahre auf `EnergyFlow_hourly`**, weil die Rohdaten nach zwölf Monaten
ausgedünnt werden — wer dort rechnet, zeigt für alte Jahre zu wenig.
* **Für Kennzahlen auf `stats_daily`.** Manches lässt sich aus Tagessummen
gar nicht rekonstruieren, etwa der Eigenverbrauch oder der Solaranteil der
Wallbox: dafür muss je Messwert eine Bedingung ausgewertet werden.
* **Einspeisung kommt aus `gridPfeed`**, dem Zählerregister, nicht aus dem
Saldo `gridP`. Innerhalb eines Fensters gibt es Bezug und Einspeisung
gleichzeitig; der Saldo löscht kurze Bezüge weg.
* **`pv_fehlbetrag_kwh`** gleicht aus, was fehlt, wenn ein Wechselrichter die
Verbindung zur OpenDTU verliert: Senken sind dann vollständig gemessen, die
Erzeugung nicht — ohne die Korrektur wird der Direktverbrauch negativ.
### Der Rollup-Job
`~/Backup-scripts/solarlog-rollup.sh` (Schema dazu:
`solarlog-rollup.sql`) hat drei Betriebsarten:
| Aufruf | Was er tut |
|---|---|
| `fill [--from … --to …]` | Stunden- und Tageswerte fortschreiben. Idempotent (`INSERT … ON DUPLICATE KEY UPDATE`), darf jederzeit wiederholt werden |
| `thin [--dry-run] [--months 12]` | Rohdaten älter als zwölf Monate ausdünnen — **nur**, wenn für den Monat ein Aggregat vorliegt. Vorher wird der betroffene Zeitraum monatsweise nach `/volume1/docker/solarLog_history` weggesichert |
| `status` | zeigt, bis wohin aggregiert und ab wann ausgedünnt ist |
Ausgedünnt werden `EnergyFlow`, `Heater` und `weatherStation`; das Zielraster
beträgt 15 Minuten. **`weatherHours` und `weatherDays` stehen bewusst nicht
dabei** — sie sind schon stündlich, und ihre Strahlungswerte sind die eine
Hälfte des Datenpaares für eine künftige Ertragsprognose (die andere ist
`EnergyFlow_hourly.pv_kwh`). Was man beim Rechnen auf den Rohdaten wissen muss, steht
ausführlich im Kopf der SQL-Datei — die wichtigsten Punkte:
* **Alles sind Momentanleistungen**, gemittelt über rund fünf Minuten. kWh
entstehen nur durch Integration über die Zeit, nie durch Mittelwerte über
Zeilen.
* **Die Einheiten sind gemischt:** die Wallbox-Spalten liefern Kilowatt, alle
übrigen Leistungsspalten Watt.
* **Die Vorzeichen sind es auch:** `totalConsumption` negativ = Hausverbrauch,
`battP` negativ = Laden, und `PL*_OG` kommt negativ, während `PL*_EG` und
`PL*_UG` positiv sind.
* Messlücken über 15 Minuten zählen nicht als durchgehende Leistung.
Die Preistabellen tragen **einen Stichtag und kein Enddatum**; das Ende holt
sich die Auswertung mit `LEAD()` beim nächsten Eintrag. Weil ein Stichtag ein
Datum ist, kann ein Tag nie zwei Tarife haben — die Verdichtung auf Tage
verliert hier also nichts.
---
## 3. `Logins` — Zugang
| Tabelle | Inhalt |
|---|---|
| `users` | Passkeys, `authKey`, `lastAuth` |
| `addUser` | Einmal-Links, mit denen ein neues Gerät einen Passkey anlegen darf |
`checkLogin()` in `helper.php` entscheidet in dieser Reihenfolge:
1. **Heimnetz** (`isLocal()`) — freier Zugang, ohne Anmeldung.
2. Gültige Sitzung mit `authKey`, höchstens zwei Tage alt.
3. Sonst: Anmeldeseite.
Daraus folgt die wichtigste Sicherheitsregel dieses Projekts: **Was im
Heimnetz ohne Anmeldung sichtbar ist, darf keinen Schlüssel preisgeben.**
Deshalb gehen API-Schlüssel nie an den Browser zurück
(→ [einstellungen.md](einstellungen.md#6-fahrzeug)), und deshalb schaltet die
Oberfläche Geräte über den Server statt direkt.
---
## 4. Pflege und Sicherung
| Aufgabe | Wer | Takt |
|---|---|---|
| Rohdaten ausdünnen, Stundenarchiv und Tageswerte fortschreiben | `~/Backup-scripts/solarlog-rollup.sh` (läuft als root im MariaDB-Container) | nachts |
| `automation_log` aufräumen | Runner selbst | täglich, 30 Tage Aufbewahrung |
| `calendar_days` füllen | `fetch_calendar.py` | jährlich per Cron |
| Datenbanksicherung | `backupDB.sh` | per Cron |
| Schema neu aufsetzen | `homeMesh_DB-layout.sql`, `homeMesh_automations.sql`, `homeMesh_grundriss.sql`, `homeMesh_energiefluss.sql` in dieser Reihenfolge, danach die Nachrüstskripte aus `SolarManager/autoActions/` | einmalig |
---
## 5. Wo fange ich an, wenn ich …
| Vorhaben | Ort |
|---|---|
| … eine neue Messreihe aufzeichnen | Tabelle in `solarLog` anlegen, Schreiber im SolarManager, Leser als `ajax/*.php` |
| … eine Kennzahl in die Jahresstatistik aufnehmen | Spalte in `stats_daily` (Rollup) **und** Eintrag in `ajax/getStats.php` |
| … wissen, warum eine Zahl in zwei Ansichten abweicht | zuerst prüfen, aus welcher Stufe sie stammt (roh, Stunde, Tag) |
| … ein Gerät endgültig loswerden | Einstellungen → Geräte; per SQL nur, wenn keine Automatik darauf zeigt |
| … eine zweite Installation aufsetzen | Schemata einspielen, dann Einstellungen → Grundriss und → Übersicht; Inhalte stehen in der Datenbank, nicht im Code |
+224
View File
@@ -0,0 +1,224 @@
# Die Einstellungsseite
Alles, was das Haus beschreibt statt es zu programmieren: Preise, Etagen und
Räume, die Solar-Übersicht, die Werte auf den Kacheln, die Zuordnung der
Geräte und der Zugang zum Fahrzeug. **Was hier eingetragen wird, steht in der
Datenbank — nicht im Quelltext.** Eine zweite Installation braucht deshalb
keine Codeänderung, sondern einen Nachmittag auf dieser Seite.
| Reiter | Speichert nach | Modell (PHP) | Maske (JS) |
|---|---|---|---|
| Preise | `solarLog.gridCosts`, `gasCosts`, `fuelCosts` | `restricted/costs.php` | `settings.js` |
| Grundriss | `homeMesh.floors`, `rooms` | `restricted/grundriss.php`, `rooms.php` | `grundriss.js` |
| Übersicht | `homeMesh.energiefluss` | `ajax/energiefluss.php` | `energieflussEinstellungen.js` |
| Home-Kacheln | `homeMesh.rooms.werte` (JSON) | `restricted/kacheln.php` | `settings.js` |
| Geräte | `homeMesh.actors.room_id` | `restricted/automations.php` | `settings.js` |
| Fahrzeug | `skoda.conf` (außerhalb des Web-Roots) | `restricted/skodaKeys.php` | `settings.js` |
Ein Endpunkt für alles: `ajax/settings.php?action=…`. Die Maske prüft nichts,
was das Modell nicht auch prüfte — sie zeigt nur die Meldung, die von dort
kommt.
```mermaid
flowchart LR
UI["settings.php<br/><small>Reiter aus reiter.php</small>"] --> JS["settings.js · grundriss.js<br/>energieflussEinstellungen.js"]
JS -->|"GET ?action=…"| EP["ajax/settings.php"]
JS -->|"POST ?action=…"| EP
EP --> M1["costs.php"] --> SL[("solarLog")]
EP --> M2["grundriss.php / rooms.php"] --> HM[("homeMesh")]
EP --> M3["kacheln.php"] --> HM
EP --> M4["automations.php<br/><small>geraeteListe, geraetLoeschen</small>"] --> HM
EP --> M5["skodaKeys.php"] --> CONF[["skoda.conf<br/><small>/volume1/homes/wagner/SolarManager</small>"]]
```
---
## 1. Preise
Drei Tabellen desselben Zuschnitts: **je Zeile ein Stichtag, kein Enddatum.**
Das Ende holt sich die Auswertung beim nächsten Eintrag (`LEAD()`), damit
eine Preiserhöhung eine Zeile ist und nicht zwei Datumsfelder, die
auseinanderlaufen können.
| Reihe | Felder | Wofür |
|---|---|---|
| Strom | Arbeitspreis, Einspeisung (ct/kWh), Grundpreis (€/Jahr) | Jahresstatistik |
| Gas | Gaspreis (ct/l), Energieinhalt (kWh/l) | Ersparnis des Heizstabs |
| Benzin | Preis (€/l), Verbrauch Verbrenner (l/100 km), Verbrauch Elektro (kWh/100 km) | „Benzin gespart“ |
Zwei Entscheidungen, die man beim Erweitern kennen sollte:
* **Gespeichert wird in Euro, eingegeben in Cent** (`faktor` je Feld). Auf
der Rechnung stehen 31,63 ct/kWh — niemand tippt gern 0,3163.
* **Nicht jedes Feld ist ein Preis.** Energieinhalt und Verbräuche stehen
hier, weil sie am Brennstoff und am Fahrzeug hängen, nicht an der Physik.
Beim Auto bewusst als zwei bekannte Verbräuche statt als „kWh je Liter“ —
diese Zahl steht auf keinem Datenblatt.
Eine weitere Tabelle desselben Zuschnitts ist ein Eintrag in
`preisReihen()`; Endpunkt, Maske und Speicherlogik bleiben unverändert.
---
## 2. Grundriss
Der Reiter pflegt `floors` und `rooms` — und damit gleichzeitig das Menü, die
Startseite, die Kacheln, die MQTT-Abos und die Etagen-Auswahl im
Automatik-Editor.
```mermaid
flowchart TB
E["Etage<br/><small>code, Bezeichnung, Reihenfolge, Bild, Standard</small>"] --> R["Raum<br/><small>Name, Kürzel, Thermostat-Zweig</small>"]
R --> K["Kachel<br/><small>x/y auf der Zeichenfläche 400 × 300</small>"]
R --> G["Geräte<br/><small>actors.room_id</small>"]
E --> A["Automatiken<br/><small>automations.floor</small>"]
```
Regeln, die das Modell durchsetzt:
* **Das Kürzel einer Etage ist nach dem Anlegen fest.** Es steht in URLs,
SVG-Ids und `automations.floor`; bis zu vier Großbuchstaben oder Ziffern.
* **Gelöscht wird nur, was leer ist.** Eine Etage mit Räumen oder Automatiken
wird abgelehnt — mit der Liste dessen, was im Weg steht. Stilles
Mitlöschen wäre Datenverlust.
* **Die Raumnummer überlebt alles.** Umbenennen und Etagenwechsel ändern sie
nicht, deshalb behalten Geräte, Regler und Automatiken ihren Raum.
* **Kein `x`/`y` heißt: keine Kachel.** Die Position wird im Plan gezogen,
Maße wie in `home.php` (Fläche 400 × 300, Kachel 24 × 19).
* **Thermostat-Zweig** ist der MQTT-Pfad des Raums. Vorschläge kommen aus den
Topics, die gerade auf `/Temp[degC]` enden.
* **Grundrissbilder**: PNG, JPEG oder WebP, geprüft am Inhalt
(`getimagesize`), nicht an der Endung. Hochgeladene landen unter
`tiles/grundriss` — dem einen Verzeichnis, in das der Webserver schreiben
darf, und bewusst außerhalb von Git: Ein Grundriss ist Inhalt dieses
Hauses, nicht Teil der Anwendung. Der Dateiname trägt einen Teil der
Prüfsumme, damit ein neues Bild eine neue Adresse bekommt und nicht im
Browser-Zwischenspeicher hängen bleibt.
---
## 3. Übersicht (Energiefluss)
Die Solar-Übersicht hat einen **Katalog im Code**
(`js/solar/energieflussKatalog.js`) — jeder Kreis genau einmal, mit Name,
Symbol, Farbe, Elternkreis, Lage und Ringskala. In der Datenbank steht nur,
**was davon abweicht**.
```mermaid
flowchart LR
KAT["energieflussKatalog.js<br/><small>der Normalfall</small>"] --> MIX["setzeEinstellungen()"]
DB[("homeMesh.energiefluss<br/><small>nur Abweichungen</small>")] --> MIX
MIX --> BILD["gezeichnete Übersicht"]
```
Änderbar sind: Kreis an/aus, Name, Lage (im Vorschaubild ziehen) und
Optionen, etwa welche Einträge von `P_PVn` zu welcher PV-Fläche gehören.
„Alles auf Katalog zurücksetzen“ löscht schlicht alle Zeilen.
Der Vorteil dieser Trennung: Eine neue Installation zeigt sofort etwas
Sinnvolles, ohne dass jemand eine Tabelle füllt — und ein Update des Katalogs
erreicht alle Installationen, ohne die Anpassungen zu überschreiben.
> Die Hochkant-Lage (`hochX`/`hochY`) steht nur im Katalog. Wer Kreise
> verschiebt, ändert also die Queransicht;
> siehe [README](../README.md) → Energiefluss.
---
## 4. Home-Kacheln
Welche Werte auf einer Kachel stehen, steht als JSON in `rooms.werte` —
höchstens drei je Kachel. Ohne Eintrag gilt `kachelVorgabe()`: mit
Thermostat Soll, Ist und Feuchte, sonst nichts.
Eine Wertdefinition hat überall dieselbe Form, damit zwischen Maske,
Datenbank und Anzeige nichts übersetzt werden muss:
| Feld | Bedeutung |
|---|---|
| `topic` | MQTT-Topic, aus dem der Wert kommt |
| `pfad` | Schlüssel in einer JSON-Nachricht (die Shelly-Zähler schicken mehrere Werte in einer) |
| `wechselrichter`, `feld` | statt `topic`: Summe benannter Wechselrichter unter `solarManager/invertersN` |
| `format` | `leistung` (Quelle meldet Watt) oder `leistung_kw` (Quelle meldet Kilowatt) — skaliert selbst zwischen W und kW |
| `einheit`, `stellen` | sonst: Einheit dahinter, Nachkommastellen |
| `negativ` | Vorzeichen drehen, für verkehrt herum eingebaute Zähler |
| `gross` | die Zahl steht groß auf der Kachel |
| `icon` | Name eines Bootstrap-Icons ohne `bi-` |
Gelesen wird das im Browser von `homeSVG.kachelWert()`. Warum die
Wechselrichter über ihren **Namen** und nicht über die Nummer gesucht
werden: `inverters0` ist eine Position in einer Liste und sagt nichts über
das Dach — ändert sich die Reihenfolge, stünde sonst stillschweigend das
falsche Dach auf der Kachel.
---
## 5. Geräte
Die Liste zeigt je Gerät: Beschriftung, Art, Raum, einen Raumvorschlag aus
dem MQTT-Topic, die Zahl der Messwerte und Kommandos, wann zuletzt etwas
gemeldet wurde — **und in welchen Automatiken es steckt**.
Genau deshalb liegt die Liste im Modell und nicht in der Maske: Wer über das
Löschen entscheidet, muss die Verweise kennen.
| Handlung | Regel |
|---|---|
| Raum zuordnen | `actors.room_id`; ordnet die Geräteliste im Automatik-Editor und bestimmt, welche Messwerte bei einer Kachel oben stehen |
| Gerät löschen | nur, wenn **keine** Automatik darauf zeigt. Sonst Ablehnung mit den Namen der Automatiken. Gelöscht wird von innen nach außen: Parameter, Kommandos, Messwerte, Gerät — in einer Transaktion |
| Wieder da? | Steht das Gerät noch im Netz, findet es der nächste Suchlauf wieder. Das ist kein Fehler, sondern sein Zweck |
Der Suchlauf selbst löscht nie (→ [datenbank.md](datenbank.md#geräte)) —
deshalb braucht es diesen Papierkorb überhaupt.
---
## 6. Fahrzeug
Hier werden die Schlüssel der MyŠkoda-API hinterlegt. Sie stehen in
`skoda.conf` **außerhalb des Web-Roots**, weil sie auch `gatherSkodaData.py`
im SolarManager liest.
**Ein Schlüssel geht nie an den Browser zurück.** Die Seite steht im
Heimnetz ohne Anmeldung offen, und so ein Schlüssel steuert das Fahrzeug von
überall auf der Welt — Laden, Klimatisierung, Standort. Die Maske sieht nur,
*ob* einer hinterlegt ist und auf welche vier Zeichen er endet. Das genügt,
um zwei auseinanderzuhalten, und taugt zu nichts sonst. Geschrieben wird nur
in eine Richtung.
Dazu gehört die Aufteilung des Kontingents (20 Anfragen je Stunde für das
ganze Konto, nicht je Schlüssel):
| Zweck | Anteil |
|---|---|
| Abruf des Fahrzeugzustands (`gatherSkodaData.py`) | 14 / Stunde |
| Befehle aus der Oberfläche (`ajax/skodaCmd.php`) | 4 / Stunde |
| Reserve | 2 / Stunde |
Ein eigener Befehlsschlüssel trennt deshalb nicht die Kontingente, sondern
nur, wer womit fragt — er lässt sich einzeln widerrufen.
---
## 7. Was die Seite bewusst **nicht** kann
* **Geräte anlegen.** Das macht der Suchlauf
(`restricted/deviceDiscovery/`); von Hand angelegte Geräte hätten keine
Verbindung zur Wirklichkeit.
* **Automatiken bearbeiten.** Dafür gibt es den Editor auf der Home-Seite
(→ [automatiken.md](automatiken.md#2-der-editor)).
* **Die Standheizung des Fahrzeugs starten.** Das verlangt die
Sicherheits-PIN; sie dauerhaft auf der NAS zu hinterlegen ist eine andere
Größenordnung als ein API-Schlüssel und soll bewusst entschieden werden.
---
## 8. Wo fange ich an, wenn ich …
| Vorhaben | Ort |
|---|---|
| … eine weitere Preistabelle | Eintrag in `preisReihen()` (`restricted/costs.php`) |
| … ein neues Feld auf einer Kachel | `kachelWertPruefen()`/`kachelKatalog()` in `kacheln.php` und `homeSVG.kachelWert()` in `homeMQTT.js` |
| … eine Etage, einen Raum, ein Grundrissbild | nur diese Seite — kein Code |
| … einen weiteren Reiter | `restricted/settings.php` (Fläche über `reiter.php`), Endpunkt in `ajax/settings.php`, Modell in `restricted/`, Maske in `settings.js` |
| … einen weiteren Zugangsschlüssel verwalten | `restricted/skodaKeys.php` als Vorbild: Modell kennt die Datei, Maske sieht nur den Stand |
+270
View File
@@ -0,0 +1,270 @@
# Das Meteogramm
Die Karte „Wetter“ auf der Solar-Seite: drei Felder mit gemeinsamer
Zeitachse — Temperatur mit Wettersymbolen, Niederschlag mit Bewölkung nach
Höhe, Wind mit Böen. Wie die Übersicht ist sie **ein Katalog plus ein
Renderer**, und genau diese Trennung macht sie erweiterbar.
![Das Meteogramm](bilder/meteogramm.png)
Bis September 2026 stand hier ein Widget von meteoblue in einem `<iframe>`.
Es passte farblich nicht zum Rest, ließ sich nicht ändern, wurde auf schmalen
Schirmen als Ganzes heruntergerechnet — und konnte das eine nicht, was diese
Anlage auszeichnet: **zeigen, was die eigene Wetterstation gemessen hat.**
Links vom Jetzt-Strich steht deshalb die Messung, rechts die Vorhersage.
| Datei | Aufgabe |
|---|---|
| `SolarManager/gatherForecastData.py` | holt die Vorhersage von Open-Meteo, alle 20 Minuten |
| `SolarManager/wetterarchiv_nachtragen.py` | füllt das Archiv rückwirkend (einmalig) |
| `solarLog_weather.sql` | die vier Tabellen samt Begründung |
| `ajax/meteogramm.php` | **verschmilzt** Vorhersage und Messung zu einer Reihe |
| `js/solar/meteogrammKatalog.js` | **was** gezeigt wird: Felder, Spuren, Bänder, Symbole |
| `js/solar/meteogramm.js` | **wie** gezeichnet wird. Kennt keine Spur beim Namen |
| `js/solar/solarMQTT.js` | setzt es auf und schaltet zwischen eigenem und meteoblue um |
---
## 1. Der Weg der Daten
```mermaid
flowchart LR
OM["Open-Meteo<br/><small>Vorhersage + ERA5-Archiv</small>"] --> G["gatherForecastData.py<br/><small>alle 20 Min.</small>"]
G --> WH[("weatherHours<br/>weatherDays")]
G --> FL[("weatherForecastLog<br/><small>erste Aussage je Stunde</small>")]
ST["Wetterstation"] --> WS[("weatherStation<br/><small>alle 5 Min.</small>")]
ST -->|MQTT| B
WH --> A["ajax/meteogramm.php"]
WS --> A
A --> B["meteogramm.js<br/><small>+ meteogrammKatalog.js</small>"]
```
Zwei Quellen, ein Bild. Der Endpunkt legt beide übereinander — aber nicht
vollständig, und das ist der Kern:
| Größe | links vom Jetzt-Strich | rechts davon |
|---|---|---|
| Temperatur, Wind, Böe, Richtung, Feuchte, Druck | **gemessen** (`weatherStation`) | Vorhersage |
| Niederschlag, Bewölkung, Strahlung, Wettercode | Vorhersage, nachgerechnet | Vorhersage |
Die Station hat weder Regenmesser noch Pyranometer noch Wolkenkamera. Für
diese vier Größen bleibt es deshalb auch in der Vergangenheit bei Open-Meteo
— dort allerdings bei der **Analyse**, nicht mehr bei der Vorhersage. Genau
dafür holt der Sammler zwei Tage Vergangenheit mit.
---
## 2. Die Tabellen
| Tabelle | Inhalt | Wächst um |
|---|---|---|
| `weatherHours` | eine Zeile je Stunde, −2 bis +7 Tage, **dauerhaft aufbewahrt** | 8.760 Zeilen/Jahr, ~1,5 MB |
| `weatherDays` | eine Zeile je Tag: Sonnenzeiten, Min/Max, Tagessummen | 365 Zeilen/Jahr |
| `weatherTilted` | Einstrahlung in Modulebene, je Stunde **und Fläche** | nur wenn Winkel konfiguriert |
| `weatherForecastLog` | die **erste** Vorhersage je Stunde, unveränderlich | 8.760 Zeilen/Jahr |
**Wie eine Stunde erwachsen wird.** Schlüssel ist der Zeitpunkt, geschrieben
wird mit `REPLACE INTO`: dieselbe Stunde kommt alle zwanzig Minuten wieder und
wird dabei genauer — erst Vorhersage für übermorgen, zuletzt Analyse des
vergangenen Tages. Woran man das sieht, steht in zwei Spalten: `abgerufen`
sagt, wann zuletzt geschrieben wurde, `ist_vorhersage`, ob die Stunde dabei
noch in der Zukunft lag.
**Warum nichts gelöscht wird.** Die Tabellen sind zugleich ein Archiv. Aus der
Einstrahlung und `EnergyFlow_hourly.pv_kwh` (gemessener Ertrag je Stunde seit
dem 02.04.2022) soll eine eigene Ertragsprognose entstehen — wer alte Zeilen
wegräumt, wirft die Hälfte jedes Datenpaares weg. Deshalb stehen sie auch
**nicht** in `THIN_TABLES` des Rollup-Jobs.
**Warum `weatherForecastLog` extra.** `weatherHours` hält immer den neuesten
Stand — für die Anzeige richtig, für die Frage „wie gut war die Vorhersage?“
unbrauchbar, weil die Vorhersage dort längst von der Analyse überschrieben
wurde. Hier landet per `INSERT IGNORE`, was zuerst über eine Stunde gesagt
wurde. Die Differenz zu `datetime` ist der Vorlauf.
> **Eine Falle beim Nachtragen:** Die Archiv-Schnittstelle rechnet *alle*
> Zeiten mit dem **heute** gültigen Zeitzonenversatz um — die Antwort für
> einen Dezembertag meldet selbst `utc_offset_seconds: 7200`. Sonnenaufgang
> am 21.12. käme so als 09:04 statt 08:04. Bei Stundenwerten fällt das nicht
> ins Gewicht (sie hängen an ihrem Zeitstempel), bei einer Uhrzeit schon.
> `wetterarchiv_nachtragen.py` schreibt Sonnenzeiten deshalb ausdrücklich
> nicht; gebraucht werden sie nur für die sichtbaren Tage, und die schreibt
> der Sammler richtig.
### Was vorher da war
`weatherHours` und `weatherDays` gab es schon — in der Form der
**OpenWeatherMap-One-Call-2.5**-API (`weatherStr`, `icon`, `pop`, `uvi`,
`moonrise`, Wind in m/s, Bewölkung als *ein* Prozentwert). Ihr Schreiber liegt
außerhalb beider Repos unter `/volume1/web/gatherWeather.php`, wurde von
keiner Aufgabe mehr gestartet, und die API ist Ende 2024 abgeschaltet worden:
**letzte Zeile 27.10.2024.** Gelesen hat sie zuletzt niemand.
Von 17 bzw. 29 Spalten wären keine fünf übriggeblieben, also wurden sie neu
aufgebaut statt erweitert. Die alten liegen als `weatherHours_alt` und
`weatherDays_alt` daneben und dürfen nach ein paar Wochen Ruhe weg.
---
## 3. Der Endpunkt
```
ajax/meteogramm.php?tage=5&rueck=24 tage 2…7, rueck 0…72 Stunden
```
Antwort wie im Haus üblich: Unix-Sekunden, Messreihen als **Tupel** statt
Objekte — bei 200 Stunden mit 16 Feldern spart das ein Vielfaches.
```json
{ "von":…, "bis":…, "jetzt":…, "stand":…,
"stunden": [[t, gemessen, temp, regen, schnee, wind, boe, richtung,
strahlung, wtief, wmittel, whoch, code, feuchte, druck, regenWkt], …],
"tage": [[t0, tmin, tmax, code, sonnenauf, sonnenunter], …] }
```
Die Spaltenlage steht zweimal: im Kopfkommentar des Endpunkts und als
`SPALTEN` im Katalog. **Beide müssen zueinander passen** — neue Größen hängen
deshalb *hinten* an, dann bleiben die Stellen bestehender Spuren gültig.
Drei Feinheiten, die man beim Lesen der SQL sonst übersieht:
* **`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 wird über die Vektorsumme gemittelt**
(`ATAN2(AVG(SIN(…)), AVG(COS(…)))`). Der arithmetische Mittelwert aus 350°
und 10° wäre 180° — die Gegenrichtung.
* **Sonnenzeiten kommen aus `weatherDays`, nicht aus `daylight`.** `daylight`
reicht nur bis morgen, das Meteogramm bis zu sieben Tage.
---
## 4. Katalog und Renderer
Der Renderer kennt **kein Feld, keine Spur und keine Farbe beim Namen**. Er
liest nur, was der Katalog deklariert.
### So kommt eine Spur dazu
1. Spalte in `ajax/meteogramm.php` hinten anhängen
2. Schlüssel in `SPALTEN` hinten anhängen
3. Eintrag in `spuren` des passenden Feldes
Sonst nichts:
```js
{
id: "wind", name: "Wind", spalte: "wind",
achse: "links", art: "linie", farbe: FARBE.wind, breit: 2.2,
legende: true,
format: w => komma(w, 1) + " km/h",
},
```
Ein **Feld** ist ein waagerechter Streifen mit eigener senkrechter Achse; alle
teilen sich die Zeitachse. Es hat `links`/`rechts` (Achsen), `nacht`
(Schattierung), `symbole`, `pfeile`, `baender` und `spuren`. Eine **Spur** hat
`art` (`linie` · `flaeche` · `balken`), `farbe`, `format()` und die Filter
`nurGemessen` / `nurVorhersage`.
**Datenfarben stehen im Katalog, Strukturfarben im Stylesheet** — dieselbe
Regel wie beim Energiefluss.
### Die drei Felder
| Feld | Links | Rechts | Dazu |
|---|---|---|---|
| Temperatur | °C | 0–1000 W/m² (Strahlung) | Wettersymbole, Tag/Nacht, Tagesmin/-max |
| Niederschlag und Bewölkung | mm/h | 0–14 km (Wolkenband) | Tag/Nacht |
| Wind | km/h | — | Richtungspfeile |
**Warum die Strahlung kein eigenes Feld hat:** gleicher Tagesrhythmus, gleiche
Nacht-Schattierung wie die Temperatur — und ein viertes Feld machte das Modul
am Handy höher als das externe Widget, also genau das Gegenteil des Ziels.
Über den Katalog ist es ein Zweizeiler, sie später auszulagern.
### Zwei Entscheidungen beim Zeichnen
**Ein einziges SVG über alle drei Felder.** Dadurch gibt es genau eine
X-Skala, und Nachtstreifen, Tagesgrenzen, Jetzt-Linie und der Zeiger laufen
ohne Kunstgriff durch alle Felder. Jedes Feld ist ein `<g transform=
"translate(0, oben)">` mit eigener `Y`-Abbildung. Auch der Tooltip gibt es
deshalb nur einmal — er zeigt alle Spuren aller Felder zu einer Stunde.
**Das Wolkenband ist ein Farbverlauf, kein Raster.**
```
je Stunde ein Rechteck je Schicht ein Rechteck mit Verlauf
▐▌▐▌▌▐▐▌▌▐▌▐▐▌▌▐▌▐▌▐▐▌ ▓▓▒▒░░▒▒▓▓▓▒░░░▒▒▓▓▓▒
zweihundert harte Kanten dieselben Zahlen, weich verbunden
```
Der erste Versuch zeichnete je Stunde und Schicht ein Rechteck und sah aus wie
Fernsehrauschen. Der Verlauf setzt einen Haltepunkt je Stunde und blendet
dazwischen über — Bewölkung springt ohnehin nicht zur vollen Stunde um.
**Und eine Vereinfachung, die man kennen sollte:** Open-Meteo liefert kein
stufenloses Höhenprofil, sondern **drei Schichten** (tief 0–2 km, mittel
2–7 km, hoch 7–14 km). Mehr Auflösung hat die Quelle nicht.
### Am Handy
Unter 520 px schalten die Feldhöhen auf `schmal` (120/100/92 statt
150/130/115) — zusammen rund 345 px statt der 500 px des eingebetteten
Widgets. Außerdem startet das Modul dort mit **zwei Tagen** statt fünf: auf
358 nutzbaren Pixeln blieben für fünf Tage drei Pixel je Stunde.
Wettersymbole und Windpfeile **dünnen sich selbst aus**: beide verdoppeln ihre
Schrittweite, bis der Abstand `mindestAbstand` erreicht. Das ist die eine
Regel, die am Handy alles rettet.
`touch-action: pan-y` auf dem SVG ist Pflicht — sonst fängt der Tooltip die
Wischgeste ab und die Seite lässt sich nicht mehr scrollen.
---
## 5. Umschalter und Einbau
Die Karte trägt zwei Flächen: `id="meteogramm"` (zwei m, das eigene Modul) und
`id="meteogram"` (ein m, das Ziel des meteoblue-Rahmens — so bleibt
`mountMeteogram()` in `common.js` unverändert). Umgeschaltet wird über
`data-mtg-quelle`, **nicht** über `data-enf-ansicht`: `zeigeAnsicht()` in
`solarMQTT.js` greift mit `document.querySelectorAll` seitenweit zu und würde
sonst die Realtime-Karte mitschalten.
`mountMeteogram()` läuft jetzt **erst beim ersten Umschalten** auf meteoblue.
Wer es nie ansieht, lädt das fremde Dokument auch nicht mehr — nebenbei der
größte Ladezeitgewinn dieser Karte.
Die Karte heißt **„Wetter“** und nicht mehr „Forecast“: direkt darunter steht
bereits eine zweite Karte dieses Namens, die aus `simPower` die PV-Ernte
vorhersagt.
---
## 6. Fehlerbilder
| Beobachtung | Wahrscheinliche Ursache |
|---|---|
| „Noch keine Vorhersage vorhanden“ | `gatherForecastData.py` läuft nicht — `ps aux \| grep gatherForecast`, dann `forecastOutput.log` |
| Fuß färbt sich gelb, „Stand“ liegt Stunden zurück | Sammler läuft, kommt aber nicht an Open-Meteo heran |
| Links vom Jetzt-Strich fehlt die gemessene Kurve | `weatherStation` bekommt nichts mehr — das schreibt die Station selbst über `/volume1/web/weatherStation.php`, kein Skript in den Repos |
| Bewölkung fehlt in der Vergangenheit | `past_days` in der `config.ini` steht auf 0 |
| Wolkenband bleibt leer | Die Zeilen stammen aus dem Nachtragen und haben keine Schichtwerte — Archiv und laufende Vorhersage liefern dieselben Felder, prüfen mit `SELECT wolken_tief … WHERE datetime = …` |
| Symbole überlappen | `mindestAbstand` im Katalog zu klein für diese Breite |
---
## 7. Wo fange ich an, wenn ich …
| Vorhaben | Ort |
|---|---|
| … eine weitere Spur zeigen | drei Zeilen, siehe Abschnitt 4 |
| … die Farben ändern | `FARBE` in `meteogrammKatalog.js` |
| … ein Feld höher machen | `HOEHEN` im Katalog, je Breite getrennt |
| … einen weiteren Zeitraum anbieten | `zeitraeume` im Katalog — der Endpunkt klemmt auf 2…7 Tage |
| … ein anderes Wettersymbol | `WETTER[code]` im Katalog; Codepunkte stehen in `css/bootstrap-icons.min.css` |
| … öfter abrufen | `intervall_minuten` in `[vorhersage]` der `config.ini` — öfter als stündlich rechnet Open-Meteo nicht |
| … die Einstrahlung in Modulebene | `flaechen = dach:30:10, …` in `[vorhersage]`; füllt `weatherTilted`, rückwirkend über `wetterarchiv_nachtragen.py` |
| … das Meteogramm auch auf der Wetterseite | `restricted/weather.php` bekommt dieselben zwei Flächen, `index.php` die zwei Skripte, `weatherMQTT.js` den Aufruf |
| … meteoblue endgültig loswerden | Umschalter aus `solar.php`, `"meteogram" => false` in `index.php`, `js/meteogram.js` und `mountMeteogram()` in `common.js` |
+285
View File
@@ -0,0 +1,285 @@
# Die Solar-Übersicht (Energiefluss)
Die Karte „Realtime“ auf der Solar-Seite: Wo kommt der Strom her, wo geht er
hin, wie viel ist es gerade. Sie ist kein gezeichnetes Bild, sondern ein
**Katalog plus ein Renderer** — und genau diese Trennung macht sie
erweiterbar.
![Die Übersicht](bilder/uebersicht.png)
| Datei | Aufgabe |
|---|---|
| `js/solar/energieflussKatalog.js` | **Was** es gibt: jeder Kreis genau einmal — Name, Symbol, Farbe, Elternkreis, Lage, Ringskala, Klickziel und `lesen()` |
| `js/solar/energiefluss.js` | **Wie** gezeichnet wird: Kreise, Ringe, Flüsse, Symbole, Plaketten, Hochkant, Bearbeitungsmodus. Kennt keinen Kreis beim Namen |
| `restricted/energiefluss.php` | speichert **Abweichungen** je Haus (`homeMesh.energiefluss`) |
| `js/solar/solarMQTT.js` | hängt beides an den MQTT-Strom und an die Reiter |
---
## 1. Der Aufbau: Kreise und ihre Eltern
Jeder Kreis nennt seinen **Elternkreis**; daraus entsteht der Fluss zwischen
beiden. Es gibt keine getrennte Liste von Verbindungen — die Struktur ist die
Elternbeziehung.
```mermaid
flowchart LR
PV["Sonne<br/><small>pv</small>"] --> WR
NETZ["Netz<br/><small>netz</small>"] --> WR
AKKU["Batterie<br/><small>akku</small>"] --> WR(("Autarkie<br/><small>wr · Pflicht</small>"))
WR --> VERB(("Verbrauch<br/><small>verbrauch · Pflicht</small>"))
VERB --> OG["Obergeschoss"] --> WBOG["Wallbox OG"]
VERB --> EG["Erdgeschoss"] --> WBEG["Wallbox EG"]
VERB --> UG["Untergeschoss"]
VERB --> HEIZ["Heizstab"]
BEW["Bewässerung<br/><small>frei stehend</small>"]
```
Ein Eintrag im Katalog sieht so aus:
```js
{
id: "heizstab", name: "Heizstab", symbol: "speicher", farbe: "#c2614e",
eltern: "verbrauch", // woher der Fluss kommt
x: 1120, y: 380, // Lage in der Queransicht
hochX: 670, hochY: 600, // optional: eigene Lage hochkant
skala: 6000, // W, bei denen der Ring voll ist
klick: { modal: "heater" }, // Ziel beim Antippen
lesen(m, opt) { // aus MQTT-Werten wird ein Zustand
const w = m.zahl("solarManager/pHeat");
return { leistung: w, fluss: w, ruhe: w < 20, neben: "…", zeichen: [...],
zustand: { an: w >= 20, temps: [...] } };
},
}
```
Was `lesen()` zurückgeben darf:
| Feld | Wirkung |
|---|---|
| `leistung` | die große Zahl und der Ring |
| `wert` | Text **statt** der Leistung (z. B. „93 %“) |
| `neben` | die kleine Zeile darunter |
| `segmente` | `[{w, farbe, knoten?}]` statt eines einfarbigen Rings |
| `fluss` | Watt vom Elternkreis hierher; negativ = Gegenrichtung |
| `ruhe` | Kreis blass zeichnen — es ist nichts los |
| `zeichen` | Plaketten am Rand: `[{art, text?, titel?}]` |
| `zustand` | was das Symbol braucht: `{an, stand, laden, temps}` |
> **Ein neuer Kreis ist ein neuer Eintrag hier — sonst nichts.** Kein
> Markup, keine CSS-Regel, keine Zeile im Renderer. Nur ein neues *Symbol*
> braucht einen Eintrag in `SYMBOLE` (`energiefluss.js`).
### Abweichungen je Haus
Der Katalog ist der Normalfall, `homeMesh.energiefluss` hält nur, was davon
abweicht: Kreis aus, anderer Name, andere Lage, andere Optionen. Eingestellt
wird das unter **Einstellungen → Übersicht**
(→ [einstellungen.md](einstellungen.md#3-übersicht-energiefluss)).
Ist ein Kreis **aus**, rückt sein Kind automatisch an den nächsten
eingeschalteten Vorfahren: `elternWirksam` sucht die Kette hinauf. Und ein
Ringsegment, das zu einem ausgeschalteten Kreis gehört, fällt in das letzte
verbleibende Segment — die Summe bleibt also richtig, auch wenn jemand die
Wallbox ausblendet.
---
## 2. Die Leistungsvisualisierung
Drei Dinge zeigen dieselbe Leistung auf drei Arten. Das ist Absicht: Die Zahl
ist genau, der Ring vergleicht, der Fluss zeigt die Richtung.
```mermaid
flowchart TB
W["Leistung in Watt<br/><small>aus lesen()</small>"] --> Z["Zahl im Kreis<br/><small>W bis 999, danach kW</small>"]
W --> R["Ring<br/><small>Anteil an skala, gestaucht</small>"]
W --> F["Fluss zum Elternkreis<br/><small>Punktgröße + Tempo</small>"]
W --> S["Symbolzustand<br/><small>an/aus, Füllstand, Temperaturen</small>"]
```
### 2.1 Der Ring — gestauchte Skala
Linear wäre der Ring unbrauchbar: Bei `skala = 15000` wären 300 W ein Strich
von zwei Grad, und genau in diesem Bereich steht die Anlage die meiste Zeit.
```
Anteil = log10(1 + 9 · w/skala)
w/skala 0 % 10 % 25 % 50 % 100 %
Ring 0 % 28 % 47 % 70 % 100 %
```
Der volle Ausschlag bleibt exakt bei `skala`, aber das untere Zehntel bekommt
knapp ein Drittel des Rings.
**Mehrere Segmente** (etwa Verbrauch = Etagen + Heizstab + Rest) werden über
die **Summe** gerechnet und nicht je Segment — die gestauchte Skala ist nicht
additiv, sonst reichten drei Viertel-Segmente weiter als ein volles:
```
Segment i = ringAnteil(Σ bis i) − ringAnteil(Σ bis i−1)
```
Zwischen zwei Segmenten bleibt eine Lücke von 0,6 %; Segmente unter 0,4 %
verschwinden ganz, sonst stünde dort ein Punkt statt eines Bogens.
### 2.2 Der Fluss — Punkte statt Linie
Ein Fluss ist ein Bogen vom Eltern- zum Kindkreis (`flussPfad()`): senkrecht
los, dann waagerecht hinein — hochkant umgekehrt. Gezeichnet wird er als
gestrichelte Linie mit runden Enden, also als Punktreihe, die über
`stroke-dashoffset` wandert.
| Größe | Formel | Wirkung |
|---|---|---|
| Punktgröße | `2,4 + 11 · (1 − e^(−W/5000))` | 2,4 px bei ein paar hundert Watt, ~13 px bei Volllast |
| Tempo | `1 / (0,7 + 1,9 · e^(−W/3000))` | 2,6 s je Abschnitt bei wenig, 0,7 s bei viel |
| Richtung | Vorzeichen von `fluss` | `enf-rueck` dreht die Animation um |
| Stillstand | unter 15 W | Punkte ausgeblendet |
Zwei Feinheiten, die teuer erkauft waren:
* **Das Tempo wird über `Animation.updatePlaybackRate()` gesetzt**, nicht über
eine neue `animation-duration`. Eine geänderte Dauer rechnet die Position
neu — die Punkte sprängen bei jeder Wertänderung.
* **Keine Bahn unter den Punkten.** Die dunkle Linie gibt es nur im
Bearbeitungsmodus, wo sie zeigt, was mit was verbunden ist. In der Ansicht
erzählen die Punkte den Weg; wo nichts fließt, steht nichts.
### 2.3 Füllstände
Batterie und Wallbox haben statt eines bloßen Rings eine **Welle im Kreis**,
die mit dem Ladestand steigt (`zustand.stand`, 0…1). Die Fläche liegt bei
22 % Deckung, beim Laden 32 %, und die Wasserlinie ist zusätzlich als Kante
gezeichnet — sonst muss man den Stand suchen. Meldet ein Kreis keinen Stand,
bleibt die Füllung ganz weg: Eine Wallbox ohne Auto zeigte sonst eine Linie
am unteren Rand.
### 2.4 Plaketten und Symbolzustände
Kleine runde Zeichen am Kreisrand, aus `lesen().zeichen`:
| `art` | Zeichen | Bedeutung |
|---|---|---|
| `eco` | Blatt | Eco-Modus (Wallbox, Heizstab) |
| `uhr` | Uhr | „Nächste Fahrt“ |
| `manuell` | Regler | fester Modus |
| `stecker`, `schloss` | Stecker, Schloss | Fahrzeug angesteckt / verriegelt |
| `heizung` | Flamme | Heizung im Geschoss an |
| `tank` | Zapfsäule | Tankfüllung, mit Text |
| `warnung` | Dreieck | Störung |
Die Glyphen sind Bootstrap-Icons und stehen als `\uF…`-Escapes im Quelltext —
die Zeichen selbst liegen im privaten Unicode-Bereich, sind im Editor
unsichtbar und werden beim Kopieren leicht verfälscht.
`zustand` steuert dagegen das Symbol selbst: `an` schaltet die
Zustandsklasse (leuchtender Kern), `laden` die Ladeanimation, `temps` färbt
die Schichten des Pufferspeichers zwischen kalt und heiß.
### 2.5 Die Bewässerung
Der einzige Kreis ohne Leistung: Statt Zahl und Ring stehen darin drei
Zeilen, je Zone ein Symbol, der Name und der Zustand. Gezeichnet werden sie
aus `katalog.zonen`, gefüllt von `bewaesserungAnzeige.js`, das die Ids
kennt, die der Renderer vergibt.
---
## 3. Zwei Ansichten aus einer Lage
```mermaid
flowchart LR
subgraph Q["quer (≥ 720 px)"]
direction LR
Q1["Quellen links"] --> Q2["Mitte"] --> Q3["Verbraucher rechts"]
end
subgraph H["hochkant (< 720 px)"]
direction TB
H1["Quellen oben"] --> H2["Mitte"] --> H3["Verbraucher unten"]
end
```
Hochkant werden schlicht **x und y vertauscht**. So braucht es keine zweite
Lage je Kreis, und eine eigene Lage aus den Einstellungen gilt in beiden
Ansichten. Wo das Vertauschen unglücklich steht, nennt der Katalog mit
`hochX`/`hochY` eine eigene Lage — so stehen UG und Heizstab hochkant
getauscht, damit die drei Geschosse untereinander liegen.
Umgeschaltet wird über einen `ResizeObserver` am Behälter, und nur dann, wenn
die Ansicht wirklich wechselt. Das Kiosk-Display (`anzeige.js`) setzt
`hochkantUnter: 0` und bleibt damit immer quer.
Der `viewBox` wird nach dem Zeichnen aus den äußersten Kreisen gerechnet
(`rahmenSetzen()`), inklusive Platz für den Namen unter dem Kreis — ein
ausgeschalteter Randkreis verkleinert das Bild also automatisch.
---
## 4. Wie die Werte hereinkommen
```mermaid
sequenceDiagram
participant M as MQTT-Broker
participant S as solarMQTT.js
participant E as Energiefluss
participant K as Katalog
M->>S: solarManager/# (viele Nachrichten je Sekunde)
Note over S: Werte in mqttData ablegen,<br/>höchstens 1× pro Sekunde weiterreichen
S->>E: aktualisieren(mqttData)
loop je sichtbarem Kreis
E->>K: lesen(m, optionen)
K-->>E: {leistung, fluss, zeichen, zustand, …}
Note over E: nur schreiben, was sich geändert hat
end
```
Drei Sparmaßnahmen, die zusammen die Karte flüssig halten:
1. **Ein Takt je Sekunde.** Der Broker schickt deutlich öfter.
2. **Nur geänderte Werte schreiben.** Jeder Schreibzugriff macht Style und
Paint ungültig; gemessen waren rund 71 % der Zugriffe reine
Wiederholungen.
3. **Animationen stehen still, wenn niemand hinsieht** — `IntersectionObserver`
für „Karte im Bild“ und `visibilitychange` für „Tab im Vordergrund“.
Ein Fehler in einem `lesen()` fängt der Renderer je Kreis ab: Er meldet ihn
auf der Konsole und zeichnet die übrigen weiter. Eine kaputte Zeile im
Katalog nimmt also nicht die ganze Übersicht mit.
---
## 5. Antippen und Bearbeiten
`klick` im Katalog sagt, was ein Tipp auf einen Kreis tut:
| Eintrag | Wirkung |
|---|---|
| `{ ansicht: "anlage" }` | schaltet auf die Anlagen-Ansicht (`anlage.js`) |
| `{ ansicht: "speicher" }` | schaltet auf die Speicher-Ansicht (`speicher.js`) |
| `{ modal: "heater" }` … | öffnet das passende Bedienfenster (`bedienfenster.js`, `ladefenster.js`) |
Klickbare Kreise bekommen ein Pfeil-Abzeichen, `tabindex`, `role="button"`
und reagieren auf Enter und Leertaste.
Im **Bearbeitungsmodus** (`bearbeiten: true`, nur in den Einstellungen) sind
die Kreise ziehbar, die Verbindungslinien sichtbar und die Lage wird beim
Loslassen auf zehn Einheiten gerundet an `onVerschoben(id, x, y)` gemeldet.
Die Vorschau dort läuft mit festen Beispielwerten, damit man auch nachts
sieht, was man einstellt.
---
## 6. Wo fange ich an, wenn ich …
| Vorhaben | Ort |
|---|---|
| … einen Kreis hinzufügen | ein Eintrag in `ENERGIEFLUSS_KATALOG.knoten` — mehr nicht |
| … ein neues Symbol | `SYMBOLE` in `energiefluss.js` (24er Raster, Mitte 0/0, `currentColor`) |
| … eine neue Plakette | `ZEICHEN` in `energiefluss.js`, Glyph als `\uF…`-Escape |
| … die Ringspreizung ändern | `ringAnteil()` — der Faktor 9 bestimmt die Stauchung |
| … Punktgröße oder Tempo | `flussSetzen()` in `energiefluss.js` |
| … die Lage hochkant korrigieren | `hochX`/`hochY` im Katalog (gilt nur dort) |
| … einen Kreis ausblenden oder verschieben | Einstellungen → Übersicht, keine Codeänderung |
+236
View File
@@ -0,0 +1,236 @@
# Die Zeitleiste der Automatiken
Die Übersicht als Tabelle beantwortete nicht, was man eigentlich wissen will:
**Läuft der Tag stimmig, und warum ist etwas noch nicht gefahren?** Die
Zeitleiste legt deshalb jede Automatik auf die Stunde, zu der sie greift.
![Die Zeitleiste](bilder/zeitleiste.png)
Zwei Dateien teilen sich die Arbeit, und die Trennung ist scharf:
| Datei | Aufgabe |
|---|---|
| `restricted/zeitleiste.php` | **rechnen**: wo liegt eine Automatik auf dem Tag, in welcher Bahn, ist sie heute dran, ist sie gelaufen |
| `js/solar/zeitleiste.js` | **zeichnen**: Bahnen, Stapeln, Etiketten, Kettenbögen, Karte, Filter |
Geliefert wird über `ajax/AutoAction.php?action=zeitleiste&datum=YYYY-MM-DD`
als JSON.
---
## 1. Was der Server ausrechnet
```mermaid
flowchart TB
A["Alle Automatiken laden<br/><small>loadAutomation() je Id</small>"] --> B["Bedingungen je ODER-Gruppe einsortieren"]
B --> C{"Was trägt die Gruppe?"}
C -->|"Uhrzeit =, Sonne ±"| P["punkt"]
C -->|"Uhrzeit >= / <"| F["start / ende"]
C -->|"Automatik + Versatz"| K["kette + versatz"]
C -->|"alles andere"| S["sensor = true<br/><small>wartet auf einen Messwert</small>"]
P & F & K & S --> D["Art bestimmen:<br/>punkt › kette › fenster › jederzeit"]
D --> E["Ketten auflösen<br/><small>bis zu 6 Runden</small>"]
E --> G["Bahn, Satz, Tagesprüfung, Läufe"]
G --> H[["JSON"]]
```
### 1.1 Die Regeln, wie eine Lage entsteht
| Bedingung | Ergebnis auf dem Tag |
|---|---|
| feste Uhrzeit `um 07:00` | **Punkt** |
| Sonnenauf-/-untergang ± Versatz | **Punkt**, an diesem Tag ausgerechnet |
| andere Automatik `+ 00:10` | **Kette**: Punkt hinter dem Auslöser |
| `ab …` / `vor …` oder ein eingeengtes Zeitfenster | **Balken** über das Fenster |
| nur Messwerte, ganzer Tag | **„jederzeit“** (eigenes Band unter der Leiste) |
Mehrere Gruppen (ODER) werden zusammengefasst: Gibt es Punkte, gewinnt der
früheste; sonst eine Kette; sonst das umschließende Fenster aus allen
Gruppengrenzen. Trägt eine Gruppe einen zeitlich bestimmten Beginn (etwa
„Sonnenuntergang + 30 **oder** dunkel“), merkt sich der Eintrag ihn als
`punkt` — er erscheint im Hinweis als „frühestens 19:01“.
### 1.2 Ketten auflösen
Die Lage eines Nachfolgers hängt vom Auslöser ab, und die Kette kann mehrere
Stufen haben (Wecker → Rollos → Schlafzimmer). Deshalb läuft die Auflösung
reihum, höchstens sechs Runden:
| Zustand des Auslösers | Nachfolger |
|---|---|
| ist an diesem Tag schon **gelaufen** | Punkt bei der echten Laufzeit + Versatz |
| hat einen **Punkt** | Punkt dort + Versatz |
| ist ein **Fenster** | Fenster ab dessen Beginn + Versatz |
| hat selbst keine Lage / Kreis | „jederzeit“ |
Immer begrenzt auf das eigene Zeitfenster. Und: Der Nachfolger **erbt**, ob
der Tag passt — läuft der Wecker heute nicht, steht auch das Rollo
gestrichelt da, mit dem Grund „Auslöser ‚Wecker Magdalena‘ ist nicht dran“.
### 1.3 Ist sie heute überhaupt dran?
`zlTagPasst()` rechnet **genau wie der Runner** (`tag_passt()` und
`gemeinter_tag()`): erst öffnen (Wochentag, „zusätzlich“ an Ferien/Feiertagen),
dann sperren (Ferien/Feiertage auf „nie“), und bei `next_day` für morgen.
Sonst hieße „heute nicht“ hier etwas anderes als dort.
> Ausgeblendet wird trotzdem nichts. Wer nicht dran ist, steht gestrichelt
> da — mit Grund im Hinweis. Eine Zeitleiste, die Einträge verschweigt,
> beantwortet die Frage „warum ist das nicht gefahren?“ gerade nicht.
### 1.4 Sonnenzeiten für beliebige Tage
`solarLog.daylight` kennt heute, morgen und die Vergangenheit. Für einen
späteren Tag nimmt `zlSonne()` denselben Kalendertag eines Vorjahres — die
Sonne verschiebt sich von Jahr zu Jahr um weniger als eine Minute, und so
braucht es keinen Standort, der in einem anderen Haus wieder falsch wäre.
Im Hinweis steht dann „(Vorjahr)“.
### 1.5 Die Bahn
Die Zeile, in der eine Automatik steht, ergibt sich aus den **Geräten, die
sie schaltet** — dieselbe Einordnung wie im Raum-Modal (`bedienform()` in
`roomControls.php`), Mehrheit gewinnt, bei Gleichstand die Bahn weiter oben.
Bewässerung ist dort „sonstiges“ und wird am Gerätetyp erkannt.
Bahnen: Licht · Beschattung · Heizung · Bewässerung · Schalter · Sonstiges.
### 1.6 Das JSON
```jsonc
{
"datum": "2026-09-21", "heute": true, "jetzt": 964, // Minuten seit Mitternacht
"sonne": { "auf": 431, "unter": 1176, "genau": true },
"bahnen": { "licht": {"titel": "Licht", "symbol": "bi-lightbulb"}, … },
"etagen": [ {"code": "OG", "label": "Obergeschoss"}, … ],
"automatiken": [{
"id": 18, "name": "Hitzeschutz Süd", "floor": "EG", "enabled": true,
"dran": true, "grund": "",
"bahn": "beschattung",
"satz": "Wozi Schiebetür, Wozi Fensterfront +1: Position+Neigung",
"wenn": "Sonne Süd: Helligkeit > 30000 Lux und …",
"laeufe": ["12:50"], // aus automation_log
"art": "fenster", // punkt | fenster | kette | jederzeit
"punkt": 570, "von": 570, "bis": 1080,
"kette": null, "versatz": 0,
"sonne": false, // Lage kommt vom Sonnenstand
"wartet": true // hängt an einem Messwert
}]
}
```
---
## 2. Was der Browser daraus macht
```mermaid
flowchart TB
L["zeitleisteLaden()<br/><small>fetch ?action=zeitleiste</small>"] --> Z["zeitleisteZeichnen()"]
Z --> F["Etagenfilter anwenden"]
F --> T["Dreiteilung:<br/>gelegt · jederzeit · pausiert"]
T --> ST["je Bahn: zlStapeln()<br/><small>Zeilen wie im Kalender</small>"]
ST --> HTML["HTML bauen:<br/>Achse, Bahnen, Bänder, Legende"]
HTML --> KET["zlKettenZeichnen()<br/><small>SVG-Bögen, nach dem Layout</small>"]
KET --> SCR["Scrollstand setzen"]
SCR --> KAR["offene Karte wieder öffnen"]
```
Jede Automatik erscheint **genau einmal**: in ihrer Bahn, unter „Jederzeit“
oder unter „Pausiert“. Der Zähler oben („22 von 22 · 3 pausiert“) ist die
Probe aufs Exempel — fehlte eine, fiele es dort auf.
### 2.1 Stapeln: warum in Pixeln und nicht in Minuten
Zwei Punkte zehn Minuten auseinander liegen zeitlich getrennt — ihre
**Beschriftungen** aber übereinander. `zlStapeln()` rechnet deshalb in
Pixeln: Es misst den Etikett-Text (einmal ein Canvas, dann nur messen) und
legt jeden Eintrag in die erste Zeile, in der er nichts überdeckt.
```
Zeile 0 ●07:00 Wecker Magdalena ▭ 09:30–18:00 Hitzeschutz Süd ✓12:50
Zeile 1 ●07:08 Wecker Rollos ●15:59 Hitzeschutz Süd zurück
└── Nachfolger direkt unter seinem Auslöser
```
Drei Sonderfälle stecken darin:
* **Punkt am rechten Rand** trägt sein Etikett links von sich — die Marke
bleibt, wo die Zeit ist.
* **Fenster-Etikett** rückt nie vor seinen Balken; sonst sähe „15:00–18:31“
aus, als finge es um halb elf an. Reicht der Platz nicht, wird der Name
gekürzt (voll steht er im Hinweis).
* **Ketten bleiben beisammen:** Eingesetzt wird familienweise — erst der
Auslöser, gleich danach seine Nachfolger in die erste freie Zeile
*darunter*. Je Zeile werden belegte Strecken geführt, nicht nur ihr Ende,
weil ein Nachfolger links von etwas liegen kann, das schon steht.
### 2.2 Zustand, Farbe, Form
`zlZustand()` kennt fünf Zustände — sie bestimmen Farbe und Zeichen:
| Zustand | Wann | Darstellung |
|---|---|---|
| `nichtdran` | Tag passt nicht | gestrichelt, Kalendersymbol, Grund im Hinweis |
| `gelaufen` | es gibt Läufe an dem Tag | Haken und echte Laufzeit |
| `vorbei` | Zeit liegt hinter „jetzt“, nichts gelaufen | matt |
| `aktiv` | „jetzt“ liegt im Fenster / hinter dem Punkt | hervorgehoben |
| `kommt` | liegt noch vor uns | normal |
Beim Punkt zeigt das Etikett nach einem Lauf die **echte** Zeit, beim Fenster
immer das Fenster und den Lauf hinten mit Haken — sonst stünde „12:50“ am
Balkenanfang bei 09:30.
### 2.3 Kettenbögen
Erst **nach** dem Einsetzen gezeichnet, weil die Lage beider Enden aus dem
Layout kommt. Die Linie verlässt den Auslöser lotrecht nach unten und biegt
mit einer kleinen Rundung von links in den Nachfolger ein; liegt der
Nachfolger links vom Auslöser, kommt sie von oben an. Jeder Pfad trägt
`data-von`/`data-nach`, damit das Überfahren eines Eintrags seine ganze Kette
hell schaltet (`zlKetteHervorheben()`).
### 2.4 Die Karte
Ein Klick auf einen Eintrag öffnet eine kleine Karte: was die Automatik tut,
wann, wie sie heute steht — und die drei Dinge, die man damit tun will
(Bearbeiten, Pausieren, Löschen). Pausieren und Löschen gehen über dieselben
Funktionen wie in der Liste, samt Rückfrage nach abhängigen Automatiken.
Zwei Fallen stecken in dieser Karte, beide behoben:
1. **Die Karte machte eine zweite Bildlaufleiste.** AdminLTE gibt
`main.app-main` `overflow: auto` bei Inhaltshöhe. Wurde die Karte zum
Messen kurz ohne Lage eingehängt, stand sie unterhalb aller Bahnen und
ragte aus `main` — Chrome zeigte dann dauerhaft eine eigene Leiste.
Heute wird sie unsichtbar oben links eingehängt, gemessen und erst dann
platziert.
2. **Die Karte verschwand sofort wieder.** Die neue Leiste machte die
Zeitleiste schmaler, der `ResizeObserver` zeichnete neu — und dabei ging
die Karte verloren. Heute merkt sich `zeitleisteZeichnen()`, welche Karte
offen war, und öffnet sie danach wieder. Außerdem klappt die Karte nach
oben, wenn sie unten aus `main` liefe.
### 2.5 Was ein Neuzeichnen auslöst
| Anlass | Folge |
|---|---|
| Tag wechseln, Ansicht öffnen | `zeitleisteLaden()` — neue Daten vom Server |
| Etagenfilter | nur `zeitleisteZeichnen()`, Auswahl in `localStorage` |
| Breite ändert sich (> 4 px) | `zeitleisteZeichnen()` nach 120 ms |
| Minutentakt (nur heute, Tab sichtbar) | `zeitleisteLaden()` — „jetzt“-Linie und neue Läufe |
| nach Speichern/Pausieren/Löschen | `refreshAutomations()` lädt die Zeitleiste mit |
Was sich der Browser merkt: offene Ansicht (Zeitleiste oder Liste),
Etagenfilter, Scrollstand innerhalb des Tages.
---
## 3. Wo fange ich an, wenn ich …
| Vorhaben | Ort |
|---|---|
| … eine Bahn hinzufügen oder umbenennen | `zeitleisteBahnen()` in `restricted/zeitleiste.php`; Farbe in `css/solar.css` (`.zl-bahn-<name>`) |
| … das Symbol einer Bahn ändern | ebenda — dasselbe Symbol muss auch im Raum-Modal und im Editor stehen (siehe `GERAETE_SYMBOL` in `autoActionFuncs.js`) |
| … eine neue Art von Lage einführen | `zeitleiste()` (Gruppen-Auswertung + Art bestimmen), dann `zlLage()`/`zlEintrag()` im Browser |
| … an der Höhe/Dichte schrauben | `ZL_ZEILE`, `ZL_KOPF`, `ZL_MIN_SPUR` in `js/solar/zeitleiste.js` |
| … verstehen, warum ein Eintrag „nicht dran“ ist | `zlTagPasst()` — und zum Gegenprüfen `tag_passt()` im Runner |
+132 -132
View File
@@ -1,96 +1,96 @@
<?php
session_start();
require_once("restricted/mysql.php");
$_SESSION["local"] =false;
// Netze, die als lokal gelten und damit ohne Anmeldung Zugriff bekommen.
// Bewusst eine sichtbare Liste statt eines Zeichenkettenvergleichs.
const LOCAL_NETWORKS = ["192.168.179.0/24"];
/**
* Liegt $ip im Netz $cidr? Verglichen wird binaer (inet_pton), also
* unabhaengig von der Schreibweise: "2001:db8::" und "2001:0db8:0000::"
* sind dieselbe Adresse.
*/
function ipInNetwork($ip, $cidr)
{
$teile = explode("/", $cidr, 2);
$adresse = @inet_pton($ip);
$netz = @inet_pton($teile[0]);
if ($adresse === false || $netz === false || strlen($adresse) !== strlen($netz)) {
return false; // ungueltig oder verschiedene Adressfamilien
}
// Praefixlaenge streng pruefen. Ohne das macht ein Tippfehler in
// LOCAL_NETWORKS die Funktion fail-open: "/-5" etwa liefert eine
// Nullmaske, und dann liegt jede Adresse in jedem Netz.
if (isset($teile[1])) {
if (!ctype_digit($teile[1])) {
return false;
}
$bits = intval($teile[1]);
if ($bits > strlen($adresse) * 8) {
return false;
}
} else {
$bits = strlen($adresse) * 8;
}
$ganzeBytes = intdiv($bits, 8);
$restBits = $bits % 8;
if ($ganzeBytes > 0 && strncmp($adresse, $netz, $ganzeBytes) !== 0) {
return false;
}
if ($restBits === 0) {
return true;
}
$maske = chr((0xFF << (8 - $restBits)) & 0xFF);
return ($adresse[$ganzeBytes] & $maske) === ($netz[$ganzeBytes] & $maske);
}
/**
* Kommt die Anfrage aus dem Heimnetz? Nur dann gilt sie ohne Anmeldung als
* berechtigt.
*
* REMOTE_ADDR stammt aus der TCP-Verbindung und ist nicht faelschbar.
* Weiterleitungs-Header wie X-Forwarded-For duerfen hier bewusst NICHT
* herangezogen werden - sie kann jeder Aufrufer frei setzen.
*/
function isLocal()
{
$_SESSION["local"] = false;
$remote = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
$server = isset($_SERVER['SERVER_ADDR']) ? $_SERVER['SERVER_ADDR'] : '';
if (filter_var($remote, FILTER_VALIDATE_IP) === false) {
return false;
}
// Auf IPv6 abgebildete IPv4-Adressen (::ffff:192.168.179.44) auf ihre
// IPv4-Form zurueckfuehren. Ein Server mit Dual-Stack-Socket meldet
// LAN-Clients so - ohne diesen Schritt faellt das Heimnetz durch.
if (preg_match('/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i', $remote, $treffer)) {
$remote = $treffer[1];
}
$lokal = false;
foreach (LOCAL_NETWORKS as $netz) {
if (ipInNetwork($remote, $netz)) {
$lokal = true;
break;
}
}
// Gleiches IPv6-/64 wie der Server. Das Praefix vergibt der Provider und
// es kann sich aendern, deshalb aus SERVER_ADDR abgeleitet statt fest.
if (!$lokal
&& filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false
&& filter_var($server, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
$lokal = substr(inet_pton($remote), 0, 8) === substr(inet_pton($server), 0, 8);
}
$_SESSION["local"] = $lokal;
return $lokal;
}
<?php
session_start();
require_once("restricted/mysql.php");
$_SESSION["local"] =false;
// Netze, die als lokal gelten und damit ohne Anmeldung Zugriff bekommen.
// Bewusst eine sichtbare Liste statt eines Zeichenkettenvergleichs.
const LOCAL_NETWORKS = ["192.168.179.0/24"];
/**
* Liegt $ip im Netz $cidr? Verglichen wird binaer (inet_pton), also
* unabhaengig von der Schreibweise: "2001:db8::" und "2001:0db8:0000::"
* sind dieselbe Adresse.
*/
function ipInNetwork($ip, $cidr)
{
$teile = explode("/", $cidr, 2);
$adresse = @inet_pton($ip);
$netz = @inet_pton($teile[0]);
if ($adresse === false || $netz === false || strlen($adresse) !== strlen($netz)) {
return false; // ungueltig oder verschiedene Adressfamilien
}
// Praefixlaenge streng pruefen. Ohne das macht ein Tippfehler in
// LOCAL_NETWORKS die Funktion fail-open: "/-5" etwa liefert eine
// Nullmaske, und dann liegt jede Adresse in jedem Netz.
if (isset($teile[1])) {
if (!ctype_digit($teile[1])) {
return false;
}
$bits = intval($teile[1]);
if ($bits > strlen($adresse) * 8) {
return false;
}
} else {
$bits = strlen($adresse) * 8;
}
$ganzeBytes = intdiv($bits, 8);
$restBits = $bits % 8;
if ($ganzeBytes > 0 && strncmp($adresse, $netz, $ganzeBytes) !== 0) {
return false;
}
if ($restBits === 0) {
return true;
}
$maske = chr((0xFF << (8 - $restBits)) & 0xFF);
return ($adresse[$ganzeBytes] & $maske) === ($netz[$ganzeBytes] & $maske);
}
/**
* Kommt die Anfrage aus dem Heimnetz? Nur dann gilt sie ohne Anmeldung als
* berechtigt.
*
* REMOTE_ADDR stammt aus der TCP-Verbindung und ist nicht faelschbar.
* Weiterleitungs-Header wie X-Forwarded-For duerfen hier bewusst NICHT
* herangezogen werden - sie kann jeder Aufrufer frei setzen.
*/
function isLocal()
{
$_SESSION["local"] = false;
$remote = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
$server = isset($_SERVER['SERVER_ADDR']) ? $_SERVER['SERVER_ADDR'] : '';
if (filter_var($remote, FILTER_VALIDATE_IP) === false) {
return false;
}
// Auf IPv6 abgebildete IPv4-Adressen (::ffff:192.168.179.44) auf ihre
// IPv4-Form zurueckfuehren. Ein Server mit Dual-Stack-Socket meldet
// LAN-Clients so - ohne diesen Schritt faellt das Heimnetz durch.
if (preg_match('/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i', $remote, $treffer)) {
$remote = $treffer[1];
}
$lokal = false;
foreach (LOCAL_NETWORKS as $netz) {
if (ipInNetwork($remote, $netz)) {
$lokal = true;
break;
}
}
// Gleiches IPv6-/64 wie der Server. Das Praefix vergibt der Provider und
// es kann sich aendern, deshalb aus SERVER_ADDR abgeleitet statt fest.
if (!$lokal
&& filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false
&& filter_var($server, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
$lokal = substr(inet_pton($remote), 0, 8) === substr(inet_pton($server), 0, 8);
}
$_SESSION["local"] = $lokal;
return $lokal;
}
/**
* Kleine HTTP-GET-Anfrage im Heimnetz, Antwort als JSON-Array.
* Ersetzt die frueher in carOG.php, heater.php und AutoAction.php je einzeln
@@ -106,43 +106,43 @@ function httpGetJson($url){
return json_decode($response, true);
}
function checkLogin(){
$mysql = new mysqli($GLOBALS["mysql_server"],$GLOBALS["mysql_user"],$GLOBALS["mysql_pass"],$GLOBALS["mysql_db"]);
if(isLocal()){
$_SESSION["local"] =true;
return true;
}
if(isset($_SESSION["authKey"])){
$res = mysqli_query($mysql,"SELECT id FROM users WHERE lastAuth > DATE_SUB(NOW(), INTERVAL 2 DAY) AND authKey = '".mysqli_real_escape_string($mysql,$_SESSION["authKey"])."' AND name = '".mysqli_real_escape_string($mysql,$_SESSION["user"])."';");
if(!$res){
echo mysqli_error($mysql);
return false;
}
if(mysqli_num_rows($res) == 1){
return isset($_SESSION["Logged"]);
}
}else{
return false;
}
}
function checkAdduser(){
$mysql = new mysqli($GLOBALS["mysql_server"],$GLOBALS["mysql_user"],$GLOBALS["mysql_pass"],$GLOBALS["mysql_db"]);
if(!mysqli_query($mysql,"DELETE FROM addUser WHERE datetime < DATE_SUB(NOW(), INTERVAL 1 MINUTE);")){
echo mysqli_error($mysql);
}
$result = mysqli_query($mysql,"SELECT * FROM addUser WHERE accesskey='".mysqli_real_escape_string($mysql,$_GET["addUser"])."';");
if(!$result){
return false;
}
if ($result->num_rows > 0) {
// Freigabe fuer die Passkey-Registrierung vermerken. authServer.php
// prueft dieses Merkmal; ohne das waeren getCreateArgs/processCreate
// direkt aufrufbar und der Einmalschluessel wirkungslos.
$_SESSION["mayRegister"] = time();
return true;
}
return false;
}
function checkLogin(){
$mysql = new mysqli($GLOBALS["mysql_server"],$GLOBALS["mysql_user"],$GLOBALS["mysql_pass"],$GLOBALS["mysql_db"]);
if(isLocal()){
$_SESSION["local"] =true;
return true;
}
if(isset($_SESSION["authKey"])){
$res = mysqli_query($mysql,"SELECT id FROM users WHERE lastAuth > DATE_SUB(NOW(), INTERVAL 2 DAY) AND authKey = '".mysqli_real_escape_string($mysql,$_SESSION["authKey"])."' AND name = '".mysqli_real_escape_string($mysql,$_SESSION["user"])."';");
if(!$res){
echo mysqli_error($mysql);
return false;
}
if(mysqli_num_rows($res) == 1){
return isset($_SESSION["Logged"]);
}
}else{
return false;
}
}
function checkAdduser(){
$mysql = new mysqli($GLOBALS["mysql_server"],$GLOBALS["mysql_user"],$GLOBALS["mysql_pass"],$GLOBALS["mysql_db"]);
if(!mysqli_query($mysql,"DELETE FROM addUser WHERE datetime < DATE_SUB(NOW(), INTERVAL 1 MINUTE);")){
echo mysqli_error($mysql);
}
$result = mysqli_query($mysql,"SELECT * FROM addUser WHERE accesskey='".mysqli_real_escape_string($mysql,$_GET["addUser"])."';");
if(!$result){
return false;
}
if ($result->num_rows > 0) {
// Freigabe fuer die Passkey-Registrierung vermerken. authServer.php
// prueft dieses Merkmal; ohne das waeren getCreateArgs/processCreate
// direkt aufrufbar und der Einmalschluessel wirkungslos.
$_SESSION["mayRegister"] = time();
return true;
}
return false;
}
?>
+1 -1
View File
@@ -32,7 +32,7 @@ CREATE TABLE `actors` (
`type` varchar(50) NOT NULL COMMENT 'Gerätetyp z.B. RollerShutter',
`name` varchar(70) NOT NULL COMMENT 'Name des Geräts',
`url` varchar(100) NOT NULL COMMENT 'Tahoma Device URL',
`room` varchar(60) DEFAULT NULL COMMENT 'Raum aus restricted/rooms.php als "Etage/Raum" (z.B. OG/Bad) oder "aussen". Wird von Hand vergeben und deshalb vom Discovery nicht angefasst - der Upsert setzt nur type und name.'
`room_id` int(11) DEFAULT NULL COMMENT 'Raum (rooms.id, siehe homeMesh_grundriss.sql). Wird von Hand vergeben und deshalb vom Discovery nicht angefasst - der Upsert setzt nur type und name.'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
--
+2 -1
View File
@@ -29,13 +29,14 @@ SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `automations` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL COMMENT 'Anzeigename, z.B. Kinder zu',
`floor` enum('UG','EG','OG') NOT NULL DEFAULT 'OG' COMMENT 'Reiter in der Uebersicht. Ohne Etage waere die Automatik in keinem davon zu sehen, deshalb gibt es keinen leeren Wert',
`floor` varchar(4) NOT NULL COMMENT 'Reiter in der Uebersicht (floors.code, Fremdschluessel legt homeMesh_grundriss.sql an). Ohne Etage waere die Automatik in keinem Reiter zu sehen, deshalb kein leerer Wert',
`enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Aktiv oder pausiert',
`window_from` time NOT NULL DEFAULT '00:00:00' COMMENT 'Aktiver Zeitraum, Beginn',
`window_to` time NOT NULL DEFAULT '23:59:00' COMMENT 'Aktiver Zeitraum, Ende. Kleiner als window_from = ueber Mitternacht',
`weekdays` tinyint(3) unsigned NOT NULL DEFAULT 127 COMMENT 'Bitmaske: Bit 0 = Montag ... Bit 6 = Sonntag',
`on_vacation` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch in den Ferien ausfuehren',
`on_holiday` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch an Feiertagen ausfuehren',
`next_day` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Wochentage, Ferien und Feiertage gelten fuer morgen (Vorabend). Nachgeruestet mit vorabend.sql im SolarManager',
`force_once` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Am Ende des Zeitraums auf jeden Fall ausfuehren',
`lockout_secs` int(11) NOT NULL DEFAULT 60 COMMENT 'Sperrzeit: so lange nach einer Ausloesung wird nicht erneut geschaltet. Gegen Messwerte, die um die Schwelle pendeln - jedes Ueberschreiten waere sonst eine echte Flanke. Der Editor bietet 0, 60 und 900 an',
`cond_met` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'War die Bedingung beim letzten Durchlauf erfuellt? Nur die steigende Flanke loest aus, sonst wuerde Temperatur groesser 22 im Sekundentakt feuern',
+33
View File
@@ -0,0 +1,33 @@
-- ---------------------------------------------------------------------------
-- Energiefluss-Uebersicht (Seite Solar, Karte "Realtime") - Datenbank `homeMesh`
-- ---------------------------------------------------------------------------
-- Welche Kreise es ueberhaupt gibt, woher ihre Werte kommen, wie sie heissen
-- und wo sie stehen, beschreibt der Katalog in js/solar/energieflussKatalog.js.
-- Hier steht nur, was ein Haus davon abweichend einstellt - unter
-- Einstellungen -> Uebersicht:
--
-- aktiv 0 blendet den Kreis samt seinen Fluessen aus. Haengen andere
-- daran (Wallbox OG am Obergeschoss), rutschen sie eine Ebene
-- hoeher (an den Verbrauch).
-- name eigene Beschriftung statt der aus dem Katalog.
-- x, y eigene Lage in der Queransicht. Die Hochkant-Ansicht auf
-- schmalen Schirmen spiegelt sie selbst.
-- optionen JSON fuer Einstellungen, die nur ein Kreis kennt - etwa die
-- Aufteilung der PV-Leistung auf Flaechen.
--
-- Fehlt eine Zeile, gilt der Katalog. Ein neuer Kreis im Katalog erscheint
-- damit ohne Zutun, und eine leere Tabelle ist die Uebersicht dieses Hauses,
-- wie sie ausgeliefert wird.
-- ---------------------------------------------------------------------------
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `energiefluss` (
`id` varchar(20) NOT NULL COMMENT 'Kennung aus dem Katalog, z.B. og, wallboxEG',
`aktiv` tinyint(1) NOT NULL DEFAULT 1,
`name` varchar(40) DEFAULT NULL COMMENT 'NULL = Name aus dem Katalog',
`x` smallint(6) DEFAULT NULL COMMENT 'Mitte in der Queransicht; NULL = Katalog',
`y` smallint(6) DEFAULT NULL,
`optionen` text DEFAULT NULL COMMENT 'JSON, je Kreis verschieden; NULL = Katalog',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
+96
View File
@@ -0,0 +1,96 @@
-- ---------------------------------------------------------------------------
-- Grundriss der Home-Ansicht: Etagen, Raeume, Kacheln - Datenbank `homeMesh`
-- ---------------------------------------------------------------------------
-- Vorher stand das alles fest in restricted/rooms.php, und die Werte auf den
-- Kacheln ließen sich getrennt davon in solarLog.kachelWerte ueberschreiben.
-- Fuer ein anderes Haus haette man den Quelltext umschreiben muessen. Jetzt
-- ist es Inhalt: eingestellt unter Einstellungen -> Grundriss, gelesen von
-- rooms.php.
--
-- In homeMesh und nicht in solarLog: actors.room und automations.floor zeigen
-- auf Raeume und Etagen, und Fremdschluessel greifen nur innerhalb einer
-- Datenbank.
--
-- Die Tabellen duerfen leer sein. Dann zeigt die Startseite einen Hinweis auf
-- die Einstellungen statt eines Hauses - das ist der Anfang jeder neuen
-- Installation.
--
-- Einspielen NACH homeMesh_DB-layout.sql und homeMesh_automations.sql: am
-- Ende haengt diese Datei die Fremdschluessel von actors.room_id und
-- automations.floor an die neuen Tabellen. Ein zweiter Lauf schadet nicht.
-- ---------------------------------------------------------------------------
SET NAMES utf8mb4;
-- ---------------------------------------------------------------------------
-- Etagen. `code` ist der Schluessel und bleibt es: er steht in URLs
-- (?floor=EG), in SVG-Element-Ids (EG_Info), in automations.floor und im
-- MQTT-Baum. Umbenennen heisst deshalb `label` aendern, nicht `code`.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `floors` (
`code` varchar(4) NOT NULL COMMENT 'Kuerzel: URL, SVG-Ids, automations.floor. Nach dem Anlegen fest',
`label` varchar(40) NOT NULL COMMENT 'Ausgeschriebener Name, z.B. Erdgeschoss',
`position` int(11) NOT NULL DEFAULT 0 COMMENT 'Reihenfolge in Menue, Reitern und Etagenschaltern',
`bild` varchar(255) DEFAULT NULL COMMENT 'Grundriss relativ zum Web-Root, z.B. tiles/grundriss/EG-1a2b.png. NULL = noch keins',
`standard` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Diese Etage oeffnet, wenn keine gewaehlt ist. Hoechstens eine',
PRIMARY KEY (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- ---------------------------------------------------------------------------
-- Raeume. `id` ist die feste Nummer, auf die Geraete zeigen sollen; Name und
-- Kuerzel duerfen sich aendern.
--
-- kuerzel Teil der SVG-Element-Ids: <floor>_w0_<kuerzel>. Je Etage
-- eindeutig, sonst schriebe ein Wert in zwei Kacheln.
-- x, y linke obere Ecke der 24 x 19 grossen Kachel in der 400 x 300
-- grossen Zeichenflaeche. NULL = Raum ohne Kachel.
-- thermostat Zweig der Thermostatwerte, z.B. Raumtemp/EG/Bad. Traegt das
-- Heizsymbol und die Vorgabewerte der Kachel (Soll, Ist,
-- Feuchte). NULL = kein Thermostat.
-- werte JSON-Liste der Kachelwerte (Form siehe restricted/kacheln.php).
-- NULL = die Vorgabe aus dem Thermostat; [] = bewusst kein
-- Messwert, die Kachel traegt nur ihren Namen.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `rooms` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`floor` varchar(4) NOT NULL,
`name` varchar(40) NOT NULL COMMENT 'Anzeigename, z.B. Wohnzimmer',
`kuerzel` varchar(40) NOT NULL COMMENT 'Teil der SVG-Ids, je Etage eindeutig',
`position` int(11) NOT NULL DEFAULT 0 COMMENT 'Reihenfolge innerhalb der Etage',
`x` smallint(6) DEFAULT NULL COMMENT 'Kachel: linke obere Ecke, NULL = keine Kachel',
`y` smallint(6) DEFAULT NULL,
`thermostat` varchar(120) DEFAULT NULL COMMENT 'MQTT-Zweig des Thermostats, NULL = keins',
`werte` text DEFAULT NULL COMMENT 'JSON der Kachelwerte, NULL = Vorgabe',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_rooms_kuerzel` (`floor`, `kuerzel`),
UNIQUE KEY `uq_rooms_name` (`floor`, `name`),
CONSTRAINT `fk_rooms_floor` FOREIGN KEY (`floor`) REFERENCES `floors` (`code`)
ON UPDATE CASCADE ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- ---------------------------------------------------------------------------
-- Verweise auf Etagen und Raeume.
--
-- actors.room_id: ein geloeschter Raum laesst seine Geraete unzugeordnet
-- zurueck (SET NULL) - sie tauchen dann unter "ohne Raum" wieder auf.
-- automations.floor: eine Etage mit Automatiken laesst sich nicht loeschen
-- (RESTRICT); die Einstellungsseite nennt vorher die Namen.
--
-- Idempotent ueber information_schema, weil MariaDB kein
-- ADD CONSTRAINT IF NOT EXISTS fuer Fremdschluessel kennt.
-- ---------------------------------------------------------------------------
SET @da := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'fk_actors_room');
SET @sql := IF(@da = 0,
'ALTER TABLE actors ADD KEY idx_actors_room (room_id),
ADD CONSTRAINT fk_actors_room FOREIGN KEY (room_id) REFERENCES rooms (id) ON DELETE SET NULL',
'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @da := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'fk_automations_floor');
SET @sql := IF(@da = 0,
'ALTER TABLE automations ADD CONSTRAINT fk_automations_floor FOREIGN KEY (floor)
REFERENCES floors (code) ON UPDATE CASCADE ON DELETE RESTRICT',
'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

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